UNPKG

@yamada-ui/number-input

Version:

Yamada UI number input component

628 lines (626 loc) • 17.8 kB
"use client" // src/number-input.tsx import { forwardRef, omitThemeProps, ui, useComponentMultiStyle } from "@yamada-ui/core"; import { formControlProperties, useFormControlProps } from "@yamada-ui/form-control"; import { ChevronIcon } from "@yamada-ui/icon"; import { useCounter } from "@yamada-ui/use-counter"; import { useEventListener } from "@yamada-ui/use-event-listener"; import { useInterval } from "@yamada-ui/use-interval"; import { ariaAttr, createContext, cx, handlerAll, mergeRefs, pickObject, useCallbackRef, useSafeLayoutEffect, useUpdateEffect } from "@yamada-ui/utils"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { jsx, jsxs } from "react/jsx-runtime"; var isDefaultValidCharacter = (character) => /^[Ee0-9+\-.]$/.test(character); var isValidNumericKeyboardEvent = ({ key, altKey, ctrlKey, metaKey }, isValid) => { const isModifierKey = ctrlKey || altKey || metaKey; const isSingleCharacterKey = key.length === 1; if (!isSingleCharacterKey || isModifierKey) return true; return isValid(key); }; var getStep = ({ ctrlKey, metaKey, shiftKey }) => { let ratio = 1; if (metaKey || ctrlKey) ratio = 0.1; if (shiftKey) ratio = 10; return ratio; }; var useNumberInput = (props = {}) => { const { id, name, allowMouseWheel, clampValueOnBlur = true, defaultValue, focusInputOnChange = true, format: formatProp, getAriaValueText: getAriaValueTextProp, inputMode = "decimal", isValidCharacter: isValidCharacterProp, keepWithinRange = true, max: maxValue = Number.MAX_SAFE_INTEGER, min: minValue = Number.MIN_SAFE_INTEGER, parse: parseProp, pattern = "[0-9]*(.[0-9]+)?", precision, step: stepProp, value: valueProp, onChange: onChangeProp, onInvalid: onInvalidProp, ...rest } = useFormControlProps(props); const { "aria-invalid": invalid, disabled, readOnly, required, onBlur: onBlurProp, onFocus: onFocusProp, ...formControlProps } = pickObject(rest, formControlProperties); const [focused, setFocused] = useState(false); const interactive = !(readOnly || disabled); const inputRef = useRef(null); const inputSelectionRef = useRef(null); const incrementRef = useRef(null); const decrementRef = useRef(null); const onFocus = useCallbackRef( handlerAll(onFocusProp, (ev) => { var _a, _b; setFocused(true); if (!inputSelectionRef.current) return; ev.target.selectionStart = (_a = inputSelectionRef.current.start) != null ? _a : ev.currentTarget.value.length; ev.currentTarget.selectionEnd = (_b = inputSelectionRef.current.end) != null ? _b : ev.currentTarget.selectionStart; }) ); const onBlur = useCallbackRef( handlerAll(onBlurProp, () => { setFocused(false); if (clampValueOnBlur) validateAndClamp(); }) ); const onInvalid = useCallbackRef(onInvalidProp); const getAriaValueText = useCallbackRef(getAriaValueTextProp); const isValidCharacter = useCallbackRef( isValidCharacterProp != null ? isValidCharacterProp : isDefaultValidCharacter ); const { cast, max, min, out, setValue, update, value, valueAsNumber, ...counter } = useCounter({ defaultValue, keepWithinRange, max: maxValue, min: minValue, precision, step: stepProp, value: valueProp, onChange: onChangeProp }); const valueText = useMemo(() => { let text = getAriaValueText(value); if (text != null) return text; text = value.toString(); return !text ? void 0 : text; }, [value, getAriaValueText]); const sanitize = useCallback( (value2) => value2.split("").filter(isValidCharacter).join(""), [isValidCharacter] ); const parse = useCallback( (value2) => { var _a; return (_a = parseProp == null ? void 0 : parseProp(value2)) != null ? _a : value2; }, [parseProp] ); const format = useCallback( (value2) => { var _a; return ((_a = formatProp == null ? void 0 : formatProp(value2)) != null ? _a : value2).toString(); }, [formatProp] ); const increment = useCallback( (step = stepProp != null ? stepProp : 1) => { if (interactive) counter.increment(step); }, [interactive, counter, stepProp] ); const decrement = useCallback( (step = stepProp != null ? stepProp : 1) => { if (interactive) counter.decrement(step); }, [interactive, counter, stepProp] ); const validateAndClamp = useCallback(() => { let nextValue = value; if (value === "") return; const valueStartsWithE = /^[eE]/.test(value.toString()); if (valueStartsWithE) { setValue(""); } else { if (valueAsNumber < minValue) nextValue = minValue; if (valueAsNumber > maxValue) nextValue = maxValue; cast(nextValue); } }, [cast, maxValue, minValue, setValue, value, valueAsNumber]); const onChange = useCallback( (ev) => { if (ev.nativeEvent.isComposing) return; const parsedInput = parse(ev.currentTarget.value); update(sanitize(parsedInput)); inputSelectionRef.current = { end: ev.currentTarget.selectionEnd, start: ev.currentTarget.selectionStart }; }, [parse, update, sanitize] ); const onKeyDown = useCallback( (ev) => { if (ev.nativeEvent.isComposing) return; if (!isValidNumericKeyboardEvent(ev, isValidCharacter)) ev.preventDefault(); const step = getStep(ev) * (stepProp != null ? stepProp : 1); const keyMap = { ArrowDown: () => decrement(step), ArrowUp: () => increment(step), End: () => update(maxValue), Home: () => update(minValue) }; const action = keyMap[ev.key]; if (!action) return; ev.preventDefault(); action(ev); }, [ decrement, increment, isValidCharacter, maxValue, minValue, stepProp, update ] ); const { down, isSpinning, stop, up } = useSpinner(increment, decrement); useAttributeObserver(incrementRef, ["disabled"], isSpinning, stop); useAttributeObserver(decrementRef, ["disabled"], isSpinning, stop); const focusInput = useCallback(() => { if (focusInputOnChange) requestAnimationFrame(() => { var _a; (_a = inputRef.current) == null ? void 0 : _a.focus(); }); }, [focusInputOnChange]); const eventUp = useCallback( (ev) => { ev.preventDefault(); up(); focusInput(); }, [focusInput, up] ); const eventDown = useCallback( (ev) => { ev.preventDefault(); down(); focusInput(); }, [focusInput, down] ); useUpdateEffect(() => { if (valueAsNumber > maxValue) { onInvalid("rangeOverflow", format(value), valueAsNumber); } else if (valueAsNumber < minValue) { onInvalid("rangeOverflow", format(value), valueAsNumber); } }, [valueAsNumber, value, format, onInvalid]); useSafeLayoutEffect(() => { if (!inputRef.current) return; const notInSync = inputRef.current.value != value; if (!notInSync) return; const parsedInput = parse(inputRef.current.value); setValue(sanitize(parsedInput)); }, [parse, sanitize]); useEventListener( () => inputRef.current, "wheel", (ev) => { var _a, _b; const ownerDocument = (_b = (_a = inputRef.current) == null ? void 0 : _a.ownerDocument) != null ? _b : document; const focused2 = ownerDocument.activeElement === inputRef.current; if (!allowMouseWheel || !focused2) return; ev.preventDefault(); const step = getStep(ev) * (stepProp != null ? stepProp : 1); const direction = Math.sign(ev.deltaY); if (direction === -1) { increment(step); } else if (direction === 1) { decrement(step); } }, { passive: false } ); const getInputProps = useCallback( (props2 = {}, ref = null) => ({ id, type: "text", name, disabled, inputMode, pattern, readOnly, required, role: "spinbutton", ...formControlProps, ...props2, ref: mergeRefs(inputRef, ref), "aria-invalid": ariaAttr(invalid != null ? invalid : out), "aria-valuemax": maxValue, "aria-valuemin": minValue, "aria-valuenow": Number.isNaN(valueAsNumber) ? void 0 : valueAsNumber, "aria-valuetext": valueText, autoComplete: "off", autoCorrect: "off", max: maxValue, min: minValue, step: stepProp, value: format(value), onBlur: handlerAll(props2.onBlur, onBlur), onChange: handlerAll(props2.onChange, onChange), onFocus: handlerAll(props2.onFocus, onFocus), onKeyDown: handlerAll(props2.onKeyDown, onKeyDown) }), [ id, name, inputMode, pattern, required, disabled, readOnly, formControlProps, maxValue, minValue, stepProp, format, value, valueAsNumber, valueText, invalid, out, onChange, onKeyDown, onFocus, onBlur ] ); const getIncrementProps = useCallback( (props2 = {}, ref = null) => { var _a; const trulyDisabled = disabled || keepWithinRange && max; return { "aria-label": "Increase", disabled: trulyDisabled, readOnly, required, ...formControlProps, ...props2, ref: mergeRefs(ref, incrementRef), style: { ...props2.style, cursor: readOnly ? "not-allowed" : (_a = props2.style) == null ? void 0 : _a.cursor }, tabIndex: -1, onPointerDown: handlerAll(props2.onPointerDown, (ev) => { if (ev.button === 0 && !trulyDisabled) eventUp(ev); }), onPointerLeave: handlerAll(props2.onPointerLeave, stop), onPointerUp: handlerAll(props2.onPointerUp, stop) }; }, [ disabled, keepWithinRange, max, required, readOnly, formControlProps, stop, eventUp ] ); const getDecrementProps = useCallback( (props2 = {}, ref = null) => { var _a; const trulyDisabled = disabled || keepWithinRange && min; return { "aria-label": "Decrease", disabled: trulyDisabled, readOnly, required, ...formControlProps, ...props2, ref: mergeRefs(ref, decrementRef), style: { ...props2.style, cursor: readOnly ? "not-allowed" : (_a = props2.style) == null ? void 0 : _a.cursor }, tabIndex: -1, onPointerDown: handlerAll(props2.onPointerDown, (ev) => { if (ev.button === 0 && !trulyDisabled) eventDown(ev); }), onPointerLeave: handlerAll(props2.onPointerLeave, stop), onPointerUp: handlerAll(props2.onPointerUp, stop) }; }, [ disabled, keepWithinRange, min, required, readOnly, formControlProps, stop, eventDown ] ); return { disabled, focused, /** * @deprecated Use `disabled` instead. */ isDisabled: disabled, /** * @deprecated Use `readOnly` instead. */ isReadOnly: readOnly, /** * @deprecated Use `required` instead. */ isRequired: required, props: rest, readOnly, required, value: format(value), valueAsNumber, getDecrementProps, getIncrementProps, getInputProps }; }; var INTERVAL = 50; var DELAY = 300; var useSpinner = (increment, decrement) => { const [isSpinning, setIsSpinning] = useState(false); const [action, setAction] = useState(null); const [isOnce, setIsOnce] = useState(true); const timeoutRef = useRef(null); const removeTimeout = () => clearTimeout(timeoutRef.current); useInterval( () => { if (action === "increment") increment(); if (action === "decrement") decrement(); }, isSpinning ? INTERVAL : null ); const up = useCallback(() => { if (isOnce) increment(); timeoutRef.current = setTimeout(() => { setIsOnce(false); setIsSpinning(true); setAction("increment"); }, DELAY); }, [increment, isOnce]); const down = useCallback(() => { if (isOnce) decrement(); timeoutRef.current = setTimeout(() => { setIsOnce(false); setIsSpinning(true); setAction("decrement"); }, DELAY); }, [decrement, isOnce]); const stop = useCallback(() => { setIsOnce(true); setIsSpinning(false); removeTimeout(); }, []); useEffect(() => { return () => removeTimeout(); }, []); return { down, isSpinning, stop, up }; }; var useAttributeObserver = (ref, attributeFilter, enabled, func) => { useEffect(() => { var _a; if (!ref.current || !enabled) return; const ownerDocument = (_a = ref.current.ownerDocument.defaultView) != null ? _a : window; const observer = new ownerDocument.MutationObserver((changes) => { for (const { type, attributeName } of changes) { if (type === "attributes" && attributeName && attributeFilter.includes(attributeName)) func(); } }); observer.observe(ref.current, { attributeFilter, attributes: true }); return () => observer.disconnect(); }); }; var [NumberInputContextProvider, useNumberInputContext] = createContext({ name: "NumberInputContext", errorMessage: `useNumberInputContext returned is 'undefined'. Seems you forgot to wrap the components in "<NumberInput />"` }); var NumberInput = forwardRef( (props, ref) => { const [styles, mergedProps] = useComponentMultiStyle("NumberInput", props); const { className, isStepper = true, stepper = isStepper, addonProps, containerProps, decrementProps, incrementProps, ...computedProps } = omitThemeProps(mergedProps); const { props: rest, getDecrementProps, getIncrementProps, getInputProps } = useNumberInput(computedProps); const css = { position: "relative", zIndex: 0, ...styles.container }; return /* @__PURE__ */ jsx( NumberInputContextProvider, { value: { styles, getDecrementProps, getIncrementProps, getInputProps }, children: /* @__PURE__ */ jsxs( ui.div, { className: cx("ui-number-input", className), role: "group", __css: css, ...containerProps, children: [ /* @__PURE__ */ jsx(NumberInputField, { ...getInputProps(rest, ref) }), stepper ? /* @__PURE__ */ jsxs(NumberInputAddon, { ...addonProps, children: [ /* @__PURE__ */ jsx(NumberIncrementStepper, { ...incrementProps }), /* @__PURE__ */ jsx(NumberDecrementStepper, { ...decrementProps }) ] }) : null ] } ) } ); } ); NumberInput.displayName = "NumberInput"; NumberInput.__ui__ = "NumberInput"; var NumberInputField = forwardRef( ({ className, ...rest }, ref) => { const { styles } = useNumberInputContext(); const css = { width: "100%", ...styles.field }; return /* @__PURE__ */ jsx( ui.input, { ref, className: cx("ui-number-input__field", className), __css: css, ...rest } ); } ); NumberInputField.displayName = "NumberInputField"; NumberInputField.__ui__ = "NumberInputField"; var NumberInputAddon = forwardRef( ({ className, ...rest }, ref) => { const { styles } = useNumberInputContext(); const css = { display: "flex", flexDirection: "column", height: "calc(100% - 2px)", insetEnd: "0px", margin: "1px", position: "absolute", top: "0", zIndex: "fallback(yamcha, 1)", ...styles.addon }; return /* @__PURE__ */ jsx( ui.div, { ref, className: cx("ui-number-input__addon", className), "aria-hidden": true, __css: css, ...rest } ); } ); NumberInputAddon.displayName = "NumberInputAddon"; NumberInputAddon.__ui__ = "NumberInputAddon"; var Stepper = ui("button", { baseStyle: { alignItems: "center", cursor: "pointer", display: "flex", flex: 1, justifyContent: "center", lineHeight: "normal", transitionDuration: "normal", transitionProperty: "common", userSelect: "none" } }); var NumberIncrementStepper = forwardRef(({ className, children, ...rest }, ref) => { const { styles, getIncrementProps } = useNumberInputContext(); const css = { ...styles.stepper }; return /* @__PURE__ */ jsx( Stepper, { className: cx("ui-number-input__stepper--up", className), ...getIncrementProps(rest, ref), __css: css, children: children != null ? children : /* @__PURE__ */ jsx(ChevronIcon, { __css: { transform: "rotate(180deg)" } }) } ); }); NumberIncrementStepper.displayName = "NumberIncrementStepper"; NumberIncrementStepper.__ui__ = "NumberIncrementStepper"; var NumberDecrementStepper = forwardRef(({ className, children, ...rest }, ref) => { const { styles, getDecrementProps } = useNumberInputContext(); const css = { ...styles.stepper }; return /* @__PURE__ */ jsx( Stepper, { className: cx("ui-number-input__stepper--down", className), ...getDecrementProps(rest, ref), __css: css, children: children != null ? children : /* @__PURE__ */ jsx(ChevronIcon, {}) } ); }); NumberDecrementStepper.displayName = "NumberDecrementStepper"; NumberDecrementStepper.__ui__ = "NumberDecrementStepper"; export { useNumberInput, NumberInput }; //# sourceMappingURL=chunk-OW4A5AQU.mjs.map