← alle entries

use-press

FC-0009pressprimitivev1.0.0

Apple-gedragslaag voor tapbare elementen: touch-down zonder vertraging, hit-marge, wegslepen-annuleert/terugslepen-herstelt; activatie via onPress.

zelfde component — andere tokens
houd mij ingedrukt

bevestigd: 0× — sleep eens weg (annuleert) en terug (herstelt), of laat nét naast het vlak los: de hit-marge vangt het op.

primitives/ui/press.ts
/**
 * usePress — de Apple-gedragslaag onder elk tapbaar element (WWDC18):
 *
 *  - Licht op bij touch-down/mousedown, ZONDER vertraging. De bevestiging
 *    volgt pas bij touch-up.
 *  - Extra hit-marge: iets naast het element loslaten telt nog als tap.
 *  - Wegslepen annuleert (pressed uit), terugslepen binnen de marge herstelt.
 *
 * Activatie loopt via `onPress`, niet via onClick: dankzij pointer-capture
 * blijft de pers gevolgd buiten het element, en beslist de marge — niet de
 * browser — of de tap doorgaat. Toetsenbord activeert via de native click
 * (e.detail === 0), zodat Enter/Spatie exact het platformgedrag houden.
 */

"use client";

import { useCallback, useRef, useState } from "react";
import type * as React from "react";

export interface UsePressOptions {
  onPress?: () => void;
  disabled?: boolean;
  /** Marge (px) rond het element waarbinnen de pers geldig blijft. */
  hitMargin?: number;
}

export interface PressHandlers {
  onPointerDown: (e: React.PointerEvent<HTMLElement>) => void;
  onPointerMove: (e: React.PointerEvent<HTMLElement>) => void;
  onPointerUp: (e: React.PointerEvent<HTMLElement>) => void;
  onPointerCancel: (e: React.PointerEvent<HTMLElement>) => void;
  onClick: (e: React.MouseEvent<HTMLElement>) => void;
  onKeyDown: (e: React.KeyboardEvent<HTMLElement>) => void;
  onKeyUp: (e: React.KeyboardEvent<HTMLElement>) => void;
}

export interface UsePressResult {
  /** Visuele pers-staat — bind aan `data-pressed`. */
  pressed: boolean;
  pressProps: PressHandlers;
}

const DEFAULT_HIT_MARGIN = 24;

interface ActivePress {
  pointerId: number;
  rect: DOMRect;
}

export function usePress(options: UsePressOptions = {}): UsePressResult {
  const { onPress, disabled = false, hitMargin = DEFAULT_HIT_MARGIN } = options;
  const [pressed, setPressed] = useState(false);
  const active = useRef<ActivePress | null>(null);

  const within = useCallback(
    (e: { clientX: number; clientY: number }): boolean => {
      const rect = active.current?.rect;
      if (!rect) return false;
      return (
        e.clientX >= rect.left - hitMargin &&
        e.clientX <= rect.right + hitMargin &&
        e.clientY >= rect.top - hitMargin &&
        e.clientY <= rect.bottom + hitMargin
      );
    },
    [hitMargin],
  );

  const onPointerDown = useCallback(
    (e: React.PointerEvent<HTMLElement>) => {
      if (disabled || !e.isPrimary) return;
      e.currentTarget.setPointerCapture(e.pointerId);
      active.current = { pointerId: e.pointerId, rect: e.currentTarget.getBoundingClientRect() };
      setPressed(true);
    },
    [disabled],
  );

  const onPointerMove = useCallback(
    (e: React.PointerEvent<HTMLElement>) => {
      if (active.current?.pointerId !== e.pointerId) return;
      setPressed(within(e)); /* wegslepen annuleert, terugslepen herstelt */
    },
    [within],
  );

  const finish = useCallback(
    (e: React.PointerEvent<HTMLElement>, activate: boolean) => {
      if (active.current?.pointerId !== e.pointerId) return;
      const hit = activate && within(e);
      active.current = null;
      setPressed(false);
      if (hit && !disabled) onPress?.();
    },
    [disabled, onPress, within],
  );

  const onPointerUp = useCallback(
    (e: React.PointerEvent<HTMLElement>) => finish(e, true),
    [finish],
  );
  const onPointerCancel = useCallback(
    (e: React.PointerEvent<HTMLElement>) => finish(e, false),
    [finish],
  );

  /* Toetsenbord: native click met detail 0 (Enter/Spatie op een button). */
  const onClick = useCallback(
    (e: React.MouseEvent<HTMLElement>) => {
      if (disabled) return;
      if (e.detail === 0) onPress?.();
    },
    [disabled, onPress],
  );

  const onKeyDown = useCallback(
    (e: React.KeyboardEvent<HTMLElement>) => {
      if (disabled) return;
      if (e.key === " " || e.key === "Enter") setPressed(true);
    },
    [disabled],
  );
  const onKeyUp = useCallback((_e: React.KeyboardEvent<HTMLElement>) => {
    setPressed(false);
  }, []);

  return {
    pressed,
    pressProps: { onPointerDown, onPointerMove, onPointerUp, onPointerCancel, onClick, onKeyDown, onKeyUp },
  };
}