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.
1192 lines
35 KiB
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)
|
|
);
|
|
}
|