import { createChangeEventDetails, createGenericEventDetails, REASONS, } from "../../internals/change-event-details"; import { CHANGE_VALUE_TICK_DELAY, DEFAULT_STEP, SCROLLING_POINTER_MOVE_DISTANCE, START_AUTO_CHANGE_DELAY, } from "../utils/constants.ts"; import { parseNumber } from "../utils/parse.ts"; import type { Direction, EventWithOptionalKeyState, IncrementValueParameters, } from "../utils/types.ts"; import { type PressAndHoldPointerHandlers, usePressAndHold, } from "../utils/use-press-and-hold.ts"; import type { NumberFieldRootChangeEventDetails } from "./number-field-root.tsx"; import type { NumberFieldRootChangeEventReason, NumberFieldRootCommitEventDetails, } from "./number-field-root-context.ts"; function isTouchLikePointerType(pointerType: string): boolean { return pointerType === "touch" || pointerType === "pen"; } export interface UseNumberFieldButtonParameters { allowInputSyncRef: { current: boolean }; disabled: () => boolean; formatOptionsRef: { current: Intl.NumberFormatOptions | undefined }; getStepAmount: (event?: EventWithOptionalKeyState) => number | undefined; id: string; incrementValue: (amount: number, params: IncrementValueParameters) => boolean; inputRef: { current: HTMLInputElement | null }; inputValue: () => string; isIncrement: boolean; locale?: (() => Intl.LocalesArgument | undefined) | undefined; readOnly: () => boolean; setValue: ( value: number | null, details: NumberFieldRootChangeEventDetails, ) => boolean; valueRef: { current: number | null }; lastChangedValueRef: { current: number | null }; onValueCommitted: ( value: number | null, eventDetails: NumberFieldRootCommitEventDetails, ) => void; } export interface UseNumberFieldButtonReturn { props: Record; } /** * Shared handler factory for `` and * ``. Mirrors React's * `useNumberFieldButton` — the single press path runs `commitValue` then * `incrementValue`; hold starts the auto-change sequence via * {@link usePressAndHold}. */ export function useNumberFieldButton( params: UseNumberFieldButtonParameters, ): UseNumberFieldButtonReturn { const pressReason: NumberFieldRootChangeEventReason = params.isIncrement ? REASONS.incrementPress : REASONS.decrementPress; function commitValue(nativeEvent: MouseEvent | PointerEvent): void { params.allowInputSyncRef.current = true; const parsedValue = parseNumber( params.inputValue(), params.locale?.(), params.formatOptionsRef.current, ); if (parsedValue !== null) { params.valueRef.current = parsedValue; params.setValue( parsedValue, createChangeEventDetails< NumberFieldRootChangeEventReason, { direction?: Direction | undefined } >(pressReason, nativeEvent, undefined, { direction: params.isIncrement ? 1 : -1, }), ); } } const { pointerHandlers, shouldSkipClick } = usePressAndHold({ disabled: () => params.disabled() || params.readOnly(), elementRef: params.inputRef, tickDelay: CHANGE_VALUE_TICK_DELAY, startDelay: START_AUTO_CHANGE_DELAY, scrollDistance: SCROLLING_POINTER_MOVE_DISTANCE, tick(triggerEvent) { const amount = params.getStepAmount(triggerEvent as EventWithOptionalKeyState) ?? DEFAULT_STEP; return params.incrementValue(amount, { direction: params.isIncrement ? 1 : -1, event: triggerEvent, reason: pressReason, }); }, onStop(nativeEvent: PointerEvent) { const committed = params.lastChangedValueRef.current ?? params.valueRef.current; params.onValueCommitted( committed, createGenericEventDetails(pressReason, nativeEvent), ); }, }); const buttonProps: Record = { // Seed `"aria-readonly"` with an empty string so Solid 2's DOM spread // keeps the key in `ownKeys` from render 1 (see // `test/internals/merge-props-undefined-attr.test.tsx`). `undefined` // collapses to "absent" via `mergePropsN`, but seeding avoids a late // binding issue when `readOnly` flips after render. get "aria-readonly"(): "true" | undefined { return params.readOnly() ? "true" : undefined; }, "aria-label": params.isIncrement ? "Increase" : "Decrease", "aria-controls": params.id, tabIndex: -1, style: { "-webkit-user-select": "none", "user-select": "none", }, // `touchstart` is scroll-blocking: a plain `onTouchStart` prop registers a // non-passive listener (Chrome perf violation). The handler only flips a // flag, so register it passive via Solid's `on:` namespace (a non-function // value is forwarded as the `addEventListener` options). `touchend` is not // scroll-blocking, so it stays an ordinary prop. "on:touchstart": { handleEvent: pointerHandlers.onTouchStart, passive: true, }, onTouchEnd: pointerHandlers.onTouchEnd, onPointerUp: pointerHandlers.onPointerUp, onPointerMove: pointerHandlers.onPointerMove, onMouseEnter: pointerHandlers.onMouseEnter, onMouseLeave: pointerHandlers.onMouseLeave, onMouseUp: pointerHandlers.onMouseUp, onClick(event: MouseEvent) { const isDisabled = params.disabled() || params.readOnly(); if (event.defaultPrevented || isDisabled || shouldSkipClick(event)) { return; } commitValue(event); const amount = params.getStepAmount(event) ?? DEFAULT_STEP; const prev = params.valueRef.current; params.incrementValue(amount, { direction: params.isIncrement ? 1 : -1, event, reason: pressReason, }); const committed = params.lastChangedValueRef.current ?? params.valueRef.current; if (committed !== prev) { params.onValueCommitted( committed, createGenericEventDetails(pressReason, event), ); } }, onPointerDown(event: PointerEvent) { const isMainButton = !event.button || event.button === 0; if ( event.defaultPrevented || params.readOnly() || !isMainButton || params.disabled() ) { return; } commitValue(event); if (!isTouchLikePointerType(event.pointerType)) { params.inputRef.current?.focus(); } pointerHandlers.onPointerDown(event); }, }; return { props: buttonProps }; } export type { PressAndHoldPointerHandlers };