298 lines
10 KiB
JavaScript
298 lines
10 KiB
JavaScript
import { afterTick, boxWith, attachRef, } from "svelte-toolbelt";
|
|
import { Context, watch } from "runed";
|
|
import { createBitsAttrs, boolToStr, boolToEmptyStrOrUndef, getDataOpenClosed, } from "../../internal/attrs.js";
|
|
import { kbd } from "../../internal/kbd.js";
|
|
import { wrapArray } from "../../internal/arrays.js";
|
|
import { onMount } from "svelte";
|
|
import { getFloatingContentCSSVars } from "../../internal/floating-svelte/floating-utils.svelte.js";
|
|
import { RovingFocusGroup } from "../../internal/roving-focus-group.js";
|
|
const menubarAttrs = createBitsAttrs({
|
|
component: "menubar",
|
|
parts: ["root", "trigger", "content"],
|
|
});
|
|
const MenubarRootContext = new Context("Menubar.Root");
|
|
const MenubarMenuContext = new Context("Menubar.Menu");
|
|
export class MenubarRootState {
|
|
static create(opts) {
|
|
return MenubarRootContext.set(new MenubarRootState(opts));
|
|
}
|
|
opts;
|
|
rovingFocusGroup;
|
|
attachment;
|
|
wasOpenedByKeyboard = $state(false);
|
|
triggerIds = $state([]);
|
|
valueToChangeHandler = new Map();
|
|
constructor(opts) {
|
|
this.opts = opts;
|
|
this.attachment = attachRef(this.opts.ref);
|
|
this.rovingFocusGroup = new RovingFocusGroup({
|
|
rootNode: this.opts.ref,
|
|
candidateAttr: menubarAttrs.trigger,
|
|
loop: this.opts.loop,
|
|
orientation: boxWith(() => "horizontal"),
|
|
});
|
|
}
|
|
/**
|
|
* @param id - the id of the trigger to register
|
|
* @returns - a function to de-register the trigger
|
|
*/
|
|
registerTrigger = (id) => {
|
|
this.triggerIds.push(id);
|
|
return () => {
|
|
this.triggerIds = this.triggerIds.filter((triggerId) => triggerId !== id);
|
|
};
|
|
};
|
|
/**
|
|
* @param value - the value of the menu to register
|
|
* @param contentId - the content id to associate with the value
|
|
* @returns - a function to de-register the menu
|
|
*/
|
|
registerMenu = (value, onOpenChange) => {
|
|
this.valueToChangeHandler.set(value, onOpenChange);
|
|
return () => {
|
|
this.valueToChangeHandler.delete(value);
|
|
};
|
|
};
|
|
updateValue = (value) => {
|
|
const currValue = this.opts.value.current;
|
|
const currHandler = this.valueToChangeHandler.get(currValue)?.current;
|
|
const nextHandler = this.valueToChangeHandler.get(value)?.current;
|
|
this.opts.value.current = value;
|
|
if (currHandler && currValue !== value) {
|
|
currHandler(false);
|
|
}
|
|
if (nextHandler) {
|
|
nextHandler(true);
|
|
}
|
|
};
|
|
getTriggers = () => {
|
|
const node = this.opts.ref.current;
|
|
if (!node)
|
|
return [];
|
|
return Array.from(node.querySelectorAll(menubarAttrs.selector("trigger")));
|
|
};
|
|
onMenuOpen = (id, triggerId) => {
|
|
this.updateValue(id);
|
|
this.rovingFocusGroup.setCurrentTabStopId(triggerId);
|
|
};
|
|
onMenuClose = () => {
|
|
this.updateValue("");
|
|
};
|
|
onMenuToggle = (id) => {
|
|
this.updateValue(this.opts.value.current ? "" : id);
|
|
};
|
|
props = $derived.by(() => ({
|
|
id: this.opts.id.current,
|
|
role: "menubar",
|
|
[menubarAttrs.root]: "",
|
|
...this.attachment,
|
|
}));
|
|
}
|
|
export class MenubarMenuState {
|
|
static create(opts) {
|
|
return MenubarMenuContext.set(new MenubarMenuState(opts, MenubarRootContext.get()));
|
|
}
|
|
opts;
|
|
root;
|
|
open = $derived.by(() => this.root.opts.value.current === this.opts.value.current);
|
|
wasOpenedByKeyboard = false;
|
|
triggerNode = $state(null);
|
|
triggerId = $derived.by(() => this.triggerNode?.id);
|
|
contentId = $derived.by(() => this.contentNode?.id);
|
|
contentNode = $state(null);
|
|
constructor(opts, root) {
|
|
this.opts = opts;
|
|
this.root = root;
|
|
watch(() => this.open, () => {
|
|
if (!this.open) {
|
|
this.wasOpenedByKeyboard = false;
|
|
}
|
|
});
|
|
onMount(() => {
|
|
return this.root.registerMenu(this.opts.value.current, opts.onOpenChange);
|
|
});
|
|
}
|
|
getTriggerNode() {
|
|
return this.triggerNode;
|
|
}
|
|
toggleMenu() {
|
|
this.root.onMenuToggle(this.opts.value.current);
|
|
}
|
|
openMenu() {
|
|
this.root.onMenuOpen(this.opts.value.current, this.triggerNode?.id ?? "");
|
|
}
|
|
}
|
|
export class MenubarTriggerState {
|
|
static create(opts) {
|
|
return new MenubarTriggerState(opts, MenubarMenuContext.get());
|
|
}
|
|
opts;
|
|
menu;
|
|
root;
|
|
attachment;
|
|
isFocused = $state(false);
|
|
#tabIndex = $state(0);
|
|
constructor(opts, menu) {
|
|
this.opts = opts;
|
|
this.menu = menu;
|
|
this.root = menu.root;
|
|
this.attachment = attachRef(this.opts.ref, (v) => (this.menu.triggerNode = v));
|
|
onMount(() => {
|
|
return this.root.registerTrigger(opts.id.current);
|
|
});
|
|
$effect(() => {
|
|
if (this.root.triggerIds.length) {
|
|
this.#tabIndex = this.root.rovingFocusGroup.getTabIndex(this.menu.getTriggerNode());
|
|
}
|
|
});
|
|
}
|
|
onpointerdown = (e) => {
|
|
// only call if the left button but not when the CTRL key is pressed
|
|
if (!this.opts.disabled.current && e.button === 0 && e.ctrlKey === false) {
|
|
// prevent trigger from focusing when opening
|
|
// which allows the content to focus without competition
|
|
if (!this.menu.open) {
|
|
e.preventDefault();
|
|
}
|
|
this.menu.toggleMenu();
|
|
}
|
|
};
|
|
onpointerenter = () => {
|
|
const isMenubarOpen = Boolean(this.root.opts.value.current);
|
|
if (isMenubarOpen && !this.menu.open) {
|
|
this.menu.openMenu();
|
|
this.menu.getTriggerNode()?.focus();
|
|
}
|
|
};
|
|
onkeydown = (e) => {
|
|
if (this.opts.disabled.current)
|
|
return;
|
|
if (e.key === kbd.TAB)
|
|
return;
|
|
if (e.key === kbd.ENTER || e.key === kbd.SPACE) {
|
|
this.root.onMenuToggle(this.menu.opts.value.current);
|
|
}
|
|
if (e.key === kbd.ARROW_DOWN) {
|
|
this.menu.openMenu();
|
|
}
|
|
// prevent keydown from scrolling window / first focused item
|
|
// from inadvertently closing the menu
|
|
if (e.key === kbd.ENTER || e.key === kbd.SPACE || e.key === kbd.ARROW_DOWN) {
|
|
this.menu.wasOpenedByKeyboard = true;
|
|
e.preventDefault();
|
|
}
|
|
this.root.rovingFocusGroup.handleKeydown(this.menu.getTriggerNode(), e);
|
|
};
|
|
onfocus = () => {
|
|
this.isFocused = true;
|
|
};
|
|
onblur = () => {
|
|
this.isFocused = false;
|
|
};
|
|
props = $derived.by(() => ({
|
|
type: "button",
|
|
role: "menuitem",
|
|
id: this.opts.id.current,
|
|
"aria-haspopup": "menu",
|
|
"aria-expanded": boolToStr(this.menu.open),
|
|
"aria-controls": this.menu.open ? this.menu.contentId : undefined,
|
|
"data-highlighted": this.isFocused ? "" : undefined,
|
|
"data-state": getDataOpenClosed(this.menu.open),
|
|
"data-disabled": boolToEmptyStrOrUndef(this.opts.disabled.current),
|
|
"data-menu-value": this.menu.opts.value.current,
|
|
disabled: this.opts.disabled.current ? true : undefined,
|
|
tabindex: this.#tabIndex,
|
|
[menubarAttrs.trigger]: "",
|
|
onpointerdown: this.onpointerdown,
|
|
onpointerenter: this.onpointerenter,
|
|
onkeydown: this.onkeydown,
|
|
onfocus: this.onfocus,
|
|
onblur: this.onblur,
|
|
...this.attachment,
|
|
}));
|
|
}
|
|
export class MenubarContentState {
|
|
static create(opts) {
|
|
return new MenubarContentState(opts, MenubarMenuContext.get());
|
|
}
|
|
opts;
|
|
menu;
|
|
root;
|
|
attachment;
|
|
constructor(opts, menu) {
|
|
this.opts = opts;
|
|
this.menu = menu;
|
|
this.root = menu.root;
|
|
this.attachment = attachRef(this.opts.ref, (v) => (this.menu.contentNode = v));
|
|
}
|
|
onCloseAutoFocus = (e) => {
|
|
this.opts.onCloseAutoFocus.current?.(e);
|
|
if (e.defaultPrevented)
|
|
return;
|
|
};
|
|
onFocusOutside = (e) => {
|
|
const target = e.target;
|
|
const isMenubarTrigger = this.root
|
|
.getTriggers()
|
|
.some((trigger) => trigger.contains(target));
|
|
if (isMenubarTrigger)
|
|
e.preventDefault();
|
|
this.opts.onFocusOutside.current(e);
|
|
};
|
|
onInteractOutside = (e) => {
|
|
this.opts.onInteractOutside.current(e);
|
|
};
|
|
onOpenAutoFocus = (e) => {
|
|
this.opts.onOpenAutoFocus.current(e);
|
|
if (e.defaultPrevented)
|
|
return;
|
|
afterTick(() => this.opts.ref.current?.focus());
|
|
};
|
|
onkeydown = (e) => {
|
|
if (e.key !== kbd.ARROW_LEFT && e.key !== kbd.ARROW_RIGHT)
|
|
return;
|
|
const target = e.target;
|
|
const targetIsSubTrigger = target.hasAttribute("data-menu-sub-trigger");
|
|
const isKeydownInsideSubMenu = target.closest("[data-menu-content]") !== e.currentTarget;
|
|
const prevMenuKey = this.root.opts.dir.current === "rtl" ? kbd.ARROW_RIGHT : kbd.ARROW_LEFT;
|
|
const isPrevKey = prevMenuKey === e.key;
|
|
const isNextKey = !isPrevKey;
|
|
// prevent navigation when opening a submenu
|
|
if (isNextKey && targetIsSubTrigger)
|
|
return;
|
|
// or if we're inside a submenu and moving back to close it
|
|
if (isKeydownInsideSubMenu && isPrevKey)
|
|
return;
|
|
const items = this.root.getTriggers().filter((trigger) => !trigger.disabled);
|
|
let candidates = items.map((item) => ({
|
|
value: item.getAttribute("data-menu-value"),
|
|
triggerId: item.id ?? "",
|
|
}));
|
|
if (isPrevKey)
|
|
candidates.reverse();
|
|
const candidateValues = candidates.map(({ value }) => value);
|
|
const currentIndex = candidateValues.indexOf(this.menu.opts.value.current);
|
|
candidates = this.root.opts.loop.current
|
|
? wrapArray(candidates, currentIndex + 1)
|
|
: candidates.slice(currentIndex + 1);
|
|
const [nextValue] = candidates;
|
|
if (nextValue)
|
|
this.menu.root.onMenuOpen(nextValue.value, nextValue.triggerId);
|
|
};
|
|
props = $derived.by(() => ({
|
|
id: this.opts.id.current,
|
|
"aria-labelledby": this.menu.triggerId,
|
|
style: getFloatingContentCSSVars("menubar"),
|
|
onkeydown: this.onkeydown,
|
|
"data-menu-content": "",
|
|
[menubarAttrs.content]: "",
|
|
...this.attachment,
|
|
}));
|
|
popperProps = {
|
|
onCloseAutoFocus: this.onCloseAutoFocus,
|
|
onFocusOutside: this.onFocusOutside,
|
|
onInteractOutside: this.onInteractOutside,
|
|
onOpenAutoFocus: this.onOpenAutoFocus,
|
|
};
|
|
}
|