diff --git a/js/app/packages/app/component/next-soup/soup-view/filters-bar/searchable-multi-select.tsx b/js/app/packages/app/component/next-soup/soup-view/filters-bar/searchable-multi-select.tsx index 349be22125..7f0b4ff353 100644 --- a/js/app/packages/app/component/next-soup/soup-view/filters-bar/searchable-multi-select.tsx +++ b/js/app/packages/app/component/next-soup/soup-view/filters-bar/searchable-multi-select.tsx @@ -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'; @@ -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 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; activeIds: Accessor; @@ -47,7 +77,13 @@ const SearchableMultiSelectItem = (itemProps: { @@ -176,6 +212,7 @@ export const SearchableMultiSelect = (props: SearchableMultiSelectProps) => { placement={props.placement ?? 'bottom-start'} gutter={props.gutter ?? 4} > + {props.children} @@ -305,6 +342,7 @@ export const SearchableMultiSelectInline = ( allowsEmptyCollection virtualized > +
( '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' )} > @@ -673,6 +673,7 @@ export const UnifiedFilterDropdown = ( setInternalOpen(v); props.onOpenChange?.(v); }; + const panel = useSplitPanelOrThrow(); const { soup, queryFilters, assigneeFilter, setAssigneeFilter, activeTab } = useSoupView(); @@ -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)); @@ -879,12 +889,10 @@ export const UnifiedFilterDropdown = ( - + @@ -895,7 +903,7 @@ export const UnifiedFilterDropdown = ( - + {(option) => { diff --git a/js/app/packages/ui/components/Dropdown.tsx b/js/app/packages/ui/components/Dropdown.tsx index d2b25db3d0..435e66e9bd 100644 --- a/js/app/packages/ui/components/Dropdown.tsx +++ b/js/app/packages/ui/components/Dropdown.tsx @@ -31,20 +31,27 @@ import { Surface, type SurfaceProps } from './Surface'; type PortalMount = ComponentProps['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 > & @@ -73,6 +80,68 @@ export type DropdownSubProps = ComponentProps; 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(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(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, @@ -82,6 +151,24 @@ function resolvePortalMount( return searchRef?.closest('.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, [ @@ -90,6 +177,8 @@ function DropdownContent(props: DropdownContentProps) { 'mount', 'portalScope', 'children', + 'autoHighlightFirst', + 'ref', ]); return ( <> @@ -98,6 +187,7 @@ function DropdownContent(props: DropdownContentProps) { mount={resolvePortalMount(searchRef, local.mount, local.portalScope)} > @@ -128,6 +220,7 @@ function DropdownSubContent(props: DropdownSubContentProps) { mount={resolvePortalMount(searchRef, local.mount, local.portalScope)} >