@react-marking-menu/core
Version:
Headless React primitives for marking menus with gestural interaction
1,046 lines (1,030 loc) • 30.8 kB
JavaScript
// src/hooks/useMarkingMenuStateMachine.ts
import { useState, useCallback, useRef } from "react";
function useMarkingMenuStateMachine() {
const [state, setState] = useState("idle");
const [origin, setOrigin] = useState(null);
const [currentDirection, setCurrentDirection] = useState(null);
const [selectedItem, setSelectedItem] = useState(null);
const pressTimerRef = useRef(null);
const startPress = useCallback((position, delay) => {
setState("pressed");
setOrigin(position);
setCurrentDirection(null);
setSelectedItem(null);
pressTimerRef.current = setTimeout(() => {
setState("active");
}, delay);
}, []);
const startImmediate = useCallback((position) => {
setState("active");
setOrigin(position);
setCurrentDirection(null);
setSelectedItem(null);
}, []);
const updatePosition = useCallback((direction) => {
setState((currentState) => {
if (currentState === "active" || currentState === "selecting") {
setCurrentDirection(direction);
if (direction !== null && currentState === "active") {
return "selecting";
}
}
return currentState;
});
}, []);
const endPress = useCallback((itemId) => {
if (pressTimerRef.current) {
clearTimeout(pressTimerRef.current);
pressTimerRef.current = null;
}
setSelectedItem(itemId);
setState("idle");
setOrigin(null);
setCurrentDirection(null);
return itemId;
}, []);
const cancel = useCallback(() => {
if (pressTimerRef.current) {
clearTimeout(pressTimerRef.current);
pressTimerRef.current = null;
}
setState("idle");
setOrigin(null);
setCurrentDirection(null);
setSelectedItem(null);
}, []);
return {
state,
origin,
currentDirection,
selectedItem,
startPress,
startImmediate,
updatePosition,
endPress,
cancel
};
}
// src/hooks/useMarkingMenuGesture.ts
import { useCallback as useCallback2, useRef as useRef2, useEffect } from "react";
// src/utils/directions.ts
function getDirectionFromPosition(x, y, originX, originY, directions = 8) {
const dx = x - originX;
const dy = y - originY;
let angle = Math.atan2(dy, dx) * (180 / Math.PI);
angle = (angle + 360) % 360;
if (directions === 8) {
return getDirection8(angle);
} else {
return getDirection4(angle);
}
}
function getDirection8(angle) {
const normalized = (angle + 22.5) % 360;
if (normalized < 45) return "E";
if (normalized < 90) return "SE";
if (normalized < 135) return "S";
if (normalized < 180) return "SW";
if (normalized < 225) return "W";
if (normalized < 270) return "NW";
if (normalized < 315) return "N";
return "NE";
}
function getDirection4(angle) {
const normalized = (angle + 45) % 360;
if (normalized < 90) return "E";
if (normalized < 180) return "S";
if (normalized < 270) return "W";
return "N";
}
function directionToAngle(direction) {
const angles = {
E: 0,
SE: 45,
S: 90,
SW: 135,
W: 180,
NW: 225,
N: 270,
NE: 315
};
return angles[direction] ?? 0;
}
function getDistance(x1, y1, x2, y2) {
const dx = x2 - x1;
const dy = y2 - y1;
return Math.sqrt(dx * dx + dy * dy);
}
function getAvailableDirections(directions) {
if (directions === 4) {
return ["N", "E", "S", "W"];
}
return ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
}
// src/utils/keyboard.ts
var ARROW_KEYS = [
"ArrowUp",
"ArrowRight",
"ArrowDown",
"ArrowLeft"
];
function isArrowKey(key) {
return ARROW_KEYS.includes(key);
}
function getDirectionFromSingleKey(key) {
const keyMap = {
ArrowUp: "N",
ArrowRight: "E",
ArrowDown: "S",
ArrowLeft: "W"
};
return keyMap[key];
}
function areOppositeKeys(key1, key2) {
return key1 === "ArrowUp" && key2 === "ArrowDown" || key1 === "ArrowDown" && key2 === "ArrowUp" || key1 === "ArrowLeft" && key2 === "ArrowRight" || key1 === "ArrowRight" && key2 === "ArrowLeft";
}
function getDirectionFromTwoKeys(key1, key2) {
if (key1 === key2) {
return getDirectionFromSingleKey(key1);
}
if (areOppositeKeys(key1, key2)) {
return getDirectionFromSingleKey(key2);
}
const diagonalMap = {
"ArrowUp+ArrowRight": "NE",
"ArrowRight+ArrowUp": "NE",
"ArrowDown+ArrowRight": "SE",
"ArrowRight+ArrowDown": "SE",
"ArrowDown+ArrowLeft": "SW",
"ArrowLeft+ArrowDown": "SW",
"ArrowUp+ArrowLeft": "NW",
"ArrowLeft+ArrowUp": "NW"
};
const combo = `${key1}+${key2}`;
const direction = diagonalMap[combo];
if (direction) {
return direction;
}
return getDirectionFromSingleKey(key2);
}
function getDirectionFromKeys(pressedKeys) {
if (pressedKeys.length === 0) {
return null;
}
if (pressedKeys.length === 1) {
return getDirectionFromSingleKey(pressedKeys[0]);
}
const last2Keys = pressedKeys.slice(-2);
return getDirectionFromTwoKeys(last2Keys[0], last2Keys[1]);
}
function createKeyboardState() {
return {
pressedKeys: [],
isReleasing: false,
latchedDirection: null
};
}
function handleKeyDown(state, key) {
if (!isArrowKey(key)) {
return { state, direction: getDirectionFromKeys(state.pressedKeys) };
}
if (state.pressedKeys.includes(key)) {
return { state, direction: getDirectionFromKeys(state.pressedKeys) };
}
const newPressedKeys = [...state.pressedKeys, key];
const direction = getDirectionFromKeys(newPressedKeys);
return {
state: {
...state,
pressedKeys: newPressedKeys
},
direction
};
}
function handleKeyUp(state, key) {
if (!isArrowKey(key)) {
return {
state,
direction: state.isReleasing ? state.latchedDirection : getDirectionFromKeys(state.pressedKeys),
allKeysReleased: false
};
}
const newPressedKeys = state.pressedKeys.filter((k) => k !== key);
let isReleasing = state.isReleasing;
let latchedDirection = state.latchedDirection;
if (!state.isReleasing && state.pressedKeys.length >= 2 && newPressedKeys.length < 2) {
isReleasing = true;
latchedDirection = getDirectionFromKeys(state.pressedKeys);
}
if (newPressedKeys.length === 0) {
return {
state: createKeyboardState(),
// Reset state
direction: latchedDirection || getDirectionFromKeys(state.pressedKeys),
allKeysReleased: true
};
}
const direction = isReleasing ? latchedDirection : getDirectionFromKeys(newPressedKeys);
return {
state: {
pressedKeys: newPressedKeys,
isReleasing,
latchedDirection
},
direction,
allKeysReleased: false
};
}
function resetKeyboardState() {
return createKeyboardState();
}
// src/hooks/useMarkingMenuGesture.ts
function useMarkingMenuGesture({
config = {},
items,
onSelect,
onCancel,
enabled = true
}) {
const {
pressThreshold = 150,
minDistance = 50,
directions = 8,
preventContextMenu = true,
originMode = "element"
} = config;
const stateMachine = useMarkingMenuStateMachine();
const {
state,
origin,
currentDirection,
selectedItem,
startPress,
startImmediate,
updatePosition,
endPress,
cancel
} = stateMachine;
const keyboardStateRef = useRef2(createKeyboardState());
const pointerIdRef = useRef2(null);
const triggerElementRef = useRef2(null);
const pointerDownTimerRef = useRef2(null);
const isPointerGestureActiveRef = useRef2(false);
const calculateOrigin = useCallback2(
(event, element) => {
switch (originMode) {
case "cursor":
if (event && "clientX" in event) {
return { x: event.clientX, y: event.clientY };
}
return calculateElementCenter(element);
case "viewport":
return {
x: window.innerWidth / 2,
y: window.innerHeight / 2
};
case "element":
default:
return calculateElementCenter(element);
}
},
[originMode]
);
const calculateElementCenter = (element) => {
if (element) {
const rect = element.getBoundingClientRect();
return {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2
};
}
return {
x: window.innerWidth / 2,
y: window.innerHeight / 2
};
};
const findItemByDirection = useCallback2(
(direction) => {
return items.find((item) => item.direction === direction && !item.disabled) || null;
},
[items]
);
const executeSelection = useCallback2(
(direction) => {
if (!direction) {
endPress(null);
return;
}
const item = findItemByDirection(direction);
if (item) {
endPress(item.id);
onSelect?.(item.id);
item.onSelect?.();
} else {
endPress(null);
}
},
[endPress, findItemByDirection, onSelect]
);
const handlePointerDown = useCallback2(
(e) => {
if (!enabled) return;
if (e.button !== 0) return;
if (pointerIdRef.current !== null) return;
e.preventDefault();
triggerElementRef.current = e.currentTarget;
pointerIdRef.current = e.pointerId;
e.target.setPointerCapture?.(e.pointerId);
const originPos = calculateOrigin(e, triggerElementRef.current);
isPointerGestureActiveRef.current = false;
pointerDownTimerRef.current = setTimeout(() => {
isPointerGestureActiveRef.current = true;
startPress(originPos, pressThreshold);
keyboardStateRef.current = createKeyboardState();
}, pressThreshold);
},
[enabled, startPress, pressThreshold, calculateOrigin]
);
const handlePointerMove = useCallback2(
(e) => {
if (!enabled) return;
if (pointerIdRef.current !== e.pointerId) return;
if (state !== "active" && state !== "selecting") return;
if (!origin) return;
e.preventDefault();
const distance = getDistance(origin.x, origin.y, e.clientX, e.clientY);
if (distance < minDistance) {
updatePosition(null);
} else {
const direction = getDirectionFromPosition(
e.clientX,
e.clientY,
origin.x,
origin.y,
directions
);
updatePosition(direction);
}
},
[enabled, state, origin, minDistance, directions, updatePosition]
);
const handlePointerUp = useCallback2(
(e) => {
if (!enabled) return;
if (pointerIdRef.current !== e.pointerId) return;
if (pointerDownTimerRef.current) {
clearTimeout(pointerDownTimerRef.current);
pointerDownTimerRef.current = null;
}
;
e.target.releasePointerCapture?.(e.pointerId);
pointerIdRef.current = null;
if (isPointerGestureActiveRef.current) {
executeSelection(currentDirection);
isPointerGestureActiveRef.current = false;
} else {
cancel();
}
},
[enabled, executeSelection, currentDirection, cancel]
);
const handlePointerCancel = useCallback2(
(e) => {
if (!enabled) return;
if (pointerIdRef.current !== e.pointerId) return;
if (pointerDownTimerRef.current) {
clearTimeout(pointerDownTimerRef.current);
pointerDownTimerRef.current = null;
}
;
e.target.releasePointerCapture?.(e.pointerId);
pointerIdRef.current = null;
isPointerGestureActiveRef.current = false;
cancel();
onCancel?.();
},
[enabled, cancel, onCancel]
);
const handleKeyDown2 = useCallback2(
(e) => {
if (!enabled) return;
if (!triggerElementRef.current) {
triggerElementRef.current = e.currentTarget;
}
if (e.key === "Escape") {
e.preventDefault();
cancel();
keyboardStateRef.current = createKeyboardState();
onCancel?.();
return;
}
if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) {
return;
}
if (!isArrowKey(e.key)) return;
e.preventDefault();
const result = handleKeyDown(keyboardStateRef.current, e.key);
keyboardStateRef.current = result.state;
if (result.direction) {
if (state === "idle") {
const originPos = calculateOrigin(e, triggerElementRef.current);
startImmediate(originPos);
}
updatePosition(result.direction);
}
},
[enabled, state, cancel, startImmediate, updatePosition, calculateOrigin]
);
const handleKeyUp2 = useCallback2(
(e) => {
if (!enabled) return;
if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) {
return;
}
if (!isArrowKey(e.key)) return;
e.preventDefault();
const result = handleKeyUp(keyboardStateRef.current, e.key);
keyboardStateRef.current = result.state;
if (result.allKeysReleased) {
executeSelection(result.direction);
} else if (result.direction) {
updatePosition(result.direction);
}
},
[enabled, updatePosition, executeSelection]
);
const handleContextMenu = useCallback2(
(e) => {
if (preventContextMenu) {
e.preventDefault();
}
},
[preventContextMenu]
);
const getTriggerProps = useCallback2(() => {
const baseProps = {
onPointerDown: handlePointerDown,
onPointerMove: handlePointerMove,
onPointerUp: handlePointerUp,
onPointerCancel: handlePointerCancel,
onKeyDown: handleKeyDown2,
onKeyUp: handleKeyUp2,
tabIndex: 0
};
if (preventContextMenu) {
return {
...baseProps,
onContextMenu: handleContextMenu
};
}
return baseProps;
}, [
handlePointerDown,
handlePointerMove,
handlePointerUp,
handlePointerCancel,
handleKeyDown2,
handleKeyUp2,
handleContextMenu,
preventContextMenu
]);
useEffect(() => {
return () => {
if (pointerDownTimerRef.current) {
clearTimeout(pointerDownTimerRef.current);
pointerDownTimerRef.current = null;
}
keyboardStateRef.current = createKeyboardState();
};
}, []);
return {
state,
origin,
currentDirection,
selectedItem,
getTriggerProps,
keyboardState: keyboardStateRef.current
};
}
// src/hooks/useMarkingMenuContext.ts
import { useContext } from "react";
// src/components/MarkingMenu.tsx
import { createContext, useState as useState2, useCallback as useCallback3, useMemo, useEffect as useEffect3 } from "react";
// src/components/LiveRegion.tsx
import React, { useEffect as useEffect2, useRef as useRef3 } from "react";
import { jsx } from "react/jsx-runtime";
function LiveRegion({
message,
politeness = "polite",
clearOnAnnounce = true,
clearDelay = 1e3
}) {
const messageRef = useRef3("");
const timeoutRef = useRef3(null);
const [currentMessage, setCurrentMessage] = React.useState("");
useEffect2(() => {
if (message && message !== messageRef.current) {
messageRef.current = message;
setCurrentMessage(message);
if (clearOnAnnounce) {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
setCurrentMessage("");
messageRef.current = "";
}, clearDelay);
}
}
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, [message, clearOnAnnounce, clearDelay]);
return /* @__PURE__ */ jsx(
"div",
{
role: "status",
"aria-live": politeness,
"aria-atomic": "true",
style: {
position: "absolute",
width: "1px",
height: "1px",
padding: 0,
margin: "-1px",
overflow: "hidden",
clip: "rect(0, 0, 0, 0)",
whiteSpace: "nowrap",
border: 0
},
children: currentMessage
}
);
}
LiveRegion.displayName = "LiveRegion";
// src/components/MarkingMenu.tsx
import { jsx as jsx2, jsxs } from "react/jsx-runtime";
var MarkingMenuContext = createContext(null);
function MarkingMenu({
children,
config,
a11y,
onSelect,
onCancel,
disabled = false
}) {
const [items, setItems] = useState2([]);
const [announcement, setAnnouncement] = useState2("");
const defaultConfig = {
pressThreshold: config?.pressThreshold ?? 150,
minDistance: config?.minDistance ?? 50,
directions: config?.directions ?? 8,
preventContextMenu: config?.preventContextMenu ?? true,
originMode: config?.originMode ?? "element"
};
const defaultA11y = {
announcements: a11y?.announcements ?? true,
messages: {
menuOpened: a11y?.messages?.menuOpened ?? "Marking menu opened",
directionChanged: a11y?.messages?.directionChanged ?? ((direction) => `${direction} direction selected`),
itemSelected: a11y?.messages?.itemSelected ?? ((label) => `${label} selected`),
menuCancelled: a11y?.messages?.menuCancelled ?? "Menu cancelled"
},
label: a11y?.label ?? "Marking menu",
description: a11y?.description ?? "Press and hold, then drag or use arrow keys to select an action"
};
const registerItem = useCallback3((item) => {
setItems((prev) => {
const existingIndex = prev.findIndex((i) => i.id === item.id);
if (existingIndex >= 0) {
const newItems = [...prev];
newItems[existingIndex] = item;
return newItems;
}
return [...prev, item];
});
}, []);
const unregisterItem = useCallback3((id) => {
setItems((prev) => prev.filter((item) => item.id !== id));
}, []);
const gesture = useMarkingMenuGesture({
config: defaultConfig,
items,
onSelect,
onCancel,
enabled: !disabled
});
const contextValue = useMemo(
() => ({
state: gesture.state,
origin: gesture.origin,
currentDirection: gesture.currentDirection,
selectedItem: gesture.selectedItem,
config: defaultConfig,
a11y: defaultA11y,
items,
registerItem,
unregisterItem,
getTriggerProps: gesture.getTriggerProps,
keyboardState: gesture.keyboardState
}),
[
gesture.state,
gesture.origin,
gesture.currentDirection,
gesture.selectedItem,
gesture.getTriggerProps,
gesture.keyboardState,
defaultConfig.pressThreshold,
defaultConfig.minDistance,
defaultConfig.directions,
defaultConfig.preventContextMenu,
defaultConfig.originMode,
defaultA11y.announcements,
defaultA11y.label,
defaultA11y.description,
items,
registerItem,
unregisterItem
]
);
useEffect3(() => {
if (!defaultA11y.announcements) return;
if (gesture.state === "active" && defaultA11y.messages.menuOpened) {
setAnnouncement(defaultA11y.messages.menuOpened);
}
}, [gesture.state, defaultA11y]);
useEffect3(() => {
if (!defaultA11y.announcements) return;
if (gesture.currentDirection && gesture.state === "selecting" && defaultA11y.messages.directionChanged) {
const item = items.find((i) => i.direction === gesture.currentDirection);
if (item) {
setAnnouncement(defaultA11y.messages.directionChanged(gesture.currentDirection));
}
}
}, [gesture.currentDirection, gesture.state, items, defaultA11y]);
return /* @__PURE__ */ jsxs(MarkingMenuContext.Provider, { value: contextValue, children: [
!disabled && children,
defaultA11y.announcements && /* @__PURE__ */ jsx2(LiveRegion, { message: announcement })
] });
}
MarkingMenu.displayName = "MarkingMenu";
// src/hooks/useMarkingMenuContext.ts
function useMarkingMenuContext() {
const context = useContext(MarkingMenuContext);
if (!context) {
throw new Error(
"useMarkingMenuContext must be used within a MarkingMenu component. Make sure your component is wrapped in <MarkingMenu>...</MarkingMenu>."
);
}
return context;
}
// src/hooks/useFocusManagement.ts
import { useEffect as useEffect4, useRef as useRef4 } from "react";
function useFocusManagement({
isActive,
restoreFocus = true,
containerRef
}) {
const previousActiveElementRef = useRef4(null);
useEffect4(() => {
if (isActive && restoreFocus) {
previousActiveElementRef.current = document.activeElement;
}
return () => {
if (!isActive && restoreFocus && previousActiveElementRef.current && document.body.contains(previousActiveElementRef.current)) {
setTimeout(() => {
previousActiveElementRef.current?.focus();
}, 0);
}
};
}, [isActive, restoreFocus]);
useEffect4(() => {
if (!isActive || !containerRef?.current) return;
const container = containerRef.current;
const handleKeyDown2 = (e) => {
if (e.key !== "Tab") return;
const focusableElements = container.querySelectorAll(
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
);
if (focusableElements.length === 0) return;
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (e.shiftKey) {
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
}
} else {
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
};
container.addEventListener("keydown", handleKeyDown2);
return () => {
container.removeEventListener("keydown", handleKeyDown2);
};
}, [isActive, containerRef]);
}
// src/hooks/useReducedMotion.ts
import { useEffect as useEffect5, useState as useState3 } from "react";
function useReducedMotion() {
const [prefersReducedMotion, setPrefersReducedMotion] = useState3(() => {
if (typeof window === "undefined" || !window.matchMedia) return false;
const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
return mediaQuery.matches;
});
useEffect5(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
const handleChange = (event) => {
setPrefersReducedMotion(event.matches);
};
handleChange(mediaQuery);
if (mediaQuery.addEventListener) {
mediaQuery.addEventListener("change", handleChange);
} else {
mediaQuery.addListener(handleChange);
}
return () => {
if (mediaQuery.removeEventListener) {
mediaQuery.removeEventListener("change", handleChange);
} else {
mediaQuery.removeListener(handleChange);
}
};
}, []);
return prefersReducedMotion;
}
// src/components/MarkingMenuTrigger.tsx
import React3 from "react";
import { Fragment, jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
function MarkingMenuTrigger({
children,
asChild = false,
className,
style
}) {
const { getTriggerProps, a11y, state } = useMarkingMenuContext();
const triggerProps = getTriggerProps();
const ariaProps = {
"aria-label": a11y.label,
"aria-expanded": state === "active" || state === "selecting",
"aria-haspopup": "menu",
"aria-describedby": a11y.description ? "marking-menu-description" : void 0
};
const dataProps = {
"data-state": state,
"data-idle": state === "idle" ? "" : void 0,
"data-pressed": state === "pressed" ? "" : void 0,
"data-active": state === "active" ? "" : void 0,
"data-selecting": state === "selecting" ? "" : void 0
};
if (asChild) {
const child = React3.Children.only(children);
return React3.cloneElement(child, {
...triggerProps,
...ariaProps,
...dataProps,
...child.props,
className: className ? `${child.props.className || ""} ${className}`.trim() : child.props.className,
style: style ? { ...child.props.style, ...style } : child.props.style
});
}
return /* @__PURE__ */ jsxs2(Fragment, { children: [
/* @__PURE__ */ jsx3(
"button",
{
...triggerProps,
...ariaProps,
...dataProps,
className,
style,
type: "button",
children
}
),
a11y.description && /* @__PURE__ */ jsx3(
"div",
{
id: "marking-menu-description",
style: {
position: "absolute",
width: "1px",
height: "1px",
padding: 0,
margin: "-1px",
overflow: "hidden",
clip: "rect(0, 0, 0, 0)",
whiteSpace: "nowrap",
border: 0
},
children: a11y.description
}
)
] });
}
MarkingMenuTrigger.displayName = "MarkingMenuTrigger";
// src/components/MarkingMenuContent.tsx
import { useMemo as useMemo2 } from "react";
import { jsx as jsx4 } from "react/jsx-runtime";
function MarkingMenuContent({
children,
render,
className,
style,
forceMount = false
}) {
const { state, origin, currentDirection, a11y } = useMarkingMenuContext();
const shouldRender = forceMount || state === "active" || state === "selecting";
const positionStyles = useMemo2(() => {
if (!origin) return {};
return {
position: "fixed",
left: origin.x,
top: origin.y,
pointerEvents: "none",
// Prevent interfering with pointer events
zIndex: 9999
// Ensure menu appears above other content
};
}, [origin]);
if (!shouldRender) {
return null;
}
const mergedStyle = { ...positionStyles, ...style };
const ariaProps = {
role: "menu",
"aria-label": a11y.label
};
if (render) {
return /* @__PURE__ */ jsx4("div", { ...ariaProps, className, style: mergedStyle, children: render({ state, origin, currentDirection }) });
}
return /* @__PURE__ */ jsx4("div", { ...ariaProps, className, style: mergedStyle, children });
}
MarkingMenuContent.displayName = "MarkingMenuContent";
// src/components/MarkingMenuItem.tsx
import { useEffect as useEffect6, useMemo as useMemo3 } from "react";
import { jsx as jsx5, jsxs as jsxs3 } from "react/jsx-runtime";
function MarkingMenuItem({
id,
direction,
label,
icon,
onSelect,
disabled = false,
children,
className,
style
}) {
const { registerItem, unregisterItem, currentDirection, selectedItem, state } = useMarkingMenuContext();
useEffect6(() => {
registerItem({
id,
direction,
label: label || "",
icon,
onSelect,
disabled
});
return () => {
unregisterItem(id);
};
}, [id, direction, label, icon, onSelect, disabled, registerItem, unregisterItem]);
const isHighlighted = currentDirection === direction;
const isSelected = selectedItem === id;
const renderProps = useMemo3(
() => ({
isHighlighted,
isSelected,
isDisabled: disabled,
direction,
state
}),
[isHighlighted, isSelected, disabled, direction, state]
);
const content = typeof children === "function" ? children(renderProps) : children ? children : /* @__PURE__ */ jsxs3("div", { style: { display: "flex", alignItems: "center", gap: "0.5rem" }, children: [
icon && /* @__PURE__ */ jsx5("span", { children: icon }),
label && /* @__PURE__ */ jsx5("span", { children: label })
] });
const mergedClassName = className ? `${className} ${isHighlighted ? "marking-menu-item-highlighted" : ""} ${isSelected ? "marking-menu-item-selected" : ""} ${disabled ? "marking-menu-item-disabled" : ""}` : `${isHighlighted ? "marking-menu-item-highlighted" : ""} ${isSelected ? "marking-menu-item-selected" : ""} ${disabled ? "marking-menu-item-disabled" : ""}`;
const ariaProps = {
role: "menuitem",
"aria-label": label || `${direction} direction`,
"aria-disabled": disabled,
"aria-current": isHighlighted ? "true" : void 0
};
return /* @__PURE__ */ jsx5(
"div",
{
...ariaProps,
className: mergedClassName.trim(),
style,
"data-direction": direction,
"data-item-id": id,
"data-highlighted": isHighlighted,
"data-selected": isSelected,
"data-disabled": disabled,
children: content
}
);
}
MarkingMenuItem.displayName = "MarkingMenuItem";
// src/components/KeyboardIndicator.tsx
import { useMemo as useMemo4 } from "react";
import { jsx as jsx6, jsxs as jsxs4 } from "react/jsx-runtime";
function KeyboardIndicator({
render,
className,
style,
show = true
}) {
const { keyboardState, currentDirection } = useMarkingMenuContext();
const formatKey = (key) => {
return key.replace("Arrow", "");
};
const defaultRender = useMemo4(() => {
if (!keyboardState.pressedKeys.length) {
return null;
}
const keys = keyboardState.pressedKeys.map(formatKey).join(" + ");
const status = keyboardState.isReleasing ? " (releasing)" : "";
return /* @__PURE__ */ jsxs4(
"div",
{
style: {
display: "flex",
flexDirection: "column",
gap: "0.25rem",
padding: "0.5rem",
background: "rgba(0, 0, 0, 0.8)",
color: "white",
borderRadius: "4px",
fontSize: "0.875rem",
fontFamily: "monospace",
...style
},
className,
children: [
/* @__PURE__ */ jsxs4("div", { children: [
/* @__PURE__ */ jsx6("strong", { children: "Keys:" }),
" ",
keys,
status
] }),
currentDirection && /* @__PURE__ */ jsxs4("div", { children: [
/* @__PURE__ */ jsx6("strong", { children: "Direction:" }),
" ",
currentDirection
] }),
keyboardState.latchedDirection && /* @__PURE__ */ jsxs4("div", { children: [
/* @__PURE__ */ jsx6("strong", { children: "Latched:" }),
" ",
keyboardState.latchedDirection
] })
]
}
);
}, [keyboardState, currentDirection, className, style]);
if (!show || !keyboardState.pressedKeys.length) {
return null;
}
if (render) {
return /* @__PURE__ */ jsx6("div", { className, style, children: render({
pressedKeys: keyboardState.pressedKeys,
isReleasing: keyboardState.isReleasing,
latchedDirection: keyboardState.latchedDirection
}) });
}
return defaultRender;
}
KeyboardIndicator.displayName = "KeyboardIndicator";
export {
KeyboardIndicator,
LiveRegion,
MarkingMenu,
MarkingMenuContent,
MarkingMenuItem,
MarkingMenuTrigger,
createKeyboardState,
directionToAngle,
getAvailableDirections,
getDirectionFromKeys,
getDirectionFromPosition,
getDirectionFromSingleKey,
getDirectionFromTwoKeys,
getDistance,
handleKeyDown,
handleKeyUp,
isArrowKey,
resetKeyboardState,
useFocusManagement,
useMarkingMenuContext,
useMarkingMenuGesture,
useMarkingMenuStateMachine,
useReducedMotion
};