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/menubar/menubar-provider.svelte.ts

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)
);
}

Powered by TurnKey Linux.