UNPKG

bits-ui

Version:

The headless components for Svelte.

961 lines (960 loc) 33.5 kB
import { afterTick, box, mergeProps, onDestroyEffect, attachRef, DOMContext, getWindow, } from "svelte-toolbelt"; import { Context, watch } from "runed"; import { FIRST_LAST_KEYS, LAST_KEYS, SELECTION_KEYS, SUB_OPEN_KEYS, getCheckedState, isMouseEvent, } from "./utils.js"; import { focusFirst } from "../../internal/focus.js"; import { CustomEventDispatcher } from "../../internal/events.js"; import { isElement, isElementOrSVGElement, isHTMLElement } from "../../internal/is.js"; import { kbd } from "../../internal/kbd.js"; import { createBitsAttrs, getAriaChecked, getAriaDisabled, getAriaExpanded, getAriaOrientation, getDataDisabled, getDataOpenClosed, } from "../../internal/attrs.js"; import { IsUsingKeyboard } from "../../index.js"; import { getTabbableFrom } from "../../internal/tabbable.js"; import { isTabbable } from "tabbable"; import { DOMTypeahead } from "../../internal/dom-typeahead.svelte.js"; import { RovingFocusGroup } from "../../internal/roving-focus-group.js"; import { GraceArea } from "../../internal/grace-area.svelte.js"; import { OpenChangeComplete } from "../../internal/open-change-complete.js"; export const CONTEXT_MENU_TRIGGER_ATTR = "data-context-menu-trigger"; const MenuRootContext = new Context("Menu.Root"); const MenuMenuContext = new Context("Menu.Root | Menu.Sub"); const MenuContentContext = new Context("Menu.Content"); const MenuGroupContext = new Context("Menu.Group | Menu.RadioGroup"); const MenuRadioGroupContext = new Context("Menu.RadioGroup"); export const MenuCheckboxGroupContext = new Context("Menu.CheckboxGroup"); export const MenuOpenEvent = new CustomEventDispatcher("bitsmenuopen", { bubbles: false, cancelable: true, }); export const menuAttrs = createBitsAttrs({ component: "menu", parts: [ "trigger", "content", "sub-trigger", "item", "group", "group-heading", "checkbox-group", "checkbox-item", "radio-group", "radio-item", "separator", "sub-content", "arrow", ], }); export class MenuRootState { static create(opts) { const root = new MenuRootState(opts); return MenuRootContext.set(root); } opts; isUsingKeyboard = new IsUsingKeyboard(); ignoreCloseAutoFocus = $state(false); isPointerInTransit = $state(false); constructor(opts) { this.opts = opts; } getBitsAttr = (part) => { return menuAttrs.getAttr(part, this.opts.variant.current); }; } export class MenuMenuState { static create(opts, root) { return MenuMenuContext.set(new MenuMenuState(opts, root, null)); } opts; root; parentMenu; contentId = box.with(() => ""); contentNode = $state(null); triggerNode = $state(null); constructor(opts, root, parentMenu) { this.opts = opts; this.root = root; this.parentMenu = parentMenu; new OpenChangeComplete({ ref: box.with(() => this.contentNode), open: this.opts.open, onComplete: () => { this.opts.onOpenChangeComplete.current(this.opts.open.current); }, }); if (parentMenu) { watch(() => parentMenu.opts.open.current, () => { if (parentMenu.opts.open.current) return; this.opts.open.current = false; }); } } toggleOpen() { this.opts.open.current = !this.opts.open.current; } onOpen() { this.opts.open.current = true; } onClose() { this.opts.open.current = false; } } export class MenuContentState { static create(opts) { return MenuContentContext.set(new MenuContentState(opts, MenuMenuContext.get())); } opts; parentMenu; rovingFocusGroup; domContext; attachment; search = $state(""); #timer = 0; #handleTypeaheadSearch; mounted = $state(false); #isSub; constructor(opts, parentMenu) { this.opts = opts; this.parentMenu = parentMenu; this.domContext = new DOMContext(opts.ref); this.attachment = attachRef(this.opts.ref, (v) => { if (this.parentMenu.contentNode !== v) { this.parentMenu.contentNode = v; } }); parentMenu.contentId = opts.id; this.#isSub = opts.isSub ?? false; this.onkeydown = this.onkeydown.bind(this); this.onblur = this.onblur.bind(this); this.onfocus = this.onfocus.bind(this); this.handleInteractOutside = this.handleInteractOutside.bind(this); new GraceArea({ contentNode: () => this.parentMenu.contentNode, triggerNode: () => this.parentMenu.triggerNode, enabled: () => this.parentMenu.opts.open.current && Boolean(this.parentMenu.triggerNode?.hasAttribute(this.parentMenu.root.getBitsAttr("sub-trigger"))), onPointerExit: () => { this.parentMenu.opts.open.current = false; }, setIsPointerInTransit: (value) => { this.parentMenu.root.isPointerInTransit = value; }, }); this.#handleTypeaheadSearch = new DOMTypeahead({ getActiveElement: () => this.domContext.getActiveElement(), getWindow: () => this.domContext.getWindow(), }).handleTypeaheadSearch; this.rovingFocusGroup = new RovingFocusGroup({ rootNode: box.with(() => this.parentMenu.contentNode), candidateAttr: this.parentMenu.root.getBitsAttr("item"), loop: this.opts.loop, orientation: box.with(() => "vertical"), }); watch(() => this.parentMenu.contentNode, (contentNode) => { if (!contentNode) return; const handler = () => { afterTick(() => { if (!this.parentMenu.root.isUsingKeyboard.current) return; this.rovingFocusGroup.focusFirstCandidate(); }); }; return MenuOpenEvent.listen(contentNode, handler); }); $effect(() => { if (!this.parentMenu.opts.open.current) { this.domContext.getWindow().clearTimeout(this.#timer); } }); } #getCandidateNodes() { const node = this.parentMenu.contentNode; if (!node) return []; const candidates = Array.from(node.querySelectorAll(`[${this.parentMenu.root.getBitsAttr("item")}]:not([data-disabled])`)); return candidates; } #isPointerMovingToSubmenu() { return this.parentMenu.root.isPointerInTransit; } onCloseAutoFocus = (e) => { this.opts.onCloseAutoFocus.current?.(e); if (e.defaultPrevented || this.#isSub) return; if (this.parentMenu.triggerNode && isTabbable(this.parentMenu.triggerNode)) { this.parentMenu.triggerNode.focus(); } }; handleTabKeyDown(e) { /** * We locate the root `menu`'s trigger by going up the tree until * we find a menu that has no parent. This will allow us to focus the next * tabbable element before/after the root trigger. */ let rootMenu = this.parentMenu; while (rootMenu.parentMenu !== null) { rootMenu = rootMenu.parentMenu; } // if for some unforeseen reason the root menu has no trigger, we bail if (!rootMenu.triggerNode) return; // cancel default tab behavior e.preventDefault(); // find the next/previous tabbable const nodeToFocus = getTabbableFrom(rootMenu.triggerNode, e.shiftKey ? "prev" : "next"); if (nodeToFocus) { /** * We set a flag to ignore the `onCloseAutoFocus` event handler * as well as the fallbacks inside the focus scope to prevent * race conditions causing focus to fall back to the body even * though we're trying to focus the next tabbable element. */ this.parentMenu.root.ignoreCloseAutoFocus = true; rootMenu.onClose(); afterTick(() => { nodeToFocus.focus(); afterTick(() => { this.parentMenu.root.ignoreCloseAutoFocus = false; }); }); } else { this.domContext.getDocument().body.focus(); } } onkeydown(e) { if (e.defaultPrevented) return; if (e.key === kbd.TAB) { this.handleTabKeyDown(e); return; } const target = e.target; const currentTarget = e.currentTarget; if (!isHTMLElement(target) || !isHTMLElement(currentTarget)) return; const isKeydownInside = target.closest(`[${this.parentMenu.root.getBitsAttr("content")}]`)?.id === this.parentMenu.contentId.current; const isModifierKey = e.ctrlKey || e.altKey || e.metaKey; const isCharacterKey = e.key.length === 1; const kbdFocusedEl = this.rovingFocusGroup.handleKeydown(target, e); if (kbdFocusedEl) return; // prevent space from being considered with typeahead if (e.code === "Space") return; const candidateNodes = this.#getCandidateNodes(); if (isKeydownInside) { if (!isModifierKey && isCharacterKey) { this.#handleTypeaheadSearch(e.key, candidateNodes); } } // focus first/last based on key pressed if (e.target?.id !== this.parentMenu.contentId.current) return; if (!FIRST_LAST_KEYS.includes(e.key)) return; e.preventDefault(); if (LAST_KEYS.includes(e.key)) { candidateNodes.reverse(); } focusFirst(candidateNodes, { select: false }, () => this.domContext.getActiveElement()); } onblur(e) { if (!isElement(e.currentTarget)) return; if (!isElement(e.target)) return; // clear search buffer when leaving the menu if (!e.currentTarget.contains?.(e.target)) { this.domContext.getWindow().clearTimeout(this.#timer); this.search = ""; } } onfocus(_) { if (!this.parentMenu.root.isUsingKeyboard.current) return; afterTick(() => this.rovingFocusGroup.focusFirstCandidate()); } onItemEnter() { return this.#isPointerMovingToSubmenu(); } onItemLeave(e) { if (e.currentTarget.hasAttribute(this.parentMenu.root.getBitsAttr("sub-trigger"))) return; if (this.#isPointerMovingToSubmenu() || this.parentMenu.root.isUsingKeyboard.current) return; const contentNode = this.parentMenu.contentNode; contentNode?.focus(); this.rovingFocusGroup.setCurrentTabStopId(""); } onTriggerLeave() { if (this.#isPointerMovingToSubmenu()) return true; return false; } onOpenAutoFocus = (e) => { if (e.defaultPrevented) return; e.preventDefault(); const contentNode = this.parentMenu.contentNode; contentNode?.focus(); }; handleInteractOutside(e) { if (!isElementOrSVGElement(e.target)) return; const triggerId = this.parentMenu.triggerNode?.id; if (e.target.id === triggerId) { e.preventDefault(); return; } if (e.target.closest(`#${triggerId}`)) { e.preventDefault(); } } snippetProps = $derived.by(() => ({ open: this.parentMenu.opts.open.current })); props = $derived.by(() => ({ id: this.opts.id.current, role: "menu", "aria-orientation": getAriaOrientation("vertical"), [this.parentMenu.root.getBitsAttr("content")]: "", "data-state": getDataOpenClosed(this.parentMenu.opts.open.current), onkeydown: this.onkeydown, onblur: this.onblur, onfocus: this.onfocus, dir: this.parentMenu.root.opts.dir.current, style: { pointerEvents: "auto", }, ...this.attachment, })); popperProps = { onCloseAutoFocus: (e) => this.onCloseAutoFocus(e), }; } class MenuItemSharedState { opts; content; attachment; #isFocused = $state(false); constructor(opts, content) { this.opts = opts; this.content = content; this.attachment = attachRef(this.opts.ref); this.onpointermove = this.onpointermove.bind(this); this.onpointerleave = this.onpointerleave.bind(this); this.onfocus = this.onfocus.bind(this); this.onblur = this.onblur.bind(this); } onpointermove(e) { if (e.defaultPrevented) return; if (!isMouseEvent(e)) return; if (this.opts.disabled.current) { this.content.onItemLeave(e); } else { const defaultPrevented = this.content.onItemEnter(); if (defaultPrevented) return; const item = e.currentTarget; if (!isHTMLElement(item)) return; item.focus(); } } onpointerleave(e) { if (e.defaultPrevented) return; if (!isMouseEvent(e)) return; this.content.onItemLeave(e); } onfocus(e) { afterTick(() => { if (e.defaultPrevented || this.opts.disabled.current) return; this.#isFocused = true; }); } onblur(e) { afterTick(() => { if (e.defaultPrevented) return; this.#isFocused = false; }); } props = $derived.by(() => ({ id: this.opts.id.current, tabindex: -1, role: "menuitem", "aria-disabled": getAriaDisabled(this.opts.disabled.current), "data-disabled": getDataDisabled(this.opts.disabled.current), "data-highlighted": this.#isFocused ? "" : undefined, [this.content.parentMenu.root.getBitsAttr("item")]: "", // onpointermove: this.onpointermove, onpointerleave: this.onpointerleave, onfocus: this.onfocus, onblur: this.onblur, ...this.attachment, })); } export class MenuItemState { static create(opts) { const item = new MenuItemSharedState(opts, MenuContentContext.get()); return new MenuItemState(opts, item); } opts; item; root; #isPointerDown = false; constructor(opts, item) { this.opts = opts; this.item = item; this.root = item.content.parentMenu.root; this.onkeydown = this.onkeydown.bind(this); this.onclick = this.onclick.bind(this); this.onpointerdown = this.onpointerdown.bind(this); this.onpointerup = this.onpointerup.bind(this); } #handleSelect() { if (this.item.opts.disabled.current) return; const selectEvent = new CustomEvent("menuitemselect", { bubbles: true, cancelable: true }); this.opts.onSelect.current(selectEvent); if (selectEvent.defaultPrevented) { this.item.content.parentMenu.root.isUsingKeyboard.current = false; return; } if (this.opts.closeOnSelect.current) { this.item.content.parentMenu.root.opts.onClose(); } } onkeydown(e) { const isTypingAhead = this.item.content.search !== ""; if (this.item.opts.disabled.current || (isTypingAhead && e.key === kbd.SPACE)) return; if (SELECTION_KEYS.includes(e.key)) { if (!isHTMLElement(e.currentTarget)) return; e.currentTarget.click(); /** * We prevent default browser behavior for selection keys as they should trigger * a selection only: * - prevents space from scrolling the page. * - if keydown causes focus to move, prevents keydown from firing on the new target. */ e.preventDefault(); } } onclick(_) { if (this.item.opts.disabled.current) return; this.#handleSelect(); } onpointerup(e) { if (e.defaultPrevented) return; if (!this.#isPointerDown) { if (!isHTMLElement(e.currentTarget)) return; e.currentTarget?.click(); } } onpointerdown(_) { this.#isPointerDown = true; } props = $derived.by(() => mergeProps(this.item.props, { onclick: this.onclick, onpointerdown: this.onpointerdown, onpointerup: this.onpointerup, onkeydown: this.onkeydown, })); } export class MenuSubTriggerState { static create(opts) { const content = MenuContentContext.get(); const item = new MenuItemSharedState(opts, content); const submenu = MenuMenuContext.get(); return new MenuSubTriggerState(opts, item, content, submenu); } opts; item; content; submenu; attachment; #openTimer = null; constructor(opts, item, content, submenu) { this.opts = opts; this.item = item; this.content = content; this.submenu = submenu; this.attachment = attachRef(this.opts.ref, (v) => (this.submenu.triggerNode = v)); this.onpointerleave = this.onpointerleave.bind(this); this.onpointermove = this.onpointermove.bind(this); this.onkeydown = this.onkeydown.bind(this); this.onclick = this.onclick.bind(this); onDestroyEffect(() => { this.#clearOpenTimer(); }); } #clearOpenTimer() { if (this.#openTimer === null) return; this.content.domContext.getWindow().clearTimeout(this.#openTimer); this.#openTimer = null; } onpointermove(e) { if (!isMouseEvent(e)) return; if (!this.item.opts.disabled.current && !this.submenu.opts.open.current && !this.#openTimer && !this.content.parentMenu.root.isPointerInTransit) { this.#openTimer = this.content.domContext.setTimeout(() => { this.submenu.onOpen(); this.#clearOpenTimer(); }, 100); } } onpointerleave(e) { if (!isMouseEvent(e)) return; this.#clearOpenTimer(); } onkeydown(e) { const isTypingAhead = this.content.search !== ""; if (this.item.opts.disabled.current || (isTypingAhead && e.key === kbd.SPACE)) return; if (SUB_OPEN_KEYS[this.submenu.root.opts.dir.current].includes(e.key)) { e.currentTarget.click(); e.preventDefault(); } } onclick(e) { if (this.item.opts.disabled.current) return; /** * We manually focus because iOS Safari doesn't always focus on click (e.g. buttons) * and we rely heavily on `onFocusOutside` for submenus to close when switching * between separate submenus. */ if (!isHTMLElement(e.currentTarget)) return; e.currentTarget.focus(); const selectEvent = new CustomEvent("menusubtriggerselect", { bubbles: true, cancelable: true, }); this.opts.onSelect.current(selectEvent); if (!this.submenu.opts.open.current) { this.submenu.onOpen(); afterTick(() => { const contentNode = this.submenu.contentNode; if (!contentNode) return; MenuOpenEvent.dispatch(contentNode); }); } } props = $derived.by(() => mergeProps({ "aria-haspopup": "menu", "aria-expanded": getAriaExpanded(this.submenu.opts.open.current), "data-state": getDataOpenClosed(this.submenu.opts.open.current), "aria-controls": this.submenu.opts.open.current ? this.submenu.contentId.current : undefined, [this.submenu.root.getBitsAttr("sub-trigger")]: "", onclick: this.onclick, onpointermove: this.onpointermove, onpointerleave: this.onpointerleave, onkeydown: this.onkeydown, ...this.attachment, }, this.item.props)); } export class MenuCheckboxItemState { static create(opts, checkboxGroup) { const item = new MenuItemState(opts, new MenuItemSharedState(opts, MenuContentContext.get())); return new MenuCheckboxItemState(opts, item, checkboxGroup); } opts; item; group; constructor(opts, item, group = null) { this.opts = opts; this.item = item; this.group = group; // Watch for value changes in the group if we're part of one if (this.group) { watch(() => this.group.opts.value.current, (groupValues) => { this.opts.checked.current = groupValues.includes(this.opts.value.current); }); // Watch for checked state changes and sync with group watch(() => this.opts.checked.current, (checked) => { if (checked) { this.group.addValue(this.opts.value.current); } else { this.group.removeValue(this.opts.value.current); } }); } } toggleChecked() { if (this.opts.indeterminate.current) { this.opts.indeterminate.current = false; this.opts.checked.current = true; } else { this.opts.checked.current = !this.opts.checked.current; } } snippetProps = $derived.by(() => ({ checked: this.opts.checked.current, indeterminate: this.opts.indeterminate.current, })); props = $derived.by(() => ({ ...this.item.props, role: "menuitemcheckbox", "aria-checked": getAriaChecked(this.opts.checked.current, this.opts.indeterminate.current), "data-state": getCheckedState(this.opts.checked.current), [this.item.root.getBitsAttr("checkbox-item")]: "", })); } export class MenuGroupState { static create(opts) { return MenuGroupContext.set(new MenuGroupState(opts, MenuRootContext.get())); } opts; root; attachment; groupHeadingId = $state(undefined); constructor(opts, root) { this.opts = opts; this.root = root; this.attachment = attachRef(this.opts.ref); } props = $derived.by(() => ({ id: this.opts.id.current, role: "group", "aria-labelledby": this.groupHeadingId, [this.root.getBitsAttr("group")]: "", ...this.attachment, })); } export class MenuGroupHeadingState { static create(opts) { // Try to get checkbox group first, then radio group, then regular group const checkboxGroup = MenuCheckboxGroupContext.getOr(null); if (checkboxGroup) return new MenuGroupHeadingState(opts, checkboxGroup); const radioGroup = MenuRadioGroupContext.getOr(null); if (radioGroup) return new MenuGroupHeadingState(opts, radioGroup); return new MenuGroupHeadingState(opts, MenuGroupContext.get()); } opts; group; attachment; constructor(opts, group) { this.opts = opts; this.group = group; this.attachment = attachRef(this.opts.ref, (v) => (this.group.groupHeadingId = v?.id)); } props = $derived.by(() => ({ id: this.opts.id.current, role: "group", [this.group.root.getBitsAttr("group-heading")]: "", ...this.attachment, })); } export class MenuSeparatorState { static create(opts) { return new MenuSeparatorState(opts, MenuRootContext.get()); } opts; root; attachment; constructor(opts, root) { this.opts = opts; this.root = root; this.attachment = attachRef(this.opts.ref); } props = $derived.by(() => ({ id: this.opts.id.current, role: "group", [this.root.getBitsAttr("separator")]: "", ...this.attachment, })); } export class MenuArrowState { static create() { return new MenuArrowState(MenuRootContext.get()); } root; constructor(root) { this.root = root; } props = $derived.by(() => ({ [this.root.getBitsAttr("arrow")]: "", })); } export class MenuRadioGroupState { static create(opts) { return MenuGroupContext.set(MenuRadioGroupContext.set(new MenuRadioGroupState(opts, MenuContentContext.get()))); } opts; content; attachment; groupHeadingId = $state(null); root; constructor(opts, content) { this.opts = opts; this.content = content; this.root = content.parentMenu.root; this.attachment = attachRef(this.opts.ref); } setValue(v) { this.opts.value.current = v; } props = $derived.by(() => ({ id: this.opts.id.current, [this.root.getBitsAttr("radio-group")]: "", role: "group", "aria-labelledby": this.groupHeadingId, ...this.attachment, })); } export class MenuRadioItemState { static create(opts) { const radioGroup = MenuRadioGroupContext.get(); const sharedItem = new MenuItemSharedState(opts, radioGroup.content); const item = new MenuItemState(opts, sharedItem); return new MenuRadioItemState(opts, item, radioGroup); } opts; item; group; attachment; isChecked = $derived.by(() => this.group.opts.value.current === this.opts.value.current); constructor(opts, item, group) { this.opts = opts; this.item = item; this.group = group; this.attachment = attachRef(this.opts.ref); } selectValue() { this.group.setValue(this.opts.value.current); } props = $derived.by(() => ({ [this.group.root.getBitsAttr("radio-item")]: "", ...this.item.props, role: "menuitemradio", "aria-checked": getAriaChecked(this.isChecked, false), "data-state": getCheckedState(this.isChecked), ...this.attachment, })); } export class DropdownMenuTriggerState { static create(opts) { return new DropdownMenuTriggerState(opts, MenuMenuContext.get()); } opts; parentMenu; attachment; constructor(opts, parentMenu) { this.opts = opts; this.parentMenu = parentMenu; this.attachment = attachRef(this.opts.ref, (v) => (this.parentMenu.triggerNode = v)); } onpointerdown = (e) => { if (this.opts.disabled.current) return; if (e.pointerType === "touch") return e.preventDefault(); if (e.button === 0 && e.ctrlKey === false) { this.parentMenu.toggleOpen(); // prevent trigger focusing when opening to allow // the content to be given focus without competition if (!this.parentMenu.opts.open.current) e.preventDefault(); } }; onpointerup = (e) => { if (this.opts.disabled.current) return; if (e.pointerType === "touch") { e.preventDefault(); this.parentMenu.toggleOpen(); } }; onkeydown = (e) => { if (this.opts.disabled.current) return; if (e.key === kbd.SPACE || e.key === kbd.ENTER) { this.parentMenu.toggleOpen(); e.preventDefault(); return; } if (e.key === kbd.ARROW_DOWN) { this.parentMenu.onOpen(); e.preventDefault(); } }; #ariaControls = $derived.by(() => { if (this.parentMenu.opts.open.current && this.parentMenu.contentId.current) return this.parentMenu.contentId.current; return undefined; }); props = $derived.by(() => ({ id: this.opts.id.current, disabled: this.opts.disabled.current, "aria-haspopup": "menu", "aria-expanded": getAriaExpanded(this.parentMenu.opts.open.current), "aria-controls": this.#ariaControls, "data-disabled": getDataDisabled(this.opts.disabled.current), "data-state": getDataOpenClosed(this.parentMenu.opts.open.current), [this.parentMenu.root.getBitsAttr("trigger")]: "", // onpointerdown: this.onpointerdown, onpointerup: this.onpointerup, onkeydown: this.onkeydown, ...this.attachment, })); } export class ContextMenuTriggerState { static create(opts) { return new ContextMenuTriggerState(opts, MenuMenuContext.get()); } opts; parentMenu; attachment; #point = $state({ x: 0, y: 0 }); virtualElement = box({ getBoundingClientRect: () => DOMRect.fromRect({ width: 0, height: 0, ...this.#point }), }); #longPressTimer = null; constructor(opts, parentMenu) { this.opts = opts; this.parentMenu = parentMenu; this.attachment = attachRef(this.opts.ref, (v) => (this.parentMenu.triggerNode = v)); this.oncontextmenu = this.oncontextmenu.bind(this); this.onpointerdown = this.onpointerdown.bind(this); this.onpointermove = this.onpointermove.bind(this); this.onpointercancel = this.onpointercancel.bind(this); this.onpointerup = this.onpointerup.bind(this); watch(() => this.#point, (point) => { this.virtualElement.current = { getBoundingClientRect: () => DOMRect.fromRect({ width: 0, height: 0, ...point }), }; }); watch(() => this.opts.disabled.current, (isDisabled) => { if (isDisabled) { this.#clearLongPressTimer(); } }); onDestroyEffect(() => this.#clearLongPressTimer()); } #clearLongPressTimer() { if (this.#longPressTimer === null) return; getWindow(this.opts.ref.current).clearTimeout(this.#longPressTimer); } #handleOpen(e) { this.#point = { x: e.clientX, y: e.clientY }; this.parentMenu.onOpen(); } oncontextmenu(e) { if (e.defaultPrevented || this.opts.disabled.current) return; this.#clearLongPressTimer(); this.#handleOpen(e); e.preventDefault(); this.parentMenu.contentNode?.focus(); } onpointerdown(e) { if (this.opts.disabled.current || isMouseEvent(e)) return; this.#clearLongPressTimer(); this.#longPressTimer = getWindow(this.opts.ref.current).setTimeout(() => this.#handleOpen(e), 700); } onpointermove(e) { if (this.opts.disabled.current || isMouseEvent(e)) return; this.#clearLongPressTimer(); } onpointercancel(e) { if (this.opts.disabled.current || isMouseEvent(e)) return; this.#clearLongPressTimer(); } onpointerup(e) { if (this.opts.disabled.current || isMouseEvent(e)) return; this.#clearLongPressTimer(); } props = $derived.by(() => ({ id: this.opts.id.current, disabled: this.opts.disabled.current, "data-disabled": getDataDisabled(this.opts.disabled.current), "data-state": getDataOpenClosed(this.parentMenu.opts.open.current), [CONTEXT_MENU_TRIGGER_ATTR]: "", tabindex: -1, // onpointerdown: this.onpointerdown, onpointermove: this.onpointermove, onpointercancel: this.onpointercancel, onpointerup: this.onpointerup, oncontextmenu: this.oncontextmenu, ...this.attachment, })); } export class MenuCheckboxGroupState { static create(opts) { return MenuCheckboxGroupContext.set(new MenuCheckboxGroupState(opts, MenuContentContext.get())); } opts; content; root; attachment; groupHeadingId = $state(null); constructor(opts, content) { this.opts = opts; this.content = content; this.root = content.parentMenu.root; this.attachment = attachRef(this.opts.ref); } addValue(checkboxValue) { if (!checkboxValue) return; if (!this.opts.value.current.includes(checkboxValue)) { const newValue = [...$state.snapshot(this.opts.value.current), checkboxValue]; this.opts.value.current = newValue; this.opts.onValueChange.current(newValue); } } removeValue(checkboxValue) { if (!checkboxValue) return; const index = this.opts.value.current.indexOf(checkboxValue); if (index === -1) return; const newValue = this.opts.value.current.filter((v) => v !== checkboxValue); this.opts.value.current = newValue; this.opts.onValueChange.current(newValue); } props = $derived.by(() => ({ id: this.opts.id.current, [this.root.getBitsAttr("checkbox-group")]: "", role: "group", "aria-labelledby": this.groupHeadingId, ...this.attachment, })); } export class MenuSubmenuState { static create(opts) { const menu = MenuMenuContext.get(); return MenuMenuContext.set(new MenuMenuState(opts, menu.root, menu)); } }