UNPKG

@base-ui-components/react

Version:

Base UI is a library of headless ('unstyled') React components and low-level hooks. You gain complete control over your app's CSS and accessibility features.

186 lines (182 loc) 6.69 kB
'use client'; import * as React from 'react'; import { DEFAULT_STEP, MAX_POINTER_MOVES_AFTER_TOUCH, SCROLLING_POINTER_MOVE_DISTANCE, TOUCH_TIMEOUT } from "../utils/constants.js"; import { parseNumber } from "../utils/parse.js"; import { createChangeEventDetails, createGenericEventDetails } from "../../utils/createBaseUIEventDetails.js"; export function useNumberFieldButton(params) { const { allowInputSyncRef, disabled, formatOptionsRef, getStepAmount, id, incrementValue, inputRef, inputValue, intentionalTouchCheckTimeout, isIncrement, isPressedRef, locale, maxWithDefault, minWithDefault, movesAfterTouchRef, readOnly, setValue, startAutoChange, stopAutoChange, value, valueRef, lastChangedValueRef, onValueCommitted } = params; const incrementDownCoordsRef = React.useRef({ x: 0, y: 0 }); const isTouchingButtonRef = React.useRef(false); const ignoreClickRef = React.useRef(false); const pointerTypeRef = React.useRef(''); const isMin = value != null && value <= minWithDefault; const isMax = value != null && value >= maxWithDefault; const pressReason = isIncrement ? 'increment-press' : 'decrement-press'; function commitValue(nativeEvent) { allowInputSyncRef.current = true; // The input may be dirty but not yet blurred, so the value won't have been committed. const parsedValue = parseNumber(inputValue, locale, formatOptionsRef.current); if (parsedValue !== null) { // The increment value function needs to know the current input value to increment it // correctly. valueRef.current = parsedValue; setValue(parsedValue, createChangeEventDetails(pressReason, nativeEvent, undefined, { direction: isIncrement ? 1 : -1 })); } } const props = { disabled: disabled || (isIncrement ? isMax : isMin), 'aria-readonly': readOnly || undefined, 'aria-label': isIncrement ? 'Increase' : 'Decrease', 'aria-controls': id, // Keyboard users shouldn't have access to the buttons, since they can use the input element // to change the value. On the other hand, `aria-hidden` is not applied because touch screen // readers should be able to use the buttons. tabIndex: -1, style: { WebkitUserSelect: 'none', userSelect: 'none' }, onTouchStart() { isTouchingButtonRef.current = true; }, onTouchEnd() { isTouchingButtonRef.current = false; }, onClick(event) { const isDisabled = disabled || readOnly || (isIncrement ? isMax : isMin); if (event.defaultPrevented || isDisabled || ( // If it's not a keyboard/virtual click, ignore. pointerTypeRef.current === 'touch' ? ignoreClickRef.current : event.detail !== 0)) { return; } commitValue(event.nativeEvent); const amount = getStepAmount(event) ?? DEFAULT_STEP; const prev = valueRef.current; incrementValue(amount, { direction: isIncrement ? 1 : -1, event: event.nativeEvent, reason: pressReason }); const committed = lastChangedValueRef.current ?? valueRef.current; if (committed !== prev) { onValueCommitted(committed, createGenericEventDetails(pressReason, event.nativeEvent)); } }, onPointerDown(event) { const isMainButton = !event.button || event.button === 0; const isDisabled = disabled || (isIncrement ? isMax : isMin); if (event.defaultPrevented || readOnly || !isMainButton || isDisabled) { return; } pointerTypeRef.current = event.pointerType; ignoreClickRef.current = false; isPressedRef.current = true; incrementDownCoordsRef.current = { x: event.clientX, y: event.clientY }; commitValue(event.nativeEvent); // Note: "pen" is sometimes returned for mouse usage on Linux Chrome. if (event.pointerType !== 'touch') { event.preventDefault(); inputRef.current?.focus(); startAutoChange(isIncrement, event); } else { // We need to check if the pointerdown was intentional, and not the result of a scroll // or pinch-zoom. In that case, we don't want to change the value. intentionalTouchCheckTimeout.start(TOUCH_TIMEOUT, () => { const moves = movesAfterTouchRef.current; movesAfterTouchRef.current = 0; // Only start auto-change if the touch is still pressed (prevents races // with pointerup occurring before the timeout fires on quick taps). const stillPressed = isPressedRef.current; if (stillPressed && moves != null && moves < MAX_POINTER_MOVES_AFTER_TOUCH) { startAutoChange(isIncrement, event); ignoreClickRef.current = true; // synthesized click should be ignored } else { // No auto-change (simple tap or scroll gesture), allow the click handler // to perform a single increment and commit. ignoreClickRef.current = false; stopAutoChange(); } }); } }, onPointerUp(event) { // Ensure we mark the press as released for touch flows even if auto-change never started, // so the delayed auto-change check won’t start after a quick tap. if (event.pointerType === 'touch') { isPressedRef.current = false; } }, onPointerMove(event) { const isDisabled = disabled || readOnly || (isIncrement ? isMax : isMin); if (isDisabled || event.pointerType !== 'touch' || !isPressedRef.current) { return; } if (movesAfterTouchRef.current != null) { movesAfterTouchRef.current += 1; } const { x, y } = incrementDownCoordsRef.current; const dx = x - event.clientX; const dy = y - event.clientY; // An alternative to this technique is to detect when the NumberField's parent container // has been scrolled if (dx ** 2 + dy ** 2 > SCROLLING_POINTER_MOVE_DISTANCE ** 2) { stopAutoChange(); } }, onMouseEnter(event) { const isDisabled = disabled || readOnly || (isIncrement ? isMax : isMin); if (event.defaultPrevented || isDisabled || !isPressedRef.current || isTouchingButtonRef.current || pointerTypeRef.current === 'touch') { return; } startAutoChange(isIncrement, event); }, onMouseLeave() { if (isTouchingButtonRef.current) { return; } stopAutoChange(); }, onMouseUp() { if (isTouchingButtonRef.current) { return; } stopAutoChange(); } }; return props; }