@base-ui/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.
168 lines (164 loc) • 6.4 kB
JavaScript
'use client';
import { useRenderElement } from "../../internals/useRenderElement.mjs";
import { useButton } from "../../internals/use-button/index.mjs";
import { isTouchLikePointerType, usePressAndHold } from "../../internals/usePressAndHold.mjs";
import { parseNumber } from "../utils/parse.mjs";
import { createChangeEventDetails, createGenericEventDetails } from "../../internals/createBaseUIEventDetails.mjs";
import { REASONS } from "../../internals/reasons.mjs";
import { useNumberFieldRootContext } from "./NumberFieldRootContext.mjs";
import { stateAttributesMapping } from "../utils/stateAttributesMapping.mjs";
const SELECT_NONE_STYLE = {
WebkitUserSelect: 'none',
userSelect: 'none'
};
/**
* Shared implementation for the increment and decrement stepper buttons. They differ only in the
* direction they step and the boundary (`max` vs `min`) at which they become disabled.
*/
export function useNumberFieldStepperButton(componentProps, forwardedRef, isIncrement) {
const {
render,
className,
disabled: disabledProp = false,
nativeButton = true,
style,
...elementProps
} = componentProps;
const {
allowInputSyncRef,
formatOptionsRef,
getStepAmount,
id,
incrementValue,
inputRef,
maxWithDefault,
minWithDefault,
setValue,
state,
valueRef,
locale,
lastChangedValueRef,
onValueCommitted
} = useNumberFieldRootContext();
const {
disabled: contextDisabled,
readOnly,
value,
inputValue
} = state;
const isAtBoundary = value != null && (isIncrement ? value >= maxWithDefault : value <= minWithDefault);
const disabled = disabledProp || contextDisabled || isAtBoundary;
const pressReason = isIncrement ? REASONS.incrementPress : REASONS.decrementPress;
function commitValue(nativeEvent) {
const shouldCommitInputValue = !allowInputSyncRef.current;
allowInputSyncRef.current = true;
if (!shouldCommitInputValue) {
// The input is already synced, so step from the authoritative numeric value rather than
// re-parsing the rounded display text. Refresh the commit ref to the current value so a
// subsequent canceled step can't commit a stale `lastChangedValueRef` left over from an
// earlier change (the `setValue` that used to refresh it is now skipped on this path).
lastChangedValueRef.current = valueRef.current;
return;
}
// The input is dirty but not yet blurred, so the value won't have been committed.
const parsedValue = parseNumber(inputValue, locale, formatOptionsRef.current);
if (parsedValue !== null) {
// Sync the dirty typed value with no direction so it isn't directionally snapped
// (`snapOnStep`) before the real increment/decrement runs, which would otherwise emit a
// spurious intermediate value.
const details = createChangeEventDetails(pressReason, nativeEvent);
setValue(parsedValue, details);
// Only sync the ref base when the commit wasn't canceled, so a subsequent increment in the
// same interaction steps from the value actually applied.
if (!details.isCanceled) {
valueRef.current = parsedValue;
}
}
}
const {
pointerHandlers,
shouldSkipClick
} = usePressAndHold({
disabled: disabled || readOnly,
elementRef: inputRef,
tick(triggerEvent) {
const amount = getStepAmount(triggerEvent);
return incrementValue(amount, {
direction: isIncrement ? 1 : -1,
event: triggerEvent,
reason: pressReason
});
},
onStop(nativeEvent) {
// `onStop` fires on every release; fall back to the current value when no tick changed it.
// Step interactions never commit `null`, so the `??` can't mask a legitimate null commit.
const committed = lastChangedValueRef.current ?? valueRef.current;
onValueCommitted(committed, createGenericEventDetails(pressReason, nativeEvent));
}
});
const props = {
disabled,
'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: SELECT_NONE_STYLE,
...pointerHandlers,
onClick(event) {
const isDisabled = disabled || readOnly;
if (event.defaultPrevented || isDisabled || shouldSkipClick(event)) {
return;
}
commitValue(event.nativeEvent);
const amount = getStepAmount(event);
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) {
if (event.defaultPrevented || readOnly || event.button || disabled) {
return;
}
// Sync dirty input value before starting the hold sequence.
commitValue(event.nativeEvent);
// Treat `lastChangedValueRef` as a per-hold result slot. If the first tick is a no-op or is
// canceled, `onStop` should fall back to the current value, not a previous interaction.
lastChangedValueRef.current = null;
if (!isTouchLikePointerType(event.pointerType)) {
// Focus the input so the user can continue with keyboard interactions.
inputRef.current?.focus();
}
pointerHandlers.onPointerDown(event);
}
};
const {
getButtonProps,
buttonRef
} = useButton({
// Read-only steppers are exposed as unavailable through button disabled semantics, while
// `data-readonly` (from `state`) is preserved for styling. `aria-readonly` isn't valid on the
// `button` role, so it's intentionally not set.
disabled: disabled || readOnly,
native: nativeButton,
focusableWhenDisabled: true
});
const buttonState = {
...state,
disabled
};
return useRenderElement('button', componentProps, {
ref: [forwardedRef, buttonRef],
state: buttonState,
props: [props, elementProps, getButtonProps],
stateAttributesMapping
});
}