INIT
This commit is contained in:
+978
@@ -0,0 +1,978 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user