@yamada-ui/number-input
Version:
Yamada UI number input component
628 lines (626 loc) • 17.8 kB
JavaScript
"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