@react-marking-menu/core
Version:
Headless React primitives for marking menus with gestural interaction
746 lines (730 loc) • 20.8 kB
TypeScript
import * as react_jsx_runtime from 'react/jsx-runtime';
import React$1 from 'react';
/**
* Core types for the marking menu library
*/
/**
* Represents one of the 8 cardinal/intercardinal directions
*/
type Direction = 'N' | 'NE' | 'E' | 'SE' | 'S' | 'SW' | 'W' | 'NW';
/**
* Represents one of the 4 cardinal directions (for simplified menus)
*/
type Direction4 = 'N' | 'E' | 'S' | 'W';
/**
* Menu state machine states
*/
type MenuState = 'idle' | 'pressed' | 'active' | 'selecting';
/**
* Position in 2D space
*/
interface Position {
x: number;
y: number;
}
/**
* Origin positioning mode for the marking menu
*/
type OriginMode = 'cursor' | 'element' | 'viewport';
/**
* Configuration for gesture recognition
*/
interface GestureConfig {
/**
* Time in milliseconds to hold before menu appears
* @default 150
*/
pressThreshold?: number;
/**
* Minimum distance in pixels to move before selecting
* @default 50
*/
minDistance?: number;
/**
* Number of directions (4 or 8)
* @default 8
*/
directions?: 4 | 8;
/**
* Prevent default context menu on right-click
* @default true
*/
preventContextMenu?: boolean;
/**
* Origin positioning mode
* - 'cursor': Center at cursor/pointer position (pointer gestures only)
* - 'element': Center at the trigger element
* - 'viewport': Center at viewport center (useful for keyboard)
* @default 'element'
*/
originMode?: OriginMode;
}
/**
* Menu item definition
*/
interface MenuItem {
/**
* Unique identifier for this menu item
*/
id: string;
/**
* Direction slot for this item
*/
direction: Direction | Direction4;
/**
* Label for the menu item
*/
label: string;
/**
* Optional icon component or element
*/
icon?: React.ReactNode;
/**
* Callback when item is selected
*/
onSelect?: () => void;
/**
* Whether this item is disabled
*/
disabled?: boolean;
}
/**
* Valid arrow key values
*/
type ArrowKey = 'ArrowUp' | 'ArrowRight' | 'ArrowDown' | 'ArrowLeft';
/**
* Check if a key is a valid arrow key
* @param key The key string to validate
* @returns True if the key is an arrow key
*/
declare function isArrowKey(key: string): key is ArrowKey;
/**
* Map a single arrow key to a cardinal direction
* @param key The arrow key
* @returns The corresponding cardinal direction
*/
declare function getDirectionFromSingleKey(key: ArrowKey): Direction;
/**
* Map two arrow keys to a direction (diagonal or cardinal)
* Handles invalid combinations by falling back to the most recent key
* @param key1 First arrow key (older)
* @param key2 Second arrow key (more recent)
* @returns The corresponding direction
*/
declare function getDirectionFromTwoKeys(key1: ArrowKey, key2: ArrowKey): Direction;
/**
* Get direction from an array of pressed keys using sliding window approach
* Uses the most recent 2 keys (or 1 if only 1 is pressed) to determine direction
* @param pressedKeys Array of currently pressed keys in chronological order
* @returns The current direction, or null if no keys are pressed
*/
declare function getDirectionFromKeys(pressedKeys: ArrowKey[]): Direction | null;
/**
* Keyboard state for tracking pressed keys and direction latching
*/
interface KeyboardState {
/**
* Stack of pressed arrow keys in chronological order
*/
pressedKeys: ArrowKey[];
/**
* Whether we're in the release phase (direction is latched)
*/
isReleasing: boolean;
/**
* The latched direction when release phase starts
*/
latchedDirection: Direction | null;
}
/**
* Create initial keyboard state
*/
declare function createKeyboardState(): KeyboardState;
/**
* Handle keydown event and update keyboard state
* @param state Current keyboard state
* @param key The key that was pressed
* @returns Updated keyboard state and the new direction
*/
declare function handleKeyDown(state: KeyboardState, key: string): {
state: KeyboardState;
direction: Direction | null;
};
/**
* Handle keyup event and update keyboard state
* Implements direction latching when transitioning from 2+ keys to fewer keys
* @param state Current keyboard state
* @param key The key that was released
* @returns Updated keyboard state, current direction, and whether all keys are released
*/
declare function handleKeyUp(state: KeyboardState, key: string): {
state: KeyboardState;
direction: Direction | null;
allKeysReleased: boolean;
};
/**
* Reset keyboard state to initial state
*/
declare function resetKeyboardState(): KeyboardState;
/**
* State machine for managing marking menu interaction states
*/
declare function useMarkingMenuStateMachine(): {
state: MenuState;
origin: Position | null;
currentDirection: "N" | "NE" | "E" | "SE" | "S" | "SW" | "W" | "NW" | null;
selectedItem: string | null;
startPress: (position: Position, delay: number) => void;
startImmediate: (position: Position) => void;
updatePosition: (direction: Direction | Direction4 | null) => void;
endPress: (itemId: string | null) => string | null;
cancel: () => void;
};
/**
* Props for the unified gesture hook
*/
interface UseMarkingMenuGestureProps {
/**
* Configuration for gesture recognition
*/
config?: GestureConfig;
/**
* Menu items to match selections against
*/
items: MenuItem[];
/**
* Callback when an item is selected
*/
onSelect?: (itemId: string) => void;
/**
* Callback when the menu is cancelled (e.g., Escape key)
*/
onCancel?: () => void;
/**
* Whether the trigger is enabled
* @default true
*/
enabled?: boolean;
}
/**
* Return type for the unified gesture hook
*/
interface UseMarkingMenuGestureReturn {
/**
* Current menu state
*/
state: ReturnType<typeof useMarkingMenuStateMachine>['state'];
/**
* Origin position where gesture started
*/
origin: ReturnType<typeof useMarkingMenuStateMachine>['origin'];
/**
* Current direction (from pointer movement or keyboard)
*/
currentDirection: ReturnType<typeof useMarkingMenuStateMachine>['currentDirection'];
/**
* Selected item ID (if any)
*/
selectedItem: ReturnType<typeof useMarkingMenuStateMachine>['selectedItem'];
/**
* Props to spread on the trigger element
*/
getTriggerProps: () => {
onPointerDown: (e: React.PointerEvent) => void;
onPointerMove: (e: React.PointerEvent) => void;
onPointerUp: (e: React.PointerEvent) => void;
onPointerCancel: (e: React.PointerEvent) => void;
onKeyDown: (e: React.KeyboardEvent) => void;
onKeyUp: (e: React.KeyboardEvent) => void;
onContextMenu?: (e: React.MouseEvent) => void;
tabIndex: number;
};
/**
* Keyboard state (for debugging/visual feedback)
*/
keyboardState: KeyboardState;
}
/**
* Unified gesture hook for marking menus
* Handles both pointer (mouse/touch) and keyboard (arrow keys) input
* using the same state machine
*/
declare function useMarkingMenuGesture({ config, items, onSelect, onCancel, enabled, }: UseMarkingMenuGestureProps): UseMarkingMenuGestureReturn;
/**
* Extended context value with gesture handlers
*/
interface MarkingMenuContextValue {
state: MenuState;
origin: Position | null;
currentDirection: Direction | Direction4 | null;
selectedItem: string | null;
config: Required<GestureConfig>;
a11y: Required<AccessibilityConfig>;
items: MenuItem[];
registerItem: (item: MenuItem) => void;
unregisterItem: (id: string) => void;
getTriggerProps: () => ReturnType<ReturnType<typeof useMarkingMenuGesture>['getTriggerProps']>;
keyboardState: KeyboardState;
}
/**
* Accessibility configuration for marking menu
*/
interface AccessibilityConfig {
/**
* Enable screen reader announcements
* @default true
*/
announcements?: boolean;
/**
* Custom announcement messages
*/
messages?: {
menuOpened?: string;
directionChanged?: (direction: Direction | Direction4) => string;
itemSelected?: (label: string) => string;
menuCancelled?: string;
};
/**
* ARIA label for the menu
*/
label?: string;
/**
* ARIA description for the menu
*/
description?: string;
}
/**
* Props for MarkingMenu root component
*/
interface MarkingMenuProps {
/**
* Child elements (typically MarkingMenuTrigger and MarkingMenuContent)
*/
children: React$1.ReactNode;
/**
* Configuration for gesture recognition
*/
config?: GestureConfig;
/**
* Accessibility configuration
*/
a11y?: AccessibilityConfig;
/**
* Callback when an item is selected
*/
onSelect?: (itemId: string) => void;
/**
* Callback when the menu is cancelled
*/
onCancel?: () => void;
/**
* Whether the menu is disabled
* @default false
*/
disabled?: boolean;
}
/**
* Root marking menu component that provides context to all child components
* This is a headless component - it only manages state and behavior
*
* @example
* ```tsx
* <MarkingMenu onSelect={(id) => console.log(id)}>
* <MarkingMenuTrigger>
* <button>Press me</button>
* </MarkingMenuTrigger>
* <MarkingMenuContent>
* <MarkingMenuItem direction="N" id="copy">
* Copy
* </MarkingMenuItem>
* </MarkingMenuContent>
* </MarkingMenu>
* ```
*/
declare function MarkingMenu({ children, config, a11y, onSelect, onCancel, disabled, }: MarkingMenuProps): react_jsx_runtime.JSX.Element;
declare namespace MarkingMenu {
var displayName: string;
}
/**
* Hook to access marking menu context
* Must be used within a MarkingMenu component
*
* @throws Error if used outside of MarkingMenu context
*
* @example
* ```tsx
* function CustomTrigger() {
* const { state, getTriggerProps } = useMarkingMenuContext()
* return <button {...getTriggerProps()}>Open menu</button>
* }
* ```
*/
declare function useMarkingMenuContext(): MarkingMenuContextValue;
/**
* Props for focus management hook
*/
interface UseFocusManagementProps {
/**
* Whether the focus trap is active
*/
isActive: boolean;
/**
* Whether to restore focus when deactivated
* @default true
*/
restoreFocus?: boolean;
/**
* Container element to trap focus within
*/
containerRef?: React.RefObject<HTMLElement>;
}
/**
* Hook for managing focus and focus restoration
* Stores the previously focused element and restores focus when deactivated
*
* @example
* ```tsx
* const containerRef = useRef<HTMLDivElement>(null)
* useFocusManagement({
* isActive: menuState === 'active',
* containerRef,
* restoreFocus: true,
* })
* ```
*/
declare function useFocusManagement({ isActive, restoreFocus, containerRef, }: UseFocusManagementProps): void;
/**
* Hook to detect user's reduced motion preference
* Returns true if the user prefers reduced motion
*
* @example
* ```tsx
* const prefersReducedMotion = useReducedMotion()
*
* return (
* <div
* style={{
* transition: prefersReducedMotion ? 'none' : 'all 0.3s ease',
* }}
* >
* Content
* </div>
* )
* ```
*/
declare function useReducedMotion(): boolean;
/**
* Calculate the direction from an origin point to a target point
* @param x Target X coordinate
* @param y Target Y coordinate
* @param originX Origin X coordinate
* @param originY Origin Y coordinate
* @param directions Number of directions (4 or 8)
* @returns Direction enum value
*/
declare function getDirectionFromPosition(x: number, y: number, originX: number, originY: number, directions?: 4 | 8): Direction | Direction4;
/**
* Convert direction to angle in degrees (for visual rendering)
* @param direction Direction enum value
* @returns Angle in degrees (0° = East)
*/
declare function directionToAngle(direction: Direction | Direction4): number;
/**
* Calculate distance between two points
*/
declare function getDistance(x1: number, y1: number, x2: number, y2: number): number;
/**
* Get all available directions for a given direction count
*/
declare function getAvailableDirections(directions: 4 | 8): Array<Direction | Direction4>;
/**
* Props for MarkingMenuTrigger component
*/
interface MarkingMenuTriggerProps {
/**
* Child element or render function
*/
children: React$1.ReactNode;
/**
* Merge props with the child element instead of wrapping
* When true, children must be a single element that accepts ref
* @default false
*/
asChild?: boolean;
/**
* Additional class name
*/
className?: string;
/**
* Additional styles
*/
style?: React$1.CSSProperties;
}
/**
* Trigger component for marking menu
* Handles the gesture interaction (pointer and keyboard)
*
* @example
* ```tsx
* // Default button
* <MarkingMenuTrigger>
* Press me
* </MarkingMenuTrigger>
*
* // Custom element with asChild
* <MarkingMenuTrigger asChild>
* <button className="my-button">Press me</button>
* </MarkingMenuTrigger>
* ```
*/
declare function MarkingMenuTrigger({ children, asChild, className, style, }: MarkingMenuTriggerProps): react_jsx_runtime.JSX.Element;
declare namespace MarkingMenuTrigger {
var displayName: string;
}
/**
* Props for MarkingMenuContent component
*/
interface MarkingMenuContentProps {
/**
* Child elements (typically MarkingMenuItem components)
*/
children: React$1.ReactNode;
/**
* Render function that receives menu state
*/
render?: (props: {
state: ReturnType<typeof useMarkingMenuContext>['state'];
origin: ReturnType<typeof useMarkingMenuContext>['origin'];
currentDirection: ReturnType<typeof useMarkingMenuContext>['currentDirection'];
}) => React$1.ReactNode;
/**
* Additional class name
*/
className?: string;
/**
* Additional styles
*/
style?: React$1.CSSProperties;
/**
* Whether to force show the menu (for testing/demo purposes)
* @default false
*/
forceMount?: boolean;
}
/**
* Content container for marking menu
* Only renders when menu is active (after press threshold)
* Provides positioning context for menu items
*
* @example
* ```tsx
* <MarkingMenuContent>
* <MarkingMenuItem direction="N" id="copy">Copy</MarkingMenuItem>
* <MarkingMenuItem direction="E" id="paste">Paste</MarkingMenuItem>
* </MarkingMenuContent>
*
* // With render prop
* <MarkingMenuContent>
* {({ state, origin, currentDirection }) => (
* <div style={{ left: origin?.x, top: origin?.y }}>
* <MarkingMenuItem direction="N" id="copy">Copy</MarkingMenuItem>
* </div>
* )}
* </MarkingMenuContent>
* ```
*/
declare function MarkingMenuContent({ children, render, className, style, forceMount, }: MarkingMenuContentProps): react_jsx_runtime.JSX.Element | null;
declare namespace MarkingMenuContent {
var displayName: string;
}
/**
* Render props for MarkingMenuItem
*/
interface MarkingMenuItemRenderProps {
/**
* Whether this item is currently highlighted (cursor/keys pointing at it)
*/
isHighlighted: boolean;
/**
* Whether this item is the selected item
*/
isSelected: boolean;
/**
* Whether the item is disabled
*/
isDisabled: boolean;
/**
* The direction slot for this item
*/
direction: Direction | Direction4;
/**
* The menu state
*/
state: 'idle' | 'pressed' | 'active' | 'selecting';
}
/**
* Props for MarkingMenuItem component
*/
interface MarkingMenuItemProps {
/**
* Unique identifier for this menu item
*/
id: string;
/**
* Direction slot for this item
*/
direction: Direction | Direction4;
/**
* Label for the menu item
*/
label?: string;
/**
* Optional icon component or element
*/
icon?: React$1.ReactNode;
/**
* Callback when item is selected
*/
onSelect?: () => void;
/**
* Whether this item is disabled
*/
disabled?: boolean;
/**
* Children (can be render function or React node)
*/
children?: React$1.ReactNode | ((props: MarkingMenuItemRenderProps) => React$1.ReactNode);
/**
* Additional class name
*/
className?: string;
/**
* Additional styles
*/
style?: React$1.CSSProperties;
}
/**
* Individual menu item component
* Automatically registers/unregisters with the parent menu
* Supports render props for custom styling based on state
*
* @example
* ```tsx
* // Simple usage
* <MarkingMenuItem id="copy" direction="N" label="Copy" onSelect={handleCopy} />
*
* // With render prop
* <MarkingMenuItem id="copy" direction="N" onSelect={handleCopy}>
* {({ isHighlighted, isSelected }) => (
* <div className={isHighlighted ? 'highlighted' : ''}>
* Copy
* </div>
* )}
* </MarkingMenuItem>
*
* // With icon
* <MarkingMenuItem
* id="copy"
* direction="N"
* label="Copy"
* icon={<CopyIcon />}
* onSelect={handleCopy}
* />
* ```
*/
declare function MarkingMenuItem({ id, direction, label, icon, onSelect, disabled, children, className, style, }: MarkingMenuItemProps): react_jsx_runtime.JSX.Element;
declare namespace MarkingMenuItem {
var displayName: string;
}
/**
* Props for LiveRegion component
*/
interface LiveRegionProps {
/**
* Message to announce to screen readers
*/
message: string;
/**
* ARIA live region politeness level
* - 'polite': Waits for user to pause before announcing
* - 'assertive': Interrupts immediately
* @default 'polite'
*/
politeness?: 'polite' | 'assertive';
/**
* Whether to clear the message after announcing
* @default true
*/
clearOnAnnounce?: boolean;
/**
* Delay in ms before clearing message
* @default 1000
*/
clearDelay?: number;
}
/**
* Live region component for screen reader announcements
* Hidden visually but accessible to screen readers
*
* @example
* ```tsx
* <LiveRegion
* message="North direction selected"
* politeness="polite"
* />
* ```
*/
declare function LiveRegion({ message, politeness, clearOnAnnounce, clearDelay, }: LiveRegionProps): react_jsx_runtime.JSX.Element;
declare namespace LiveRegion {
var displayName: string;
}
/**
* Props for KeyboardIndicator component
*/
interface KeyboardIndicatorProps {
/**
* Render function for custom visualization
*/
render?: (props: {
pressedKeys: ArrowKey[];
isReleasing: boolean;
latchedDirection: string | null;
}) => React$1.ReactNode;
/**
* Additional class name
*/
className?: string;
/**
* Additional styles
*/
style?: React$1.CSSProperties;
/**
* Whether to show the indicator
* @default true
*/
show?: boolean;
}
/**
* Visual indicator showing which arrow keys are currently pressed
* Helpful for learning keyboard navigation
*
* @example
* ```tsx
* <KeyboardIndicator />
*
* // Custom rendering
* <KeyboardIndicator>
* {({ pressedKeys, isReleasing }) => (
* <div>
* Keys: {pressedKeys.join(' + ')}
* {isReleasing && ' (releasing)'}
* </div>
* )}
* </KeyboardIndicator>
* ```
*/
declare function KeyboardIndicator({ render, className, style, show, }: KeyboardIndicatorProps): react_jsx_runtime.JSX.Element | null;
declare namespace KeyboardIndicator {
var displayName: string;
}
export { type AccessibilityConfig, type ArrowKey, type Direction, type Direction4, type GestureConfig, KeyboardIndicator, type KeyboardIndicatorProps, type KeyboardState, LiveRegion, type LiveRegionProps, MarkingMenu, MarkingMenuContent, type MarkingMenuContentProps, type MarkingMenuContextValue, MarkingMenuItem, type MarkingMenuItemProps, type MarkingMenuItemRenderProps, type MarkingMenuProps, MarkingMenuTrigger, type MarkingMenuTriggerProps, type MenuItem, type MenuState, type OriginMode, type Position, type UseFocusManagementProps, type UseMarkingMenuGestureProps, type UseMarkingMenuGestureReturn, createKeyboardState, directionToAngle, getAvailableDirections, getDirectionFromKeys, getDirectionFromPosition, getDirectionFromSingleKey, getDirectionFromTwoKeys, getDistance, handleKeyDown, handleKeyUp, isArrowKey, resetKeyboardState, useFocusManagement, useMarkingMenuContext, useMarkingMenuGesture, useMarkingMenuStateMachine, useReducedMotion };