Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { useSelectedFirst } from '@core/util/useSelectedFirst';
import type { CollectionNode } from '@kobalte/core';
import { Combobox } from '@kobalte/core/combobox';
import { Combobox, useComboboxContext } from '@kobalte/core/combobox';
import CheckIcon from '@phosphor/check.svg';
import SearchIcon from '@phosphor/magnifying-glass.svg';
import { cn, Layer } from '@ui';
import {
type Accessor,
createEffect,
createMemo,
createSignal,
type JSX,
onCleanup,
Show,
} from 'solid-js';
import { Virtualizer, type VirtualizerHandle } from 'virtua/solid';
Expand All @@ -17,6 +19,34 @@ import type { SearchableOption } from './search-filter-controls';
const ITEM_HEIGHT = 36;
const LISTBOX_CLASS = 'max-h-[240px] overflow-y-auto scrollbar-hidden';

/**
* Keeps exactly one option highlighted while the combobox is open. Kobalte leaves the
* highlight empty on open and clears the focused key on every keystroke; reacting to the
* focused key re-seeds it to the first (filtered) option whenever it goes empty. We never
* override an existing highlight, so hover and arrow navigation still win. Rendered as a
* child of <Combobox> so it can read the combobox context.
*/
const HighlightFirstOption = () => {
const ctx = useComboboxContext();
createEffect(() => {
if (!ctx.isOpen()) return;
const selection = ctx.listState().selectionManager();
if (selection.focusedKey() != null) return;
const first = ctx.listState().collection().getFirstKey();
if (first == null) return;

const raf = requestAnimationFrame(() => {
if (!ctx.isOpen()) return;
if (selection.focusedKey() != null) return;
selection.setFocused(true);
selection.setFocusedKey(first);
});

onCleanup(() => cancelAnimationFrame(raf));
});
return null;
};

type SearchableMultiSelectProps = {
options: Accessor<SearchableOption[]>;
activeIds: Accessor<string[]>;
Expand Down Expand Up @@ -47,7 +77,13 @@ const SearchableMultiSelectItem = (itemProps: {
<span
class={cn(
'size-3.5 flex items-center justify-center shrink-0 rounded-sm border text-surface',
'border-transparent group-hover:not-hover:border-edge-muted group-data-highlighted:not-hover:border-edge-muted hover:border-accent',
// invisible at rest; only appears once the row is hovered/highlighted
'border-transparent',
// visible (and a touch stronger than before) when the row is hovered/highlighted but the box isn't directly hovered
'group-hover:not-hover:border-edge group-data-highlighted:not-hover:border-edge',
// accent when the box itself is hovered
'hover:border-accent',
// selected: filled accent + white check
'group-data-selected:bg-accent group-data-selected:border-accent'
)}
>
Expand Down Expand Up @@ -176,6 +212,7 @@ export const SearchableMultiSelect = (props: SearchableMultiSelectProps) => {
placement={props.placement ?? 'bottom-start'}
gutter={props.gutter ?? 4}
>
<HighlightFirstOption />
<Combobox.Control class="flex items-center h-full">
{props.children}
<Combobox.Input class="sr-only" />
Expand Down Expand Up @@ -305,6 +342,7 @@ export const SearchableMultiSelectInline = (
allowsEmptyCollection
virtualized
>
<HighlightFirstOption />
<div class="flex items-center gap-2 px-3 py-2 border-b border-edge-muted">
<SearchIcon class="size-3.5 text-ink-muted shrink-0" />
<Combobox.Input
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export const TypeIndicator = (props: { active: boolean }) => (
'size-3.5 flex items-center justify-center shrink-0 rounded-sm border text-surface',
props.active
? 'bg-accent border-accent'
: 'border-transparent group-hover:not-hover:border-edge-muted group-data-highlighted:not-hover:border-edge-muted hover:border-accent'
: 'border-transparent group-hover:not-hover:border-edge group-data-highlighted:not-hover:border-edge hover:border-accent'
)}
>
<Show when={props.active}>
Expand Down Expand Up @@ -673,6 +673,7 @@ export const UnifiedFilterDropdown = (
setInternalOpen(v);
props.onOpenChange?.(v);
};

const panel = useSplitPanelOrThrow();
const { soup, queryFilters, assigneeFilter, setAssigneeFilter, activeTab } =
useSoupView();
Expand Down Expand Up @@ -810,6 +811,15 @@ export const UnifiedFilterDropdown = (

const isTasksView = () => currentView() === 'tasks';
const isSearchView = () => currentView() === 'search';

// The top-level menu renders its options as checkboxes directly only for a single-category
// view (e.g. inbox); otherwise it renders caret sub-menu triggers (tasks, search). Auto-
// highlight belongs on checkbox menus, not caret menus — so the top-level content opts in
// only here. Category sub-menus and the searchable assignee list are checkbox menus and opt
// in independently below.
const topLevelShowsCheckboxes = createMemo(
() => categories().length === 1 && !isTasksView() && !isSearchView()
);
const hasActiveIndex = () =>
INDEX_OPTIONS.some((opt) => soup.predicates.isActive(opt.value));

Expand Down Expand Up @@ -879,12 +889,10 @@ export const UnifiedFilterDropdown = (
</Switch>
</Show>

<Dropdown.Content>
<Dropdown.Content autoHighlightFirst={topLevelShowsCheckboxes()}>
<Dropdown.Group>
<Show
when={
categories().length === 1 && !isTasksView() && !isSearchView()
}
when={topLevelShowsCheckboxes()}
fallback={
<>
<For each={categories()}>
Expand All @@ -895,7 +903,7 @@ export const UnifiedFilterDropdown = (
<CaretRightIcon class="size-3 text-ink-muted" />
</Dropdown.SubTrigger>

<Dropdown.SubContent>
<Dropdown.SubContent autoHighlightFirst>
<Dropdown.Group>
<For each={category.options}>
{(option) => {
Expand Down
97 changes: 95 additions & 2 deletions js/app/packages/ui/components/Dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,20 +31,27 @@ import { Surface, type SurfaceProps } from './Surface';
type PortalMount = ComponentProps<typeof KobalteDropdownMenu.Portal>['mount'];
type DropdownPortalScope = 'local';

/**
* `autoHighlightFirst`: opt in to "first row highlighted on open, and one row always
* highlighted while open". Off by default; enable per menu where it fits (e.g. filter menus).
* See {@link attachAutoHighlightFirst} for how it works and its caveats.
*/
type AutoHighlightProp = { autoHighlightFirst?: boolean };

export type DropdownSubContentProps = ComponentProps<
typeof KobalteDropdownMenu.SubContent
> & {
depth?: SurfaceProps['depth'];
mount?: PortalMount;
portalScope?: DropdownPortalScope;
};
} & AutoHighlightProp;
export type DropdownContentProps = ComponentProps<
typeof KobalteDropdownMenu.Content
> & {
depth?: SurfaceProps['depth'];
mount?: PortalMount;
portalScope?: DropdownPortalScope;
};
} & AutoHighlightProp;
export type DropdownTriggerProps = ComponentProps<
typeof KobalteDropdownMenu.Trigger
> &
Expand Down Expand Up @@ -73,6 +80,68 @@ export type DropdownSubProps = ComponentProps<typeof KobalteDropdownMenu.Sub>;
const ROW_CLASS =
'group rounded-lg w-full flex items-center gap-2 px-2 h-8 text-left font-medium text-xs cursor-default outline-none hover:bg-ink/5 data-highlighted:bg-ink/5 data-disabled:opacity-50 data-disabled:cursor-not-allowed';

const MENU_ITEM_SELECTOR =
'[role="menuitem"],[role="menuitemcheckbox"],[role="menuitemradio"]';

/**
* Keeps one row highlighted while a menu (content or sub-content) is open. Kobalte's menu
* context — which owns the highlighted key — isn't publicly exported, so we use the public
* lever: DOM focus. Focusing a menu item fires its onFocus, which sets the highlighted key;
* crucially, that key survives the item losing DOM focus (the menu's onFocusOut only flips an
* "is focused" flag, it doesn't clear the key), so the highlight sticks even when focus moves
* elsewhere. The listener/handlers live on the content element, which Kobalte discards when the
* menu closes, so they need no explicit teardown.
*
* Top-level menu: opens focused on its *container* (no row highlighted), and focus returns to
* the container whenever the pointer leaves a row. We listen for focus landing on the container
* and redirect it to the first row. Caveat: that means the highlight snaps back to the *first*
* row on mouse-away rather than staying on the last-hovered row.
*/
function attachAutoHighlightFirst(content: HTMLElement) {
const focusFirstRow = () => {
// Leave an existing highlight alone — hover or arrow keys already chose a row.
if (content.querySelector('[data-highlighted]')) return;
content
.querySelector<HTMLElement>(MENU_ITEM_SELECTOR)
?.focus({ preventScroll: true });
};
content.addEventListener('focusin', (e) => {
if (e.target === content) focusFirstRow();
});
requestAnimationFrame(focusFirstRow);
}

/**
* Sub-content variant. A sub-menu opened by hover never receives focus — Kobalte keeps it on
* the parent trigger — so the focusin path never fires; we seed the first row after one frame
* (the frame lets Kobalte's DismissableLayer register, so moving focus in isn't read as "focus
* outside" and doesn't close the menu). But focusing the row also flags the sub-menu "focused",
* and the parent SubTrigger's onPointerMove clears the highlighted key whenever it sees that
* flag set — so the highlight would vanish the instant the cursor moves over the parent. To
* avoid that, we focus the row to set the key, then immediately restore focus to wherever it was
* (the trigger). The key persists (onFocusOut doesn't clear it) and, with the sub-menu no longer
* "focused", the SubTrigger leaves it alone — so the highlight survives the cursor sitting on
* the parent.
*/
function attachAutoHighlightFirstSub(content: HTMLElement) {
// Capture the opener (the trigger that opened this sub-menu) *now*, before deferring — by the
// time the frame runs the pointer may have slid to a sibling trigger.
const opener = document.activeElement as HTMLElement | null;
requestAnimationFrame(() => {
if (content.querySelector('[data-highlighted]')) return;
// If focus already moved on (pointer slid to a sibling trigger), bail rather than fight it:
// focusing+restoring here would thrash focus across two triggers and can leave both rows
// highlighted. The sibling's own sub-menu will seed itself when it opens.
if (document.activeElement !== opener) return;
const first = content.querySelector<HTMLElement>(MENU_ITEM_SELECTOR);
if (!first) return;
first.focus({ preventScroll: true });
if (opener && opener !== first && opener.isConnected) {
opener.focus({ preventScroll: true });
}
});
}

function resolvePortalMount(
searchRef: HTMLElement | undefined,
mount: PortalMount,
Expand All @@ -82,6 +151,24 @@ function resolvePortalMount(
return searchRef?.closest<HTMLElement>('.portal-scope') ?? undefined;
}

// Composes an optional user-supplied ref with the auto-highlight wiring, so passing
// `autoHighlightFirst` doesn't clobber a `ref` on the same content. Sub-content needs the
// focus-restoring variant (see attachAutoHighlightFirstSub).
function composeContentRef(
autoHighlightFirst: boolean | undefined,
isSubContent: boolean,
userRef: unknown
) {
return (el: HTMLElement) => {
if (autoHighlightFirst) {
if (isSubContent) attachAutoHighlightFirstSub(el);
else attachAutoHighlightFirst(el);
}
if (typeof userRef === 'function')
(userRef as (el: HTMLElement) => void)(el);
};
}

function DropdownContent(props: DropdownContentProps) {
let searchRef: HTMLDivElement | undefined;
const [local, rest] = splitProps(props, [
Expand All @@ -90,6 +177,8 @@ function DropdownContent(props: DropdownContentProps) {
'mount',
'portalScope',
'children',
'autoHighlightFirst',
'ref',
]);
return (
<>
Expand All @@ -98,6 +187,7 @@ function DropdownContent(props: DropdownContentProps) {
mount={resolvePortalMount(searchRef, local.mount, local.portalScope)}
>
<KobalteDropdownMenu.Content
ref={composeContentRef(local.autoHighlightFirst, false, local.ref)}
class={cn('rounded-xl size-auto z-action-menu', local.class)}
depth={local.depth ?? 2}
as={Surface}
Expand All @@ -120,6 +210,8 @@ function DropdownSubContent(props: DropdownSubContentProps) {
'mount',
'portalScope',
'children',
'autoHighlightFirst',
'ref',
]);
return (
<>
Expand All @@ -128,6 +220,7 @@ function DropdownSubContent(props: DropdownSubContentProps) {
mount={resolvePortalMount(searchRef, local.mount, local.portalScope)}
>
<KobalteDropdownMenu.SubContent
ref={composeContentRef(local.autoHighlightFirst, true, local.ref)}
class={cn('rounded-xl size-auto z-action-menu', local.class)}
depth={local.depth ?? 2}
as={Surface}
Expand Down
Loading