UNPKG

bits-ui

Version:

The headless components for Svelte.

298 lines (297 loc) 10.6 kB
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, }; }