diff --git a/.changeset/witty-flies-reflect.md b/.changeset/witty-flies-reflect.md new file mode 100644 index 0000000000..1050961106 --- /dev/null +++ b/.changeset/witty-flies-reflect.md @@ -0,0 +1,8 @@ +--- +"@heroui/number-input": patch +"@heroui/shared-icons": patch +"@heroui/theme": patch +"@heroui/react": patch +--- + +introduce NumberInput diff --git a/apps/docs/config/routes.json b/apps/docs/config/routes.json index 32ae712b6d..0b5f53c099 100644 --- a/apps/docs/config/routes.json +++ b/apps/docs/config/routes.json @@ -338,6 +338,13 @@ "keywords": "navbar, navigation, top menu, website header", "path": "/docs/components/navbar.mdx" }, + { + "key": "number-input", + "title": "Number Input", + "keywords": "input, numeric input, number input", + "path": "/docs/components/number-input.mdx", + "newPost": true + }, { "key": "pagination", "title": "Pagination", diff --git a/apps/docs/content/components/index.ts b/apps/docs/content/components/index.ts index ced4352a6f..2c252e2b20 100644 --- a/apps/docs/content/components/index.ts +++ b/apps/docs/content/components/index.ts @@ -34,3 +34,4 @@ export * from "./table"; export * from "./autocomplete"; export * from "./alert"; export * from "./drawer"; +export * from "./number-input"; diff --git a/apps/docs/content/components/number-input/clear-button.raw.jsx b/apps/docs/content/components/number-input/clear-button.raw.jsx new file mode 100644 index 0000000000..a82cf732bd --- /dev/null +++ b/apps/docs/content/components/number-input/clear-button.raw.jsx @@ -0,0 +1,16 @@ +import {NumberInput} from "@heroui/react"; + +export default function App() { + return ( + console.log("number input cleared")} + /> + ); +} diff --git a/apps/docs/content/components/number-input/clear-button.ts b/apps/docs/content/components/number-input/clear-button.ts new file mode 100644 index 0000000000..f84fe2f74e --- /dev/null +++ b/apps/docs/content/components/number-input/clear-button.ts @@ -0,0 +1,9 @@ +import App from "./clear-button.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/number-input/colors.raw.jsx b/apps/docs/content/components/number-input/colors.raw.jsx new file mode 100644 index 0000000000..8f7a386083 --- /dev/null +++ b/apps/docs/content/components/number-input/colors.raw.jsx @@ -0,0 +1,20 @@ +import {NumberInput} from "@heroui/react"; + +export default function App() { + const colors = ["default", "primary", "secondary", "success", "warning", "danger"]; + + return ( +
+ {colors.map((color) => ( + + ))} +
+ ); +} diff --git a/apps/docs/content/components/number-input/colors.ts b/apps/docs/content/components/number-input/colors.ts new file mode 100644 index 0000000000..d5bef810aa --- /dev/null +++ b/apps/docs/content/components/number-input/colors.ts @@ -0,0 +1,9 @@ +import App from "./colors.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/number-input/controlled.raw.jsx b/apps/docs/content/components/number-input/controlled.raw.jsx new file mode 100644 index 0000000000..1f11a3cc9e --- /dev/null +++ b/apps/docs/content/components/number-input/controlled.raw.jsx @@ -0,0 +1,18 @@ +import React from "react"; +import {NumberInput} from "@heroui/react"; + +export default function App() { + const [value, setValue] = React.useState(); + + return ( +
+ +

NumberInput value: {value}

+
+ ); +} diff --git a/apps/docs/content/components/number-input/controlled.ts b/apps/docs/content/components/number-input/controlled.ts new file mode 100644 index 0000000000..2c3f0cacb4 --- /dev/null +++ b/apps/docs/content/components/number-input/controlled.ts @@ -0,0 +1,9 @@ +import App from "./controlled.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/number-input/custom-styles.raw.jsx b/apps/docs/content/components/number-input/custom-styles.raw.jsx new file mode 100644 index 0000000000..4d602d7563 --- /dev/null +++ b/apps/docs/content/components/number-input/custom-styles.raw.jsx @@ -0,0 +1,39 @@ +import {NumberInput} from "@heroui/react"; + +export default function App() { + return ( +
+ +
+ ); +} diff --git a/apps/docs/content/components/number-input/custom-styles.ts b/apps/docs/content/components/number-input/custom-styles.ts new file mode 100644 index 0000000000..da3ea9093a --- /dev/null +++ b/apps/docs/content/components/number-input/custom-styles.ts @@ -0,0 +1,9 @@ +import App from "./custom-styles.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/number-input/custom-validation.raw.jsx b/apps/docs/content/components/number-input/custom-validation.raw.jsx new file mode 100644 index 0000000000..8086f0387e --- /dev/null +++ b/apps/docs/content/components/number-input/custom-validation.raw.jsx @@ -0,0 +1,43 @@ +import React from "react"; +import {Button, Form, NumberInput} from "@heroui/react"; + +export default function App() { + const [submitted, setSubmitted] = React.useState(null); + + const onSubmit = (e) => { + e.preventDefault(); + const data = Object.fromEntries(new FormData(e.currentTarget)); + + setSubmitted(data); + }; + + return ( +
+ { + if (value < 100) { + return "Number must be greater than 100"; + } + + if (value > 1000) { + return "Number must be less than 1000"; + } + + return value === 777 ? "Nice try!" : null; + }} + /> + + {submitted && ( +
+ You submitted: {JSON.stringify(submitted)} +
+ )} + + ); +} diff --git a/apps/docs/content/components/number-input/custom-validation.ts b/apps/docs/content/components/number-input/custom-validation.ts new file mode 100644 index 0000000000..b0bf5b8588 --- /dev/null +++ b/apps/docs/content/components/number-input/custom-validation.ts @@ -0,0 +1,9 @@ +import App from "./custom-validation.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/number-input/description.raw.jsx b/apps/docs/content/components/number-input/description.raw.jsx new file mode 100644 index 0000000000..6afae214e0 --- /dev/null +++ b/apps/docs/content/components/number-input/description.raw.jsx @@ -0,0 +1,12 @@ +import {NumberInput} from "@heroui/react"; + +export default function App() { + return ( + + ); +} diff --git a/apps/docs/content/components/number-input/description.ts b/apps/docs/content/components/number-input/description.ts new file mode 100644 index 0000000000..aeb6340b6b --- /dev/null +++ b/apps/docs/content/components/number-input/description.ts @@ -0,0 +1,9 @@ +import App from "./description.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/number-input/disabled.raw.jsx b/apps/docs/content/components/number-input/disabled.raw.jsx new file mode 100644 index 0000000000..2eaac6efe5 --- /dev/null +++ b/apps/docs/content/components/number-input/disabled.raw.jsx @@ -0,0 +1,13 @@ +import {NumberInput} from "@heroui/react"; + +export default function App() { + return ( + + ); +} diff --git a/apps/docs/content/components/number-input/disabled.ts b/apps/docs/content/components/number-input/disabled.ts new file mode 100644 index 0000000000..1a215cc91f --- /dev/null +++ b/apps/docs/content/components/number-input/disabled.ts @@ -0,0 +1,9 @@ +import App from "./disabled.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/number-input/error-message.raw.jsx b/apps/docs/content/components/number-input/error-message.raw.jsx new file mode 100644 index 0000000000..05cb705833 --- /dev/null +++ b/apps/docs/content/components/number-input/error-message.raw.jsx @@ -0,0 +1,15 @@ +import {NumberInput} from "@heroui/react"; + +export default function App() { + return ( + + ); +} diff --git a/apps/docs/content/components/number-input/error-message.ts b/apps/docs/content/components/number-input/error-message.ts new file mode 100644 index 0000000000..fb8101b132 --- /dev/null +++ b/apps/docs/content/components/number-input/error-message.ts @@ -0,0 +1,9 @@ +import App from "./error-message.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/number-input/format-options.raw.jsx b/apps/docs/content/components/number-input/format-options.raw.jsx new file mode 100644 index 0000000000..718f3a0ed3 --- /dev/null +++ b/apps/docs/content/components/number-input/format-options.raw.jsx @@ -0,0 +1,60 @@ +import {NumberInput} from "@heroui/react"; + +export default function App() { + return ( +
+
+ + + +
+
+ + +
+
+ ); +} diff --git a/apps/docs/content/components/number-input/format-options.ts b/apps/docs/content/components/number-input/format-options.ts new file mode 100644 index 0000000000..1b5e16a4c6 --- /dev/null +++ b/apps/docs/content/components/number-input/format-options.ts @@ -0,0 +1,9 @@ +import App from "./format-options.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/number-input/hide-stepper.raw.jsx b/apps/docs/content/components/number-input/hide-stepper.raw.jsx new file mode 100644 index 0000000000..f35cc5fade --- /dev/null +++ b/apps/docs/content/components/number-input/hide-stepper.raw.jsx @@ -0,0 +1,13 @@ +import {NumberInput} from "@heroui/react"; + +export default function App() { + return ( + + ); +} diff --git a/apps/docs/content/components/number-input/hide-stepper.ts b/apps/docs/content/components/number-input/hide-stepper.ts new file mode 100644 index 0000000000..4c2ca38524 --- /dev/null +++ b/apps/docs/content/components/number-input/hide-stepper.ts @@ -0,0 +1,9 @@ +import App from "./hide-stepper.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/number-input/index.ts b/apps/docs/content/components/number-input/index.ts new file mode 100644 index 0000000000..779bbb8385 --- /dev/null +++ b/apps/docs/content/components/number-input/index.ts @@ -0,0 +1,51 @@ +import usage from "./usage"; +import disabled from "./disabled"; +import readOnly from "./readonly"; +import required from "./required"; +import sizes from "./sizes"; +import colors from "./colors"; +import variants from "./variants"; +import radius from "./radius"; +import description from "./description"; +import isWheelDisabled from "./is-wheel-disabled"; +import label from "./label"; +import minValue from "./min-value"; +import maxValue from "./max-value"; +import hideStepper from "./hide-stepper"; +import clearButton from "./clear-button"; +import startEndContent from "./start-end-content"; +import errorMessage from "./error-message"; +import controlled from "./controlled"; +import customValidation from "./custom-validation"; +import realTimeValidation from "./real-time-validation"; +import serverValidation from "./server-validation"; +import customStyles from "./custom-styles"; +import formatOptions from "./format-options"; +import labelPlacements from "./label-placements"; + +export const numberInputContent = { + usage, + disabled, + readOnly, + required, + sizes, + colors, + variants, + radius, + description, + label, + isWheelDisabled, + minValue, + maxValue, + clearButton, + hideStepper, + startEndContent, + errorMessage, + controlled, + customValidation, + realTimeValidation, + serverValidation, + customStyles, + formatOptions, + labelPlacements, +}; diff --git a/apps/docs/content/components/number-input/is-wheel-disabled.raw.jsx b/apps/docs/content/components/number-input/is-wheel-disabled.raw.jsx new file mode 100644 index 0000000000..3cd7a7b3ae --- /dev/null +++ b/apps/docs/content/components/number-input/is-wheel-disabled.raw.jsx @@ -0,0 +1,13 @@ +import {NumberInput} from "@heroui/react"; + +export default function App() { + return ( + + ); +} diff --git a/apps/docs/content/components/number-input/is-wheel-disabled.ts b/apps/docs/content/components/number-input/is-wheel-disabled.ts new file mode 100644 index 0000000000..a44b3147fa --- /dev/null +++ b/apps/docs/content/components/number-input/is-wheel-disabled.ts @@ -0,0 +1,9 @@ +import App from "./is-wheel-disabled.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/number-input/label-placements.raw.jsx b/apps/docs/content/components/number-input/label-placements.raw.jsx new file mode 100644 index 0000000000..b1eabd4564 --- /dev/null +++ b/apps/docs/content/components/number-input/label-placements.raw.jsx @@ -0,0 +1,37 @@ +import {NumberInput} from "@heroui/react"; + +export default function App() { + const placements = ["inside", "outside", "outside-left"]; + + return ( +
+
+

Without placeholder

+
+ {placements.map((placement) => ( + + ))} +
+
+
+

With placeholder

+
+ {placements.map((placement) => ( + + ))} +
+
+
+ ); +} diff --git a/apps/docs/content/components/number-input/label-placements.ts b/apps/docs/content/components/number-input/label-placements.ts new file mode 100644 index 0000000000..cd2a65d352 --- /dev/null +++ b/apps/docs/content/components/number-input/label-placements.ts @@ -0,0 +1,9 @@ +import App from "./label-placements.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/number-input/label.raw.jsx b/apps/docs/content/components/number-input/label.raw.jsx new file mode 100644 index 0000000000..8b7b6e368a --- /dev/null +++ b/apps/docs/content/components/number-input/label.raw.jsx @@ -0,0 +1,5 @@ +import {NumberInput} from "@heroui/react"; + +export default function App() { + return ; +} diff --git a/apps/docs/content/components/number-input/label.ts b/apps/docs/content/components/number-input/label.ts new file mode 100644 index 0000000000..254b95afb8 --- /dev/null +++ b/apps/docs/content/components/number-input/label.ts @@ -0,0 +1,9 @@ +import App from "./label.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/number-input/max-value.raw.jsx b/apps/docs/content/components/number-input/max-value.raw.jsx new file mode 100644 index 0000000000..6ff15aba48 --- /dev/null +++ b/apps/docs/content/components/number-input/max-value.raw.jsx @@ -0,0 +1,14 @@ +import {NumberInput} from "@heroui/react"; + +export default function App() { + return ( + + ); +} diff --git a/apps/docs/content/components/number-input/max-value.ts b/apps/docs/content/components/number-input/max-value.ts new file mode 100644 index 0000000000..80f848912b --- /dev/null +++ b/apps/docs/content/components/number-input/max-value.ts @@ -0,0 +1,9 @@ +import App from "./max-value.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/number-input/min-value.raw.jsx b/apps/docs/content/components/number-input/min-value.raw.jsx new file mode 100644 index 0000000000..fd716da2c3 --- /dev/null +++ b/apps/docs/content/components/number-input/min-value.raw.jsx @@ -0,0 +1,14 @@ +import {NumberInput} from "@heroui/react"; + +export default function App() { + return ( + + ); +} diff --git a/apps/docs/content/components/number-input/min-value.ts b/apps/docs/content/components/number-input/min-value.ts new file mode 100644 index 0000000000..73ea3be003 --- /dev/null +++ b/apps/docs/content/components/number-input/min-value.ts @@ -0,0 +1,9 @@ +import App from "./min-value.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/number-input/radius.raw.jsx b/apps/docs/content/components/number-input/radius.raw.jsx new file mode 100644 index 0000000000..0765103af4 --- /dev/null +++ b/apps/docs/content/components/number-input/radius.raw.jsx @@ -0,0 +1,21 @@ +import {NumberInput} from "@heroui/react"; + +export default function App() { + const radius = ["full", "lg", "md", "sm", "none"]; + + return ( +
+ {radius.map((r) => ( + + ))} +
+ ); +} diff --git a/apps/docs/content/components/number-input/radius.ts b/apps/docs/content/components/number-input/radius.ts new file mode 100644 index 0000000000..7b78db1ce0 --- /dev/null +++ b/apps/docs/content/components/number-input/radius.ts @@ -0,0 +1,9 @@ +import App from "./radius.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/number-input/readonly.raw.jsx b/apps/docs/content/components/number-input/readonly.raw.jsx new file mode 100644 index 0000000000..07d9f9ad15 --- /dev/null +++ b/apps/docs/content/components/number-input/readonly.raw.jsx @@ -0,0 +1,14 @@ +import {NumberInput} from "@heroui/react"; + +export default function App() { + return ( + + ); +} diff --git a/apps/docs/content/components/number-input/readonly.ts b/apps/docs/content/components/number-input/readonly.ts new file mode 100644 index 0000000000..fabd05ba36 --- /dev/null +++ b/apps/docs/content/components/number-input/readonly.ts @@ -0,0 +1,9 @@ +import App from "./readonly.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/number-input/real-time-validation.raw.jsx b/apps/docs/content/components/number-input/real-time-validation.raw.jsx new file mode 100644 index 0000000000..3fd342424c --- /dev/null +++ b/apps/docs/content/components/number-input/real-time-validation.raw.jsx @@ -0,0 +1,54 @@ +import {Button, Form, NumberInput} from "@heroui/react"; + +export default function App() { + const [submitted, setSubmitted] = React.useState(null); + const [amount, setAmount] = React.useState(null); + const errors = []; + + const onSubmit = (e) => { + e.preventDefault(); + const data = Object.fromEntries(new FormData(e.currentTarget)); + + setSubmitted(data); + }; + + if (!amount) { + errors.push("The value must not be empty"); + } + + if (amount < 100) { + errors.push("The value must be greater than 100"); + } + + if (amount > 1000) { + errors.push("The value must be less than 1000"); + } + + return ( +
+ ( +
    + {errors.map((error, i) => ( +
  • {error}
  • + ))} +
+ )} + isInvalid={errors.length > 0} + label="Amount" + name="amount" + placeholder="Enter a number" + value={amount} + onValueChange={setAmount} + /> + + {submitted && ( +
+ You submitted: {JSON.stringify(submitted)} +
+ )} + + ); +} diff --git a/apps/docs/content/components/number-input/real-time-validation.ts b/apps/docs/content/components/number-input/real-time-validation.ts new file mode 100644 index 0000000000..6f8034a877 --- /dev/null +++ b/apps/docs/content/components/number-input/real-time-validation.ts @@ -0,0 +1,9 @@ +import App from "./real-time-validation.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/number-input/required.raw.jsx b/apps/docs/content/components/number-input/required.raw.jsx new file mode 100644 index 0000000000..4be69b0aca --- /dev/null +++ b/apps/docs/content/components/number-input/required.raw.jsx @@ -0,0 +1,13 @@ +import {NumberInput} from "@heroui/react"; + +export default function App() { + return ( + + ); +} diff --git a/apps/docs/content/components/number-input/required.ts b/apps/docs/content/components/number-input/required.ts new file mode 100644 index 0000000000..b50b781e6f --- /dev/null +++ b/apps/docs/content/components/number-input/required.ts @@ -0,0 +1,9 @@ +import App from "./required.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/number-input/server-validation.raw.jsx b/apps/docs/content/components/number-input/server-validation.raw.jsx new file mode 100644 index 0000000000..55acf4ade4 --- /dev/null +++ b/apps/docs/content/components/number-input/server-validation.raw.jsx @@ -0,0 +1,43 @@ +import {Button, Form, NumberInput} from "@heroui/react"; + +export default function App() { + const [isLoading, setIsLoading] = React.useState(false); + const [errors, setErrors] = React.useState({}); + + const onSubmit = async (e) => { + e.preventDefault(); + setIsLoading(true); + + const data = Object.fromEntries(new FormData(e.currentTarget)); + const result = await callServer(data); + + setErrors(result.errors); + setIsLoading(false); + }; + + return ( +
+ + + + ); +} + +// Fake server used in this example. +async function callServer(_) { + await new Promise((resolve) => setTimeout(resolve, 500)); + + return { + errors: { + amount: "Sorry, this amount is not valid.", + }, + }; +} diff --git a/apps/docs/content/components/number-input/server-validation.ts b/apps/docs/content/components/number-input/server-validation.ts new file mode 100644 index 0000000000..84a2823b6a --- /dev/null +++ b/apps/docs/content/components/number-input/server-validation.ts @@ -0,0 +1,9 @@ +import App from "./server-validation.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/number-input/sizes.raw.jsx b/apps/docs/content/components/number-input/sizes.raw.jsx new file mode 100644 index 0000000000..3c2122d933 --- /dev/null +++ b/apps/docs/content/components/number-input/sizes.raw.jsx @@ -0,0 +1,15 @@ +import {NumberInput} from "@heroui/react"; + +export default function App() { + const sizes = ["sm", "md", "lg"]; + + return ( +
+ {sizes.map((size) => ( +
+ +
+ ))} +
+ ); +} diff --git a/apps/docs/content/components/number-input/sizes.ts b/apps/docs/content/components/number-input/sizes.ts new file mode 100644 index 0000000000..85a2f5b30b --- /dev/null +++ b/apps/docs/content/components/number-input/sizes.ts @@ -0,0 +1,9 @@ +import App from "./sizes.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/number-input/start-end-content.raw.jsx b/apps/docs/content/components/number-input/start-end-content.raw.jsx new file mode 100644 index 0000000000..8d69f72da4 --- /dev/null +++ b/apps/docs/content/components/number-input/start-end-content.raw.jsx @@ -0,0 +1,47 @@ +import {NumberInput} from "@heroui/react"; + +export default function App() { + return ( +
+
+ + $ +
+ } + /> + + + +
+ } + label="Price" + placeholder="0.00" + /> + + + ); +} diff --git a/apps/docs/content/components/number-input/start-end-content.ts b/apps/docs/content/components/number-input/start-end-content.ts new file mode 100644 index 0000000000..e99c7e5997 --- /dev/null +++ b/apps/docs/content/components/number-input/start-end-content.ts @@ -0,0 +1,9 @@ +import App from "./start-end-content.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/number-input/usage.raw.jsx b/apps/docs/content/components/number-input/usage.raw.jsx new file mode 100644 index 0000000000..2ff441b18d --- /dev/null +++ b/apps/docs/content/components/number-input/usage.raw.jsx @@ -0,0 +1,5 @@ +import {NumberInput} from "@heroui/react"; + +export default function App() { + return ; +} diff --git a/apps/docs/content/components/number-input/usage.ts b/apps/docs/content/components/number-input/usage.ts new file mode 100644 index 0000000000..1118304c37 --- /dev/null +++ b/apps/docs/content/components/number-input/usage.ts @@ -0,0 +1,9 @@ +import App from "./usage.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/components/number-input/variants.raw.jsx b/apps/docs/content/components/number-input/variants.raw.jsx new file mode 100644 index 0000000000..eb650b47ed --- /dev/null +++ b/apps/docs/content/components/number-input/variants.raw.jsx @@ -0,0 +1,16 @@ +import {NumberInput} from "@heroui/react"; + +export default function App() { + const variants = ["flat", "bordered", "underlined", "faded"]; + + return ( +
+ {variants.map((variant) => ( +
+ + +
+ ))} +
+ ); +} diff --git a/apps/docs/content/components/number-input/variants.ts b/apps/docs/content/components/number-input/variants.ts new file mode 100644 index 0000000000..ddea95fb2e --- /dev/null +++ b/apps/docs/content/components/number-input/variants.ts @@ -0,0 +1,9 @@ +import App from "./variants.raw.jsx?raw"; + +const react = { + "/App.jsx": App, +}; + +export default { + ...react, +}; diff --git a/apps/docs/content/docs/components/number-input.mdx b/apps/docs/content/docs/components/number-input.mdx new file mode 100644 index 0000000000..054fddf9b7 --- /dev/null +++ b/apps/docs/content/docs/components/number-input.mdx @@ -0,0 +1,473 @@ +--- +title: "Number Input" +description: "The numeric input component is designed for users to enter a number, and increase or decrease the value using stepper buttons" +--- + +import {numberInputContent} from "@/content/components/number-input"; + +# Number Input + +The numeric input component is designed for users to enter a number, and increase or decrease the value using stepper buttons + + + +--- + + + +## Installation + + + +## Usage + + + +### Disabled + + + +### Read Only + + + +### Required + +If you pass the `isRequired` property to the input, it will have a `danger` asterisk at +the end of the label and the input will be required. + + + +### Sizes + + + +### Colors + + + +### Variants + + + +### Radius + + + +### Label Placements + +You can change the position of the label by setting the `labelPlacement` property to `inside`, `outside` or `outside-left`. + + + +> **Note**: If the `label` is not passed, the `labelPlacement` property will be `outside` by default. + +### Clear Button + +If you pass the `isClearable` property to the input, it will have a clear button at the +end of input, it will be visible when input has a value. + + + +### Hide Stepper + +You can hide the stepper buttons by passing the `hideStepper` property. + + + +### Start & End Content + +You can use the `startContent` and `endContent` properties to add content to the start and end of NumberInput. + + + +### With Label + +You can add a label to the input by passing the `label` property. + + + +### With Description + +You can add a description to the input by passing the `description` property. + + + +### With Min Value + +You can set the minimum value of the input by passing the `minValue` property. + + + +### With Max Value + +You can set the maximum value of the input by passing the `maxValue` property. + + + +### With Wheel Disabled + +By default, you can increase or decrease the value with scroll wheel. You can disable changing the vaule with scroll in NumberInput by passing the `isWheelDisabled` property. + + + +### With Format Options + +You can format the value of the input by passing the `formatOptions` property. + + + +### With Error Message + +You can combine the `isInvalid` and `errorMessage` properties to show an invalid input. `errorMessage` is only shown when `isInvalid` is set to `true`. + + + +### Controlled + +You can use the `value` and `onValueChange` properties to control the input value. + + + +> **Note**: HeroUI `NumberInput` also supports native events like `onChange`, useful for form libraries +> such as [Formik](https://formik.org/) and [React Hook Form](https://react-hook-form.com/). + +### With Form + +`NumberInput` can be used with a `Form` component to leverage form state management. For more on form and validation behaviors, see the [Forms](/docs/guide/forms) guide. + +#### Custom Validation + +In addition to built-in constraints, you can provide a function to the `validate` property for custom validation. + + + +#### Realtime Validation + +If you want to display validation errors while the user is typing, you can control the field value and use the `isInvalid` prop along with the `errorMessage` prop. + + + +#### Server Validation + +Client-side validation provides immediate feedback, but you should also validate data on the server to ensure accuracy and security. +HeroUI allows you to display server-side validation errors by using the `validationErrors` prop in the `Form` component. +This prop should be an object where each key is the field `name` and the value is the error message. + + + +## Slots + +- **base**: Input wrapper, it handles alignment, placement, and general appearance. +- **label**: Label of the input, it is the one that is displayed above, inside or left of the input. +- **mainWrapper**: Wraps the `inputWrapper` +- **inputWrapper**: Wraps the `label` (when it is inside) and the `innerWrapper`. +- **innerWrapper**: Wraps the `input`, the `startContent` and the `endContent`. +- **input**: The input element. +- **clearButton**: The clear button, it is at the end of the input. +- **stepperButton**: The stepper button to increase or decrease the value. +- **stepperWrapper**: The wrapper for the stepper. +- **description**: The description of NumberInput. +- **errorMessage**: The error message of NumberInput. + +### Custom Styles + +You can customize the `NumberInput` component by passing custom Tailwind CSS classes to the component slots. + + + + + +## Data Attributes + +`NumberInput` has the following attributes on the `base` element: + +- **data-invalid**: + When the input is invalid. Based on `isInvalid` prop. +- **data-required**: + When the input is required. Based on `isRequired` prop. +- **data-readonly**: + When the input is readonly. Based on `isReadOnly` prop. +- **data-hover**: + When the input is being hovered. Based on [useHover](https://react-spectrum.adobe.com/react-aria/useHover.html) +- **data-focus**: + When the input is being focused. Based on [useFocusRing](https://react-spectrum.adobe.com/react-aria/useFocusRing.html). +- **data-focus-within**: + When the input is being focused or any of its children. Based on [useFocusWithin](https://react-spectrum.adobe.com/react-aria/useFocusWithin.html). +- **data-focus-visible**: + When the input is being focused with the keyboard. Based on [useFocusRing](https://react-spectrum.adobe.com/react-aria/useFocusRing.html). +- **data-disabled**: + When the input is disabled. Based on `isDisabled` prop. +- **data-filled**: + When the input has content, placeholder, start content or the placeholder is shown. +- **data-has-elements**: + When the input has any element (label, helper text, description, error message). +- **data-has-helper**: + When the input has helper text. +- **data-has-description**: + When the input has a description. +- **data-has-label**: + When the input has a label. +- **data-has-value**: + When the input has a value (placeholder is not shown). + + + + +## Accessibility + +- Built with a native `` element with `type="number"`. +- Visual and ARIA labeling support. +- Change, clipboard, composition, selection, and input event support. +- Required and invalid states exposed to assistive technology via ARIA. +- Support for description, helper text, and error message linked to the input via ARIA. + + + +## API + +### NumberInput Props + + ReactNode)", + description: "An error message for the input. It is only shown when isInvalid is set to true", + default: "-" + }, + { + attribute: "validate", + type: "(value: string) => ValidationError | true | null | undefined", + description: "Validate input values when committing (e.g. on blur), returning error messages for invalid values.", + default: "-" + }, + { + attribute: "validationBehavior", + type: "native | aria", + description: "Whether to use native HTML form validation or ARIA validation. When wrapped in a Form component, the default is `aria`. Otherwise, the default is `native`.", + default: "native" + }, + { + attribute: "minValue", + type: "number", + description: "The minimum value of the input.", + default: "-" + }, + { + attribute: "maxValue", + type: "number", + description: "The maximum value of the input.", + default: "-" + }, + { + attribute: "formatOptions", + type: "Intl.NumberFormatOptions", + description: "The format options for the input.", + default: "-" + }, + { + attribute: "step", + type: "number", + description: "The amount that the input value changes with each increment or decrement tick.", + default: "1" + }, + { + attribute: "hideStepper", + type: "boolean", + description: "Whether the stepper buttons should be hidden.", + default: "-" + }, + { + attribute: "isWheelDisabled", + type: "boolean", + description: "Whether the wheel should be disabled.", + default: "-" + }, + { + attribute: "startContent", + type: "ReactNode", + description: "Element to be rendered in the left side of the input.", + default: "-" + }, + { + attribute: "endContent", + type: "ReactNode", + description: "Element to be rendered in the right side of the input.", + default: "-" + }, + { + attribute: "labelPlacement", + type: "inside | outside | outside-left", + description: "The position of the label.", + default: "inside" + }, + { + attribute: "fullWidth", + type: "boolean", + description: "Whether the input should take up the width of its parent.", + default: "true" + }, + { + attribute: "isClearable", + type: "boolean", + description: "Whether the input should have a clear button.", + default: "false" + }, + { + attribute: "isRequired", + type: "boolean", + description: "Whether user input is required on the input before form submission.", + default: "false" + }, + { + attribute: "isReadOnly", + type: "boolean", + description: "Whether the input can be selected but not changed by the user.", + default: "false" + }, + { + attribute: "isDisabled", + type: "boolean", + description: "Whether the input is disabled.", + default: "false" + }, + { + attribute: "isInvalid", + type: "boolean", + description: "Whether the input is invalid.", + default: "false" + }, + { + attribute: "incrementAriaLabel", + type: "string", + description: "A custom aria-label for the increment button. If not provided, the localized string `Increment` is used.", + default: "-" + }, + { + attribute: "decrementAriaLabel", + type: "string", + description: "A custom aria-label for the decrement button. If not provided, the localized string `Decrement` is used.", + default: "-" + }, + { + attribute: "baseRef", + type: "RefObject", + description: "The ref to the base element.", + default: "-" + }, + { + attribute: "disableAnimation", + type: "boolean", + description: "Whether the input should be animated.", + default: "false" + }, + { + attribute: "classNames", + type: "Partial>", + description: "Allows to set custom class names for the Input slots.", + default: "-" + } + ]} +/> + +### NumberInput Events + +", + description: "Handler that is called when the element's value changes. You can pull out the new value by accessing event.target.value (string).", + default: "-" + }, + { + attribute: "onValueChange", + type: "(value: number) => void", + description: "Handler that is called when the element's value changes.", + default: "-" + }, + { + attribute: "onClear", + type: "() => void", + description: "Handler that is called when the clear button is clicked.", + default: "-" + } + ]} +/> + diff --git a/apps/docs/content/docs/guide/forms.mdx b/apps/docs/content/docs/guide/forms.mdx index 240a64be00..b24d7a50c8 100644 --- a/apps/docs/content/docs/guide/forms.mdx +++ b/apps/docs/content/docs/guide/forms.mdx @@ -232,7 +232,7 @@ You can change the form defaults for your entire app using [HeroUI Provider](/do Supported constraints include: - `isRequired` indicates that a field must have a value before the form can be submitted. -- `minValue` and `maxValue` specify the minimum and maximum value in a date picker or number field. +- `minValue` and `maxValue` specify the minimum and maximum value in a date picker or number input. - `minLength` and `maxLength` specify the minimum and length of text input. - `pattern` provides a custom regular expression that a text input must conform to. - `type="email"` and `type="url"` provide built-in validation for email addresses and URLs. diff --git a/packages/components/number-input/README.md b/packages/components/number-input/README.md new file mode 100644 index 0000000000..3860d86969 --- /dev/null +++ b/packages/components/number-input/README.md @@ -0,0 +1,24 @@ +# @heroui/number-input + +NumberInput is a component that allows users to enter number. It can be used to get user inputs in forms, search fields, and more. + +Please refer to the [documentation](https://heroui.com/docs/components/number-input) for more information. + +## Installation + +```sh +yarn add @heroui/number-input +# or +npm i @heroui/number-input +``` + +## Contribution + +Yes please! See the +[contributing guidelines](https://github.com/heroui-inc/heroui/blob/master/CONTRIBUTING.md) +for details. + +## License + +This project is licensed under the terms of the +[MIT license](https://github.com/heroui-inc/heroui/blob/master/LICENSE). diff --git a/packages/components/number-input/__tests__/number-input.test.tsx b/packages/components/number-input/__tests__/number-input.test.tsx new file mode 100644 index 0000000000..99e2d1c6b2 --- /dev/null +++ b/packages/components/number-input/__tests__/number-input.test.tsx @@ -0,0 +1,510 @@ +import * as React from "react"; +import {render, renderHook, fireEvent, act} from "@testing-library/react"; +import userEvent, {UserEvent} from "@testing-library/user-event"; +import {useForm} from "react-hook-form"; +import {Form} from "@heroui/form"; + +import {NumberInput} from "../src"; + +describe("NumberInput", () => { + let user: UserEvent; + + beforeEach(() => { + user = userEvent.setup(); + }); + + it("should render correctly", () => { + const wrapper = render(); + + expect(() => wrapper.unmount()).not.toThrow(); + }); + + it("ref should be forwarded", () => { + const ref = React.createRef(); + + render(); + + expect(ref.current).not.toBeNull(); + }); + + it("should have aria-invalid when invalid", () => { + const {container} = render(); + + expect(container.querySelector("input")).toHaveAttribute("aria-invalid", "true"); + }); + + it("should have aria-readonly when isReadOnly", () => { + const {container} = render(); + + expect(container.querySelector("input")).toHaveAttribute("aria-readonly", "true"); + }); + + it("should have disabled attribute when isDisabled", () => { + const {container} = render(); + + expect(container.querySelector("input")).toHaveAttribute("disabled"); + }); + + it("should disable the clear button when isDisabled", () => { + const {getByRole} = render( + , + ); + + const clearButton = getByRole("button"); + + expect(clearButton).toBeDisabled(); + }); + + it("should not allow clear button to be focusable", () => { + const {getByRole} = render(); + + const clearButton = getByRole("button"); + + expect(clearButton).toHaveAttribute("tabIndex", "-1"); + }); + + it("should have required attribute when isRequired with native validationBehavior", () => { + const {container} = render( + , + ); + + expect(container.querySelector("input")).toHaveAttribute("required"); + expect(container.querySelector("input")).not.toHaveAttribute("aria-required"); + }); + + it("should have aria-required attribute when isRequired with aria validationBehavior", () => { + const {container} = render( + , + ); + + expect(container.querySelector("input")).not.toHaveAttribute("required"); + expect(container.querySelector("input")).toHaveAttribute("aria-required", "true"); + }); + + it("should have aria-describedby when description is provided", () => { + const {container} = render(); + + expect(container.querySelector("input")).toHaveAttribute("aria-describedby"); + }); + + it("should have aria-describedby when errorMessage is provided", () => { + const {container} = render( + , + ); + + expect(container.querySelector("input")).toHaveAttribute("aria-describedby"); + }); + + it("should have the same aria-labelledby as label id", () => { + const {container} = render(); + + const labelId = container.querySelector("label")?.id; + + const labelledBy = container.querySelector("input")?.getAttribute("aria-labelledby"); + + expect(labelledBy?.includes(labelId as string)).toBeTruthy(); + }); + + it("should call dom event handlers only once", () => { + const onFocus = jest.fn(); + + const {container} = render(); + + act(() => { + container.querySelector("input")?.focus(); + + container.querySelector("input")?.blur(); + + expect(onFocus).toHaveBeenCalledTimes(1); + }); + }); + + it("ref should update the value", () => { + const ref = React.createRef(); + + const {container} = render(); + + if (!ref.current) { + throw new Error("ref is null"); + } + const value = "1234"; + + ref.current!.value = value; + + act(() => { + container.querySelector("input")?.focus(); + + expect(ref.current?.value)?.toBe(value); + }); + }); + + it("should clear the value and onClear is triggered", async () => { + const onClear = jest.fn(); + + const ref = React.createRef(); + + const {getByRole} = render( + , + ); + + const clearButton = getByRole("button")!; + + expect(clearButton).not.toBeNull(); + + const user = userEvent.setup(); + + await user.click(clearButton); + + expect(ref.current?.value)?.toBe(""); + + expect(onClear).toHaveBeenCalledTimes(1); + }); + + it("should disable clear button when isReadOnly is true", async () => { + const onClear = jest.fn(); + + const ref = React.createRef(); + + const {getByRole} = render( + , + ); + + const clearButton = getByRole("button")!; + + expect(clearButton).not.toBeNull(); + + const user = userEvent.setup(); + + await user.click(clearButton); + + expect(onClear).toHaveBeenCalledTimes(0); + }); + + it("should reset to max value if the value exceeds", async () => { + const {container} = render( + , + ); + + const input = container.querySelector("input") as HTMLInputElement; + + await user.click(input); + await user.keyboard("1024"); + await user.tab(); + + expect(input).toHaveValue("100"); + }); + + it("should reset to min value if the value subceed", async () => { + const {container} = render( + , + ); + + const input = container.querySelector("input") as HTMLInputElement; + + await user.click(input); + await user.keyboard("50"); + await user.tab(); + + expect(input).toHaveValue("100"); + }); + + it("should render stepper", async () => { + const {container} = render(); + + const stepperButton = container.querySelector("[data-slot='stepper-wrapper'] button")!; + + expect(stepperButton).not.toBeNull(); + }); + + it("should hide stepper", async () => { + const {container} = render( + , + ); + + const stepperButton = container.querySelector("[data-slot='stepper-wrapper'] button")!; + + expect(stepperButton).toBeNull(); + }); +}); + +describe("NumberInput with React Hook Form", () => { + let input1: HTMLInputElement; + let input2: HTMLInputElement; + let input3: HTMLInputElement; + let submitButton: HTMLButtonElement; + let onSubmit: () => void; + + beforeEach(() => { + const {result} = renderHook(() => + useForm({ + defaultValues: { + withDefaultValue: 1234, + withoutDefaultValue: undefined, + requiredField: undefined, + }, + }), + ); + + const { + handleSubmit, + register, + formState: {errors}, + } = result.current; + + onSubmit = jest.fn(); + + render( +
+ + + + {errors.requiredField && This field is required} + + , + ); + + input1 = document.querySelector("input[name=withDefaultValue]")!; + input2 = document.querySelector("input[name=withoutDefaultValue]")!; + input3 = document.querySelector("input[name=requiredField]")!; + submitButton = document.querySelector('button[type="submit"]')!; + }); + + it("should work with defaultValues", () => { + expect(input1).toHaveValue("1234"); + expect(input2).not.toHaveValue(); + expect(input3).not.toHaveValue(); + }); + + it("should not submit form when required field is empty", async () => { + const user = userEvent.setup(); + + await user.click(submitButton); + + expect(onSubmit).toHaveBeenCalledTimes(0); + }); + + it("should submit form when required field is not empty", async () => { + fireEvent.change(input3, {target: {value: 123}}); + + const user = userEvent.setup(); + + await user.click(submitButton); + + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + + describe("validation", () => { + let user: UserEvent; + + beforeEach(() => { + user = userEvent.setup(); + }); + + describe("validationBehavior=native", () => { + it("supports isRequired", async () => { + const {getByTestId} = render( +
+ + , + ); + + const input = getByTestId("input") as HTMLInputElement; + + expect(input).toHaveAttribute("required"); + expect(input).not.toHaveAttribute("aria-required"); + expect(input).not.toHaveAttribute("aria-describedby"); + expect(input.validity.valid).toBe(false); + + act(() => { + (getByTestId("form") as HTMLFormElement).checkValidity(); + }); + + expect(document.activeElement).toBe(input); + expect(input).toHaveAttribute("aria-describedby"); + expect(document.getElementById(input.getAttribute("aria-describedby")!)).toHaveTextContent( + "Constraints not satisfied", + ); + + await user.keyboard("1234"); + + expect(input).toHaveAttribute("aria-describedby"); + expect(input.validity.valid).toBe(true); + + await user.tab(); + + expect(input).not.toHaveAttribute("aria-describedby"); + }); + + it("supports validate function", async () => { + const {getByTestId} = render( +
+ (v === 1234 ? "Invalid amount" : null)} + /> + , + ); + + const input = getByTestId("input") as HTMLInputElement; + + expect(input).not.toHaveAttribute("aria-describedby"); + expect(input.validity.valid).toBe(false); + + act(() => { + (getByTestId("form") as HTMLFormElement).checkValidity(); + }); + + expect(document.activeElement).toBe(input); + expect(input).toHaveAttribute("aria-describedby"); + expect(document.getElementById(input.getAttribute("aria-describedby")!)).toHaveTextContent( + "Invalid amount", + ); + + await user.keyboard("4321"); + await user.tab(); + + act(() => { + (getByTestId("form") as HTMLFormElement).checkValidity(); + }); + + expect(input.validity.valid).toBe(true); + expect(input).not.toHaveAttribute("aria-describedby"); + }); + + it("supports server validation", async () => { + function Test() { + let [serverErrors, setServerErrors] = React.useState({}); + let onSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setServerErrors({ + name: "Invalid amount.", + }); + }; + + return ( +
+ + + + ); + } + + const {getByTestId} = render(); + + const input = getByTestId("input") as HTMLInputElement; + const submitButton = getByTestId("submit"); + + expect(input).not.toHaveAttribute("aria-describedby"); + + await user.click(submitButton); + act(() => { + (getByTestId("form") as HTMLFormElement).checkValidity(); + }); + + expect(input).toHaveAttribute("aria-describedby"); + expect(document.getElementById(input.getAttribute("aria-describedby")!)).toHaveTextContent( + "Invalid amount.", + ); + expect(input.validity.valid).toBe(false); + + // Clicking twice doesn't clear server errors. + await user.click(submitButton); + act(() => { + (getByTestId("form") as HTMLFormElement).checkValidity(); + }); + + expect(document.activeElement).toBe(input); + expect(input).toHaveAttribute("aria-describedby"); + expect(document.getElementById(input.getAttribute("aria-describedby")!)).toHaveTextContent( + "Invalid amount.", + ); + expect(input.validity.valid).toBe(false); + + await user.keyboard("1234"); + await user.tab(); + + expect(input).not.toHaveAttribute("aria-describedby"); + expect(input.validity.valid).toBe(true); + }); + }); + + describe('validationBehavior="aria"', () => { + it("supports validate function", async () => { + const {getByTestId} = render( +
+ (v === 1234 ? "Invalid amount" : null)} + /> + , + ); + + const input = getByTestId("input") as HTMLInputElement; + + expect(input).toHaveAttribute("aria-describedby"); + expect(input).toHaveAttribute("aria-invalid", "true"); + expect(document.getElementById(input.getAttribute("aria-describedby")!)).toHaveTextContent( + "Invalid amount", + ); + expect(input.validity.valid).toBe(true); + + await user.tab(); + await user.keyboard("1234"); + }); + + it("supports server validation", async () => { + const {getByTestId} = render( +
+ + , + ); + + const input = getByTestId("input"); + + expect(input).toHaveAttribute("aria-describedby"); + expect(input).toHaveAttribute("aria-invalid", "true"); + expect(document.getElementById(input.getAttribute("aria-describedby")!)).toHaveTextContent( + "Invalid amount", + ); + + await user.tab(); + await user.keyboard("1234"); + await user.tab(); + }); + }); + }); +}); diff --git a/packages/components/number-input/package.json b/packages/components/number-input/package.json new file mode 100644 index 0000000000..fcf58c740a --- /dev/null +++ b/packages/components/number-input/package.json @@ -0,0 +1,71 @@ +{ + "name": "@heroui/number-input", + "version": "2.0.0", + "description": "The numeric input component is designed for users to enter a number, and increase or decrease the value using stepper buttons", + "keywords": [ + "input", + "number", + "numeric input" + ], + "author": "HeroUI ", + "homepage": "https://heroui.com", + "license": "MIT", + "main": "src/index.ts", + "sideEffects": false, + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/heroui-inc/heroui.git", + "directory": "packages/components/number-input" + }, + "bugs": { + "url": "https://github.com/heroui-inc/heroui/issues" + }, + "scripts": { + "build": "tsup src --dts", + "build:fast": "tsup src", + "dev": "pnpm build:fast --watch", + "clean": "rimraf dist .turbo", + "typecheck": "tsc --noEmit", + "prepack": "clean-package", + "postpack": "clean-package restore" + }, + "peerDependencies": { + "react": ">=18 || >=19.0.0-rc.0", + "react-dom": ">=18 || >=19.0.0-rc.0", + "@heroui/theme": ">=2.4.7", + "@heroui/system": ">=2.4.8" + }, + "dependencies": { + "@heroui/form": "workspace:*", + "@heroui/button": "workspace:*", + "@heroui/react-utils": "workspace:*", + "@heroui/shared-icons": "workspace:*", + "@heroui/shared-utils": "workspace:*", + "@heroui/use-safe-layout-effect": "workspace:*", + "@react-aria/focus": "3.19.1", + "@react-aria/i18n": "3.12.5", + "@react-aria/interactions": "3.23.0", + "@react-aria/numberfield": "3.11.10", + "@react-aria/utils": "3.27.0", + "@react-stately/utils": "3.10.5", + "@react-stately/numberfield": "3.9.9", + "@react-types/shared": "3.27.0", + "@react-types/numberfield": "3.8.8", + "@react-types/button": "3.10.2" + }, + "devDependencies": { + "@heroui/system": "workspace:*", + "@heroui/theme": "workspace:*", + "clean-package": "2.2.0", + "react": "^18.0.0", + "react-dom": "^18.0.0", + "react-hook-form": "^7.51.3" + }, + "clean-package": "../../../clean-package.config.json" +} diff --git a/packages/components/number-input/src/index.ts b/packages/components/number-input/src/index.ts new file mode 100644 index 0000000000..ca772fe83b --- /dev/null +++ b/packages/components/number-input/src/index.ts @@ -0,0 +1,10 @@ +import NumberInput from "./number-input"; + +// export types +export type {NumberInputProps} from "./number-input"; + +// export hooks +export {useNumberInput} from "./use-number-input"; + +// export component +export {NumberInput}; diff --git a/packages/components/number-input/src/number-input-stepper.tsx b/packages/components/number-input/src/number-input-stepper.tsx new file mode 100644 index 0000000000..10307e3357 --- /dev/null +++ b/packages/components/number-input/src/number-input-stepper.tsx @@ -0,0 +1,21 @@ +import type {AriaButtonProps} from "@react-types/button"; +import type {ButtonProps} from "@heroui/button"; + +import {Button} from "@heroui/button"; +import {ChevronUpIcon, ChevronDownIcon} from "@heroui/shared-icons"; + +export interface NumberInputStepperProps extends Omit { + direction: "up" | "down"; +} + +const NumberInputStepper = ({direction, ...otherProps}: NumberInputStepperProps) => { + return ( + + ); +}; + +NumberInputStepper.displayName = "HeroUI.NumberInputStepper"; + +export default NumberInputStepper; diff --git a/packages/components/number-input/src/number-input.tsx b/packages/components/number-input/src/number-input.tsx new file mode 100644 index 0000000000..1a596d992b --- /dev/null +++ b/packages/components/number-input/src/number-input.tsx @@ -0,0 +1,146 @@ +import {CloseFilledIcon} from "@heroui/shared-icons"; +import {useMemo} from "react"; +import {forwardRef} from "@heroui/system"; + +import {UseNumberInputProps, useNumberInput} from "./use-number-input"; +import NumberInputStepper from "./number-input-stepper"; + +export interface NumberInputProps extends UseNumberInputProps {} + +const NumberInput = forwardRef<"input", NumberInputProps>((props, ref) => { + const { + Component, + label, + description, + isClearable, + startContent, + endContent, + labelPlacement, + hasHelper, + isOutsideLeft, + shouldLabelBeOutside, + errorMessage, + isInvalid, + hideStepper, + getBaseProps, + getLabelProps, + getNumberInputProps, + getHiddenNumberInputProps, + getInnerWrapperProps, + getInputWrapperProps, + getMainWrapperProps, + getHelperWrapperProps, + getDescriptionProps, + getErrorMessageProps, + getClearButtonProps, + getStepperIncreaseButtonProps, + getStepperDecreaseButtonProps, + getStepperWrapperProps, + } = useNumberInput({...props, ref}); + + const labelContent = label ? : null; + + const end = useMemo(() => { + if (isClearable) { + return ( + <> + + {endContent} + + ); + } + + return endContent; + }, [isClearable, getClearButtonProps]); + + const helperWrapper = useMemo(() => { + const shouldShowError = isInvalid && errorMessage; + const hasContent = shouldShowError || description; + + if (!hasHelper || !hasContent) return null; + + return ( +
+ {shouldShowError ? ( +
{errorMessage}
+ ) : ( +
{description}
+ )} +
+ ); + }, [ + hasHelper, + isInvalid, + errorMessage, + description, + getHelperWrapperProps, + getErrorMessageProps, + getDescriptionProps, + ]); + + const innerWrapper = useMemo(() => { + return ( +
+ {startContent} + + + {end} + {!hideStepper && ( +
+ + +
+ )} +
+ ); + }, [startContent, end, getNumberInputProps, getInnerWrapperProps]); + + const mainWrapper = useMemo(() => { + if (shouldLabelBeOutside) { + return ( +
+
+ {!isOutsideLeft ? labelContent : null} + {innerWrapper} +
+ {helperWrapper} +
+ ); + } + + return ( + <> +
+ {labelContent} + {innerWrapper} +
+ {helperWrapper} + + ); + }, [ + labelPlacement, + helperWrapper, + shouldLabelBeOutside, + labelContent, + innerWrapper, + errorMessage, + description, + getMainWrapperProps, + getInputWrapperProps, + getErrorMessageProps, + getDescriptionProps, + ]); + + return ( + + {isOutsideLeft ? labelContent : null} + {mainWrapper} + + ); +}); + +NumberInput.displayName = "HeroUI.NumberInput"; + +export default NumberInput; diff --git a/packages/components/number-input/src/use-number-input.ts b/packages/components/number-input/src/use-number-input.ts new file mode 100644 index 0000000000..1de8811ed7 --- /dev/null +++ b/packages/components/number-input/src/use-number-input.ts @@ -0,0 +1,562 @@ +import type {NumberInputVariantProps, SlotsToClasses, NumberInputSlots} from "@heroui/theme"; +import type {AriaNumberFieldProps} from "@react-types/numberfield"; +import type {NumberFieldStateOptions} from "@react-stately/numberfield"; +import type {HTMLHeroUIProps, PropGetter} from "@heroui/system"; + +import {useLabelPlacement, mapPropsVariants, useProviderContext} from "@heroui/system"; +import {useSafeLayoutEffect} from "@heroui/use-safe-layout-effect"; +import {useFocusRing} from "@react-aria/focus"; +import {numberInput} from "@heroui/theme"; +import {useDOMRef, filterDOMProps} from "@heroui/react-utils"; +import {useFocusWithin, useHover, usePress} from "@react-aria/interactions"; +import {useLocale} from "@react-aria/i18n"; +import {clsx, dataAttr, isEmpty, objectToDeps} from "@heroui/shared-utils"; +import {useNumberFieldState} from "@react-stately/numberfield"; +import {useNumberField as useAriaNumberInput} from "@react-aria/numberfield"; +import {useMemo, Ref, useCallback, useState} from "react"; +import {chain, mergeProps} from "@react-aria/utils"; +import {FormContext, useSlottedContext} from "@heroui/form"; + +export interface Props extends Omit, keyof NumberInputVariantProps> { + /** + * Ref to the DOM node. + */ + ref?: Ref; + /** + * Ref to the container DOM node. + */ + baseRef?: Ref; + /** + * Ref to the input wrapper DOM node. + * This is the element that wraps the input label and the innerWrapper when the labelPlacement="inside" + * and the input has start/end content. + */ + wrapperRef?: Ref; + /** + * Ref to the input inner wrapper DOM node. + * This is the element that wraps the input and the start/end content when passed. + */ + innerWrapperRef?: Ref; + /** + * Element to be rendered in the left side of the input. + */ + startContent?: React.ReactNode; + /** + * Element to be rendered in the right side of the input. + * if you pass this prop and the `onClear` prop, the passed element + * will have the clear button props and it will be rendered instead of the + * default clear button. + */ + endContent?: React.ReactNode; + /** + * Classname or List of classes to change the classNames of the element. + * if `className` is passed, it will be added to the base slot. + * + * @example + * ```ts + * + * ``` + */ + classNames?: SlotsToClasses; + /** + * Whether to hide the increment and decrement buttons. + */ + hideStepper?: boolean; + /** + * Callback fired when the value is cleared. + * if you pass this prop, the clear button will be shown. + */ + onClear?: () => void; + /** + * React aria onChange event. + */ + onValueChange?: AriaNumberFieldProps["onChange"]; +} + +export type UseNumberInputProps = Props & + Omit & + Omit & + NumberInputVariantProps; + +export function useNumberInput(originalProps: UseNumberInputProps) { + const globalContext = useProviderContext(); + const {validationBehavior: formValidationBehavior} = useSlottedContext(FormContext) || {}; + + const [props, variantProps] = mapPropsVariants(originalProps, numberInput.variantKeys); + + const { + ref, + as, + label, + baseRef, + wrapperRef, + description, + className, + classNames, + autoFocus, + startContent, + endContent, + onClear, + onChange, + validationBehavior = formValidationBehavior ?? globalContext?.validationBehavior ?? "native", + innerWrapperRef: innerWrapperRefProp, + onValueChange, + hideStepper, + ...otherProps + } = props; + + const [isFocusWithin, setFocusWithin] = useState(false); + + const Component = as || "div"; + + const disableAnimation = + originalProps.disableAnimation ?? globalContext?.disableAnimation ?? 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: onValueChange, + }); + + 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?.base, className, isFilled ? "is-filled" : ""); + + const handleClear = useCallback(() => { + state.setInputValue(""); + + onClear?.(); + domRef.current?.focus(); + }, [state.setInputValue, onClear]); + + // if we use `react-hook-form`, it will set the input value using the ref in register + // i.e. setting ref.current.value to something which is uncontrolled + // hence, sync the state with `ref.current.value` + 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?.isDisabled}); + + const {isHovered: isLabelHovered, hoverProps: labelHoverProps} = useHover({ + isDisabled: !!originalProps?.isDisabled, + }); + + const {focusProps: clearFocusProps, isFocusVisible: isClearButtonFocusVisible} = useFocusRing(); + + const {focusWithinProps} = useFocusWithin({ + onFocusWithinChange: setFocusWithin, + }); + + const {pressProps: clearPressProps} = usePress({ + isDisabled: !!originalProps?.isDisabled || !!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?.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 getBaseProps: PropGetter = useCallback( + (props = {}) => { + 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, + ...props, + }; + }, + [ + slots, + baseStyles, + isFilled, + isFocused, + isHovered, + isLabelHovered, + isInvalid, + hasHelper, + hasLabel, + hasElements, + isPlaceholderShown, + hasStartContent, + isFocusWithin, + isFocusVisible, + hasPlaceholder, + focusWithinProps, + originalProps.isReadOnly, + originalProps.isRequired, + originalProps.isDisabled, + ], + ); + + const getLabelProps: PropGetter = useCallback( + (props = {}) => { + return { + "data-slot": "label", + className: slots.label({class: classNames?.label}), + ...mergeProps(labelProps, labelHoverProps, props), + }; + }, + [slots, isLabelHovered, labelProps, classNames?.label], + ); + + const getNumberInputProps: PropGetter = useCallback( + (props = {}) => { + 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?.input, isFilled ? "is-filled" : ""), + }), + ...mergeProps( + focusProps, + inputProps, + filterDOMProps(otherProps, { + enabled: true, + labelable: true, + omitEventNames: new Set(Object.keys(inputProps)), + omitPropNames: new Set(["value"]), + }), + props, + ), + "aria-readonly": dataAttr(originalProps.isReadOnly), + onChange: chain(inputProps.onChange, onChange), + ref: domRef, + }; + }, + [ + slots, + focusProps, + inputProps, + otherProps, + isFilled, + hasStartContent, + endContent, + classNames?.input, + originalProps.isReadOnly, + originalProps.isRequired, + onChange, + ], + ); + + const getHiddenNumberInputProps: PropGetter = useCallback( + (props = {}) => { + return { + name: originalProps.name, + value: inputValue, + "data-slot": "hidden-input", + type: "hidden", + ...props, + }; + }, + [inputValue, originalProps.name], + ); + + const getInputWrapperProps: PropGetter = useCallback( + (props = {}) => { + 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?.inputWrapper, isFilled ? "is-filled" : ""), + }), + ...mergeProps(props, hoverProps), + onClick: (e) => { + if (domRef.current && e.currentTarget === e.target) { + domRef.current.focus(); + } + }, + style: { + cursor: "text", + ...props.style, + }, + }; + }, + [ + slots, + isHovered, + isLabelHovered, + isFocusVisible, + isFocused, + inputValue, + classNames?.inputWrapper, + ], + ); + + const getInnerWrapperProps: PropGetter = useCallback( + (props = {}) => { + 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?.innerWrapper, props?.className), + }), + ...mergeProps(groupProps, props), + }; + }, + [slots, classNames?.innerWrapper], + ); + + const getMainWrapperProps: PropGetter = useCallback( + (props = {}) => { + return { + ...props, + "data-slot": "main-wrapper", + className: slots.mainWrapper({ + class: clsx(classNames?.mainWrapper, props?.className), + }), + }; + }, + [slots, classNames?.mainWrapper], + ); + + const getHelperWrapperProps: PropGetter = useCallback( + (props = {}) => { + return { + ...props, + "data-slot": "helper-wrapper", + className: slots.helperWrapper({ + class: clsx(classNames?.helperWrapper, props?.className), + }), + }; + }, + [slots, classNames?.helperWrapper], + ); + + const getDescriptionProps: PropGetter = useCallback( + (props = {}) => { + return { + ...props, + ...descriptionProps, + "data-slot": "description", + className: slots.description({class: clsx(classNames?.label, props?.className)}), + }; + }, + [slots, classNames?.description], + ); + + const getErrorMessageProps: PropGetter = useCallback( + (props = {}) => { + return { + ...props, + ...errorMessageProps, + "data-slot": "error-message", + className: slots.errorMessage({class: clsx(classNames?.errorMessage, props?.className)}), + }; + }, + [slots, errorMessageProps, classNames?.errorMessage], + ); + + const getClearButtonProps: PropGetter = useCallback( + (props = {}) => { + return { + ...props, + 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?.clearButton, props?.className)}), + ...mergeProps(clearPressProps, clearFocusProps), + }; + }, + [slots, isClearButtonFocusVisible, clearPressProps, clearFocusProps, classNames?.clearButton], + ); + + const getStepperWrapperProps: PropGetter = useCallback( + (props = {}) => { + return { + ...props, + "data-slot": "stepper-wrapper", + className: slots.stepperWrapper({ + class: clsx(classNames?.stepperWrapper, props?.className), + }), + }; + }, + [slots], + ); + + const getStepperIncreaseButtonProps: PropGetter = useCallback( + (props = {}) => { + return { + ...props, + type: "button", + disabled: originalProps.isDisabled, + "data-slot": "increase-button", + className: slots.stepperButton({ + class: clsx(classNames?.stepperButton, props?.className), + }), + ...mergeProps(incrementButtonProps, props), + }; + }, + [slots], + ); + + const getStepperDecreaseButtonProps: PropGetter = useCallback( + (props = {}) => { + return { + type: "button", + disabled: originalProps.isDisabled, + "data-slot": "decrease-button", + className: slots.stepperButton({ + class: clsx(classNames?.stepperButton, props?.className), + }), + ...mergeProps(decrementButtonProps, props), + }; + }, + [slots], + ); + + return { + Component, + classNames, + 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 type UseNumberInputReturn = ReturnType; diff --git a/packages/components/number-input/stories/number-input.stories.tsx b/packages/components/number-input/stories/number-input.stories.tsx new file mode 100644 index 0000000000..5632dc87b9 --- /dev/null +++ b/packages/components/number-input/stories/number-input.stories.tsx @@ -0,0 +1,555 @@ +/* eslint-disable jsx-a11y/interactive-supports-focus */ +/* eslint-disable jsx-a11y/click-events-have-key-events */ +import type {ValidationResult} from "@react-types/shared"; + +import React from "react"; +import {Meta} from "@storybook/react"; +import {button} from "@heroui/theme"; +import {Form} from "@heroui/form"; +import {numberInput} from "@heroui/theme"; + +import {NumberInput, NumberInputProps} from "../src"; + +export default { + title: "Components/NumberInput", + component: NumberInput, + argTypes: { + variant: { + control: { + type: "select", + }, + options: ["flat", "faded", "bordered", "underlined"], + }, + color: { + control: { + type: "select", + }, + options: ["default", "primary", "secondary", "success", "warning", "danger"], + }, + radius: { + control: { + type: "select", + }, + options: ["none", "sm", "md", "lg", "full"], + }, + size: { + control: { + type: "select", + }, + options: ["sm", "md", "lg"], + }, + labelPlacement: { + control: { + type: "select", + }, + options: ["inside", "outside", "outside-left"], + }, + isDisabled: { + control: { + type: "boolean", + }, + }, + validationBehavior: { + control: { + type: "select", + }, + options: ["aria", "native"], + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} as Meta; + +const defaultProps = { + ...numberInput.defaultVariants, + defaultValue: 24, +}; + +const Template = (args) => ( +
+ +
+); + +const FormTemplate = (args) => ( +
{ + alert(`Submitted value: ${e.target["example"].value}`); + e.preventDefault(); + }} + > + + + +); + +const ControlledTemplate = (args) => { + const [value, setValue] = React.useState(0); + + return ( +
+ +

NumberInput value: {value}

+
+ ); +}; + +const LabelPlacementTemplate = (args) => ( +
+
+

Without placeholder

+
+ + + +
+
+
+

With placeholder

+
+ + + +
+
+
+); + +// const WithReactHookFormTemplate = (args: NumberInputProps) => { +// const { +// register, +// formState: {errors}, +// handleSubmit, +// } = useForm({ +// defaultValues: { +// withDefaultValue: 24, +// withoutDefaultValue: "", +// requiredField: "", +// }, +// }); + +// const onSubmit = (data: any) => { +// // eslint-disable-next-line no-console +// console.log(data); +// alert("Submitted value: " + JSON.stringify(data)); +// }; + +// return ( +//
+// +// +// +// {errors.requiredField && This field is required} +// +// +// ); +// }; + +const ServerValidationTemplate = (args: NumberInputProps) => { + const [serverErrors, setServerErrors] = React.useState({}); + const onSubmit = (e) => { + e.preventDefault(); + setServerErrors({ + amount: "Please provide a valid number.", + }); + }; + + return ( +
+ + + + ); +}; + +export const Default = { + render: Template, + + args: { + ...defaultProps, + "aria-label": "Amount", + }, +}; + +export const WithLabel = { + render: Template, + + args: { + ...defaultProps, + label: "Amount", + }, +}; + +export const WithDescription = { + render: Template, + + args: { + ...defaultProps, + label: "Amount", + description: "Specify the amount", + }, +}; + +export const WithStepValue = { + render: Template, + + args: { + ...defaultProps, + label: "Amount", + step: 10, + description: "Set `step` to `10` to increment / decrement the value by 10.", + }, +}; + +export const WithWheelDisabled = { + render: Template, + + args: { + ...defaultProps, + label: "Amount", + step: 10, + description: "Set `isWheelDisabled` to `true` to disable the wheel.", + isWheelDisabled: true, + }, +}; + +export const WithFormatOptions = { + render: Template, + + args: { + ...defaultProps, + label: "Transaction amount", + formatOptions: { + style: "currency", + currency: "EUR", + currencyDisplay: "code", + currencySign: "accounting", + }, + }, +}; + +export const HideStepper = { + render: Template, + + args: { + ...defaultProps, + hideStepper: true, + label: "Hide Stepper", + description: "Set `hideStepper` to `true` to hide the stepper.", + }, +}; + +export const Required = { + render: FormTemplate, + + args: { + ...defaultProps, + label: "Amount", + isRequired: true, + defaultValue: undefined, + placeholder: "Enter a number", + }, +}; + +export const Disabled = { + render: Template, + + args: { + ...defaultProps, + variant: "faded", + isDisabled: true, + "aria-label": "amount", + }, +}; + +export const ReadOnly = { + render: Template, + + args: { + ...defaultProps, + variant: "bordered", + isReadOnly: true, + "aria-label": "amount", + }, +}; + +export const LabelPlacement = { + render: LabelPlacementTemplate, + + args: { + ...defaultProps, + label: "Amount", + defaultValue: undefined, + }, +}; + +export const Clearable = { + render: Template, + + args: { + ...defaultProps, + variant: "bordered", + placeholder: "Enter a number", + // eslint-disable-next-line no-console + onClear: () => console.log("number input cleared"), + "aria-label": "amount", + }, +}; + +export const StartContent = { + render: Template, + + args: { + ...defaultProps, + variant: "bordered", + label: "Price", + placeholder: "0.00", + startContent: ( +
+ $ +
+ ), + }, +}; + +export const EndContent = { + render: Template, + + args: { + ...defaultProps, + variant: "bordered", + label: "Price", + placeholder: "0.00", + endContent: ( +
+ +
+ ), + }, +}; + +export const StartAndEndContent = { + render: Template, + + args: { + ...defaultProps, + variant: "bordered", + label: "Price", + placeholder: "0.00", + endContent: ( +
+ + +
+ ), + startContent: ( +
+ $ +
+ ), + }, +}; + +export const WithErrorMessage = { + render: Template, + + args: { + ...defaultProps, + isInvalid: true, + errorMessage: "Please enter a valid number", + "aria-label": "amount", + }, +}; + +export const WithErrorMessageFunction = { + render: FormTemplate, + + args: { + ...defaultProps, + min: "0", + max: "100", + isRequired: true, + label: "Number", + validationBehavior: "native", + placeholder: "Enter a number(0-100)", + errorMessage: (value: ValidationResult) => { + if (value.validationDetails.rangeOverflow) { + return "Value is too high"; + } + if (value.validationDetails.rangeUnderflow) { + return "Value is too low"; + } + if (value.validationDetails.valueMissing) { + return "Value is required"; + } + }, + }, +}; + +export const WithValidation = { + render: FormTemplate, + + args: { + ...defaultProps, + validate: (value) => { + if (value < 0 || value > 100) { + return "Value must be between 0 and 100"; + } + }, + isRequired: true, + label: "Number", + placeholder: "Enter a number(0-100)", + }, +}; + +export const WithServerValidation = { + render: ServerValidationTemplate, + + args: { + ...defaultProps, + label: "amount", + name: "amount", + }, +}; + +export const IsInvalid = { + render: Template, + + args: { + ...defaultProps, + variant: "bordered", + isInvalid: true, + placeholder: "Enter a number", + errorMessage: "Please enter a valid range of numbers", + "aria-label": "amount", + }, +}; + +export const Controlled = { + render: ControlledTemplate, + + args: { + ...defaultProps, + variant: "bordered", + placeholder: "Enter a number", + "aria-label": "amount", + }, +}; + +export const MinValue = { + render: Template, + + args: { + ...defaultProps, + label: "Enter a number (min value: 60)", + minValue: 60, + defaultValue: 64, + }, +}; + +export const MaxValue = { + render: Template, + + args: { + ...defaultProps, + label: "Enter a number (max value: 100)", + defaultValue: 0, + maxValue: 100, + }, +}; + +export const CustomWithClassNames = { + render: Template, + + args: { + ...defaultProps, + classNames: { + label: "hidden", + inputWrapper: [ + "bg-slate-100", + "border", + "shadow", + "hover:bg-slate-200", + "focus-within:!bg-slate-100", + "dark:bg-slate-900", + "dark:hover:bg-slate-800", + "dark:border-slate-800", + "dark:focus-within:!bg-slate-900", + ], + innerWrapper: "gap-3", + input: [ + "text-base", + "text-slate-500", + "placeholder:text-slate-500", + "dark:text-slate-400", + "dark:placeholder:text-slate-400", + ], + }, + endContent:
, + placeholder: "Enter the amount", + "aria-label": "amount", + }, +}; + +// export const CustomWithHooks = { +// render: CustomWithHooksTemplate, + +// args: { +// ...defaultProps, +// label: "Search", +// type: "search", +// placeholder: "Type to search...", +// startContent: ( +// +// ), +// }, +// }; + +// export const WithReactHookForm = { +// render: WithReactHookFormTemplate, + +// args: { +// ...defaultProps, +// }, +// }; diff --git a/packages/components/number-input/tsconfig.json b/packages/components/number-input/tsconfig.json new file mode 100644 index 0000000000..1f783ade25 --- /dev/null +++ b/packages/components/number-input/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "tailwind-variants": ["../../../node_modules/tailwind-variants"] + } + }, + "include": ["src", "index.ts"] +} diff --git a/packages/components/number-input/tsup.config.ts b/packages/components/number-input/tsup.config.ts new file mode 100644 index 0000000000..3e2bcff6cc --- /dev/null +++ b/packages/components/number-input/tsup.config.ts @@ -0,0 +1,8 @@ +import {defineConfig} from "tsup"; + +export default defineConfig({ + clean: true, + target: "es2019", + format: ["cjs", "esm"], + banner: {js: '"use client";'}, +}); diff --git a/packages/core/react/package.json b/packages/core/react/package.json index c7682f4981..7aa09a85b9 100644 --- a/packages/core/react/package.json +++ b/packages/core/react/package.json @@ -89,6 +89,7 @@ "@heroui/drawer": "workspace:*", "@heroui/form": "workspace:*", "@heroui/alert": "workspace:*", + "@heroui/number-input": "workspace:*", "@heroui/toast": "workspace:*", "@react-aria/visually-hidden": "3.8.19" }, diff --git a/packages/core/react/src/index.ts b/packages/core/react/src/index.ts index 50aec9e35c..02ae5a8240 100644 --- a/packages/core/react/src/index.ts +++ b/packages/core/react/src/index.ts @@ -47,6 +47,7 @@ export * from "@heroui/form"; export * from "@heroui/alert"; export * from "@heroui/drawer"; export * from "@heroui/input-otp"; +export * from "@heroui/number-input"; export * from "@heroui/toast"; /** diff --git a/packages/core/theme/src/components/index.ts b/packages/core/theme/src/components/index.ts index 68832da61e..86c9f920d7 100644 --- a/packages/core/theme/src/components/index.ts +++ b/packages/core/theme/src/components/index.ts @@ -41,4 +41,5 @@ export * from "./date-picker"; export * from "./alert"; export * from "./drawer"; export * from "./form"; +export * from "./number-input"; export * from "./toast"; diff --git a/packages/core/theme/src/components/number-input.ts b/packages/core/theme/src/components/number-input.ts new file mode 100644 index 0000000000..02d6126073 --- /dev/null +++ b/packages/core/theme/src/components/number-input.ts @@ -0,0 +1,854 @@ +import type {VariantProps} from "tailwind-variants"; + +import {tv} from "../utils/tv"; +import {dataFocusVisibleClasses, groupDataFocusVisibleClasses} from "../utils"; + +/** + * NumberInput wrapper **Tailwind Variants** component + * + * @example + * ```js + * const {base, label, inputWrapper, input, clearButton, description, errorMessage} = numberInput({...}) + * + *
+ * + *
+ * + * + *
+ * Description + * Invalid input + *
+ * ``` + */ +const numberInput = tv({ + slots: { + base: "group flex flex-col data-[hidden=true]:hidden", + label: [ + "absolute", + "z-10", + "pointer-events-none", + "origin-top-left", + "flex-shrink-0", + // Using RTL here as Tailwind CSS doesn't support `start` and `end` logical properties for transforms yet. + "rtl:origin-top-right", + "subpixel-antialiased", + "block", + "text-small", + "text-foreground-500", + ], + mainWrapper: "h-full", + inputWrapper: + "relative w-full inline-flex tap-highlight-transparent flex-row items-center shadow-sm px-3 gap-3", + innerWrapper: "inline-flex w-full items-center h-full box-border", + input: [ + "w-full font-normal bg-transparent !outline-none placeholder:text-foreground-500 focus-visible:outline-none", + "data-[has-start-content=true]:ps-1.5", + "data-[has-end-content=true]:pe-1.5", + "autofill:bg-transparent bg-clip-text", + ], + clearButton: [ + "p-2", + "-m-2", + "z-10", + "end-3", + "start-auto", + "pointer-events-none", + "appearance-none", + "outline-none", + "select-none", + "opacity-0", + "hover:!opacity-100", + "cursor-pointer", + "active:!opacity-70", + "rounded-full", + // focus ring + ...dataFocusVisibleClasses, + ], + stepperButton: [ + "bg-transparent", + "flex", + "justify-center", + "items-center", + "before:absolute", + "before:w-8", // the max width that won't block clear button + "before:h-8", + "before:rounded-full", + "after:shadow-small", + "after:bg-background", + "data-[focused=true]:z-10", + "min-w-5", + "w-5", + "h-5", + "overflow-visible", + "transition-opacity", + "data-[hover=true]:opacity-70", + "data-[pressed=true]:opacity-disabled", + ], + stepperWrapper: ["flex", "flex-col", "ps-1", "h-full", "justify-center"], + helperWrapper: "hidden group-data-[has-helper=true]:flex py-2 relative flex-col gap-1.5", + description: "text-tiny text-foreground-400", + errorMessage: "text-tiny text-danger", + }, + variants: { + variant: { + flat: { + inputWrapper: [ + "bg-default-100", + "data-[hover=true]:bg-default-200", + "group-data-[focus=true]:bg-default-100", + ], + }, + faded: { + inputWrapper: [ + "bg-default-100", + "border-medium", + "border-default-200", + "data-[hover=true]:border-default-400 focus-within:border-default-400", + ], + value: "group-data-[has-value=true]:text-default-foreground", + }, + bordered: { + inputWrapper: [ + "border-medium", + "border-default-200", + "data-[hover=true]:border-default-400", + "group-data-[focus=true]:border-default-foreground", + ], + }, + underlined: { + inputWrapper: [ + "!px-1", + "!pb-0", + "!gap-0", + "relative", + "box-border", + "border-b-medium", + "shadow-[0_1px_0px_0_rgba(0,0,0,0.05)]", + "border-default-200", + "!rounded-none", + "hover:border-default-300", + "after:content-['']", + "after:w-0", + "after:origin-center", + "after:bg-default-foreground", + "after:absolute", + "after:left-1/2", + "after:-translate-x-1/2", + "after:-bottom-[2px]", + "after:h-[2px]", + "group-data-[focus=true]:after:w-full", + ], + innerWrapper: "pb-1", + label: "group-data-[filled-within=true]:text-foreground", + }, + }, + color: { + default: {}, + primary: { + stepperButton: "text-primary", + }, + secondary: { + stepperButton: "text-secondary", + }, + success: { + stepperButton: "text-success", + }, + warning: { + stepperButton: "text-warning", + }, + danger: { + stepperButton: "text-danger", + }, + }, + size: { + sm: { + label: "text-tiny", + inputWrapper: "h-8 min-h-8 px-2 rounded-small", + input: "text-small", + clearButton: "text-medium", + }, + md: { + inputWrapper: "h-10 min-h-10 rounded-medium", + input: "text-small", + clearButton: "text-large", + }, + lg: { + label: "text-medium", + inputWrapper: "h-12 min-h-12 rounded-large", + input: "text-medium", + clearButton: "text-large", + }, + }, + radius: { + none: { + inputWrapper: "rounded-none", + }, + sm: { + inputWrapper: "rounded-small", + }, + md: { + inputWrapper: "rounded-medium", + }, + lg: { + inputWrapper: "rounded-large", + }, + full: { + inputWrapper: "rounded-full", + }, + }, + labelPlacement: { + outside: { + mainWrapper: "flex flex-col", + stepperButton: "min-w-3 w-3 h-3", + }, + "outside-left": { + base: "flex-row items-center flex-nowrap data-[has-helper=true]:items-start", + inputWrapper: "flex-1", + mainWrapper: "flex flex-col", + label: "relative text-foreground pe-2 ps-2 pointer-events-auto", + stepperButton: "min-w-3 w-3 h-3", + }, + inside: { + label: "cursor-text", + inputWrapper: "flex-col items-start justify-center gap-0", + innerWrapper: "group-data-[has-label=true]:items-end", + }, + }, + fullWidth: { + true: { + base: "w-full", + }, + false: {}, + }, + isClearable: { + true: { + input: "peer pe-6 input-search-cancel-button-none", + clearButton: [ + "peer-data-[filled=true]:pointer-events-auto", + "peer-data-[filled=true]:opacity-70 peer-data-[filled=true]:block", + "peer-data-[filled=true]:scale-100", + ], + }, + }, + isDisabled: { + true: { + base: "opacity-disabled pointer-events-none", + inputWrapper: "pointer-events-none", + label: "pointer-events-none", + }, + }, + isInvalid: { + true: { + label: "!text-danger", + input: "!placeholder:text-danger !text-danger", + }, + }, + isRequired: { + true: { + label: "after:content-['*'] after:text-danger after:ms-0.5", + }, + }, + disableAnimation: { + true: { + input: "transition-none", + inputWrapper: "transition-none", + label: "transition-none", + }, + false: { + inputWrapper: "transition-background motion-reduce:transition-none !duration-150", + label: [ + "will-change-auto", + "!duration-200", + "!ease-out", + "motion-reduce:transition-none", + "transition-[transform,color,left,opacity]", + ], + clearButton: [ + "scale-90", + "ease-out", + "duration-150", + "transition-[opacity,transform]", + "motion-reduce:transition-none", + "motion-reduce:scale-100", + ], + }, + }, + }, + defaultVariants: { + variant: "flat", + color: "default", + size: "md", + fullWidth: true, + labelPlacement: "inside", + isDisabled: false, + }, + compoundVariants: [ + // flat & color + { + variant: "flat", + color: "default", + class: { + input: "group-data-[has-value=true]:text-default-foreground", + }, + }, + { + variant: "flat", + color: "primary", + class: { + inputWrapper: [ + "bg-primary-100", + "data-[hover=true]:bg-primary-50", + "text-primary", + "group-data-[focus=true]:bg-primary-50", + "placeholder:text-primary", + ], + input: "placeholder:text-primary", + label: "text-primary", + }, + }, + { + variant: "flat", + color: "secondary", + class: { + inputWrapper: [ + "bg-secondary-100", + "text-secondary", + "data-[hover=true]:bg-secondary-50", + "group-data-[focus=true]:bg-secondary-50", + "placeholder:text-secondary", + ], + input: "placeholder:text-secondary", + label: "text-secondary", + }, + }, + { + variant: "flat", + color: "success", + class: { + inputWrapper: [ + "bg-success-100", + "text-success-600", + "dark:text-success", + "placeholder:text-success-600", + "dark:placeholder:text-success", + "data-[hover=true]:bg-success-50", + "group-data-[focus=true]:bg-success-50", + ], + input: "placeholder:text-success-600 dark:placeholder:text-success", + label: "text-success-600 dark:text-success", + }, + }, + { + variant: "flat", + color: "warning", + class: { + inputWrapper: [ + "bg-warning-100", + "text-warning-600", + "dark:text-warning", + "placeholder:text-warning-600", + "dark:placeholder:text-warning", + "data-[hover=true]:bg-warning-50", + "group-data-[focus=true]:bg-warning-50", + ], + input: "placeholder:text-warning-600 dark:placeholder:text-warning", + label: "text-warning-600 dark:text-warning", + }, + }, + { + variant: "flat", + color: "danger", + class: { + inputWrapper: [ + "bg-danger-100", + "text-danger", + "dark:text-danger-500", + "placeholder:text-danger", + "dark:placeholder:text-danger-500", + "data-[hover=true]:bg-danger-50", + "group-data-[focus=true]:bg-danger-50", + ], + input: "placeholder:text-danger dark:placeholder:text-danger-500", + label: "text-danger dark:text-danger-500", + }, + }, + // faded & color + { + variant: "faded", + color: "primary", + class: { + label: "text-primary", + inputWrapper: "data-[hover=true]:border-primary focus-within:border-primary", + }, + }, + { + variant: "faded", + color: "secondary", + class: { + label: "text-secondary", + inputWrapper: "data-[hover=true]:border-secondary focus-within:border-secondary", + }, + }, + { + variant: "faded", + color: "success", + class: { + label: "text-success", + inputWrapper: "data-[hover=true]:border-success focus-within:border-success", + }, + }, + { + variant: "faded", + color: "warning", + class: { + label: "text-warning", + inputWrapper: "data-[hover=true]:border-warning focus-within:border-warning", + }, + }, + { + variant: "faded", + color: "danger", + class: { + label: "text-danger", + inputWrapper: "data-[hover=true]:border-danger focus-within:border-danger", + }, + }, + // underlined & color + { + variant: "underlined", + color: "default", + class: { + input: "group-data-[has-value=true]:text-foreground", + }, + }, + { + variant: "underlined", + color: "primary", + class: { + inputWrapper: "after:bg-primary", + label: "text-primary", + }, + }, + { + variant: "underlined", + color: "secondary", + class: { + inputWrapper: "after:bg-secondary", + label: "text-secondary", + }, + }, + { + variant: "underlined", + color: "success", + class: { + inputWrapper: "after:bg-success", + label: "text-success", + }, + }, + { + variant: "underlined", + color: "warning", + class: { + inputWrapper: "after:bg-warning", + label: "text-warning", + }, + }, + { + variant: "underlined", + color: "danger", + class: { + inputWrapper: "after:bg-danger", + label: "text-danger", + }, + }, + // bordered & color + { + variant: "bordered", + color: "primary", + class: { + inputWrapper: "group-data-[focus=true]:border-primary", + label: "text-primary", + }, + }, + { + variant: "bordered", + color: "secondary", + class: { + inputWrapper: "group-data-[focus=true]:border-secondary", + label: "text-secondary", + }, + }, + { + variant: "bordered", + color: "success", + class: { + inputWrapper: "group-data-[focus=true]:border-success", + label: "text-success", + }, + }, + { + variant: "bordered", + color: "warning", + class: { + inputWrapper: "group-data-[focus=true]:border-warning", + label: "text-warning", + }, + }, + { + variant: "bordered", + color: "danger", + class: { + inputWrapper: "group-data-[focus=true]:border-danger", + label: "text-danger", + }, + }, + // labelPlacement=inside & default + { + labelPlacement: "inside", + color: "default", + class: { + label: "group-data-[filled-within=true]:text-default-600", + }, + }, + // labelPlacement=outside & default + { + labelPlacement: "outside", + color: "default", + class: { + label: "group-data-[filled-within=true]:text-foreground", + }, + }, + // radius-full & size + { + radius: "full", + size: ["sm"], + class: { + inputWrapper: "px-3", + }, + }, + { + radius: "full", + size: "md", + class: { + inputWrapper: "px-4", + }, + }, + { + radius: "full", + size: "lg", + class: { + inputWrapper: "px-5", + }, + }, + // !disableAnimation & variant + { + disableAnimation: false, + variant: ["faded", "bordered"], + class: { + inputWrapper: "transition-colors motion-reduce:transition-none", + }, + }, + { + disableAnimation: false, + variant: "underlined", + class: { + inputWrapper: "after:transition-width motion-reduce:after:transition-none", + }, + }, + // flat & faded + { + variant: ["flat", "faded"], + class: { + inputWrapper: [ + // focus ring + ...groupDataFocusVisibleClasses, + ], + }, + }, + // isInvalid & variant + { + isInvalid: true, + variant: "flat", + class: { + inputWrapper: [ + "!bg-danger-50", + "data-[hover=true]:!bg-danger-100", + "group-data-[focus=true]:!bg-danger-50", + ], + }, + }, + { + isInvalid: true, + variant: "bordered", + class: { + inputWrapper: "!border-danger group-data-[focus=true]:!border-danger", + }, + }, + { + isInvalid: true, + variant: "underlined", + class: { + inputWrapper: "after:!bg-danger", + }, + }, + // size & labelPlacement + { + labelPlacement: "inside", + size: "sm", + class: { + inputWrapper: "h-12 py-1.5 px-3", + }, + }, + { + labelPlacement: "inside", + size: "md", + class: { + inputWrapper: "h-14 py-2", + }, + }, + { + labelPlacement: "inside", + size: "lg", + class: { + inputWrapper: "h-16 py-2.5 gap-0", + }, + }, + // size & labelPlacement & variant=[faded, bordered] + { + labelPlacement: "inside", + size: "sm", + variant: ["bordered", "faded"], + class: { + inputWrapper: "py-1", + }, + }, + // labelPlacement=[inside,outside] + { + labelPlacement: ["inside", "outside"], + class: { + label: ["group-data-[filled-within=true]:pointer-events-auto"], + }, + }, + // labelPlacement=[outside] + { + labelPlacement: "outside", + class: { + base: "relative justify-end", + label: [ + "pb-0", + "z-20", + "top-1/2", + "-translate-y-1/2", + "group-data-[filled-within=true]:start-0", + ], + }, + }, + // labelPlacement=[inside] + { + labelPlacement: ["inside"], + class: { + label: ["group-data-[filled-within=true]:scale-85"], + }, + }, + { + labelPlacement: "inside", + size: "sm", + class: { + label: [ + "group-data-[filled-within=true]:-translate-y-[calc(50%_+_theme(fontSize.tiny)/2_-_8px)]", + ], + }, + }, + { + labelPlacement: "inside", + size: "md", + class: { + label: [ + "group-data-[filled-within=true]:-translate-y-[calc(50%_+_theme(fontSize.small)/2_-_6px)]", + ], + }, + }, + { + labelPlacement: "inside", + size: "lg", + class: { + label: [ + "text-medium", + "group-data-[filled-within=true]:-translate-y-[calc(50%_+_theme(fontSize.small)/2_-_8px)]", + ], + }, + }, + // labelPlacement=[inside] & variant=flat + { + labelPlacement: ["inside"], + variant: "flat", + class: { + innerWrapper: "pb-0.5", + }, + }, + // variant=underlined & size + { + variant: "underlined", + size: "sm", + class: { + innerWrapper: "pb-1", + }, + }, + { + variant: "underlined", + size: ["md", "lg"], + class: { + innerWrapper: "pb-1.5", + }, + }, + // inside & size + { + labelPlacement: "inside", + size: ["sm", "md"], + class: { + label: "text-small", + }, + }, + // inside & size & [faded, bordered] + { + labelPlacement: "inside", + variant: ["faded", "bordered"], + size: "sm", + class: { + label: [ + "group-data-[filled-within=true]:-translate-y-[calc(50%_+_theme(fontSize.tiny)/2_-_8px_-_theme(borderWidth.medium))]", + ], + }, + }, + { + labelPlacement: "inside", + variant: ["faded", "bordered"], + isMultiline: false, + size: "md", + class: { + label: [ + "group-data-[filled-within=true]:-translate-y-[calc(50%_+_theme(fontSize.small)/2_-_6px_-_theme(borderWidth.medium))]", + ], + }, + }, + { + labelPlacement: "inside", + variant: ["faded", "bordered"], + size: "lg", + class: { + label: [ + "text-medium", + "group-data-[filled-within=true]:-translate-y-[calc(50%_+_theme(fontSize.small)/2_-_8px_-_theme(borderWidth.medium))]", + ], + }, + }, + // inside & size & underlined + { + labelPlacement: "inside", + variant: "underlined", + size: "sm", + class: { + label: [ + "group-data-[filled-within=true]:-translate-y-[calc(50%_+_theme(fontSize.tiny)/2_-_5px)]", + ], + }, + }, + { + labelPlacement: "inside", + variant: "underlined", + size: "md", + class: { + label: [ + "group-data-[filled-within=true]:-translate-y-[calc(50%_+_theme(fontSize.small)/2_-_3.5px)]", + ], + }, + }, + { + labelPlacement: "inside", + variant: "underlined", + size: "lg", + class: { + label: [ + "text-medium", + "group-data-[filled-within=true]:-translate-y-[calc(50%_+_theme(fontSize.small)/2_-_4px)]", + ], + }, + }, + // outside & size + { + labelPlacement: "outside", + size: "sm", + class: { + label: [ + "start-2", + "text-tiny", + "group-data-[filled-within=true]:-translate-y-[calc(100%_+_theme(fontSize.tiny)/2_+_16px)]", + ], + base: "data-[has-label=true]:mt-[calc(theme(fontSize.small)_+_8px)]", + }, + }, + { + labelPlacement: "outside", + size: "md", + class: { + label: [ + "start-3", + "end-auto", + "text-small", + "group-data-[filled-within=true]:-translate-y-[calc(100%_+_theme(fontSize.small)/2_+_20px)]", + ], + base: "data-[has-label=true]:mt-[calc(theme(fontSize.small)_+_10px)]", + }, + }, + { + labelPlacement: "outside", + size: "lg", + class: { + label: [ + "start-3", + "end-auto", + "text-medium", + "group-data-[filled-within=true]:-translate-y-[calc(100%_+_theme(fontSize.small)/2_+_24px)]", + ], + base: "data-[has-label=true]:mt-[calc(theme(fontSize.small)_+_12px)]", + stepperButton: "min-4 w-4 h-4", + }, + }, + // outside-left & size & hasHelper + { + labelPlacement: "outside-left", + size: "sm", + class: { + label: "group-data-[has-helper=true]:pt-2", + }, + }, + { + labelPlacement: "outside-left", + size: "md", + class: { + label: "group-data-[has-helper=true]:pt-3", + }, + }, + { + labelPlacement: "outside-left", + size: "lg", + class: { + label: "group-data-[has-helper=true]:pt-4", + stepperButton: "min-4 w-4 h-4", + }, + }, + // text truncate labelPlacement=[inside,outside] + { + labelPlacement: ["inside", "outside"], + class: { + label: ["pe-2", "max-w-full", "text-ellipsis", "overflow-hidden"], + }, + }, + ], +}); + +export type NumberInputVariantProps = VariantProps; +export type NumberInputSlots = keyof ReturnType; + +export {numberInput}; diff --git a/packages/utilities/shared-icons/src/chevron-left.tsx b/packages/utilities/shared-icons/src/chevron-left.tsx new file mode 100644 index 0000000000..04f76f895e --- /dev/null +++ b/packages/utilities/shared-icons/src/chevron-left.tsx @@ -0,0 +1,20 @@ +import {IconSvgProps} from "./types"; + +export const ChevronLeftIcon = (props: IconSvgProps) => ( + +); diff --git a/packages/utilities/shared-icons/src/index.ts b/packages/utilities/shared-icons/src/index.ts index 664f7da714..1be0e2b205 100644 --- a/packages/utilities/shared-icons/src/index.ts +++ b/packages/utilities/shared-icons/src/index.ts @@ -5,9 +5,10 @@ export * from "./avatar"; export * from "./close"; export * from "./close-filled"; export * from "./chevron"; +export * from "./chevron-up"; export * from "./chevron-down"; +export * from "./chevron-left"; export * from "./chevron-right"; -export * from "./chevron-up"; export * from "./ellipsis"; export * from "./forward"; export * from "./sun"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 01eaf337cc..d18307092b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -123,16 +123,16 @@ importers: version: 7.32.0 eslint-config-airbnb: specifier: ^18.2.1 - version: 18.2.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@5.7.3))(eslint-import-resolver-typescript@2.7.1)(eslint@7.32.0))(eslint-plugin-jsx-a11y@6.10.2(eslint@7.32.0))(eslint-plugin-react-hooks@4.6.2(eslint@7.32.0))(eslint-plugin-react@7.37.3(eslint@7.32.0))(eslint@7.32.0) + version: 18.2.1(eslint-plugin-import@2.31.0)(eslint-plugin-jsx-a11y@6.10.2(eslint@7.32.0))(eslint-plugin-react-hooks@4.6.2(eslint@7.32.0))(eslint-plugin-react@7.37.3(eslint@7.32.0))(eslint@7.32.0) eslint-config-airbnb-typescript: specifier: ^12.3.1 - version: 12.3.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@5.7.3))(eslint-import-resolver-typescript@2.7.1)(eslint@7.32.0))(eslint-plugin-jsx-a11y@6.10.2(eslint@7.32.0))(eslint-plugin-react-hooks@4.6.2(eslint@7.32.0))(eslint-plugin-react@7.37.3(eslint@7.32.0))(eslint@7.32.0)(typescript@5.7.3) + version: 12.3.1(eslint-plugin-import@2.31.0)(eslint-plugin-jsx-a11y@6.10.2(eslint@7.32.0))(eslint-plugin-react-hooks@4.6.2(eslint@7.32.0))(eslint-plugin-react@7.37.3(eslint@7.32.0))(eslint@7.32.0)(typescript@5.7.3) eslint-config-prettier: specifier: ^8.2.0 version: 8.10.0(eslint@7.32.0) eslint-config-react-app: specifier: ^6.0.0 - version: 6.0.0(@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@5.7.3))(eslint@7.32.0)(typescript@5.7.3))(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@5.7.3))(babel-eslint@10.1.0(eslint@7.32.0))(eslint-plugin-flowtype@5.10.0(eslint@7.32.0))(eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@5.7.3))(eslint-import-resolver-typescript@2.7.1)(eslint@7.32.0))(eslint-plugin-jest@24.7.0(@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@5.7.3))(eslint@7.32.0)(typescript@5.7.3))(eslint@7.32.0)(typescript@5.7.3))(eslint-plugin-jsx-a11y@6.10.2(eslint@7.32.0))(eslint-plugin-react-hooks@4.6.2(eslint@7.32.0))(eslint-plugin-react@7.37.3(eslint@7.32.0))(eslint@7.32.0)(typescript@5.7.3) + version: 6.0.0(@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@5.7.3))(eslint@7.32.0)(typescript@5.7.3))(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@5.7.3))(babel-eslint@10.1.0(eslint@7.32.0))(eslint-plugin-flowtype@5.10.0(eslint@7.32.0))(eslint-plugin-import@2.31.0)(eslint-plugin-jest@24.7.0(@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@5.7.3))(eslint@7.32.0)(typescript@5.7.3))(eslint@7.32.0)(typescript@5.7.3))(eslint-plugin-jsx-a11y@6.10.2(eslint@7.32.0))(eslint-plugin-react-hooks@4.6.2(eslint@7.32.0))(eslint-plugin-react@7.37.3(eslint@7.32.0))(eslint@7.32.0)(typescript@5.7.3) eslint-config-ts-lambdas: specifier: ^1.2.3 version: 1.2.3(@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@5.7.3))(eslint@7.32.0)(typescript@5.7.3))(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@5.7.3))(eslint@7.32.0)(typescript@5.7.3) @@ -141,7 +141,7 @@ importers: version: 2.7.1(eslint-plugin-import@2.31.0)(eslint@7.32.0) eslint-loader: specifier: ^4.0.2 - version: 4.0.2(eslint@7.32.0)(webpack@5.97.1(@swc/core@1.10.6(@swc/helpers@0.5.15))(esbuild@0.24.2)(webpack-cli@3.3.12)) + version: 4.0.2(eslint@7.32.0)(webpack@5.97.1) eslint-plugin-import: specifier: ^2.26.0 version: 2.31.0(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@5.7.3))(eslint-import-resolver-typescript@2.7.1)(eslint@7.32.0) @@ -195,13 +195,13 @@ importers: version: 10.7.11 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@15.14.9)(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@20.5.1)(typescript@5.7.3)) + version: 29.7.0(@types/node@15.14.9)(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@15.14.9)(typescript@5.7.3)) jest-environment-jsdom: specifier: ^29.7.0 version: 29.7.0 jest-watch-typeahead: specifier: 2.2.2 - version: 2.2.2(jest@29.7.0(@types/node@15.14.9)(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@20.5.1)(typescript@5.7.3))) + version: 2.2.2(jest@29.7.0(@types/node@15.14.9)(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@15.14.9)(typescript@5.7.3))) lint-staged: specifier: ^13.0.3 version: 13.3.0(enquirer@2.4.1) @@ -252,7 +252,7 @@ importers: version: 5.7.3 webpack: specifier: ^5.53.0 - version: 5.97.1(@swc/core@1.10.6(@swc/helpers@0.5.15))(esbuild@0.24.2)(webpack-cli@3.3.12) + version: 5.97.1(@swc/core@1.10.6(@swc/helpers@0.5.15))(esbuild@0.24.2)(webpack-cli@3.3.12(webpack@5.97.1)) webpack-bundle-analyzer: specifier: ^4.4.2 version: 4.10.2 @@ -2211,6 +2211,76 @@ importers: specifier: 0.13.0 version: 0.13.0(react@18.3.0) + packages/components/number-input: + dependencies: + '@heroui/button': + specifier: workspace:* + version: link:../button + '@heroui/form': + specifier: workspace:* + version: link:../form + '@heroui/react-utils': + specifier: workspace:* + version: link:../../utilities/react-utils + '@heroui/shared-icons': + specifier: workspace:* + version: link:../../utilities/shared-icons + '@heroui/shared-utils': + specifier: workspace:* + version: link:../../utilities/shared-utils + '@heroui/use-safe-layout-effect': + specifier: workspace:* + version: link:../../hooks/use-safe-layout-effect + '@react-aria/focus': + specifier: 3.19.1 + version: 3.19.1(react-dom@18.3.0(react@18.3.0))(react@18.3.0) + '@react-aria/i18n': + specifier: 3.12.5 + version: 3.12.5(react-dom@18.3.0(react@18.3.0))(react@18.3.0) + '@react-aria/interactions': + specifier: 3.23.0 + version: 3.23.0(react-dom@18.3.0(react@18.3.0))(react@18.3.0) + '@react-aria/numberfield': + specifier: 3.11.10 + version: 3.11.10(react-dom@18.3.0(react@18.3.0))(react@18.3.0) + '@react-aria/utils': + specifier: 3.27.0 + version: 3.27.0(react-dom@18.3.0(react@18.3.0))(react@18.3.0) + '@react-stately/numberfield': + specifier: 3.9.9 + version: 3.9.9(react@18.3.0) + '@react-stately/utils': + specifier: 3.10.5 + version: 3.10.5(react@18.3.0) + '@react-types/button': + specifier: 3.10.2 + version: 3.10.2(react@18.3.0) + '@react-types/numberfield': + specifier: 3.8.8 + version: 3.8.8(react@18.3.0) + '@react-types/shared': + specifier: 3.27.0 + version: 3.27.0(react@18.3.0) + devDependencies: + '@heroui/system': + specifier: workspace:* + version: link:../../core/system + '@heroui/theme': + specifier: workspace:* + version: link:../../core/theme + clean-package: + specifier: 2.2.0 + version: 2.2.0 + react: + specifier: 18.3.0 + version: 18.3.0 + react-dom: + specifier: 18.3.0 + version: 18.3.0(react@18.3.0) + react-hook-form: + specifier: ^7.51.3 + version: 7.54.2(react@18.3.0) + packages/components/pagination: dependencies: '@heroui/react-utils': @@ -3278,6 +3348,9 @@ importers: '@heroui/navbar': specifier: workspace:* version: link:../../components/navbar + '@heroui/number-input': + specifier: workspace:* + version: link:../../components/number-input '@heroui/pagination': specifier: workspace:* version: link:../../components/pagination @@ -3421,7 +3494,7 @@ importers: version: 18.3.0 tailwind-variants: specifier: ^0.3.0 - version: 0.3.0(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@15.14.9)(typescript@5.7.3))) + version: 0.3.0(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@20.5.1)(typescript@5.7.3))) packages/core/theme: dependencies: @@ -3448,7 +3521,7 @@ importers: version: 2.5.4 tailwind-variants: specifier: 0.3.0 - version: 0.3.0(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@15.14.9)(typescript@5.7.3))) + version: 0.3.0(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@20.5.1)(typescript@5.7.3))) devDependencies: '@types/color': specifier: ^4.2.0 @@ -3461,7 +3534,7 @@ importers: version: 2.2.0 tailwindcss: specifier: ^3.4.16 - version: 3.4.17(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@15.14.9)(typescript@5.7.3)) + version: 3.4.17(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@20.5.1)(typescript@5.7.3)) packages/hooks/use-aria-accordion: dependencies: @@ -7005,6 +7078,12 @@ packages: react: 18.3.0 react-dom: 18.3.0 + '@react-aria/numberfield@3.11.10': + resolution: {integrity: sha512-bYbTfO9NbAKMFOfEGGs+lvlxk0I9L0lU3WD2PFQZWdaoBz9TCkL+vK0fJk1zsuKaVjeGsmHP9VesBPRmaP0MiA==} + peerDependencies: + react: 18.3.0 + react-dom: 18.3.0 + '@react-aria/overlays@3.25.0': resolution: {integrity: sha512-UEqJJ4duowrD1JvwXpPZreBuK79pbyNjNxFUVpFSskpGEJe3oCWwsSDKz7P1O7xbx5OYp+rDiY8fk/sE5rkaKw==} peerDependencies: @@ -7183,6 +7262,11 @@ packages: peerDependencies: react: 18.3.0 + '@react-stately/numberfield@3.9.9': + resolution: {integrity: sha512-hZsLiGGHTHmffjFymbH1qVmA633rU2GNjMFQTuSsN4lqqaP8fgxngd5pPCoTCUFEkUgWjdHenw+ZFByw8lIE+g==} + peerDependencies: + react: 18.3.0 + '@react-stately/overlays@3.6.13': resolution: {integrity: sha512-WsU85Gf/b+HbWsnnYw7P/Ila3wD+C37Uk/WbU4/fHgJ26IEOWsPE6wlul8j54NZ1PnLNhV9Fn+Kffi+PaJMQXQ==} peerDependencies: @@ -7314,6 +7398,11 @@ packages: peerDependencies: react: 18.3.0 + '@react-types/numberfield@3.8.8': + resolution: {integrity: sha512-825JPppxDaWh0Zxb0Q+wSslgRQYOtQPCAuhszPuWEy6d2F/M+hLR+qQqvQm9+LfMbdwiTg6QK5wxdWFCp2t7jw==} + peerDependencies: + react: 18.3.0 + '@react-types/overlays@3.8.12': resolution: {integrity: sha512-ZvR1t0YV7/6j+6OD8VozKYjvsXT92+C/2LOIKozy7YUNS5KI4MkXbRZzJvkuRECVZOmx8JXKTUzhghWJM/3QuQ==} peerDependencies: @@ -16865,7 +16954,7 @@ snapshots: lodash.merge: 4.6.2 lodash.uniq: 4.5.0 resolve-from: 5.0.0 - ts-node: 10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@15.14.9)(typescript@5.7.3) + ts-node: 10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@20.5.1)(typescript@5.7.3) typescript: 5.7.3 transitivePeerDependencies: - '@swc/core' @@ -17584,7 +17673,7 @@ snapshots: jest-util: 29.7.0 slash: 3.0.0 - '@jest/core@29.7.0(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@20.5.1)(typescript@5.7.3))': + '@jest/core@29.7.0(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@15.14.9)(typescript@5.7.3))': dependencies: '@jest/console': 29.7.0 '@jest/reporters': 29.7.0 @@ -17598,7 +17687,7 @@ snapshots: exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@15.14.9)(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@20.5.1)(typescript@5.7.3)) + jest-config: 29.7.0(@types/node@15.14.9)(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@15.14.9)(typescript@5.7.3)) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -19226,6 +19315,22 @@ snapshots: react: 18.3.0 react-dom: 18.3.0(react@18.3.0) + '@react-aria/numberfield@3.11.10(react-dom@18.3.0(react@18.3.0))(react@18.3.0)': + dependencies: + '@react-aria/i18n': 3.12.5(react-dom@18.3.0(react@18.3.0))(react@18.3.0) + '@react-aria/interactions': 3.23.0(react-dom@18.3.0(react@18.3.0))(react@18.3.0) + '@react-aria/spinbutton': 3.6.11(react-dom@18.3.0(react@18.3.0))(react@18.3.0) + '@react-aria/textfield': 3.16.0(react-dom@18.3.0(react@18.3.0))(react@18.3.0) + '@react-aria/utils': 3.27.0(react-dom@18.3.0(react@18.3.0))(react@18.3.0) + '@react-stately/form': 3.1.1(react@18.3.0) + '@react-stately/numberfield': 3.9.9(react@18.3.0) + '@react-types/button': 3.10.2(react@18.3.0) + '@react-types/numberfield': 3.8.8(react@18.3.0) + '@react-types/shared': 3.27.0(react@18.3.0) + '@swc/helpers': 0.5.15 + react: 18.3.0 + react-dom: 18.3.0(react@18.3.0) + '@react-aria/overlays@3.25.0(react-dom@18.3.0(react@18.3.0))(react@18.3.0)': dependencies: '@react-aria/focus': 3.19.1(react-dom@18.3.0(react@18.3.0))(react@18.3.0) @@ -19577,6 +19682,15 @@ snapshots: '@swc/helpers': 0.5.15 react: 18.3.0 + '@react-stately/numberfield@3.9.9(react@18.3.0)': + dependencies: + '@internationalized/number': 3.6.0 + '@react-stately/form': 3.1.1(react@18.3.0) + '@react-stately/utils': 3.10.5(react@18.3.0) + '@react-types/numberfield': 3.8.8(react@18.3.0) + '@swc/helpers': 0.5.15 + react: 18.3.0 + '@react-stately/overlays@3.6.13(react@18.3.0)': dependencies: '@react-stately/utils': 3.10.5(react@18.3.0) @@ -19755,6 +19869,11 @@ snapshots: '@react-types/shared': 3.27.0(react@18.3.0) react: 18.3.0 + '@react-types/numberfield@3.8.8(react@18.3.0)': + dependencies: + '@react-types/shared': 3.27.0(react@18.3.0) + react: 18.3.0 + '@react-types/overlays@3.8.12(react@18.3.0)': dependencies: '@react-types/shared': 3.27.0(react@18.3.0) @@ -22127,7 +22246,7 @@ snapshots: dependencies: '@types/node': 20.5.1 cosmiconfig: 8.3.6(typescript@5.7.3) - ts-node: 10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@15.14.9)(typescript@5.7.3) + ts-node: 10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@20.5.1)(typescript@5.7.3) typescript: 5.7.3 cosmiconfig@8.3.6(typescript@5.7.3): @@ -22148,13 +22267,13 @@ snapshots: optionalDependencies: typescript: 5.7.3 - create-jest@29.7.0(@types/node@15.14.9)(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@20.5.1)(typescript@5.7.3)): + create-jest@29.7.0(@types/node@15.14.9)(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@15.14.9)(typescript@5.7.3)): dependencies: '@jest/types': 29.6.3 chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@15.14.9)(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@20.5.1)(typescript@5.7.3)) + jest-config: 29.7.0(@types/node@15.14.9)(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@15.14.9)(typescript@5.7.3)) jest-util: 29.7.0 prompts: 2.4.2 transitivePeerDependencies: @@ -22817,7 +22936,7 @@ snapshots: optionalDependencies: source-map: 0.6.1 - eslint-config-airbnb-base@14.2.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@5.7.3))(eslint-import-resolver-typescript@2.7.1)(eslint@7.32.0))(eslint@7.32.0): + eslint-config-airbnb-base@14.2.1(eslint-plugin-import@2.31.0)(eslint@7.32.0): dependencies: confusing-browser-globals: 1.0.11 eslint: 7.32.0 @@ -22825,11 +22944,11 @@ snapshots: object.assign: 4.1.7 object.entries: 1.1.8 - eslint-config-airbnb-typescript@12.3.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@5.7.3))(eslint-import-resolver-typescript@2.7.1)(eslint@7.32.0))(eslint-plugin-jsx-a11y@6.10.2(eslint@7.32.0))(eslint-plugin-react-hooks@4.6.2(eslint@7.32.0))(eslint-plugin-react@7.37.3(eslint@7.32.0))(eslint@7.32.0)(typescript@5.7.3): + eslint-config-airbnb-typescript@12.3.1(eslint-plugin-import@2.31.0)(eslint-plugin-jsx-a11y@6.10.2(eslint@7.32.0))(eslint-plugin-react-hooks@4.6.2(eslint@7.32.0))(eslint-plugin-react@7.37.3(eslint@7.32.0))(eslint@7.32.0)(typescript@5.7.3): dependencies: '@typescript-eslint/parser': 4.33.0(eslint@7.32.0)(typescript@5.7.3) - eslint-config-airbnb: 18.2.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@5.7.3))(eslint-import-resolver-typescript@2.7.1)(eslint@7.32.0))(eslint-plugin-jsx-a11y@6.10.2(eslint@7.32.0))(eslint-plugin-react-hooks@4.6.2(eslint@7.32.0))(eslint-plugin-react@7.37.3(eslint@7.32.0))(eslint@7.32.0) - eslint-config-airbnb-base: 14.2.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@5.7.3))(eslint-import-resolver-typescript@2.7.1)(eslint@7.32.0))(eslint@7.32.0) + eslint-config-airbnb: 18.2.1(eslint-plugin-import@2.31.0)(eslint-plugin-jsx-a11y@6.10.2(eslint@7.32.0))(eslint-plugin-react-hooks@4.6.2(eslint@7.32.0))(eslint-plugin-react@7.37.3(eslint@7.32.0))(eslint@7.32.0) + eslint-config-airbnb-base: 14.2.1(eslint-plugin-import@2.31.0)(eslint@7.32.0) transitivePeerDependencies: - eslint - eslint-plugin-import @@ -22839,10 +22958,10 @@ snapshots: - supports-color - typescript - eslint-config-airbnb@18.2.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@5.7.3))(eslint-import-resolver-typescript@2.7.1)(eslint@7.32.0))(eslint-plugin-jsx-a11y@6.10.2(eslint@7.32.0))(eslint-plugin-react-hooks@4.6.2(eslint@7.32.0))(eslint-plugin-react@7.37.3(eslint@7.32.0))(eslint@7.32.0): + eslint-config-airbnb@18.2.1(eslint-plugin-import@2.31.0)(eslint-plugin-jsx-a11y@6.10.2(eslint@7.32.0))(eslint-plugin-react-hooks@4.6.2(eslint@7.32.0))(eslint-plugin-react@7.37.3(eslint@7.32.0))(eslint@7.32.0): dependencies: eslint: 7.32.0 - eslint-config-airbnb-base: 14.2.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@5.7.3))(eslint-import-resolver-typescript@2.7.1)(eslint@7.32.0))(eslint@7.32.0) + eslint-config-airbnb-base: 14.2.1(eslint-plugin-import@2.31.0)(eslint@7.32.0) eslint-plugin-import: 2.31.0(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@5.7.3))(eslint-import-resolver-typescript@2.7.1)(eslint@7.32.0) eslint-plugin-jsx-a11y: 6.10.2(eslint@7.32.0) eslint-plugin-react: 7.37.3(eslint@7.32.0) @@ -22874,7 +22993,7 @@ snapshots: dependencies: eslint: 7.32.0 - eslint-config-react-app@6.0.0(@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@5.7.3))(eslint@7.32.0)(typescript@5.7.3))(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@5.7.3))(babel-eslint@10.1.0(eslint@7.32.0))(eslint-plugin-flowtype@5.10.0(eslint@7.32.0))(eslint-plugin-import@2.31.0(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@5.7.3))(eslint-import-resolver-typescript@2.7.1)(eslint@7.32.0))(eslint-plugin-jest@24.7.0(@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@5.7.3))(eslint@7.32.0)(typescript@5.7.3))(eslint@7.32.0)(typescript@5.7.3))(eslint-plugin-jsx-a11y@6.10.2(eslint@7.32.0))(eslint-plugin-react-hooks@4.6.2(eslint@7.32.0))(eslint-plugin-react@7.37.3(eslint@7.32.0))(eslint@7.32.0)(typescript@5.7.3): + eslint-config-react-app@6.0.0(@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@5.7.3))(eslint@7.32.0)(typescript@5.7.3))(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@5.7.3))(babel-eslint@10.1.0(eslint@7.32.0))(eslint-plugin-flowtype@5.10.0(eslint@7.32.0))(eslint-plugin-import@2.31.0)(eslint-plugin-jest@24.7.0(@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@5.7.3))(eslint@7.32.0)(typescript@5.7.3))(eslint@7.32.0)(typescript@5.7.3))(eslint-plugin-jsx-a11y@6.10.2(eslint@7.32.0))(eslint-plugin-react-hooks@4.6.2(eslint@7.32.0))(eslint-plugin-react@7.37.3(eslint@7.32.0))(eslint@7.32.0)(typescript@5.7.3): dependencies: '@typescript-eslint/eslint-plugin': 5.62.0(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@5.7.3))(eslint@7.32.0)(typescript@5.7.3) '@typescript-eslint/parser': 5.62.0(eslint@7.32.0)(typescript@5.7.3) @@ -22933,7 +23052,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-loader@4.0.2(eslint@7.32.0)(webpack@5.97.1(@swc/core@1.10.6(@swc/helpers@0.5.15))(esbuild@0.24.2)(webpack-cli@3.3.12)): + eslint-loader@4.0.2(eslint@7.32.0)(webpack@5.97.1): dependencies: eslint: 7.32.0 find-cache-dir: 3.3.2 @@ -22941,9 +23060,9 @@ snapshots: loader-utils: 2.0.4 object-hash: 2.2.0 schema-utils: 2.7.1 - webpack: 5.97.1(@swc/core@1.10.6(@swc/helpers@0.5.15))(esbuild@0.24.2)(webpack-cli@3.3.12) + webpack: 5.97.1(@swc/core@1.10.6(@swc/helpers@0.5.15))(esbuild@0.24.2)(webpack-cli@3.3.12(webpack@5.97.1)) - eslint-module-utils@2.12.0(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@2.7.1(eslint-plugin-import@2.31.0)(eslint@7.32.0))(eslint@7.32.0): + eslint-module-utils@2.12.0(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@2.7.1)(eslint@7.32.0): dependencies: debug: 3.2.7 optionalDependencies: @@ -22977,7 +23096,7 @@ snapshots: doctrine: 2.1.0 eslint: 7.32.0 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@2.7.1(eslint-plugin-import@2.31.0)(eslint@7.32.0))(eslint@7.32.0) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@5.62.0(eslint@7.32.0)(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@2.7.1)(eslint@7.32.0) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -24703,16 +24822,16 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@29.7.0(@types/node@15.14.9)(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@20.5.1)(typescript@5.7.3)): + jest-cli@29.7.0(@types/node@15.14.9)(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@15.14.9)(typescript@5.7.3)): dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@20.5.1)(typescript@5.7.3)) + '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@15.14.9)(typescript@5.7.3)) '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 - create-jest: 29.7.0(@types/node@15.14.9)(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@20.5.1)(typescript@5.7.3)) + create-jest: 29.7.0(@types/node@15.14.9)(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@15.14.9)(typescript@5.7.3)) exit: 0.1.2 import-local: 3.2.0 - jest-config: 29.7.0(@types/node@15.14.9)(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@20.5.1)(typescript@5.7.3)) + jest-config: 29.7.0(@types/node@15.14.9)(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@15.14.9)(typescript@5.7.3)) jest-util: 29.7.0 jest-validate: 29.7.0 yargs: 17.7.2 @@ -24722,7 +24841,7 @@ snapshots: - supports-color - ts-node - jest-config@29.7.0(@types/node@15.14.9)(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@20.5.1)(typescript@5.7.3)): + jest-config@29.7.0(@types/node@15.14.9)(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@15.14.9)(typescript@5.7.3)): dependencies: '@babel/core': 7.26.0 '@jest/test-sequencer': 29.7.0 @@ -24748,7 +24867,7 @@ snapshots: strip-json-comments: 3.1.1 optionalDependencies: '@types/node': 15.14.9 - ts-node: 10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@15.14.9)(typescript@5.7.3) + ts-node: 10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@20.5.1)(typescript@5.7.3) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -24965,11 +25084,11 @@ snapshots: leven: 3.1.0 pretty-format: 29.7.0 - jest-watch-typeahead@2.2.2(jest@29.7.0(@types/node@15.14.9)(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@20.5.1)(typescript@5.7.3))): + jest-watch-typeahead@2.2.2(jest@29.7.0(@types/node@15.14.9)(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@15.14.9)(typescript@5.7.3))): dependencies: ansi-escapes: 6.2.1 chalk: 5.4.1 - jest: 29.7.0(@types/node@15.14.9)(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@20.5.1)(typescript@5.7.3)) + jest: 29.7.0(@types/node@15.14.9)(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@15.14.9)(typescript@5.7.3)) jest-regex-util: 29.6.3 jest-watcher: 29.7.0 slash: 5.1.0 @@ -25000,12 +25119,12 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 - jest@29.7.0(@types/node@15.14.9)(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@20.5.1)(typescript@5.7.3)): + jest@29.7.0(@types/node@15.14.9)(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@15.14.9)(typescript@5.7.3)): dependencies: - '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@20.5.1)(typescript@5.7.3)) + '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@15.14.9)(typescript@5.7.3)) '@jest/types': 29.6.3 import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@15.14.9)(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@20.5.1)(typescript@5.7.3)) + jest-cli: 29.7.0(@types/node@15.14.9)(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@15.14.9)(typescript@5.7.3)) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -27034,14 +27153,6 @@ snapshots: camelcase-css: 2.0.1 postcss: 8.4.49 - postcss-load-config@4.0.2(postcss@8.4.49)(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@15.14.9)(typescript@5.7.3)): - dependencies: - lilconfig: 3.1.3 - yaml: 2.7.0 - optionalDependencies: - postcss: 8.4.49 - ts-node: 10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@15.14.9)(typescript@5.7.3) - postcss-load-config@4.0.2(postcss@8.4.49)(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@20.2.5)(typescript@5.7.3)): dependencies: lilconfig: 3.1.3 @@ -28647,10 +28758,10 @@ snapshots: tailwind-merge: 2.5.4 tailwindcss: 3.4.14(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@20.2.5)(typescript@5.7.3)) - tailwind-variants@0.3.0(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@15.14.9)(typescript@5.7.3))): + tailwind-variants@0.3.0(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@20.5.1)(typescript@5.7.3))): dependencies: tailwind-merge: 2.5.4 - tailwindcss: 3.4.17(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@15.14.9)(typescript@5.7.3)) + tailwindcss: 3.4.17(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@20.5.1)(typescript@5.7.3)) tailwindcss@3.4.14(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@20.2.5)(typescript@5.7.3)): dependencies: @@ -28706,7 +28817,7 @@ snapshots: transitivePeerDependencies: - ts-node - tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@15.14.9)(typescript@5.7.3)): + tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@20.5.1)(typescript@5.7.3)): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -28725,7 +28836,7 @@ snapshots: postcss: 8.4.49 postcss-import: 15.1.0(postcss@8.4.49) postcss-js: 4.0.1(postcss@8.4.49) - postcss-load-config: 4.0.2(postcss@8.4.49)(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@15.14.9)(typescript@5.7.3)) + postcss-load-config: 4.0.2(postcss@8.4.49)(ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@20.5.1)(typescript@5.7.3)) postcss-nested: 6.2.0(postcss@8.4.49) postcss-selector-parser: 6.1.2 resolve: 1.22.10 @@ -28781,7 +28892,7 @@ snapshots: term-size@2.2.1: {} - terser-webpack-plugin@5.3.11(@swc/core@1.10.6(@swc/helpers@0.5.15))(esbuild@0.24.2)(webpack@5.97.1(@swc/core@1.10.6(@swc/helpers@0.5.15))(esbuild@0.24.2)(webpack-cli@3.3.12(webpack@5.97.1))): + terser-webpack-plugin@5.3.11(@swc/core@1.10.6(@swc/helpers@0.5.15))(esbuild@0.24.2)(webpack@5.97.1): dependencies: '@jridgewell/trace-mapping': 0.3.25 jest-worker: 27.5.1 @@ -28793,18 +28904,6 @@ snapshots: '@swc/core': 1.10.6(@swc/helpers@0.5.15) esbuild: 0.24.2 - terser-webpack-plugin@5.3.11(@swc/core@1.10.6(@swc/helpers@0.5.15))(esbuild@0.24.2)(webpack@5.97.1(@swc/core@1.10.6(@swc/helpers@0.5.15))(esbuild@0.24.2)(webpack-cli@3.3.12)): - dependencies: - '@jridgewell/trace-mapping': 0.3.25 - jest-worker: 27.5.1 - schema-utils: 4.3.0 - serialize-javascript: 6.0.2 - terser: 5.37.0 - webpack: 5.97.1(@swc/core@1.10.6(@swc/helpers@0.5.15))(esbuild@0.24.2)(webpack-cli@3.3.12) - optionalDependencies: - '@swc/core': 1.10.6(@swc/helpers@0.5.15) - esbuild: 0.24.2 - terser@5.37.0: dependencies: '@jridgewell/source-map': 0.3.6 @@ -28942,26 +29041,6 @@ snapshots: ts-interface-checker@0.1.13: {} - ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@15.14.9)(typescript@5.7.3): - dependencies: - '@cspotcode/source-map-support': 0.8.1 - '@tsconfig/node10': 1.0.11 - '@tsconfig/node12': 1.0.11 - '@tsconfig/node14': 1.0.3 - '@tsconfig/node16': 1.0.4 - '@types/node': 15.14.9 - acorn: 8.14.0 - acorn-walk: 8.3.4 - arg: 4.1.3 - create-require: 1.1.1 - diff: 4.0.2 - make-error: 1.3.6 - typescript: 5.7.3 - v8-compile-cache-lib: 3.0.1 - yn: 3.1.1 - optionalDependencies: - '@swc/core': 1.10.6(@swc/helpers@0.5.15) - ts-node@10.9.2(@swc/core@1.10.6(@swc/helpers@0.5.15))(@types/node@20.2.5)(typescript@5.7.3): dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -29002,7 +29081,6 @@ snapshots: yn: 3.1.1 optionalDependencies: '@swc/core': 1.10.6(@swc/helpers@0.5.15) - optional: true ts-pattern@5.6.0: {} @@ -29634,7 +29712,7 @@ snapshots: loader-utils: 1.4.2 supports-color: 6.1.0 v8-compile-cache: 2.4.0 - webpack: 5.97.1(@swc/core@1.10.6(@swc/helpers@0.5.15))(esbuild@0.24.2)(webpack-cli@3.3.12) + webpack: 5.97.1(@swc/core@1.10.6(@swc/helpers@0.5.15))(esbuild@0.24.2)(webpack-cli@3.3.12(webpack@5.97.1)) yargs: 13.3.2 webpack-merge@5.10.0: @@ -29669,39 +29747,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 3.3.0 tapable: 2.2.1 - terser-webpack-plugin: 5.3.11(@swc/core@1.10.6(@swc/helpers@0.5.15))(esbuild@0.24.2)(webpack@5.97.1(@swc/core@1.10.6(@swc/helpers@0.5.15))(esbuild@0.24.2)(webpack-cli@3.3.12(webpack@5.97.1))) - watchpack: 2.4.2 - webpack-sources: 3.2.3 - optionalDependencies: - webpack-cli: 3.3.12(webpack@5.97.1) - transitivePeerDependencies: - - '@swc/core' - - esbuild - - uglify-js - - webpack@5.97.1(@swc/core@1.10.6(@swc/helpers@0.5.15))(esbuild@0.24.2)(webpack-cli@3.3.12): - dependencies: - '@types/eslint-scope': 3.7.7 - '@types/estree': 1.0.6 - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/wasm-edit': 1.14.1 - '@webassemblyjs/wasm-parser': 1.14.1 - acorn: 8.14.0 - browserslist: 4.24.3 - chrome-trace-event: 1.0.4 - enhanced-resolve: 5.18.0 - es-module-lexer: 1.6.0 - eslint-scope: 5.1.1 - events: 3.3.0 - glob-to-regexp: 0.4.1 - graceful-fs: 4.2.11 - json-parse-even-better-errors: 2.3.1 - loader-runner: 4.3.0 - mime-types: 2.1.35 - neo-async: 2.6.2 - schema-utils: 3.3.0 - tapable: 2.2.1 - terser-webpack-plugin: 5.3.11(@swc/core@1.10.6(@swc/helpers@0.5.15))(esbuild@0.24.2)(webpack@5.97.1(@swc/core@1.10.6(@swc/helpers@0.5.15))(esbuild@0.24.2)(webpack-cli@3.3.12)) + terser-webpack-plugin: 5.3.11(@swc/core@1.10.6(@swc/helpers@0.5.15))(esbuild@0.24.2)(webpack@5.97.1) watchpack: 2.4.2 webpack-sources: 3.2.3 optionalDependencies: