bits-ui
Version:
The headless components for Svelte.
1,394 lines (1,378 loc) • 58.1 kB
JavaScript
import { afterTick, mergeProps, onDestroyEffect, attachRef, DOMContext, getDocument, 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, getDataTransitionAttrs, } 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 { 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",
],
});
/*
interface MenuIntentDebugSnapshot {
active: boolean;
target: IntentTarget | null;
exitPoint: Point | null;
pointerPoint: Point | null;
corridor: Polygon | null;
intentPolygon: Polygon | null;
}
class MenuIntentDebugOverlay {
readonly #enabled: () => boolean;
readonly #getDocument: () => Document | null;
#root: HTMLDivElement | null = null;
#corridorPolygon: SVGPolygonElement | null = null;
#intentPolygon: SVGPolygonElement | null = null;
#exitPoint: SVGCircleElement | null = null;
#pointerPoint: SVGCircleElement | null = null;
constructor(opts: { enabled: () => boolean; getDocument: () => Document | null }) {
this.#enabled = opts.enabled;
this.#getDocument = opts.getDocument;
}
update(state: MenuIntentDebugSnapshot) {
if (!this.#enabled()) {
this.#detach();
return;
}
this.#ensureRoot();
if (!this.#root) return;
if (!state.active || !state.corridor || !state.intentPolygon || !state.exitPoint) {
this.#root.style.display = "none";
return;
}
const color = state.target === "trigger" ? "16 185 129" : "59 130 246";
const strokeColor = `rgb(${color})`;
const fillColor = `rgb(${color} / 0.18)`;
const corridorFill = `rgb(${color} / 0.1)`;
this.#root.style.display = "block";
this.#setPolygon(this.#corridorPolygon, state.corridor, corridorFill, strokeColor, 1.5);
this.#setPolygon(this.#intentPolygon, state.intentPolygon, fillColor, strokeColor, 2);
this.#setPoint(this.#exitPoint, state.exitPoint, strokeColor, 5);
this.#setPoint(
this.#pointerPoint,
state.pointerPoint,
"rgb(15 23 42 / 0.9)",
4,
"rgb(255 255 255 / 0.95)"
);
}
destroy() {
this.#detach();
}
#setPolygon(
node: SVGPolygonElement | null,
points: Polygon | null,
fill: string,
stroke: string,
strokeWidth: number
) {
if (!node || !points || points.length === 0) return;
node.setAttribute("points", polygonToSvgPoints(points));
node.setAttribute("fill", fill);
node.setAttribute("stroke", stroke);
node.setAttribute("stroke-width", `${strokeWidth}`);
node.setAttribute("stroke-dasharray", "6 4");
}
#setPoint(
node: SVGCircleElement | null,
point: Point | null,
fill: string,
radius: number,
stroke = "transparent"
) {
if (!node || !point) return;
node.setAttribute("cx", `${point.x}`);
node.setAttribute("cy", `${point.y}`);
node.setAttribute("r", `${radius}`);
node.setAttribute("fill", fill);
node.setAttribute("stroke", stroke);
node.setAttribute("stroke-width", "1.5");
}
#ensureRoot() {
if (this.#root) return;
const doc = this.#getDocument();
if (!doc?.body) return;
const root = doc.createElement("div");
root.setAttribute("aria-hidden", "true");
root.style.position = "fixed";
root.style.inset = "0";
root.style.pointerEvents = "none";
root.style.zIndex = "2147483647";
root.style.display = "none";
const svg = doc.createElementNS(SVG_NS, "svg");
svg.setAttribute("width", "100%");
svg.setAttribute("height", "100%");
svg.style.overflow = "visible";
const corridorPolygon = doc.createElementNS(SVG_NS, "polygon");
const intentPolygon = doc.createElementNS(SVG_NS, "polygon");
const exitPoint = doc.createElementNS(SVG_NS, "circle");
const pointerPoint = doc.createElementNS(SVG_NS, "circle");
svg.append(corridorPolygon, intentPolygon, exitPoint, pointerPoint);
root.append(svg);
doc.body.append(root);
this.#root = root;
this.#corridorPolygon = corridorPolygon;
this.#intentPolygon = intentPolygon;
this.#exitPoint = exitPoint;
this.#pointerPoint = pointerPoint;
}
#detach() {
this.#root?.remove();
this.#root = null;
this.#corridorPolygon = null;
this.#intentPolygon = null;
this.#exitPoint = null;
this.#pointerPoint = null;
}
}
*/
class MenuSubmenuIntent {
#opts;
// readonly #debugOverlay: MenuIntentDebugOverlay;
#cleanupDocMove = null;
#fallbackTimer = null;
#active = false;
#target = null;
#apex = null;
#pointerPoint = null;
// #corridor: Polygon | null = null;
// #intentPolygon: Polygon | null = null;
#launchPoint = null;
constructor(opts) {
this.#opts = opts;
// this.#debugOverlay = new MenuIntentDebugOverlay({
// enabled: () => this.#opts.debugMode(),
// getDocument: () => getDocument(this.#opts.triggerNode() ?? this.#opts.contentNode()),
// });
watch([opts.triggerNode, opts.contentNode, opts.enabled], ([triggerNode, contentNode, enabled]) => {
this.#reset();
if (!triggerNode || !contentNode || !enabled)
return;
const onTriggerMove = (e) => {
if (!isMouseEvent(e))
return;
this.#launchPoint = { x: e.clientX, y: e.clientY };
if (!this.#active)
this.#preview(e, "content");
};
const onTriggerLeave = (e) => {
if (!isMouseEvent(e))
return;
this.#engage(e, "content");
};
const onContentMove = (e) => {
if (!isMouseEvent(e))
return;
if (!this.#active)
this.#preview(e, "trigger");
};
const onContentLeave = (e) => {
if (!isMouseEvent(e))
return;
if (isElement(e.relatedTarget)) {
const selector = this.#opts.subContentSelector();
const matchedSubContent = e.relatedTarget.closest(selector);
if (matchedSubContent &&
matchedSubContent !== contentNode &&
matchedSubContent.id) {
const isChild = !!contentNode.querySelector(`[aria-controls="${matchedSubContent.id}"]`);
if (isChild) {
return;
}
}
}
this.#engage(e, "trigger");
};
const onTriggerEnter = (e) => {
if (!isMouseEvent(e))
return;
this.#disengage();
};
const onContentEnter = (e) => {
if (!isMouseEvent(e))
return;
this.#disengage();
};
triggerNode.addEventListener("pointermove", onTriggerMove);
triggerNode.addEventListener("pointerleave", onTriggerLeave);
triggerNode.addEventListener("pointerenter", onTriggerEnter);
contentNode.addEventListener("pointermove", onContentMove);
contentNode.addEventListener("pointerleave", onContentLeave);
contentNode.addEventListener("pointerenter", onContentEnter);
return () => {
triggerNode.removeEventListener("pointermove", onTriggerMove);
triggerNode.removeEventListener("pointerleave", onTriggerLeave);
triggerNode.removeEventListener("pointerenter", onTriggerEnter);
contentNode.removeEventListener("pointermove", onContentMove);
contentNode.removeEventListener("pointerleave", onContentLeave);
contentNode.removeEventListener("pointerenter", onContentEnter);
this.#reset();
};
});
onDestroyEffect(() => {
this.#reset();
// this.#debugOverlay.destroy();
});
}
#parentTargetRect() {
const parent = this.#opts.parentContentNode();
if (parent)
return parent.getBoundingClientRect();
return this.#opts.triggerNode()?.getBoundingClientRect() ?? null;
}
#computePolygons(pointerPt, target) {
const triggerNode = this.#opts.triggerNode();
const contentNode = this.#opts.contentNode();
if (!triggerNode || !contentNode)
return null;
const triggerRect = triggerNode.getBoundingClientRect();
const contentRect = contentNode.getBoundingClientRect();
const side = getSide(triggerRect, contentRect);
let apex;
let targetRect;
let sourceRect;
if (target === "content") {
apex = this.#active ? (this.#apex ?? pointerPt) : pointerPt;
targetRect = contentRect;
}
else {
apex = this.#launchPoint ?? pointerPt;
targetRect = this.#parentTargetRect() ?? triggerRect;
sourceRect = contentRect;
}
this.#apex = apex;
return {
corridor: getCorridorPolygon(triggerRect, contentRect, side),
intent: getIntentPolygon(apex, targetRect, side, target, sourceRect),
targetRect,
side,
};
}
#isInSafeZone(pt, corridor, intent) {
return isPointInPolygon(pt, corridor) || isPointInPolygon(pt, intent);
}
#preview(e, target) {
const pt = { x: e.clientX, y: e.clientY };
const geo = this.#computePolygons(pt, target);
if (!geo)
return;
this.#target = target;
this.#pointerPoint = pt;
// this.#corridor = geo.corridor;
// this.#intentPolygon = geo.intent;
// this.#syncDebug();
}
#engage(e, target) {
if (!this.#opts.enabled())
return;
const triggerNode = this.#opts.triggerNode();
const contentNode = this.#opts.contentNode();
if (!triggerNode || !contentNode)
return;
const related = e.relatedTarget;
if (isElement(related)) {
if (target === "content" && contentNode.contains(related))
return;
if (target === "trigger" && triggerNode.contains(related))
return;
}
const pt = { x: e.clientX, y: e.clientY };
const geo = this.#computePolygons(pt, target);
if (!geo)
return;
if (!isInsideRect(pt, geo.targetRect) &&
!this.#isInSafeZone(pt, geo.corridor, geo.intent)) {
this.#clearVisuals();
return;
}
this.#active = true;
this.#target = target;
this.#pointerPoint = pt;
// this.#corridor = geo.corridor;
// this.#intentPolygon = geo.intent;
this.#opts.setIsPointerInTransit(true);
this.#attachDocMove();
this.#startFallback();
// this.#syncDebug();
}
#disengageTimer = null;
#disengage() {
if (!this.#active)
return;
const wasReturning = this.#target === "trigger";
this.#detachDocMove();
this.#clearFallback();
this.#active = false;
this.#clearVisuals();
if (wasReturning) {
this.#clearDisengageTimer();
this.#disengageTimer = setTimeout(() => {
this.#disengageTimer = null;
this.#opts.setIsPointerInTransit(false);
}, 100);
}
else {
this.#opts.setIsPointerInTransit(false);
}
}
#clearDisengageTimer() {
if (this.#disengageTimer === null)
return;
clearTimeout(this.#disengageTimer);
this.#disengageTimer = null;
}
#intentExit() {
const pointerPoint = this.#pointerPoint;
this.#detachDocMove();
this.#clearFallback();
this.#clearDisengageTimer();
this.#active = false;
this.#opts.setIsPointerInTransit(false);
this.#clearVisuals();
this.#opts.onIntentExit(pointerPoint);
}
#reset() {
this.#detachDocMove();
this.#clearFallback();
this.#clearDisengageTimer();
if (this.#active)
this.#opts.setIsPointerInTransit(false);
this.#active = false;
this.#target = null;
this.#apex = null;
this.#pointerPoint = null;
// this.#corridor = null;
// this.#intentPolygon = null;
this.#launchPoint = null;
// this.#syncDebug();
}
#isPointerInDescendantSubContent(pt) {
const contentNode = this.#opts.contentNode();
if (!contentNode)
return false;
const doc = contentNode.ownerDocument;
const el = doc.elementFromPoint(pt.x, pt.y);
if (!el)
return false;
const selector = this.#opts.subContentSelector();
const subContent = el.closest(selector);
if (!subContent || subContent === contentNode)
return false;
if (subContent.id)
return !!contentNode.querySelector(`[aria-controls="${subContent.id}"]`);
return false;
}
#onDocMove = (e) => {
if (!this.#active || !this.#target)
return;
if (!isMouseEvent(e))
return;
const triggerNode = this.#opts.triggerNode();
const contentNode = this.#opts.contentNode();
if (!triggerNode || !contentNode) {
this.#intentExit();
return;
}
this.#clearFallback();
const pt = { x: e.clientX, y: e.clientY };
this.#pointerPoint = pt;
const triggerRect = triggerNode.getBoundingClientRect();
const contentRect = contentNode.getBoundingClientRect();
if (this.#target === "content" && isInsideRect(pt, contentRect)) {
this.#disengage();
return;
}
if (this.#target === "trigger" && isInsideInsetRect(pt, triggerRect, 4)) {
this.#disengage();
return;
}
if (this.#isPointerInDescendantSubContent(pt)) {
this.#startFallback();
return;
}
const geo = this.#computePolygons(pt, this.#target);
if (!geo) {
this.#intentExit();
return;
}
// this.#corridor = geo.corridor;
// this.#intentPolygon = geo.intent;
// this.#syncDebug();
if (this.#isInSafeZone(pt, geo.corridor, geo.intent)) {
this.#startFallback();
return;
}
this.#intentExit();
};
#attachDocMove() {
if (this.#cleanupDocMove)
return;
const doc = getDocument(this.#opts.triggerNode() ?? this.#opts.contentNode());
if (!doc)
return;
doc.addEventListener("pointermove", this.#onDocMove, true);
this.#cleanupDocMove = () => {
doc.removeEventListener("pointermove", this.#onDocMove, true);
this.#cleanupDocMove = null;
};
}
#detachDocMove() {
this.#cleanupDocMove?.();
}
#startFallback() {
this.#clearFallback();
this.#fallbackTimer = setTimeout(() => {
this.#fallbackTimer = null;
if (this.#active)
this.#intentExit();
}, 500);
}
#clearFallback() {
if (this.#fallbackTimer === null)
return;
clearTimeout(this.#fallbackTimer);
this.#fallbackTimer = null;
}
#clearVisuals() {
this.#target = null;
this.#apex = null;
this.#pointerPoint = null;
// this.#corridor = null;
// this.#intentPolygon = null;
// this.#syncDebug();
}
}
/*
function polygonToSvgPoints(points: Polygon): string {
return points.map((point) => `${point.x},${point.y}`).join(" ");
}
*/
function isPointInPolygon(point, polygon) {
const { x, y } = point;
let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const xi = polygon[i].x;
const yi = polygon[i].y;
const xj = polygon[j].x;
const yj = polygon[j].y;
// prettier-ignore
const intersect = ((yi > y) !== (yj > y)) && (x < ((xj - xi) * (y - yi)) / (yj - yi) + xi);
if (intersect)
inside = !inside;
}
return inside;
}
function isInsideRect(point, rect) {
return (point.x >= rect.left &&
point.x <= rect.right &&
point.y >= rect.top &&
point.y <= rect.bottom);
}
function isInsideInsetRect(point, rect, inset) {
return (point.x >= rect.left + inset &&
point.x <= rect.right - inset &&
point.y >= rect.top + inset &&
point.y <= rect.bottom - inset);
}
function getSide(triggerRect, contentRect) {
const triggerCenterX = triggerRect.left + triggerRect.width / 2;
const triggerCenterY = triggerRect.top + triggerRect.height / 2;
const contentCenterX = contentRect.left + contentRect.width / 2;
const contentCenterY = contentRect.top + contentRect.height / 2;
const deltaX = contentCenterX - triggerCenterX;
const deltaY = contentCenterY - triggerCenterY;
if (Math.abs(deltaX) > Math.abs(deltaY)) {
return deltaX > 0 ? "right" : "left";
}
return deltaY > 0 ? "bottom" : "top";
}
function getCorridorPolygon(triggerRect, contentRect, side) {
const buffer = 2;
switch (side) {
case "top":
return [
{ x: Math.min(triggerRect.left, contentRect.left) - buffer, y: triggerRect.top },
{ x: Math.min(triggerRect.left, contentRect.left) - buffer, y: contentRect.bottom },
{
x: Math.max(triggerRect.right, contentRect.right) + buffer,
y: contentRect.bottom,
},
{ x: Math.max(triggerRect.right, contentRect.right) + buffer, y: triggerRect.top },
];
case "bottom":
return [
{ x: Math.min(triggerRect.left, contentRect.left) - buffer, y: triggerRect.bottom },
{ x: Math.min(triggerRect.left, contentRect.left) - buffer, y: contentRect.top },
{ x: Math.max(triggerRect.right, contentRect.right) + buffer, y: contentRect.top },
{
x: Math.max(triggerRect.right, contentRect.right) + buffer,
y: triggerRect.bottom,
},
];
case "left":
return [
{ x: triggerRect.left, y: Math.min(triggerRect.top, contentRect.top) - buffer },
{ x: contentRect.right, y: Math.min(triggerRect.top, contentRect.top) - buffer },
{
x: contentRect.right,
y: Math.max(triggerRect.bottom, contentRect.bottom) + buffer,
},
{
x: triggerRect.left,
y: Math.max(triggerRect.bottom, contentRect.bottom) + buffer,
},
];
case "right":
return [
{ x: triggerRect.right, y: Math.min(triggerRect.top, contentRect.top) - buffer },
{ x: contentRect.left, y: Math.min(triggerRect.top, contentRect.top) - buffer },
{
x: contentRect.left,
y: Math.max(triggerRect.bottom, contentRect.bottom) + buffer,
},
{
x: triggerRect.right,
y: Math.max(triggerRect.bottom, contentRect.bottom) + buffer,
},
];
}
}
function getIntentPolygon(exitPoint, targetRect, side, target, sourceRect) {
const edgeBuffer = 8;
const effectiveSide = target === "trigger" ? flipSide(side) : side;
const top = sourceRect
? Math.min(targetRect.top, sourceRect.top) - edgeBuffer
: targetRect.top - edgeBuffer;
const bottom = sourceRect
? Math.max(targetRect.bottom, sourceRect.bottom) + edgeBuffer
: targetRect.bottom + edgeBuffer;
const left = sourceRect
? Math.min(targetRect.left, sourceRect.left) - edgeBuffer
: targetRect.left - edgeBuffer;
const right = sourceRect
? Math.max(targetRect.right, sourceRect.right) + edgeBuffer
: targetRect.right + edgeBuffer;
switch (effectiveSide) {
case "right":
return [exitPoint, { x: targetRect.left, y: top }, { x: targetRect.left, y: bottom }];
case "left":
return [exitPoint, { x: targetRect.right, y: top }, { x: targetRect.right, y: bottom }];
case "bottom":
return [exitPoint, { x: left, y: targetRect.top }, { x: right, y: targetRect.top }];
case "top":
return [
exitPoint,
{ x: left, y: targetRect.bottom },
{ x: right, y: targetRect.bottom },
];
}
}
function flipSide(side) {
switch (side) {
case "top":
return "bottom";
case "bottom":
return "top";
case "left":
return "right";
case "right":
return "left";
}
}
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);
},
shouldSkipExitAnimation: () => {
if (this.root.opts.variant.current !== "menubar" || this.parentMenu !== null) {
return false;
}
return this.root.opts.shouldSkipExitAnimation?.() ?? false;
},
});
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 MenuSubmenuIntent({
contentNode: () => this.parentMenu.contentNode,
triggerNode: () => this.parentMenu.triggerNode,
parentContentNode: () => this.parentMenu.parentMenu?.contentNode ?? null,
subContentSelector: () => `[${this.parentMenu.root.getBitsAttr("sub-content")}]`,
// debugMode: () => this.parentMenu.root.opts.debugMode.current,
enabled: () => this.parentMenu.opts.open.current &&
Boolean(this.parentMenu.triggerNode?.hasAttribute(this.parentMenu.root.getBitsAttr("sub-trigger"))),
onIntentExit: (pointerPoint) => {
this.parentMenu.opts.open.current = false;
this.#dispatchPointerMoveToHoveredSubTrigger(pointerPoint);
},
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;
}
#dispatchPointerMoveToHoveredSubTrigger(pointerPoint) {
if (!pointerPoint)
return;
const parentContentNode = this.parentMenu.parentMenu?.contentNode;
if (!parentContentNode)
return;
const hoveredNode = this.domContext
.getDocument()
.elementFromPoint(pointerPoint.x, pointerPoint.y);
if (!isElement(hoveredNode))
return;
const hoveredSubTrigger = hoveredNode.closest(`[${this.parentMenu.root.getBitsAttr("sub-trigger")}]`);
if (!hoveredSubTrigger || !parentContentNode.contains(hoveredSubTrigger))
return;
if (hoveredSubTrigger === this.parentMenu.triggerNode)
return;
hoveredSubTrigger.dispatchEvent(new PointerEvent("pointermove", {
bubbles: true,
cancelable: true,
pointerType: "mouse",
clientX: pointerPoint.x,
clientY: pointerPoint.y,
}));
}
onCloseAutoFocus = (e) => {
this.opts.onCloseAutoFocus.current?.(e);
if (e.defaultPrevented || this.#isSub)
return;
if (this.parentMenu.root.ignoreCloseAutoFocus) {
e.preventDefault();
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 (!rootMenu.triggerNode)
return;
e.preventDefault();
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({ preventScroll: true });
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();
return;
}
/**
* when the menu closes due to an outside pointer interaction (for example,
* clicking another dropdown trigger), avoid focusing this menu's trigger
* to prevent stealing focus from the new interaction target.
*/
this.parentMenu.root.ignoreCloseAutoFocus = true;
afterTick(() => {
this.parentMenu.root.ignoreCloseAutoFocus = false;
});
}
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),
...getDataTransitionAttrs(this.parentMenu.contentPresence.transitionStatus),
onkeydown: this.onkeydown,
onblur: this.onblur,
onfocus: this.onfocus,
dir: this.parentMenu.root.opts.dir.current,
style: {
pointerEvents: "auto",
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({ preventScroll: true });
}
}
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.submenu.root.isPointerInTransit) {
if (this.#openTimer !== null)
this.#clearOpenTimer();
return;
}
if (!this.item.opts.disabled.current &&
!this.submenu.opts.open.current &&
!this.#openTimer) {
const openDelay = this.opts.openDelay.current;
if (openDelay <= 0) {
this.submenu.onOpen();
return;
}
this.#openTimer = this.content.domContext.setTimeout(() => {
if (this.submenu.root.isPointerInTransit) {
this.#clearOpenTimer();
return;
}
this.submenu.onOpen();
this.#clearOpenTimer();
}, openDelay);
}
}
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.c