bits-ui
Version:
The headless components for Svelte.
298 lines (297 loc) • 10.6 kB
JavaScript
import { afterTick, box, attachRef, } from "svelte-toolbelt";
import { Context, watch } from "runed";
import { createBitsAttrs, getAriaExpanded, getDataDisabled, 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: box.with(() => "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": getAriaExpanded(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": getDataDisabled(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,
};
}