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.
433 lines
14 KiB
433 lines
14 KiB
import { context, type WithRefOpts } from '../../provider';
|
|
import { boolToEmptyStrOrUndef, getDataOpenClosed } from '$adom';
|
|
import {
|
|
readableActive,
|
|
writableActive,
|
|
state,
|
|
type ActiveProps,
|
|
type StateProps
|
|
} from '$libs/reactive';
|
|
import type {
|
|
OnChangeFn,
|
|
SomaMouseEvent,
|
|
SomaKeyboardEvent,
|
|
SomaFocusEvent,
|
|
Direction
|
|
} from '../../types';
|
|
import { KEYS, getDirectionalKeys } from '../../keyboard';
|
|
import { MenuProvider as DropdownMenuProvider } from '../dropdown-menu/dropdown-menu-provider.svelte';
|
|
import { FloatingAnchor } from '../../layers/floating';
|
|
|
|
import { menubarMorfo } from '../../../morfo/components/menubar';
|
|
import { Soma } from '../../core/soma.svelte';
|
|
import type { SomaRuntime, SomaRuntimePart } from '../../runtime.svelte';
|
|
|
|
|
|
// ── Root ─────────────────────────────────────────────────────────────────────
|
|
|
|
interface MenubarOpts
|
|
extends
|
|
WithRefOpts,
|
|
StateProps<{ value: string }>,
|
|
ActiveProps<{
|
|
dir: Direction;
|
|
loop: boolean;
|
|
}> {}
|
|
|
|
export class MenubarProvider {
|
|
readonly opts: MenubarOpts;
|
|
readonly runtimePart: SomaRuntimePart;
|
|
readonly soma: Soma;
|
|
readonly runtime: SomaRuntime;
|
|
static readonly ctx = context<MenubarProvider>('Menubar');
|
|
static get(): MenubarProvider | undefined {
|
|
return this.ctx.getOr(undefined) as MenubarProvider | undefined;
|
|
}
|
|
static require(): MenubarProvider {
|
|
return this.ctx.get();
|
|
}
|
|
|
|
static create(opts: MenubarOpts) {
|
|
return new MenubarProvider(opts);
|
|
}
|
|
|
|
/** Registry of trigger nodes keyed by menu value. Preserves insertion order. */
|
|
private triggerRegistry = new Map<string, HTMLElement>();
|
|
|
|
/** Map from menu value → open-change callback. */
|
|
private openChangeListeners = new Map<string, OnChangeFn<boolean>>();
|
|
|
|
private constructor(opts: MenubarOpts) {
|
|
this.opts = opts;
|
|
this.soma = Soma.require();
|
|
this.runtime = this.soma.runtime(menubarMorfo, {});
|
|
this.runtimePart = this.runtime.part('provider', {
|
|
id: opts.id,
|
|
ref: opts.ref,
|
|
owner: this,
|
|
context: MenubarProvider.ctx
|
|
});
|
|
}
|
|
|
|
registerTrigger(value: string, el: HTMLElement) {
|
|
this.triggerRegistry.set(value, el);
|
|
}
|
|
|
|
unregisterTrigger(value: string) {
|
|
this.triggerRegistry.delete(value);
|
|
}
|
|
|
|
registerMenu(value: string, onOpenChange: OnChangeFn<boolean>) {
|
|
this.openChangeListeners.set(value, onOpenChange);
|
|
}
|
|
|
|
unregisterMenu(value: string) {
|
|
this.openChangeListeners.delete(value);
|
|
}
|
|
|
|
/** Menus ordered by trigger registration (DOM order). */
|
|
getOrderedValues(): string[] {
|
|
return [...this.triggerRegistry.keys()].filter((v) => {
|
|
const el = this.triggerRegistry.get(v);
|
|
return el && !el.hasAttribute('data-disabled');
|
|
});
|
|
}
|
|
|
|
/** Open a menu by value; fires onOpenChange for the newly opened and previously open menus. */
|
|
open(value: string) {
|
|
const prev = this.opts.value.current;
|
|
if (prev === value) return;
|
|
this.opts.value.current = value;
|
|
if (prev) this.openChangeListeners.get(prev)?.(false);
|
|
this.openChangeListeners.get(value)?.(true);
|
|
}
|
|
|
|
/** Close the currently open menu. */
|
|
close() {
|
|
const prev = this.opts.value.current;
|
|
if (!prev) return;
|
|
this.opts.value.current = '';
|
|
this.openChangeListeners.get(prev)?.(false);
|
|
}
|
|
|
|
toggle(value: string) {
|
|
if (this.opts.value.current === value) this.close();
|
|
else this.open(value);
|
|
}
|
|
|
|
/** Focus the trigger for the given value. */
|
|
focusTrigger(value: string) {
|
|
this.triggerRegistry.get(value)?.focus();
|
|
}
|
|
|
|
/** Move focus to the next/previous trigger, respecting `loop` and `dir`. */
|
|
navigateAdjacent(currentValue: string, delta: 1 | -1) {
|
|
const values = this.getOrderedValues();
|
|
if (values.length === 0) return;
|
|
const idx = values.indexOf(currentValue);
|
|
let next: number;
|
|
if (idx === -1) {
|
|
next = delta === 1 ? 0 : values.length - 1;
|
|
} else if (this.opts.loop.current) {
|
|
next = (idx + delta + values.length) % values.length;
|
|
} else {
|
|
next = Math.max(0, Math.min(values.length - 1, idx + delta));
|
|
}
|
|
const target = values[next];
|
|
if (target && target !== currentValue) this.focusTrigger(target);
|
|
}
|
|
|
|
/** Focus first or last trigger. */
|
|
focusEdge(edge: 'first' | 'last') {
|
|
const values = this.getOrderedValues();
|
|
if (values.length === 0) return;
|
|
this.focusTrigger(edge === 'first' ? values[0] : values[values.length - 1]);
|
|
}
|
|
|
|
/**
|
|
* True when any menu is currently open. Used to decide whether hovering a
|
|
* sibling trigger should switch the open menu (hover-follow behavior).
|
|
*/
|
|
get isAnyOpen(): boolean {
|
|
return this.opts.value.current !== '';
|
|
}
|
|
|
|
readonly props = $derived.by(() =>
|
|
this.runtimePart.assert({
|
|
...this.runtimePart.props,
|
|
role: 'menubar' as const,
|
|
'aria-orientation': 'horizontal' as const,
|
|
dir: this.opts.dir.current
|
|
} as const)
|
|
);
|
|
}
|
|
|
|
// ── Menu ─────────────────────────────────────────────────────────────────────
|
|
|
|
interface MenubarMenuOpts extends ActiveProps<{
|
|
value: string;
|
|
onOpenChange: OnChangeFn<boolean>;
|
|
onOpenChangeComplete: OnChangeFn<boolean>;
|
|
}> {}
|
|
|
|
/**
|
|
* Scope for a single menu inside the menubar. Owns a nested
|
|
* DropdownMenu.MenuProvider whose `open` is bound to the menubar's `value`.
|
|
* Re-exposes its identifier so `MenubarTrigger` and the existing DropdownMenu
|
|
* parts (Item, Content, Sub, etc.) work unchanged.
|
|
*/
|
|
export class MenubarMenuProvider {
|
|
static readonly ctx = context<MenubarMenuProvider>('MenubarMenu');
|
|
static get(): MenubarMenuProvider | undefined {
|
|
return this.ctx.getOr(undefined) as MenubarMenuProvider | undefined;
|
|
}
|
|
static require(): MenubarMenuProvider {
|
|
return this.ctx.get();
|
|
}
|
|
|
|
static create(opts: MenubarMenuOpts) {
|
|
return MenubarMenuProvider.ctx.set(new MenubarMenuProvider(opts));
|
|
}
|
|
|
|
readonly opts: MenubarMenuOpts;
|
|
readonly provider: MenubarProvider;
|
|
/** Nested DropdownMenu provider — supplies Content/Item/Sub with context. */
|
|
readonly menu: DropdownMenuProvider;
|
|
|
|
private readonly menuId = state(`soma-menubar-menu-${Math.random().toString(36).slice(2, 8)}`);
|
|
|
|
private constructor(opts: MenubarMenuOpts) {
|
|
this.opts = opts;
|
|
this.provider = MenubarProvider.require();
|
|
|
|
// Register the onOpenChange callback with the menubar so it fires when
|
|
// value transitions to/away from ours.
|
|
this.provider.registerMenu(opts.value.current, (v) => opts.onOpenChange.current(v));
|
|
$effect(() => {
|
|
return () => this.provider.unregisterMenu(this.opts.value.current);
|
|
});
|
|
|
|
// `open` for the internal DropdownMenu is a live binding against
|
|
// `menubar.value === this.value`. Writing to it routes back to
|
|
// `menubar.open()` / `menubar.close()`.
|
|
const boundOpen = writableActive(
|
|
() => this.provider.opts.value.current === this.opts.value.current,
|
|
(v) => {
|
|
if (v) this.provider.open(this.opts.value.current);
|
|
else if (this.provider.opts.value.current === this.opts.value.current)
|
|
this.provider.close();
|
|
}
|
|
);
|
|
|
|
this.menu = DropdownMenuProvider.create({
|
|
id: readableActive(() => this.menuId.current),
|
|
ref: state<HTMLElement | null>(null),
|
|
open: boundOpen,
|
|
modal: readableActive(() => false),
|
|
dir: this.provider.opts.dir,
|
|
accessibleWhenDisabled: readableActive(() => true),
|
|
onOpenChangeComplete: opts.onOpenChangeComplete
|
|
});
|
|
}
|
|
|
|
readonly isOpen = $derived.by(() => this.provider.opts.value.current === this.opts.value.current);
|
|
|
|
/**
|
|
* Cross-menu arrow navigation while focus is inside this menu's Content.
|
|
*
|
|
* - `nextKey` (ArrowRight in LTR / ArrowLeft in RTL): close current, open
|
|
* next menu — unless focus is on a `Sub.Trigger` (opens the submenu) or
|
|
* already inside a `Sub.Content` (let DropdownMenu handle it).
|
|
* - `prevKey`: close current, open previous menu — unless focus is inside
|
|
* a `Sub.Content` (closes the submenu instead).
|
|
* - `Escape`: deferred focus restore to this menu's trigger (after
|
|
* DropdownMenu's own dismissal runs).
|
|
*
|
|
* Called by `<Menubar.Content>`'s wrapper as the composed `onkeydown`.
|
|
* Returning here without `preventDefault` lets the consumer's own
|
|
* keydown + DropdownMenu's internal handlers run normally.
|
|
*/
|
|
readonly onContentKeydown = (e: KeyboardEvent): void => {
|
|
if (e.key === KEYS.ESCAPE) {
|
|
const current = this.opts.value.current;
|
|
queueMicrotask(() => this.provider.focusTrigger(current));
|
|
return;
|
|
}
|
|
|
|
const dir = this.provider.opts.dir.current;
|
|
const { nextKey, prevKey } = getDirectionalKeys(dir, 'horizontal');
|
|
if (e.key !== nextKey && e.key !== prevKey) return;
|
|
|
|
const target = e.target as HTMLElement | null;
|
|
if (!target) return;
|
|
|
|
const isSubTrigger = target.hasAttribute('data-dropdown-menu-sub-trigger');
|
|
const isInsideSub = !!target.closest('[data-dropdown-menu-sub-content]');
|
|
if (e.key === nextKey && isSubTrigger) return;
|
|
if (e.key === prevKey && isInsideSub) return;
|
|
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) return;
|
|
|
|
e.preventDefault();
|
|
const currentValue = this.opts.value.current;
|
|
const delta: 1 | -1 = e.key === nextKey ? 1 : -1;
|
|
const values = this.provider.getOrderedValues();
|
|
if (values.length === 0) return;
|
|
|
|
const idx = values.indexOf(currentValue);
|
|
let nextIdx: number;
|
|
if (idx === -1) {
|
|
nextIdx = delta === 1 ? 0 : values.length - 1;
|
|
} else if (this.provider.opts.loop.current) {
|
|
nextIdx = (idx + delta + values.length) % values.length;
|
|
} else {
|
|
nextIdx = Math.max(0, Math.min(values.length - 1, idx + delta));
|
|
}
|
|
const nextValue = values[nextIdx];
|
|
if (!nextValue || nextValue === currentValue) return;
|
|
|
|
this.provider.open(nextValue);
|
|
this.provider.focusTrigger(nextValue);
|
|
};
|
|
}
|
|
|
|
// ── Trigger ──────────────────────────────────────────────────────────────────
|
|
|
|
interface MenubarTriggerOpts extends WithRefOpts, ActiveProps<{ disabled: boolean }> {}
|
|
|
|
export class MenubarTriggerProvider {
|
|
readonly opts: MenubarTriggerOpts;
|
|
readonly runtimePart: SomaRuntimePart;
|
|
static create(opts: MenubarTriggerOpts) {
|
|
return new MenubarTriggerProvider(opts);
|
|
}
|
|
|
|
readonly menu: MenubarMenuProvider;
|
|
readonly provider: MenubarProvider;
|
|
focused = $state(false);
|
|
|
|
private constructor(opts: MenubarTriggerOpts) {
|
|
this.opts = opts;
|
|
this.menu = MenubarMenuProvider.require();
|
|
this.provider = this.menu.provider;
|
|
this.runtimePart = this.provider.runtime.part('trigger', {
|
|
id: opts.id,
|
|
ref: opts.ref,
|
|
owner: this
|
|
});
|
|
|
|
// Pipe the trigger id so the internal DropdownMenu.MenuProvider's
|
|
// content aria-labelledby targets this button.
|
|
this.menu.menu.triggerId.current = opts.id.current;
|
|
|
|
// Register this button as the floating anchor for the nested
|
|
// DropdownMenu content. Without this the Content layer has no reference
|
|
// element and positioning fails (content renders off-screen or at 0,0).
|
|
FloatingAnchor.create({
|
|
id: opts.id,
|
|
ref: readableActive(() => opts.ref.current)
|
|
});
|
|
|
|
// Register/unregister in the menubar trigger registry so cross-menu
|
|
// navigation can locate siblings.
|
|
$effect(() => {
|
|
const el = opts.ref.current;
|
|
const value = this.menu.opts.value.current;
|
|
if (el) this.provider.registerTrigger(value, el);
|
|
return () => this.provider.unregisterTrigger(value);
|
|
});
|
|
}
|
|
|
|
readonly isOpen = $derived.by(() => this.menu.isOpen);
|
|
|
|
/**
|
|
* Roving tab index: the open menu's trigger (or the focused one, or the
|
|
* first one when nothing is active) is the single tab stop.
|
|
*/
|
|
readonly tabindex = $derived.by(() => {
|
|
const values = this.provider.getOrderedValues();
|
|
const myValue = this.menu.opts.value.current;
|
|
const openValue = this.provider.opts.value.current;
|
|
if (openValue && myValue === openValue) return 0;
|
|
if (!openValue && values[0] === myValue) return 0;
|
|
return -1;
|
|
});
|
|
|
|
readonly onpointerdown = (e: SomaMouseEvent<HTMLButtonElement>) => {
|
|
if (this.opts.disabled.current || e.button !== 0 || e.ctrlKey) return;
|
|
// Prevent focus shift before we decide — the click itself toggles.
|
|
if (!this.menu.isOpen) e.preventDefault();
|
|
this.provider.toggle(this.menu.opts.value.current);
|
|
};
|
|
|
|
readonly onpointerenter = () => {
|
|
if (this.opts.disabled.current) return;
|
|
// Hover-follow: only switch when another menu is already open.
|
|
if (this.provider.isAnyOpen && !this.menu.isOpen) {
|
|
this.provider.open(this.menu.opts.value.current);
|
|
this.opts.ref.current?.focus();
|
|
}
|
|
};
|
|
|
|
readonly onkeydown = (e: SomaKeyboardEvent<HTMLButtonElement>) => {
|
|
if (this.opts.disabled.current) return;
|
|
const dir = this.provider.opts.dir.current;
|
|
const { nextKey, prevKey } = getDirectionalKeys(dir, 'horizontal');
|
|
const myValue = this.menu.opts.value.current;
|
|
|
|
if (e.key === KEYS.ENTER || e.key === KEYS.SPACE || e.key === KEYS.ARROW_DOWN) {
|
|
e.preventDefault();
|
|
this.provider.open(myValue);
|
|
return;
|
|
}
|
|
if (e.key === nextKey) {
|
|
e.preventDefault();
|
|
this.provider.navigateAdjacent(myValue, 1);
|
|
return;
|
|
}
|
|
if (e.key === prevKey) {
|
|
e.preventDefault();
|
|
this.provider.navigateAdjacent(myValue, -1);
|
|
return;
|
|
}
|
|
if (e.key === KEYS.HOME) {
|
|
e.preventDefault();
|
|
this.provider.focusEdge('first');
|
|
return;
|
|
}
|
|
if (e.key === KEYS.END) {
|
|
e.preventDefault();
|
|
this.provider.focusEdge('last');
|
|
return;
|
|
}
|
|
};
|
|
|
|
readonly onfocus = (_e: SomaFocusEvent<HTMLButtonElement>) => {
|
|
this.focused = true;
|
|
};
|
|
readonly onblur = (_e: SomaFocusEvent<HTMLButtonElement>) => {
|
|
this.focused = false;
|
|
};
|
|
|
|
readonly props = $derived.by(() =>
|
|
this.runtimePart.assert({
|
|
...this.runtimePart.props,
|
|
type: 'button' as const,
|
|
role: 'menuitem' as const,
|
|
'aria-haspopup': 'menu' as const,
|
|
'aria-expanded': this.isOpen,
|
|
'aria-controls': this.isOpen ? this.menu.menu.contentId.current || undefined : undefined,
|
|
'data-state': getDataOpenClosed(this.isOpen),
|
|
'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current),
|
|
'data-highlighted': boolToEmptyStrOrUndef(this.focused),
|
|
'data-menubar-value': this.menu.opts.value.current,
|
|
disabled: this.opts.disabled.current || undefined,
|
|
tabindex: this.tabindex,
|
|
onpointerdown: this.onpointerdown,
|
|
onpointerenter: this.onpointerenter,
|
|
onkeydown: this.onkeydown,
|
|
onfocus: this.onfocus,
|
|
onblur: this.onblur
|
|
} as const)
|
|
);
|
|
}
|