UNPKG

@react-marking-menu/core

Version:

Headless React primitives for marking menus with gestural interaction

1,046 lines (1,030 loc) 30.8 kB
// 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 };