@v0xoss/number-input
Version:
The numeric input component is designed for users to enter a number, and increase or decrease the value using stepper buttons
445 lines (442 loc) • 15.5 kB
JavaScript
"use client";
// src/use-number-input.ts
import { useLabelPlacement, mapPropsVariants, useProviderContext } from "@v0xoss/system";
import { useSafeLayoutEffect } from "@v0xoss/use-safe-layout-effect";
import { useFocusRing } from "@react-aria/focus";
import { numberInput } from "@v0xoss/theme";
import { useDOMRef, filterDOMProps } from "@v0xoss/react-utils";
import { useFocusWithin, useHover, usePress } from "@react-aria/interactions";
import { useLocale } from "@react-aria/i18n";
import { clsx, dataAttr, isEmpty, objectToDeps, chain, mergeProps } from "@v0xoss/shared-utils";
import { useNumberFieldState } from "@react-stately/numberfield";
import { useNumberField as useAriaNumberInput } from "@react-aria/numberfield";
import { useMemo, useCallback, useState } from "react";
import { FormContext, useSlottedContext } from "@v0xoss/form";
function useNumberInput(originalProps) {
var _a, _b, _c;
const globalContext = useProviderContext();
const { validationBehavior: formValidationBehavior } = useSlottedContext(FormContext) || {};
const [props, variantProps] = mapPropsVariants(originalProps, numberInput.variantKeys);
const {
ref,
as,
type,
label,
baseRef,
wrapperRef,
description,
className,
classNames,
autoFocus,
startContent,
endContent,
onClear,
onChange,
validationBehavior = (_a = formValidationBehavior != null ? formValidationBehavior : globalContext == null ? void 0 : globalContext.validationBehavior) != null ? _a : "native",
innerWrapperRef: innerWrapperRefProp,
onValueChange,
hideStepper,
...otherProps
} = props;
const [isFocusWithin, setFocusWithin] = useState(false);
const Component = as || "div";
const disableAnimation = (_c = (_b = originalProps.disableAnimation) != null ? _b : globalContext == null ? void 0 : globalContext.disableAnimation) != null ? _c : false;
const domRef = useDOMRef(ref);
const baseDomRef = useDOMRef(baseRef);
const inputWrapperRef = useDOMRef(wrapperRef);
const innerWrapperRef = useDOMRef(innerWrapperRefProp);
const { locale } = useLocale();
const state = useNumberFieldState({
...originalProps,
validationBehavior,
locale,
onChange: chain(onValueChange, onChange)
});
const {
groupProps,
labelProps,
inputProps,
incrementButtonProps,
decrementButtonProps,
descriptionProps,
errorMessageProps,
isInvalid,
validationErrors,
validationDetails
} = useAriaNumberInput({ ...originalProps, validationBehavior }, state, domRef);
const inputValue = isNaN(state.numberValue) ? "" : state.numberValue;
const isFilled = !isEmpty(inputValue);
const isFilledWithin = isFilled || isFocusWithin;
const baseStyles = clsx(classNames == null ? void 0 : classNames.base, className, isFilled ? "is-filled" : "");
const handleClear = useCallback(() => {
var _a2;
state.setInputValue("");
onClear == null ? void 0 : onClear();
(_a2 = domRef.current) == null ? void 0 : _a2.focus();
}, [state.setInputValue, onClear]);
useSafeLayoutEffect(() => {
if (!domRef.current) return;
state.setInputValue(domRef.current.value);
}, [domRef.current]);
const { isFocusVisible, isFocused, focusProps } = useFocusRing({
autoFocus,
isTextInput: true
});
const { isHovered, hoverProps } = useHover({ isDisabled: !!(originalProps == null ? void 0 : originalProps.isDisabled) });
const { isHovered: isLabelHovered, hoverProps: labelHoverProps } = useHover({
isDisabled: !!(originalProps == null ? void 0 : originalProps.isDisabled)
});
const { focusProps: clearFocusProps, isFocusVisible: isClearButtonFocusVisible } = useFocusRing();
const { focusWithinProps } = useFocusWithin({
onFocusWithinChange: setFocusWithin
});
const { pressProps: clearPressProps } = usePress({
isDisabled: !!(originalProps == null ? void 0 : originalProps.isDisabled) || !!(originalProps == null ? void 0 : originalProps.isReadOnly),
onPress: handleClear
});
const labelPlacement = useLabelPlacement({
labelPlacement: originalProps.labelPlacement,
label
});
const errorMessage = typeof props.errorMessage === "function" ? props.errorMessage({ isInvalid, validationErrors, validationDetails }) : props.errorMessage || (validationErrors == null ? void 0 : validationErrors.join(" "));
const isClearable = !!onClear || originalProps.isClearable;
const hasElements = !!label || !!description || !!errorMessage;
const hasPlaceholder = !!props.placeholder;
const hasLabel = !!label;
const hasHelper = !!description || !!errorMessage;
const shouldLabelBeOutside = labelPlacement === "outside" || labelPlacement === "outside-left";
const shouldLabelBeInside = labelPlacement === "inside";
const isPlaceholderShown = domRef.current ? (!domRef.current.value || domRef.current.value === "" || !inputValue) && hasPlaceholder : false;
const isOutsideLeft = labelPlacement === "outside-left";
const hasStartContent = !!startContent;
const isLabelOutside = shouldLabelBeOutside ? labelPlacement === "outside-left" || hasPlaceholder || labelPlacement === "outside" && hasStartContent : false;
const isLabelOutsideAsPlaceholder = labelPlacement === "outside" && !hasPlaceholder && !hasStartContent;
const slots = useMemo(
() => numberInput({
...variantProps,
isInvalid,
isClearable,
disableAnimation
}),
[objectToDeps(variantProps), isInvalid, isClearable, hasStartContent, disableAnimation]
);
const handleKeyDown = useCallback(
(e) => {
if (e.key === "Escape" && inputValue && (isClearable || onClear) && !originalProps.isReadOnly) {
state.setInputValue("");
onClear == null ? void 0 : onClear();
}
},
[inputValue, state.setInputValue, onClear, isClearable, originalProps.isReadOnly]
);
const getBaseProps = useCallback(
(props2 = {}) => {
return {
ref: baseDomRef,
className: slots.base({ class: baseStyles }),
"data-slot": "base",
"data-filled": dataAttr(
isFilled || hasPlaceholder || hasStartContent || isPlaceholderShown
),
"data-filled-within": dataAttr(
isFilledWithin || hasPlaceholder || hasStartContent || isPlaceholderShown
),
"data-focus-within": dataAttr(isFocusWithin),
"data-focus-visible": dataAttr(isFocusVisible),
"data-readonly": dataAttr(originalProps.isReadOnly),
"data-focus": dataAttr(isFocused),
"data-hover": dataAttr(isHovered || isLabelHovered),
"data-required": dataAttr(originalProps.isRequired),
"data-invalid": dataAttr(isInvalid),
"data-disabled": dataAttr(originalProps.isDisabled),
"data-has-elements": dataAttr(hasElements),
"data-has-helper": dataAttr(hasHelper),
"data-has-label": dataAttr(hasLabel),
"data-has-value": dataAttr(!isPlaceholderShown),
...focusWithinProps,
...props2
};
},
[
slots,
baseStyles,
isFilled,
isFocused,
isHovered,
isLabelHovered,
isInvalid,
hasHelper,
hasLabel,
hasElements,
isPlaceholderShown,
hasStartContent,
isFocusWithin,
isFocusVisible,
hasPlaceholder,
focusWithinProps,
originalProps.isReadOnly,
originalProps.isRequired,
originalProps.isDisabled
]
);
const getLabelProps = useCallback(
(props2 = {}) => {
return {
"data-slot": "label",
className: slots.label({ class: classNames == null ? void 0 : classNames.label }),
...mergeProps(labelProps, labelHoverProps, props2)
};
},
[slots, isLabelHovered, labelProps, classNames == null ? void 0 : classNames.label]
);
const getNumberInputProps = useCallback(
(props2 = {}) => {
return {
"data-slot": "input",
"data-filled": dataAttr(isFilled),
"data-has-start-content": dataAttr(hasStartContent),
"data-has-end-content": dataAttr(!!endContent),
className: slots.input({
class: clsx(classNames == null ? void 0 : classNames.input, isFilled ? "is-filled" : "")
}),
...mergeProps(
focusProps,
inputProps,
filterDOMProps(otherProps, {
enabled: true,
labelable: true,
omitEventNames: new Set(Object.keys(inputProps)),
omitPropNames: /* @__PURE__ */ new Set(["value"])
}),
props2
),
"aria-readonly": dataAttr(originalProps.isReadOnly),
onChange: chain(inputProps.onChange, onChange),
onKeyDown: chain(inputProps.onKeyDown, props2.onKeyDown, handleKeyDown),
ref: domRef
};
},
[
slots,
focusProps,
inputProps,
otherProps,
isFilled,
hasStartContent,
endContent,
classNames == null ? void 0 : classNames.input,
originalProps.isReadOnly,
originalProps.isRequired,
onChange,
handleKeyDown
]
);
const getHiddenNumberInputProps = useCallback(
(props2 = {}) => {
return {
name: originalProps.name,
value: inputValue,
"data-slot": "hidden-input",
type: "hidden",
...props2
};
},
[inputValue, originalProps.name]
);
const getInputWrapperProps = useCallback(
(props2 = {}) => {
return {
ref: inputWrapperRef,
"data-slot": "input-wrapper",
"data-hover": dataAttr(isHovered || isLabelHovered),
"data-focus-visible": dataAttr(isFocusVisible),
"data-focus": dataAttr(isFocused),
className: slots.inputWrapper({
class: clsx(classNames == null ? void 0 : classNames.inputWrapper, isFilled ? "is-filled" : "")
}),
...mergeProps(props2, hoverProps),
onClick: (e) => {
if (domRef.current && e.currentTarget === e.target) {
domRef.current.focus();
}
},
style: {
cursor: "text",
...props2.style
}
};
},
[
slots,
isHovered,
isLabelHovered,
isFocusVisible,
isFocused,
inputValue,
classNames == null ? void 0 : classNames.inputWrapper
]
);
const getInnerWrapperProps = useCallback(
(props2 = {}) => {
return {
ref: innerWrapperRef,
"data-slot": "inner-wrapper",
onClick: (e) => {
if (domRef.current && e.currentTarget === e.target) {
domRef.current.focus();
}
},
className: slots.innerWrapper({
class: clsx(classNames == null ? void 0 : classNames.innerWrapper, props2 == null ? void 0 : props2.className)
}),
...mergeProps(groupProps, props2)
};
},
[slots, classNames == null ? void 0 : classNames.innerWrapper]
);
const getMainWrapperProps = useCallback(
(props2 = {}) => {
return {
...props2,
"data-slot": "main-wrapper",
className: slots.mainWrapper({
class: clsx(classNames == null ? void 0 : classNames.mainWrapper, props2 == null ? void 0 : props2.className)
})
};
},
[slots, classNames == null ? void 0 : classNames.mainWrapper]
);
const getHelperWrapperProps = useCallback(
(props2 = {}) => {
return {
...props2,
"data-slot": "helper-wrapper",
className: slots.helperWrapper({
class: clsx(classNames == null ? void 0 : classNames.helperWrapper, props2 == null ? void 0 : props2.className)
})
};
},
[slots, classNames == null ? void 0 : classNames.helperWrapper]
);
const getDescriptionProps = useCallback(
(props2 = {}) => {
return {
...props2,
...descriptionProps,
"data-slot": "description",
className: slots.description({ class: clsx(classNames == null ? void 0 : classNames.description, props2 == null ? void 0 : props2.className) })
};
},
[slots, classNames == null ? void 0 : classNames.description]
);
const getErrorMessageProps = useCallback(
(props2 = {}) => {
return {
...props2,
...errorMessageProps,
"data-slot": "error-message",
className: slots.errorMessage({ class: clsx(classNames == null ? void 0 : classNames.errorMessage, props2 == null ? void 0 : props2.className) })
};
},
[slots, errorMessageProps, classNames == null ? void 0 : classNames.errorMessage]
);
const getClearButtonProps = useCallback(
(props2 = {}) => {
return {
...props2,
type: "button",
tabIndex: -1,
disabled: originalProps.isDisabled,
"aria-label": "clear input",
"data-slot": "clear-button",
"data-focus-visible": dataAttr(isClearButtonFocusVisible),
className: slots.clearButton({ class: clsx(classNames == null ? void 0 : classNames.clearButton, props2 == null ? void 0 : props2.className) }),
...mergeProps(clearPressProps, clearFocusProps)
};
},
[slots, isClearButtonFocusVisible, clearPressProps, clearFocusProps, classNames == null ? void 0 : classNames.clearButton]
);
const getStepperWrapperProps = useCallback(
(props2 = {}) => {
return {
...props2,
"data-slot": "stepper-wrapper",
className: slots.stepperWrapper({
class: clsx(classNames == null ? void 0 : classNames.stepperWrapper, props2 == null ? void 0 : props2.className)
})
};
},
[slots]
);
const getStepperIncreaseButtonProps = useCallback(
(props2 = {}) => {
return {
...props2,
type: "button",
disabled: originalProps.isDisabled,
"data-slot": "increase-button",
className: slots.stepperButton({
class: clsx(classNames == null ? void 0 : classNames.stepperButton, props2 == null ? void 0 : props2.className)
}),
...mergeProps(incrementButtonProps, props2)
};
},
[slots, incrementButtonProps, classNames == null ? void 0 : classNames.stepperButton]
);
const getStepperDecreaseButtonProps = useCallback(
(props2 = {}) => {
return {
type: "button",
disabled: originalProps.isDisabled,
"data-slot": "decrease-button",
className: slots.stepperButton({
class: clsx(classNames == null ? void 0 : classNames.stepperButton, props2 == null ? void 0 : props2.className)
}),
...mergeProps(decrementButtonProps, props2)
};
},
[slots, decrementButtonProps, classNames == null ? void 0 : classNames.stepperButton]
);
return {
Component,
classNames,
type,
domRef,
label,
description,
startContent,
endContent,
labelPlacement,
isClearable,
hasHelper,
hasStartContent,
isLabelOutside,
isOutsideLeft,
isLabelOutsideAsPlaceholder,
shouldLabelBeOutside,
shouldLabelBeInside,
hasPlaceholder,
isInvalid,
errorMessage,
hideStepper,
incrementButtonProps,
decrementButtonProps,
getBaseProps,
getLabelProps,
getNumberInputProps,
getHiddenNumberInputProps,
getMainWrapperProps,
getInputWrapperProps,
getInnerWrapperProps,
getHelperWrapperProps,
getDescriptionProps,
getErrorMessageProps,
getClearButtonProps,
getStepperIncreaseButtonProps,
getStepperDecreaseButtonProps,
getStepperWrapperProps
};
}
export {
useNumberInput
};