import { Ref } from '../../refs.js';
import {
KeyboardLikeEvent,
PointerLikeEvent,
DefaultPreventable,
PropagationStoppable,
} from '../../utilities.js';
/**
* pressable
*
* Interaction helper that produces VNode props for 'press' semantics.
* - Pure and deterministic: no DOM construction or mutation here
* - The runtime owns event attachment and scheduling
* - This helper returns plain props (handlers) intended to be attached by the runtime
*
* Behaviour:
* - For native buttons: only an `onClick` prop is provided (no ARIA or keyboard shims)
* - For non-button elements: add `role="button"` and `tabIndex` and keyboard handlers
* - Activation: `Enter` activates on keydown, `Space` activates on keyup (matches native button)
* - Disabled: handlers short-circuit and `aria-disabled` is set for all hosts
*
* POLICY DECISIONS (LOCKED):
*
* 1. Activation Timing (Platform Parity)
* - Enter fires on keydown (immediate response)
* - Space fires on keyup (allows cancel by moving focus, matches native)
* - Space keydown prevents scroll (matches native button behavior)
*
* 2. Disabled Enforcement Strategy
* - Native buttons: Use HTML `disabled` attribute (platform-enforced non-interactivity)
* AND `aria-disabled` (consistent a11y signaling)
* - Non-native: Use `tabIndex=-1` (removes from tab order)
* AND `aria-disabled` (signals disabled state to AT)
* - Click handler short-circuits as defense-in-depth (prevents leaked focus issues)
*
* 3. Key Repeat Behavior
* - Held Enter/Space will fire onPress repeatedly (matches native button)
* - No debouncing or repeat prevention (platform parity)
*/
/** Options for {@link pressable}. */
interface PressableOptions {
disabled?: boolean;
onPress?: (e: PressEvent) => void;
/**
* Whether the host is a native button. Defaults to false.
*/
isNativeButton?: boolean;
}
type PressEvent = DefaultPreventable & PropagationStoppable;
/** Element props returned by {@link pressable}. */
interface PressableResult {
onClick: (e: PressEvent) => void;
disabled?: true;
role?: 'button';
tabIndex?: number;
onKeyDown?: (e: KeyboardLikeEvent) => void;
onKeyUp?: (e: KeyboardLikeEvent) => void;
'aria-disabled'?: 'true';
}
/** Produce click/keyboard props implementing 'press' semantics for an element. */
declare function pressable({
disabled,
onPress,
isNativeButton,
}: PressableOptions): PressableResult;
/**
* dismissable
*
* THE dismissal primitive. Handles Escape key and outside interactions.
*
* INVARIANTS:
* 1. Returns props that compose via mergeProps (no factories)
* 2. Disabled state respected exactly once, here
* 3. No side effects - pure props generation
* 4. Outside detection requires explicit node reference
* 5. This is the ONLY dismissal primitive - do not create alternatives
*
* DESIGN:
* - Returns standard event handler props (onKeyDown, onPointerDownCapture)
* - Composable via mergeProps with other foundations
* - Caller provides the protected node reference for outside detection
* - Portaled descendants are registered explicitly as additional inside nodes
* - Returned capture props must be attached to a surface that can observe both
* the protected subtree and the outside interaction path (for example, an
* overlay or wrapper around the protected node)
* - Single onDismiss callback for all dismiss triggers
*
* PIT OF SUCCESS:
* ✓ Can't accidentally bypass (only way to get dismiss behavior)
* ✓ Can't duplicate (disabled checked once)
* ✓ Composes via mergeProps (standard props)
* ✓ Wrong usage is hard (no factories to misuse)
*
* USAGE:
* const props = dismissable({
* node: elementRef,
* additionalInsideNodes: [dropdownPortalRef],
* disabled: false,
* onDismiss: () => close()
* });
*
*
*
Content
*
*
* MISUSE EXAMPLE (PREVENTED):
* ❌ Can't forget to check disabled - checked inside dismissable
* ❌ Can't create custom escape handler - this is the only one
* ❌ Can't bypass via direct event listeners - mergeProps composes correctly
*/
/** Options for {@link dismissable}. */
interface DismissableOptions {
/**
* Reference to the protected element for outside click detection. Attach the
* returned capture props to a surface that encloses this node.
*/
node?: Node | null;
/**
* Additional roots that are logically inside the protected surface even
* when a portal places them outside `node` in the DOM tree.
*/
additionalInsideNodes?: readonly (Node | null | undefined)[];
/**
* Whether dismiss is disabled
*/
disabled?: boolean;
/**
* Called when dismiss is triggered (Escape or outside click)
*/
onDismiss?: (trigger: 'escape' | 'outside') => void;
}
/** Produce keydown/outside-click props that invoke `onDismiss` on Escape or an outside click. */
declare function dismissable({
node,
additionalInsideNodes,
disabled,
onDismiss,
}: DismissableOptions): {
onKeyDown: (e: KeyboardLikeEvent) => void;
onPointerDownCapture: (e: PointerLikeEvent) => void;
};
/**
* focusable
*
* Normalize focus-related props for hosts.
* - No DOM manipulation here; returns props that the runtime may attach.
*/
/** Options for {@link focusable}. */
interface FocusableOptions {
disabled?: boolean;
tabIndex?: number | undefined;
}
/** Element props returned by {@link focusable}. */
interface FocusableResult {
tabIndex?: number;
'aria-disabled'?: 'true';
}
/** Normalize `tabIndex`/`aria-disabled` props for a focusable host. */
declare function focusable({
disabled,
tabIndex,
}: FocusableOptions): FocusableResult;
/**
* hoverable
*
* Produces props for pointer enter/leave handling. Pure and deterministic.
*/
/** Options for {@link hoverable}. */
interface HoverableOptions {
disabled?: boolean;
onEnter?: (e: HoverEvent) => void;
onLeave?: (e: HoverEvent) => void;
}
type HoverEvent = DefaultPreventable & PropagationStoppable;
/** Element props returned by {@link hoverable}. */
interface HoverableResult {
onPointerEnter?: (e: HoverEvent) => void;
onPointerLeave?: (e: HoverEvent) => void;
}
/** Produce pointer enter/leave props that call `onEnter`/`onLeave` unless disabled. */
declare function hoverable({
disabled,
onEnter,
onLeave,
}: HoverableOptions): HoverableResult;
/** Arrow-key axis for {@link rovingFocus}. */
type Orientation = 'horizontal' | 'vertical' | 'both';
/** Options for {@link rovingFocus}. */
interface RovingFocusOptions {
/**
* Current focused index
*/
currentIndex: number;
/**
* Total number of items
*/
itemCount: number;
/**
* Navigation orientation
* - horizontal: ArrowLeft/ArrowRight
* - vertical: ArrowUp/ArrowDown
* - both: all arrow keys
*/
orientation?: Orientation;
/**
* Whether to loop when reaching the end
*/
loop?: boolean;
/**
* Callback when navigation occurs
*/
onNavigate?: (index: number) => void;
/**
* Optional disabled state check per index
*/
isDisabled?: (index: number) => boolean;
}
/** Container and per-item props returned by {@link rovingFocus}. */
interface RovingFocusResult {
/**
* Props for the container element (composes via mergeProps)
*/
container: {
onKeyDown: (e: KeyboardLikeEvent) => void;
};
/**
* Generate props for an item at the given index (composes via mergeProps)
*/
item: (index: number) => {
tabIndex: number;
'data-roving-index': number;
};
}
/** Implement arrow-key roving tabindex navigation over a set of items. */
declare function rovingFocus(options: RovingFocusOptions): RovingFocusResult;
/**
* USAGE EXAMPLE:
*
* function Menu() {
* const [focusIndex, setFocusIndex] = state(0);
* const items = ['File', 'Edit', 'View'];
*
* const navigation = rovingFocus({
* currentIndex: focusIndex(),
* itemCount: items.length,
* orientation: 'horizontal',
* loop: true,
* onNavigate: setFocusIndex,
* });
*
* return (
*
* {items.map((label, index) => (
*
* ))}
*
* );
* }
*/
/** Input to {@link applyInteractionPolicy}. */
interface InteractionPolicyInput {
/** Whether the host element is a native interactive element (button, a, etc) */
isNative: boolean;
/** Disabled state - checked ONLY here, never in components */
disabled: boolean;
/** User-provided press handler - semantic action, not DOM event */
onPress?: (e: Event) => void;
/** Optional ref to compose */
ref?: Ref;
}
/**
* THE interaction policy. Components MUST use this, NEVER implement
* interaction logic directly.
*/
declare function applyInteractionPolicy({
isNative,
disabled,
onPress,
ref,
}: InteractionPolicyInput):
| {
disabled: true | undefined;
onClick: (e: Event) => void;
ref: Ref;
}
| {
tabIndex: number;
ref: Ref;
'aria-disabled'?: 'true';
onClick: (e: DefaultPreventable & PropagationStoppable) => void;
disabled?: true;
role?: 'button';
onKeyDown?: (e: KeyboardLikeEvent) => void;
onKeyUp?: (e: KeyboardLikeEvent) => void;
};
/**
* Merge rule for Slot / asChild
*
* Precedence:
* policy → user → child
*
* Event handlers are composed (policy first).
* Refs are always composed.
* Policy props MUST take precedence to enforce invariants.
*/
declare function mergeInteractionProps(
childProps: Record,
policyProps: Record,
userProps?: Record
): Record;
export {
type DismissableOptions,
type FocusableOptions,
type FocusableResult,
type HoverableOptions,
type HoverableResult,
type InteractionPolicyInput,
type Orientation,
type PressableOptions,
type PressableResult,
type RovingFocusOptions,
type RovingFocusResult,
applyInteractionPolicy,
dismissable,
focusable,
hoverable,
mergeInteractionProps,
pressable,
rovingFocus,
};