979 lines
34 KiB
JavaScript
979 lines
34 KiB
JavaScript
import { afterTick, mergeProps, onDestroyEffect, attachRef, DOMContext, getWindow, simpleBox, boxWith, } 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, boolToStr, getDataOpenClosed, boolToEmptyStrOrUndef, } from "../../internal/attrs.js";
|
|
import { IsUsingKeyboard } from "../utilities/is-using-keyboard/is-using-keyboard.svelte.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 { PresenceManager } from "../../internal/presence-manager.svelte.js";
|
|
import { arraysAreEqual } from "../../internal/arrays.js";
|
|
export const CONTEXT_MENU_TRIGGER_ATTR = "data-context-menu-trigger";
|
|
export const CONTEXT_MENU_CONTENT_ATTR = "data-context-menu-content";
|
|
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 = boxWith(() => "");
|
|
contentNode = $state(null);
|
|
contentPresence;
|
|
triggerNode = $state(null);
|
|
constructor(opts, root, parentMenu) {
|
|
this.opts = opts;
|
|
this.root = root;
|
|
this.parentMenu = parentMenu;
|
|
this.contentPresence = new PresenceManager({
|
|
ref: boxWith(() => 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: boxWith(() => this.parentMenu.contentNode),
|
|
candidateAttr: this.parentMenu.root.getBitsAttr("item"),
|
|
loop: this.opts.loop,
|
|
orientation: boxWith(() => "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)) {
|
|
e.preventDefault();
|
|
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;
|
|
}
|
|
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();
|
|
}
|
|
}
|
|
get shouldRender() {
|
|
return this.parentMenu.contentPresence.shouldRender;
|
|
}
|
|
snippetProps = $derived.by(() => ({ open: this.parentMenu.opts.open.current }));
|
|
props = $derived.by(() => ({
|
|
id: this.opts.id.current,
|
|
role: "menu",
|
|
"aria-orientation": "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",
|
|
// CSS containment isolates style/layout/paint calculations from the rest of the page
|
|
contain: "layout style",
|
|
},
|
|
...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": boolToStr(this.opts.disabled.current),
|
|
"data-disabled": boolToEmptyStrOrUndef(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();
|
|
}, this.opts.openDelay.current);
|
|
}
|
|
}
|
|
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": boolToStr(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));
|
|
}
|
|
onclick = (e) => {
|
|
/**
|
|
* MacOS VoiceOver sends a click in Safari/Firefox bypassing the keydown event
|
|
* when V0+Space is pressed. Since we already handle the keydown event and the
|
|
* pointerdown events separately, we ignore it if the detail is not 0.
|
|
*/
|
|
if (this.opts.disabled.current || e.detail !== 0)
|
|
return;
|
|
this.parentMenu.toggleOpen();
|
|
e.preventDefault();
|
|
};
|
|
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": boolToStr(this.parentMenu.opts.open.current),
|
|
"aria-controls": this.#ariaControls,
|
|
"data-disabled": boolToEmptyStrOrUndef(this.opts.disabled.current),
|
|
"data-state": getDataOpenClosed(this.parentMenu.opts.open.current),
|
|
[this.parentMenu.root.getBitsAttr("trigger")]: "",
|
|
//
|
|
onclick: this.onclick,
|
|
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 = simpleBox({
|
|
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": boolToEmptyStrOrUndef(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;
|
|
if (arraysAreEqual(this.opts.value.current, newValue))
|
|
return;
|
|
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;
|
|
if (arraysAreEqual(this.opts.value.current, newValue))
|
|
return;
|
|
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));
|
|
}
|
|
}
|