You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/uix/soma/components/dropdown-menu/dropdown-menu-provider.svel...

1192 lines
35 KiB

import { context, type ProviderOpts, type WithRefOpts } from '../../provider';
import { createAttrs } from '$uix/morfo';
import { boolToEmptyStrOrUndef, getDataOpenClosed } from '$adom';
import {
readableActive,
state,
type Active,
type ActiveProps,
type StateProps
} from '$libs/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';
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
import type { ActiveTimers, TimerHandle } from '$timer';
const attrs = createAttrs(dropdownMenuMorfo);
// ── Typeahead helper ─────────────────────────────────────────────────────────
class Typeahead {
constructor(
private readonly timers: ActiveTimers,
private readonly key: () => string
) {}
private search = '';
private timer: TimerHandle | null = null;
handle(key: string, items: HTMLElement[]): HTMLElement | null {
if (key.length !== 1) return null;
this.search += key.toLowerCase();
this.cancelTimer();
this.timer = this.timers.schedule(
this.key(),
500,
() => {
this.search = '';
this.timer = null;
},
{ replace: true, meta: { component: 'dropdown-menu', action: 'typeahead-reset' } }
);
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 = '';
this.cancelTimer();
}
private cancelTimer() {
if (this.timer) {
this.timer.cancel();
this.timer = null;
}
}
destroy() {
this.reset();
}
}
// ── Root ─────────────────────────────────────────────────────────────────────
interface MenuOpts
extends
ProviderOpts,
StateProps<{ open: boolean }>,
ActiveProps<{
modal: boolean;
dir: Direction;
accessibleWhenDisabled: boolean;
onOpenChangeComplete: OnChangeFn<boolean>;
}> {}
export class MenuProvider {
readonly opts: MenuOpts;
readonly runtimePart: SomaRuntimePart;
readonly soma: Soma;
readonly runtime: SomaRuntime;
static readonly ctx = context<MenuProvider>('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 floatingProvider: FloatingProvider;
readonly contentPresence: Presence;
readonly typeahead: Typeahead;
contentId = state('');
triggerId = state('');
private contentRef = state<HTMLElement | null>(null);
private constructor(opts: MenuOpts) {
this.opts = opts;
this.soma = Soma.require();
this.typeahead = new Typeahead(
this.soma.uix.timers,
() => `soma:dropdown-menu:${this.opts.id.current}:typeahead`
);
this.runtime = this.soma.runtime(dropdownMenuMorfo, {});
this.runtimePart = this.runtime.part('provider', {
id: opts.id,
ref: opts.ref,
owner: this,
context: 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<HTMLElement>(selector)).filter(
(el) => el.closest(`[${attrs.content}], [${attrs['sub-content']}]`) === container
);
}
}
// ── Trigger ──────────────────────────────────────────────────────────────────
interface MenuTriggerOpts extends WithRefOpts {}
export class MenuTriggerProvider {
readonly opts: MenuTriggerOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: MenuTriggerOpts) {
return new MenuTriggerProvider(opts);
}
readonly provider: MenuProvider;
private constructor(opts: MenuTriggerOpts) {
this.opts = opts;
this.provider = MenuProvider.require();
this.runtimePart = this.provider.runtime.part('trigger', {
id: opts.id,
ref: opts.ref,
owner: this
});
this.provider.triggerId.current = opts.id.current;
FloatingAnchor.create({
id: opts.id,
ref: readableActive(() => opts.ref.current)
});
}
readonly onclick = (_e: SomaMouseEvent<HTMLButtonElement>) => {
if (this.provider.opts.open.current) this.provider.handleClose();
else this.provider.handleOpen();
};
readonly onkeydown = (e: SomaKeyboardEvent<HTMLButtonElement>) => {
if (e.key === KEYS.ARROW_DOWN || e.key === KEYS.ENTER || e.key === KEYS.SPACE) {
e.preventDefault();
this.provider.handleOpen();
}
};
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
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<Boundary>;
collisionPadding: number | Partial<Record<Side, number>>;
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 {
readonly opts: MenuContentOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: MenuContentOpts) {
return new MenuContentProvider(opts);
}
readonly provider: MenuProvider;
readonly floating: FloatingContent;
readonly focusScope: ReturnType<typeof FocusScope.use>;
readonly dismissal: Dismissal;
readonly scrollLock: ScrollLock;
readonly textSelection: TextSelection;
private constructor(opts: MenuContentOpts) {
this.opts = opts;
this.provider = MenuProvider.require();
this.runtimePart = this.provider.runtime.part('content', {
id: opts.id,
ref: opts.ref,
owner: this,
onRefChange: (el) => {
this.provider.setContentRef(el);
}
});
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),
dom: this.provider.soma.dom,
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.runtimePart.assert({
...this.runtimePart.props,
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 {
readonly opts: MenuArrowOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: MenuArrowOpts) {
return new MenuArrowProvider(opts);
}
readonly floatingArrow: FloatingArrow;
private constructor(opts: MenuArrowOpts) {
this.opts = opts;
const provider = MenuProvider.require();
this.runtimePart = provider.runtime.part('arrow', {
id: opts.id,
ref: opts.ref,
owner: this
});
this.floatingArrow = FloatingArrow.create(opts);
}
readonly props = $derived.by(() =>
this.runtimePart.assert({ ...this.runtimePart.props, ...this.floatingArrow.props } as const)
);
}
// ── Item ─────────────────────────────────────────────────────────────────────
interface MenuItemOpts
extends
WithRefOpts,
ActiveProps<{
disabled: boolean;
textValue: string;
closeOnSelect: boolean;
onSelect: () => void;
}> {}
export class MenuItemProvider {
readonly opts: MenuItemOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: MenuItemOpts) {
return new MenuItemProvider(opts);
}
readonly provider: MenuProvider;
private constructor(opts: MenuItemOpts) {
this.opts = opts;
this.provider = MenuProvider.require();
this.runtimePart = this.provider.runtime.part('item', {
id: opts.id,
ref: opts.ref,
owner: this
});
}
readonly onclick = (_e: SomaMouseEvent<HTMLElement>) => {
if (this.opts.disabled.current) return;
this.opts.onSelect.current();
if (this.opts.closeOnSelect.current) this.provider.handleClose();
};
readonly onkeydown = (e: SomaKeyboardEvent<HTMLElement>) => {
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.runtimePart.assert({
...this.runtimePart.props,
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 {
readonly opts: MenuGroupOpts;
readonly runtimePart: SomaRuntimePart;
static readonly ctx = context<MenuGroupProvider>('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) {
this.opts = opts;
const provider = MenuProvider.require();
this.runtimePart = provider.runtime.part('group', {
id: opts.id,
ref: opts.ref,
owner: this,
context: MenuGroupProvider.ctx
});
}
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
role: 'group' as const,
'aria-labelledby': this.headingId.current || undefined
} as const)
);
}
// ── GroupHeading ─────────────────────────────────────────────────────────────
interface MenuGroupHeadingOpts extends WithRefOpts {}
export class MenuGroupHeadingProvider {
readonly opts: MenuGroupHeadingOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: MenuGroupHeadingOpts) {
return new MenuGroupHeadingProvider(opts);
}
private constructor(opts: MenuGroupHeadingOpts) {
this.opts = opts;
const provider = MenuProvider.require();
this.runtimePart = provider.runtime.part('group-heading', {
id: opts.id,
ref: opts.ref,
owner: this
});
// 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.runtimePart.assert({ ...this.runtimePart.props } as const)
);
}
// ── Separator ────────────────────────────────────────────────────────────────
interface MenuSeparatorOpts extends WithRefOpts {}
export class MenuSeparatorProvider {
readonly opts: MenuSeparatorOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: MenuSeparatorOpts) {
return new MenuSeparatorProvider(opts);
}
private constructor(opts: MenuSeparatorOpts) {
this.opts = opts;
const provider = MenuProvider.require();
this.runtimePart = provider.runtime.part('separator', {
id: opts.id,
ref: opts.ref,
owner: this
});
}
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
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<MenuCheckboxGroupProvider>('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 {
readonly opts: MenuCheckboxItemOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: MenuCheckboxItemOpts) {
return new MenuCheckboxItemProvider(opts);
}
readonly provider: MenuProvider;
readonly group: MenuCheckboxGroupProvider | undefined;
private constructor(opts: MenuCheckboxItemOpts) {
this.opts = opts;
this.provider = MenuProvider.require();
this.runtimePart = this.provider.runtime.part('checkbox-item', {
id: opts.id,
ref: opts.ref,
owner: this
});
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<HTMLElement>) => {
this.activate();
};
readonly onkeydown = (e: SomaKeyboardEvent<HTMLElement>) => {
if (e.key === KEYS.ENTER || e.key === KEYS.SPACE) {
e.preventDefault();
this.activate();
}
};
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
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 {
readonly opts: MenuRadioGroupOpts;
readonly runtimePart: SomaRuntimePart;
static readonly ctx = context<MenuRadioGroupProvider>('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) {
this.opts = opts;
const provider = MenuProvider.require();
this.runtimePart = provider.runtime.part('radio-group', {
id: opts.id,
ref: opts.ref,
owner: this,
context: MenuRadioGroupProvider.ctx
});
}
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
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 {
readonly opts: MenuRadioItemOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: MenuRadioItemOpts) {
return new MenuRadioItemProvider(opts);
}
readonly provider: MenuProvider;
readonly radioGroup: MenuRadioGroupProvider;
private constructor(opts: MenuRadioItemOpts) {
this.opts = opts;
this.provider = MenuProvider.require();
this.runtimePart = this.provider.runtime.part('radio-item', {
id: opts.id,
ref: opts.ref,
owner: this
});
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<HTMLElement>) => {
this.activate();
};
readonly onkeydown = (e: SomaKeyboardEvent<HTMLElement>) => {
if (e.key === KEYS.ENTER || e.key === KEYS.SPACE) {
e.preventDefault();
this.activate();
}
};
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
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<MenuSubProvider>('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<HTMLElement | null>(null);
private contentRef = state<HTMLElement | null>(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 {
readonly opts: MenuSubTriggerOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: MenuSubTriggerOpts) {
return new MenuSubTriggerProvider(opts);
}
readonly sub: MenuSubProvider;
private openTimer: TimerHandle | null = null;
private constructor(opts: MenuSubTriggerOpts) {
// A30: register subTriggerRef via `onRefChange` (attachRef fires at
// mount/unmount inside `untrack`), avoiding the `$effect` feedback.
this.opts = opts;
this.sub = MenuSubProvider.require();
this.runtimePart = this.sub.provider.runtime.part('sub-trigger', {
id: opts.id,
ref: opts.ref,
owner: this,
onRefChange: (el) => {
this.sub.subTriggerRef.current = el;
}
});
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.openTimer) {
this.openTimer.cancel();
this.openTimer = null;
}
}
readonly onpointerenter = () => {
if (this.opts.disabled.current) return;
this.clearOpenTimeout();
// SafePolygon handles the close coordination — just open on enter
this.openTimer = this.sub.provider.soma.uix.timers.schedule(
this.openTimerKey,
100,
() => {
this.openTimer = null;
this.sub.handleOpen();
},
{ replace: true, meta: { component: 'dropdown-menu', action: 'submenu-open' } }
);
};
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<HTMLElement>) => {
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<HTMLElement>) => {
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();
}
};
private get openTimerKey() {
return `soma:dropdown-menu:${this.opts.id.current}:submenu-open`;
}
readonly props = $derived.by(() =>
this.runtimePart.assert({
...this.runtimePart.props,
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<Boundary>;
collisionPadding: number | Partial<Record<Side, number>>;
loop: boolean;
dir: Direction;
style: StyleProperties | null | undefined | string;
}> {}
export class MenuSubContentProvider {
readonly opts: MenuSubContentOpts;
readonly runtimePart: SomaRuntimePart;
static create(opts: MenuSubContentOpts) {
return new MenuSubContentProvider(opts);
}
readonly sub: MenuSubProvider;
readonly floating: FloatingContent;
private constructor(opts: MenuSubContentOpts) {
this.opts = opts;
this.sub = MenuSubProvider.require();
this.runtimePart = this.sub.provider.runtime.part('sub-content', {
id: opts.id,
ref: opts.ref,
owner: this,
onRefChange: (el) => {
this.sub.setContentRef(el);
}
});
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.runtimePart.assert({
...this.runtimePart.props,
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)
);
}

Powered by TurnKey Linux.