import { Provider, context, type ProviderOpts, type WithRefOpts } from '../../provider'; import { createAttrs, registerContract, boolToEmptyStrOrUndef, getDataOpenClosed } from '../../attrs'; import { readableActive, state, type Active, type ActiveProps, type StateProps } from '../../reactive'; import type { OnChangeFn, SomaMouseEvent, SomaKeyboardEvent, Direction, Arrayable, StyleProperties } from '../../types'; import { KEYS, getDirectionalKeys } from '../../keyboard'; import { Soma } from '../../core/soma.svelte'; import { Presence } from '../../layers/presence.svelte'; import { FocusScope } from '../../layers/focus-scope.svelte'; import { Dismissal, type DismissalBehavior } from '../../layers/dismissal.svelte'; import { ScrollLock } from '../../layers/scroll-lock.svelte'; import { TextSelection } from '../../layers/text-selection.svelte'; import { FloatingProvider, FloatingContent, FloatingArrow, FloatingAnchor, type Side, type Align, type Boundary } from '../../layers/floating'; import { SafePolygon } from '../../layers/floating/safe-polygon'; import { dropdownMenuMorfo } from '../../../morfo/components/dropdown-menu'; const attrs = createAttrs(dropdownMenuMorfo); registerContract(dropdownMenuMorfo); // ── Typeahead helper ───────────────────────────────────────────────────────── class Typeahead { private search = ''; private timer: ReturnType | null = null; handle(key: string, items: HTMLElement[]): HTMLElement | null { if (key.length !== 1) return null; this.search += key.toLowerCase(); if (this.timer) clearTimeout(this.timer); this.timer = setTimeout(() => { this.search = ''; this.timer = null; }, 500); const match = items.find((el) => { const text = el.getAttribute('data-text-value') || el.textContent || ''; return text.toLowerCase().startsWith(this.search); }); return match ?? null; } reset() { this.search = ''; if (this.timer) { clearTimeout(this.timer); this.timer = null; } } destroy() { this.reset(); } } // ── Root ───────────────────────────────────────────────────────────────────── interface MenuOpts extends ProviderOpts, StateProps<{ open: boolean }>, ActiveProps<{ modal: boolean; dir: Direction; accessibleWhenDisabled: boolean; onOpenChangeComplete: OnChangeFn; }> {} export class MenuProvider extends Provider { static readonly ctx = context('DropdownMenu'); static get(): MenuProvider | undefined { return this.ctx.getOr(undefined) as MenuProvider | undefined; } static require(): MenuProvider { return this.ctx.get(); } static create(opts: MenuOpts) { return new MenuProvider(opts); } readonly config = Soma.get(); readonly floatingProvider: FloatingProvider; readonly contentPresence: Presence; readonly typeahead = new Typeahead(); contentId = state(''); triggerId = state(''); private contentRef = state(null); private constructor(opts: MenuOpts) { super(opts, 'DropdownMenu', 'provider', attrs.provider, MenuProvider.ctx); this.floatingProvider = FloatingProvider.create(); this.contentPresence = new Presence({ open: opts.open, ref: this.contentRef, onComplete: (open) => opts.onOpenChangeComplete.current(open) }); // Cleanup typeahead timer on unmount $effect(() => { return () => this.typeahead.destroy(); }); } handleOpen() { this.opts.open.current = true; } handleClose() { this.typeahead.reset(); this.opts.open.current = false; } setContentRef(el: HTMLElement | null) { this.contentRef.current = el; } /** * Focusable items in content, scoped to exclude nested submenus. * When `accessibleWhenDisabled` is true (APG-compliant default), disabled * items are included in the navigation order and can receive focus — they * just cannot be activated. */ getItems(container: HTMLElement | null): HTMLElement[] { if (!container) return []; const skip = this.opts.accessibleWhenDisabled.current ? '' : ':not([data-disabled])'; const selector = `[${attrs.item}]${skip}, [${attrs['checkbox-item']}]${skip}, [${attrs['radio-item']}]${skip}, [${attrs['sub-trigger']}]${skip}`; return Array.from(container.querySelectorAll(selector)).filter( (el) => el.closest(`[${attrs.content}], [${attrs['sub-content']}]`) === container ); } } // ── Trigger ────────────────────────────────────────────────────────────────── interface MenuTriggerOpts extends WithRefOpts {} export class MenuTriggerProvider extends Provider { static create(opts: MenuTriggerOpts) { return new MenuTriggerProvider(opts); } readonly provider: MenuProvider; private constructor(opts: MenuTriggerOpts) { super(opts, 'DropdownMenu', 'trigger', attrs.trigger); this.provider = MenuProvider.require(); this.provider.triggerId.current = opts.id.current; FloatingAnchor.create({ id: opts.id, ref: readableActive(() => opts.ref.current) }); } readonly onclick = (_e: SomaMouseEvent) => { if (this.provider.opts.open.current) this.provider.handleClose(); else this.provider.handleOpen(); }; readonly onkeydown = (e: SomaKeyboardEvent) => { if (e.key === KEYS.ARROW_DOWN || e.key === KEYS.ENTER || e.key === KEYS.SPACE) { e.preventDefault(); this.provider.handleOpen(); } }; readonly props = $derived.by(() => this.assertProps({ ...this.baseProps, type: 'button' as const, 'aria-haspopup': 'menu' as const, 'aria-expanded': this.provider.opts.open.current, 'aria-controls': this.provider.contentId.current || undefined, 'data-state': getDataOpenClosed(this.provider.opts.open.current), onclick: this.onclick, onkeydown: this.onkeydown } as const) ); } // ── Content ────────────────────────────────────────────────────────────────── interface MenuContentOpts extends WithRefOpts, ActiveProps<{ side: Side; sideOffset: number; align: Align; alignOffset: number; arrowPadding: number; avoidCollisions: boolean; collisionBoundary: Arrayable; collisionPadding: number | Partial>; sticky: 'partial' | 'always'; hideWhenDetached: boolean; updatePositionStrategy: 'optimized' | 'always'; strategy: 'fixed' | 'absolute'; loop: boolean; trapFocus: boolean; preventScroll: boolean; onOpenAutoFocus: (e: Event) => void; onCloseAutoFocus: (e: Event) => void; onEscapeKeydown: (e: KeyboardEvent) => void; escapeKeydownBehavior: DismissalBehavior; onInteractOutside: (e: PointerEvent) => void; interactOutsideBehavior: DismissalBehavior | undefined; onFocusOutside: (e: FocusEvent) => void; dir: Direction; style: StyleProperties | null | undefined | string; }> {} export class MenuContentProvider extends Provider { static create(opts: MenuContentOpts) { return new MenuContentProvider(opts); } readonly provider: MenuProvider; readonly floating: FloatingContent; readonly focusScope: ReturnType; readonly dismissal: Dismissal; readonly scrollLock: ScrollLock; readonly textSelection: TextSelection; private constructor(opts: MenuContentOpts) { super(opts, 'DropdownMenu', 'content', attrs.content, undefined, (el) => { this.provider.setContentRef(el); }); this.provider = MenuProvider.require(); this.provider.contentId.current = opts.id.current; // Floating this.floating = FloatingContent.create({ id: opts.id, wrapperId: readableActive(() => `${opts.id.current}-wrapper`), side: opts.side, sideOffset: opts.sideOffset, align: opts.align, alignOffset: opts.alignOffset, arrowPadding: opts.arrowPadding, avoidCollisions: opts.avoidCollisions, collisionBoundary: opts.collisionBoundary, collisionPadding: opts.collisionPadding, sticky: opts.sticky, hideWhenDetached: opts.hideWhenDetached, updatePositionStrategy: opts.updatePositionStrategy, strategy: opts.strategy, onPlaced: readableActive(() => () => {}), dir: opts.dir, style: opts.style, enabled: readableActive(() => this.provider.opts.open.current), customAnchor: readableActive(() => null) }); // Focus this.focusScope = FocusScope.use({ onOpenAutoFocus: opts.onOpenAutoFocus, onCloseAutoFocus: opts.onCloseAutoFocus, trap: opts.trapFocus, loop: opts.loop, ref: opts.ref, enabled: readableActive(() => this.provider.contentPresence.isPresent) }); // Dismissal — menus always close on outside click (unlike dialogs where modal=true ignores it) const resolvedInteractBehavior = readableActive( () => opts.interactOutsideBehavior.current ?? 'close' ); this.dismissal = Dismissal.use({ ref: readableActive(() => opts.ref.current), enabled: readableActive(() => this.provider.opts.open.current), onEscapeKeydown: readableActive(() => (e: KeyboardEvent) => { opts.onEscapeKeydown.current(e); if (!e.defaultPrevented) this.provider.handleClose(); }), escapeKeydownBehavior: opts.escapeKeydownBehavior, onInteractOutside: readableActive(() => (e: PointerEvent) => { opts.onInteractOutside.current(e); if (!e.defaultPrevented) this.provider.handleClose(); }), interactOutsideBehavior: resolvedInteractBehavior, onFocusOutside: opts.onFocusOutside, isValidEvent: readableActive(() => () => false) }); // Scroll lock this.scrollLock = new ScrollLock(); $effect(() => { this.scrollLock.locked.current = this.provider.opts.open.current && opts.preventScroll.current; }); // Text selection this.textSelection = TextSelection.use({ ref: readableActive(() => opts.ref.current), enabled: readableActive(() => this.provider.opts.open.current), onPointerDown: readableActive(() => () => {}), onPointerUp: readableActive(() => () => {}) }); } readonly isPresent = $derived.by(() => this.provider.contentPresence.isPresent); readonly wrapperProps = $derived.by(() => ({ ...this.floating.wrapperProps, style: { ...(typeof this.floating.wrapperProps.style === 'object' ? this.floating.wrapperProps.style : {}), 'pointer-events': 'auto' } })); readonly onkeydown = (e: KeyboardEvent) => { const container = this.opts.ref.current; if (!container) return; const items = this.provider.getItems(container); const active = document.activeElement as HTMLElement; const currentIndex = items.indexOf(active); const dir = this.provider.opts.dir.current; const { nextKey, prevKey } = getDirectionalKeys(dir, 'vertical'); let targetIndex = -1; if (e.key === nextKey) { e.preventDefault(); targetIndex = this.opts.loop.current ? (currentIndex + 1) % items.length : Math.min(currentIndex + 1, items.length - 1); } else if (e.key === prevKey) { e.preventDefault(); targetIndex = this.opts.loop.current ? (currentIndex - 1 + items.length) % items.length : Math.max(currentIndex - 1, 0); } else if (e.key === KEYS.HOME) { e.preventDefault(); targetIndex = 0; } else if (e.key === KEYS.END) { e.preventDefault(); targetIndex = items.length - 1; } else { // Typeahead const match = this.provider.typeahead.handle(e.key, items); if (match) match.focus(); return; } if (targetIndex >= 0 && items[targetIndex]) { items[targetIndex].focus(); } }; readonly props = $derived.by(() => this.assertProps({ ...this.baseProps, role: 'menu' as const, 'aria-labelledby': this.provider.triggerId.current || undefined, 'aria-orientation': 'vertical' as const, 'data-state': getDataOpenClosed(this.provider.opts.open.current), ...this.floating.props, ...this.provider.contentPresence.transitionAttrs, ...this.focusScope.props, ...this.dismissal.props, onkeydown: this.onkeydown } as const) ); } // ── Arrow ──────────────────────────────────────────────────────────────────── interface MenuArrowOpts extends WithRefOpts {} export class MenuArrowProvider extends Provider { static create(opts: MenuArrowOpts) { return new MenuArrowProvider(opts); } readonly floatingArrow: FloatingArrow; private constructor(opts: MenuArrowOpts) { super(opts, 'DropdownMenu', 'arrow', attrs.arrow); this.floatingArrow = FloatingArrow.create(opts); } readonly props = $derived.by(() => this.assertProps({ ...this.baseProps, ...this.floatingArrow.props } as const) ); } // ── Item ───────────────────────────────────────────────────────────────────── interface MenuItemOpts extends WithRefOpts, ActiveProps<{ disabled: boolean; textValue: string; closeOnSelect: boolean; onSelect: () => void; }> {} export class MenuItemProvider extends Provider { static create(opts: MenuItemOpts) { return new MenuItemProvider(opts); } readonly provider: MenuProvider; private constructor(opts: MenuItemOpts) { super(opts, 'DropdownMenu', 'item', attrs.item); this.provider = MenuProvider.require(); } readonly onclick = (_e: SomaMouseEvent) => { if (this.opts.disabled.current) return; this.opts.onSelect.current(); if (this.opts.closeOnSelect.current) this.provider.handleClose(); }; readonly onkeydown = (e: SomaKeyboardEvent) => { if (this.opts.disabled.current) return; if (e.key === KEYS.ENTER || e.key === KEYS.SPACE) { e.preventDefault(); this.opts.onSelect.current(); if (this.opts.closeOnSelect.current) this.provider.handleClose(); } }; readonly props = $derived.by(() => this.assertProps({ ...this.baseProps, role: 'menuitem' as const, tabindex: this.opts.disabled.current && !this.provider.opts.accessibleWhenDisabled.current ? undefined : -1, 'aria-disabled': this.opts.disabled.current || undefined, 'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current), 'data-text-value': this.opts.textValue.current || undefined, onclick: this.onclick, onkeydown: this.onkeydown } as const) ); } // ── Group ──────────────────────────────────────────────────────────────────── interface MenuGroupOpts extends WithRefOpts {} export class MenuGroupProvider extends Provider { static readonly ctx = context('DropdownMenuGroup'); static get(): MenuGroupProvider | undefined { return this.ctx.getOr(undefined) as MenuGroupProvider | undefined; } static require(): MenuGroupProvider { return this.ctx.get(); } static create(opts: MenuGroupOpts) { return new MenuGroupProvider(opts); } headingId = state(''); private constructor(opts: MenuGroupOpts) { super(opts, 'DropdownMenu', 'group', attrs.group, MenuGroupProvider.ctx); } readonly props = $derived.by(() => this.assertProps({ ...this.baseProps, role: 'group' as const, 'aria-labelledby': this.headingId.current || undefined } as const) ); } // ── GroupHeading ───────────────────────────────────────────────────────────── interface MenuGroupHeadingOpts extends WithRefOpts {} export class MenuGroupHeadingProvider extends Provider { static create(opts: MenuGroupHeadingOpts) { return new MenuGroupHeadingProvider(opts); } private constructor(opts: MenuGroupHeadingOpts) { super(opts, 'DropdownMenu', 'group-heading', attrs['group-heading']); // Register with parent group if available const group = MenuGroupProvider.get() as MenuGroupProvider | undefined; if (group) { group.headingId.current = opts.id.current; } } readonly props = $derived.by(() => this.assertProps({ ...this.baseProps } as const)); } // ── Separator ──────────────────────────────────────────────────────────────── interface MenuSeparatorOpts extends WithRefOpts {} export class MenuSeparatorProvider extends Provider { static create(opts: MenuSeparatorOpts) { return new MenuSeparatorProvider(opts); } private constructor(opts: MenuSeparatorOpts) { super(opts, 'DropdownMenu', 'separator', attrs.separator); } readonly props = $derived.by(() => this.assertProps({ ...this.baseProps, role: 'separator' as const, 'aria-orientation': 'horizontal' as const } as const) ); } // ── CheckboxGroup (optional parent — Bits UI pattern) ─────────────────────── interface MenuCheckboxGroupOpts extends StateProps<{ value: string[] }> {} export class MenuCheckboxGroupProvider { static readonly ctx = context('DropdownMenuCheckboxGroup'); static get(): MenuCheckboxGroupProvider | undefined { return this.ctx.getOr(undefined) as MenuCheckboxGroupProvider | undefined; } static require(): MenuCheckboxGroupProvider { return this.ctx.get(); } static create(opts: MenuCheckboxGroupOpts) { return MenuCheckboxGroupProvider.ctx.set(new MenuCheckboxGroupProvider(opts)); } readonly opts: MenuCheckboxGroupOpts; private constructor(opts: MenuCheckboxGroupOpts) { this.opts = opts; } contains(value: string): boolean { return this.opts.value.current.includes(value); } toggle(value: string): void { const arr = this.opts.value.current; this.opts.value.current = arr.includes(value) ? arr.filter((v) => v !== value) : [...arr, value]; } } // ── CheckboxItem ───────────────────────────────────────────────────────────── interface MenuCheckboxItemOpts extends WithRefOpts, StateProps<{ checked: boolean }>, ActiveProps<{ value: string | undefined; disabled: boolean; textValue: string; closeOnSelect: boolean; onSelect: () => void; }> {} export class MenuCheckboxItemProvider extends Provider { static create(opts: MenuCheckboxItemOpts) { return new MenuCheckboxItemProvider(opts); } readonly provider: MenuProvider; readonly group: MenuCheckboxGroupProvider | undefined; private constructor(opts: MenuCheckboxItemOpts) { super(opts, 'DropdownMenu', 'checkbox-item', attrs['checkbox-item']); this.provider = MenuProvider.require(); this.group = MenuCheckboxGroupProvider.get(); } /** Effective checked state: group membership when inside a CheckboxGroup, own `checked` otherwise. */ readonly effectiveChecked = $derived.by(() => { if (this.group && this.opts.value.current !== undefined) { return this.group.contains(this.opts.value.current); } return this.opts.checked.current; }); private activate() { if (this.opts.disabled.current) return; if (this.group && this.opts.value.current !== undefined) { this.group.toggle(this.opts.value.current); } else { const next = !this.opts.checked.current; this.opts.checked.current = next; } this.opts.onSelect.current(); if (this.opts.closeOnSelect.current) this.provider.handleClose(); } readonly onclick = (_e: SomaMouseEvent) => { this.activate(); }; readonly onkeydown = (e: SomaKeyboardEvent) => { if (e.key === KEYS.ENTER || e.key === KEYS.SPACE) { e.preventDefault(); this.activate(); } }; readonly props = $derived.by(() => this.assertProps({ ...this.baseProps, role: 'menuitemcheckbox' as const, 'aria-checked': this.effectiveChecked, tabindex: this.opts.disabled.current && !this.provider.opts.accessibleWhenDisabled.current ? undefined : -1, 'aria-disabled': this.opts.disabled.current || undefined, 'data-state': this.effectiveChecked ? 'checked' : 'unchecked', 'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current), 'data-text-value': this.opts.textValue.current || undefined, onclick: this.onclick, onkeydown: this.onkeydown } as const) ); } // ── RadioGroup ─────────────────────────────────────────────────────────────── interface MenuRadioGroupOpts extends WithRefOpts, StateProps<{ value: string }> {} export class MenuRadioGroupProvider extends Provider { static readonly ctx = context('DropdownMenuRadioGroup'); static get(): MenuRadioGroupProvider | undefined { return this.ctx.getOr(undefined) as MenuRadioGroupProvider | undefined; } static require(): MenuRadioGroupProvider { return this.ctx.get(); } static create(opts: MenuRadioGroupOpts) { return new MenuRadioGroupProvider(opts); } private constructor(opts: MenuRadioGroupOpts) { super(opts, 'DropdownMenu', 'radio-group', attrs['radio-group'], MenuRadioGroupProvider.ctx); } readonly props = $derived.by(() => this.assertProps({ ...this.baseProps, role: 'group' as const } as const) ); } // ── RadioItem ──────────────────────────────────────────────────────────────── interface MenuRadioItemOpts extends WithRefOpts, ActiveProps<{ value: string; disabled: boolean; textValue: string; closeOnSelect: boolean; onSelect: () => void; }> {} export class MenuRadioItemProvider extends Provider { static create(opts: MenuRadioItemOpts) { return new MenuRadioItemProvider(opts); } readonly provider: MenuProvider; readonly radioGroup: MenuRadioGroupProvider; private constructor(opts: MenuRadioItemOpts) { super(opts, 'DropdownMenu', 'radio-item', attrs['radio-item']); this.provider = MenuProvider.require(); this.radioGroup = MenuRadioGroupProvider.require(); } readonly isChecked = $derived.by( () => this.radioGroup.opts.value.current === this.opts.value.current ); private activate() { if (this.opts.disabled.current) return; this.radioGroup.opts.value.current = this.opts.value.current; this.opts.onSelect.current(); if (this.opts.closeOnSelect.current) this.provider.handleClose(); } readonly onclick = (_e: SomaMouseEvent) => { this.activate(); }; readonly onkeydown = (e: SomaKeyboardEvent) => { if (e.key === KEYS.ENTER || e.key === KEYS.SPACE) { e.preventDefault(); this.activate(); } }; readonly props = $derived.by(() => this.assertProps({ ...this.baseProps, role: 'menuitemradio' as const, 'aria-checked': this.isChecked, tabindex: this.opts.disabled.current && !this.provider.opts.accessibleWhenDisabled.current ? undefined : -1, 'aria-disabled': this.opts.disabled.current || undefined, 'data-state': this.isChecked ? 'checked' : 'unchecked', 'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current), 'data-text-value': this.opts.textValue.current || undefined, onclick: this.onclick, onkeydown: this.onkeydown } as const) ); } // ── Sub ────────────────────────────────────────────────────────────────────── interface MenuSubOpts extends StateProps<{ open: boolean }> {} export class MenuSubProvider { static readonly ctx = context('DropdownMenuSub'); static get(): MenuSubProvider | undefined { return this.ctx.getOr(undefined) as MenuSubProvider | undefined; } static require(): MenuSubProvider { return this.ctx.get(); } static create(opts: MenuSubOpts) { return MenuSubProvider.ctx.set(new MenuSubProvider(opts)); } readonly opts: MenuSubOpts; readonly provider: MenuProvider; readonly contentPresence: Presence; readonly floatingProvider: FloatingProvider; readonly safePolygon: SafePolygon; contentId = state(''); subTriggerId = state(''); subTriggerRef = state(null); private contentRef = state(null); private constructor(opts: MenuSubOpts) { this.opts = opts; this.provider = MenuProvider.require(); this.floatingProvider = FloatingProvider.create(); this.contentPresence = new Presence({ open: opts.open, ref: this.contentRef }); // SafePolygon handles the pointer gap between SubTrigger and SubContent this.safePolygon = new SafePolygon({ enabled: () => opts.open.current, triggerNode: () => this.subTriggerRef.current, contentNode: () => this.contentRef.current, onPointerExit: () => this.handleClose(), buffer: 2, transitIntentTimeout: 300 }); } handleOpen() { this.opts.open.current = true; } handleClose() { this.opts.open.current = false; } setContentRef(el: HTMLElement | null) { this.contentRef.current = el; } } // ── SubTrigger ─────────────────────────────────────────────────────────────── interface MenuSubTriggerOpts extends WithRefOpts, ActiveProps<{ disabled: boolean; textValue: string }> {} export class MenuSubTriggerProvider extends Provider { static create(opts: MenuSubTriggerOpts) { return new MenuSubTriggerProvider(opts); } readonly sub: MenuSubProvider; private openTimeout: ReturnType | null = null; private constructor(opts: MenuSubTriggerOpts) { // A30: register subTriggerRef via `onRefChange` (attachRef fires at // mount/unmount inside `untrack`), avoiding the `$effect` feedback. super(opts, 'DropdownMenu', 'sub-trigger', attrs['sub-trigger'], undefined, (el) => { this.sub.subTriggerRef.current = el; }); this.sub = MenuSubProvider.require(); this.sub.subTriggerId.current = opts.id.current; FloatingAnchor.create({ id: opts.id, ref: readableActive(() => opts.ref.current) }); // Cleanup open timeout on unmount $effect(() => { return () => this.clearOpenTimeout(); }); } private clearOpenTimeout() { if (this.openTimeout) { clearTimeout(this.openTimeout); this.openTimeout = null; } } readonly onpointerenter = () => { if (this.opts.disabled.current) return; this.clearOpenTimeout(); // SafePolygon handles the close coordination — just open on enter this.openTimeout = setTimeout(() => { this.openTimeout = null; this.sub.handleOpen(); }, 100); }; readonly onpointerleave = () => { this.clearOpenTimeout(); // Don't schedule close here — SafePolygon tracks the pointer // and calls onPointerExit when it leaves the safe zone }; /** * Click-to-open (pointer + touch + pen). Required because: * - touch taps don't reliably fire the hover-based `onpointerenter` path * - a user can reach the trigger with the mouse without triggering enter * (e.g. focus via keyboard, then click) and expect a click to open * - matches Radix/Bits/Ariakit behavior */ readonly onclick = (e: SomaMouseEvent) => { if (this.opts.disabled.current) return; this.clearOpenTimeout(); // Don't let the item-level "select" handler on the surrounding content // interpret this click as an activation that closes the whole menu. e.preventDefault(); e.stopPropagation(); if (!this.sub.opts.open.current) this.sub.handleOpen(); }; readonly onkeydown = (e: SomaKeyboardEvent) => { if (this.opts.disabled.current) return; const dir = this.sub.provider.opts.dir.current; const { nextKey } = getDirectionalKeys(dir, 'horizontal'); if (e.key === nextKey || e.key === KEYS.ENTER || e.key === KEYS.SPACE) { e.preventDefault(); this.sub.handleOpen(); } }; readonly props = $derived.by(() => this.assertProps({ ...this.baseProps, role: 'menuitem' as const, 'aria-haspopup': 'menu' as const, 'aria-expanded': this.sub.opts.open.current, 'aria-controls': this.sub.contentId.current || undefined, tabindex: this.opts.disabled.current && !this.sub.provider.opts.accessibleWhenDisabled.current ? undefined : -1, 'aria-disabled': this.opts.disabled.current || undefined, 'data-state': getDataOpenClosed(this.sub.opts.open.current), 'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current), 'data-text-value': this.opts.textValue.current || undefined, onclick: this.onclick, onpointerenter: this.onpointerenter, onpointerleave: this.onpointerleave, onkeydown: this.onkeydown } as const) ); } // ── SubContent ─────────────────────────────────────────────────────────────── interface MenuSubContentOpts extends WithRefOpts, ActiveProps<{ side: Side; sideOffset: number; align: Align; alignOffset: number; avoidCollisions: boolean; collisionBoundary: Arrayable; collisionPadding: number | Partial>; loop: boolean; dir: Direction; style: StyleProperties | null | undefined | string; }> {} export class MenuSubContentProvider extends Provider { static create(opts: MenuSubContentOpts) { return new MenuSubContentProvider(opts); } readonly sub: MenuSubProvider; readonly floating: FloatingContent; private constructor(opts: MenuSubContentOpts) { super(opts, 'DropdownMenu', 'sub-content', attrs['sub-content'], undefined, (el) => { this.sub.setContentRef(el); }); this.sub = MenuSubProvider.require(); this.sub.contentId.current = opts.id.current; this.floating = FloatingContent.create({ id: opts.id, wrapperId: readableActive(() => `${opts.id.current}-wrapper`), side: opts.side, sideOffset: opts.sideOffset, align: opts.align, alignOffset: opts.alignOffset, arrowPadding: readableActive(() => 0), avoidCollisions: opts.avoidCollisions, collisionBoundary: opts.collisionBoundary, collisionPadding: opts.collisionPadding, sticky: readableActive(() => 'partial' as const), hideWhenDetached: readableActive(() => true), updatePositionStrategy: readableActive(() => 'optimized' as const), strategy: readableActive(() => 'fixed' as const), onPlaced: readableActive(() => () => {}), dir: opts.dir, style: opts.style, enabled: readableActive(() => this.sub.opts.open.current), customAnchor: readableActive(() => null) }); } readonly isPresent = $derived.by(() => this.sub.contentPresence.isPresent); readonly wrapperProps = $derived.by(() => ({ ...this.floating.wrapperProps, style: { ...(typeof this.floating.wrapperProps.style === 'object' ? this.floating.wrapperProps.style : {}), 'pointer-events': 'auto' } })); readonly onkeydown = (e: KeyboardEvent) => { const dir = this.sub.provider.opts.dir.current; const { prevKey } = getDirectionalKeys(dir, 'horizontal'); const { nextKey: downKey, prevKey: upKey } = getDirectionalKeys(dir, 'vertical'); if (e.key === prevKey || e.key === KEYS.ESCAPE) { e.preventDefault(); this.sub.handleClose(); } // Arrow nav within sub const container = this.opts.ref.current; if (!container) return; const items = this.sub.provider.getItems(container); const active = document.activeElement as HTMLElement; const currentIndex = items.indexOf(active); let targetIndex = -1; if (e.key === downKey) { e.preventDefault(); targetIndex = this.opts.loop.current ? (currentIndex + 1) % items.length : Math.min(currentIndex + 1, items.length - 1); } else if (e.key === upKey) { e.preventDefault(); targetIndex = this.opts.loop.current ? (currentIndex - 1 + items.length) % items.length : Math.max(currentIndex - 1, 0); } else if (e.key === KEYS.HOME) { e.preventDefault(); targetIndex = 0; } else if (e.key === KEYS.END) { e.preventDefault(); targetIndex = items.length - 1; } else { // Typeahead fallback const match = this.sub.provider.typeahead.handle(e.key, items); if (match) match.focus(); return; } if (targetIndex >= 0 && items[targetIndex]) { items[targetIndex].focus(); } }; readonly props = $derived.by(() => this.assertProps({ ...this.baseProps, role: 'menu' as const, 'aria-labelledby': this.sub.subTriggerId.current || undefined, 'aria-orientation': 'vertical' as const, 'data-state': getDataOpenClosed(this.sub.opts.open.current), ...this.floating.props, ...this.sub.contentPresence.transitionAttrs, onkeydown: this.onkeydown } as const) ); }