bits-ui
Version:
The headless components for Svelte.
961 lines (960 loc) • 33.5 kB
JavaScript
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));
}
}