From e005f881f0102ab39ee259071b5e0f34d3ea3d45 Mon Sep 17 00:00:00 2001 From: Albert Yu Date: Thu, 3 Aug 2023 18:16:55 +0800 Subject: [PATCH] [base-ui] Create useNumberInput and NumberInput (#36119) Co-authored-by: zanivan --- .../NumberInputBasic/css/index.js | 146 ++++++ .../NumberInputBasic/css/index.tsx | 146 ++++++ .../NumberInputBasic/css/index.tsx.preview | 13 + .../NumberInputBasic/system/index.js | 147 ++++++ .../NumberInputBasic/system/index.tsx | 153 ++++++ .../NumberInputBasic/system/index.tsx.preview | 6 + .../NumberInputBasic/tailwind/index.js | 48 ++ .../NumberInputBasic/tailwind/index.tsx | 54 +++ .../tailwind/index.tsx.preview | 6 + .../number-input/NumberInputIntroduction.js | 142 ++++++ .../number-input/NumberInputIntroduction.tsx | 148 ++++++ .../NumberInputIntroduction.tsx.preview | 1 + .../number-input/QuantityInput/css/index.js | 152 ++++++ .../number-input/QuantityInput/css/index.tsx | 155 +++++++ .../QuantityInput/css/index.tsx.preview | 4 + .../QuantityInput/system/index.js | 138 ++++++ .../QuantityInput/system/index.tsx | 141 ++++++ .../QuantityInput/system/index.tsx.preview | 1 + .../QuantityInput/tailwind/index.js | 37 ++ .../QuantityInput/tailwind/index.tsx | 40 ++ .../QuantityInput/tailwind/index.tsx.preview | 1 + .../components/number-input/UseNumberInput.js | 170 +++++++ .../number-input/UseNumberInput.tsx | 175 +++++++ .../number-input/UseNumberInput.tsx.preview | 1 + .../UseNumberInputCompact/css/index.js | 183 ++++++++ .../UseNumberInputCompact/css/index.tsx | 190 ++++++++ .../css/index.tsx.preview | 12 + .../UseNumberInputCompact/system/index.js | 164 +++++++ .../UseNumberInputCompact/system/index.tsx | 171 +++++++ .../system/index.tsx.preview | 11 + .../UseNumberInputCompact/tailwind/index.js | 88 ++++ .../UseNumberInputCompact/tailwind/index.tsx | 90 ++++ .../tailwind/index.tsx.preview | 9 + .../components/number-input/number-input.md | 157 +++++++ docs/data/base/pages.ts | 1 + docs/data/base/pagesApi.js | 8 + docs/pages/base-ui/api/number-input.json | 53 +++ docs/pages/base-ui/api/use-number-input.json | 120 +++++ .../react-number-input/[docsTab]/index.js | 48 ++ .../pages/base-ui/react-number-input/index.js | 13 + .../components/ComponentsApiContent.js | 27 +- .../src/modules/components/HooksApiContent.js | 8 +- .../number-input-unstyled.json | 19 + .../number-input/number-input.json | 44 ++ .../use-number-input/use-number-input.json | 66 +++ docs/translations/translations.json | 3 + package.json | 1 + .../Unstable_NumberInput/NumberInput.test.tsx | 436 ++++++++++++++++++ .../src/Unstable_NumberInput/NumberInput.tsx | 297 ++++++++++++ .../Unstable_NumberInput/NumberInput.types.ts | 104 +++++ .../src/Unstable_NumberInput/index.ts | 7 + .../numberInputClasses.ts | 49 ++ packages/mui-base/src/index.d.ts | 6 + packages/mui-base/src/index.js | 6 + .../src/unstable_useNumberInput/index.ts | 4 + .../useNumberInput.test.tsx | 274 +++++++++++ .../unstable_useNumberInput/useNumberInput.ts | 377 +++++++++++++++ .../useNumberInput.types.ts | 227 +++++++++ .../src/unstable_useNumberInput/utils.test.ts | 50 ++ .../src/unstable_useNumberInput/utils.ts | 34 ++ .../src/composeClasses/composeClasses.ts | 2 +- yarn.lock | 5 + 62 files changed, 5383 insertions(+), 6 deletions(-) create mode 100644 docs/data/base/components/number-input/NumberInputBasic/css/index.js create mode 100644 docs/data/base/components/number-input/NumberInputBasic/css/index.tsx create mode 100644 docs/data/base/components/number-input/NumberInputBasic/css/index.tsx.preview create mode 100644 docs/data/base/components/number-input/NumberInputBasic/system/index.js create mode 100644 docs/data/base/components/number-input/NumberInputBasic/system/index.tsx create mode 100644 docs/data/base/components/number-input/NumberInputBasic/system/index.tsx.preview create mode 100644 docs/data/base/components/number-input/NumberInputBasic/tailwind/index.js create mode 100644 docs/data/base/components/number-input/NumberInputBasic/tailwind/index.tsx create mode 100644 docs/data/base/components/number-input/NumberInputBasic/tailwind/index.tsx.preview create mode 100644 docs/data/base/components/number-input/NumberInputIntroduction.js create mode 100644 docs/data/base/components/number-input/NumberInputIntroduction.tsx create mode 100644 docs/data/base/components/number-input/NumberInputIntroduction.tsx.preview create mode 100644 docs/data/base/components/number-input/QuantityInput/css/index.js create mode 100644 docs/data/base/components/number-input/QuantityInput/css/index.tsx create mode 100644 docs/data/base/components/number-input/QuantityInput/css/index.tsx.preview create mode 100644 docs/data/base/components/number-input/QuantityInput/system/index.js create mode 100644 docs/data/base/components/number-input/QuantityInput/system/index.tsx create mode 100644 docs/data/base/components/number-input/QuantityInput/system/index.tsx.preview create mode 100644 docs/data/base/components/number-input/QuantityInput/tailwind/index.js create mode 100644 docs/data/base/components/number-input/QuantityInput/tailwind/index.tsx create mode 100644 docs/data/base/components/number-input/QuantityInput/tailwind/index.tsx.preview create mode 100644 docs/data/base/components/number-input/UseNumberInput.js create mode 100644 docs/data/base/components/number-input/UseNumberInput.tsx create mode 100644 docs/data/base/components/number-input/UseNumberInput.tsx.preview create mode 100644 docs/data/base/components/number-input/UseNumberInputCompact/css/index.js create mode 100644 docs/data/base/components/number-input/UseNumberInputCompact/css/index.tsx create mode 100644 docs/data/base/components/number-input/UseNumberInputCompact/css/index.tsx.preview create mode 100644 docs/data/base/components/number-input/UseNumberInputCompact/system/index.js create mode 100644 docs/data/base/components/number-input/UseNumberInputCompact/system/index.tsx create mode 100644 docs/data/base/components/number-input/UseNumberInputCompact/system/index.tsx.preview create mode 100644 docs/data/base/components/number-input/UseNumberInputCompact/tailwind/index.js create mode 100644 docs/data/base/components/number-input/UseNumberInputCompact/tailwind/index.tsx create mode 100644 docs/data/base/components/number-input/UseNumberInputCompact/tailwind/index.tsx.preview create mode 100644 docs/data/base/components/number-input/number-input.md create mode 100644 docs/pages/base-ui/api/number-input.json create mode 100644 docs/pages/base-ui/api/use-number-input.json create mode 100644 docs/pages/base-ui/react-number-input/[docsTab]/index.js create mode 100644 docs/pages/base-ui/react-number-input/index.js create mode 100644 docs/translations/api-docs-base/number-input-unstyled/number-input-unstyled.json create mode 100644 docs/translations/api-docs-base/number-input/number-input.json create mode 100644 docs/translations/api-docs/use-number-input/use-number-input.json create mode 100644 packages/mui-base/src/Unstable_NumberInput/NumberInput.test.tsx create mode 100644 packages/mui-base/src/Unstable_NumberInput/NumberInput.tsx create mode 100644 packages/mui-base/src/Unstable_NumberInput/NumberInput.types.ts create mode 100644 packages/mui-base/src/Unstable_NumberInput/index.ts create mode 100644 packages/mui-base/src/Unstable_NumberInput/numberInputClasses.ts create mode 100644 packages/mui-base/src/unstable_useNumberInput/index.ts create mode 100644 packages/mui-base/src/unstable_useNumberInput/useNumberInput.test.tsx create mode 100644 packages/mui-base/src/unstable_useNumberInput/useNumberInput.ts create mode 100644 packages/mui-base/src/unstable_useNumberInput/useNumberInput.types.ts create mode 100644 packages/mui-base/src/unstable_useNumberInput/utils.test.ts create mode 100644 packages/mui-base/src/unstable_useNumberInput/utils.ts diff --git a/docs/data/base/components/number-input/NumberInputBasic/css/index.js b/docs/data/base/components/number-input/NumberInputBasic/css/index.js new file mode 100644 index 00000000000000..04b6cab9946f82 --- /dev/null +++ b/docs/data/base/components/number-input/NumberInputBasic/css/index.js @@ -0,0 +1,146 @@ +import * as React from 'react'; +import NumberInput, { numberInputClasses } from '@mui/base/Unstable_NumberInput'; +import { useTheme } from '@mui/system'; + +export default function NumberInputBasic() { + return ( + + + + + ); +} + +const cyan = { + 50: '#E9F8FC', + 100: '#BDEBF4', + 200: '#99D8E5', + 300: '#66BACC', + 400: '#1F94AD', + 500: '#0D5463', + 600: '#094855', + 700: '#063C47', + 800: '#043039', + 900: '#022127', +}; + +const grey = { + 50: '#F3F6F9', + 100: '#E7EBF0', + 200: '#E0E3E7', + 300: '#CDD2D7', + 400: '#B2BAC2', + 500: '#A0AAB4', + 600: '#6F7E8C', + 700: '#3E5060', + 800: '#2D3843', + 900: '#1A2027', +}; + +function useIsDarkMode() { + const theme = useTheme(); + return theme.palette.mode === 'dark'; +} + +function Styles() { + // Replace this with your app logic for determining dark mode + const isDarkMode = useIsDarkMode(); + + return ( + + ); +} diff --git a/docs/data/base/components/number-input/NumberInputBasic/css/index.tsx b/docs/data/base/components/number-input/NumberInputBasic/css/index.tsx new file mode 100644 index 00000000000000..04b6cab9946f82 --- /dev/null +++ b/docs/data/base/components/number-input/NumberInputBasic/css/index.tsx @@ -0,0 +1,146 @@ +import * as React from 'react'; +import NumberInput, { numberInputClasses } from '@mui/base/Unstable_NumberInput'; +import { useTheme } from '@mui/system'; + +export default function NumberInputBasic() { + return ( + + + + + ); +} + +const cyan = { + 50: '#E9F8FC', + 100: '#BDEBF4', + 200: '#99D8E5', + 300: '#66BACC', + 400: '#1F94AD', + 500: '#0D5463', + 600: '#094855', + 700: '#063C47', + 800: '#043039', + 900: '#022127', +}; + +const grey = { + 50: '#F3F6F9', + 100: '#E7EBF0', + 200: '#E0E3E7', + 300: '#CDD2D7', + 400: '#B2BAC2', + 500: '#A0AAB4', + 600: '#6F7E8C', + 700: '#3E5060', + 800: '#2D3843', + 900: '#1A2027', +}; + +function useIsDarkMode() { + const theme = useTheme(); + return theme.palette.mode === 'dark'; +} + +function Styles() { + // Replace this with your app logic for determining dark mode + const isDarkMode = useIsDarkMode(); + + return ( + + ); +} diff --git a/docs/data/base/components/number-input/NumberInputBasic/css/index.tsx.preview b/docs/data/base/components/number-input/NumberInputBasic/css/index.tsx.preview new file mode 100644 index 00000000000000..beb20ef0d8c30d --- /dev/null +++ b/docs/data/base/components/number-input/NumberInputBasic/css/index.tsx.preview @@ -0,0 +1,13 @@ + + + + \ No newline at end of file diff --git a/docs/data/base/components/number-input/NumberInputBasic/system/index.js b/docs/data/base/components/number-input/NumberInputBasic/system/index.js new file mode 100644 index 00000000000000..4f2a18e30d4e65 --- /dev/null +++ b/docs/data/base/components/number-input/NumberInputBasic/system/index.js @@ -0,0 +1,147 @@ +import * as React from 'react'; +import NumberInput, { numberInputClasses } from '@mui/base/Unstable_NumberInput'; +import { styled } from '@mui/system'; + +const CustomNumberInput = React.forwardRef(function CustomNumberInput(props, ref) { + return ( + + ); +}); + +export default function NumberInputBasic() { + const [value, setValue] = React.useState(); + return ( + setValue(val)} + /> + ); +} + +const blue = { + 100: '#DAECFF', + 200: '#80BFFF', + 400: '#3399FF', + 500: '#007FFF', + 600: '#0072E5', +}; + +const grey = { + 50: '#F3F6F9', + 100: '#E7EBF0', + 200: '#E0E3E7', + 300: '#CDD2D7', + 400: '#B2BAC2', + 500: '#A0AAB4', + 600: '#6F7E8C', + 700: '#3E5060', + 800: '#2D3843', + 900: '#1A2027', +}; + +const StyledInputRoot = styled('div')( + ({ theme }) => ` + font-family: IBM Plex Sans, sans-serif; + font-weight: 400; + border-radius: 8px; + color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]}; + background: ${theme.palette.mode === 'dark' ? grey[900] : '#fff'}; + border: 1px solid ${theme.palette.mode === 'dark' ? grey[700] : grey[200]}; + box-shadow: 0px 2px 2px ${theme.palette.mode === 'dark' ? grey[900] : grey[50]}; + display: grid; + grid-template-columns: 1fr 19px; + grid-template-rows: 1fr 1fr; + overflow: hidden; + + + &.${numberInputClasses.focused} { + border-color: ${blue[400]}; + box-shadow: 0 0 0 3px ${theme.palette.mode === 'dark' ? blue[500] : blue[200]}; + } + + &:hover { + border-color: ${blue[400]}; + } + + // firefox + &:focus-visible { + outline: 0; + } +`, +); + +const StyledInputElement = styled('input')( + ({ theme }) => ` + font-size: 0.875rem; + font-family: inherit; + font-weight: 400; + line-height: 1.5; + grid-column: 1/2; + grid-row: 1/3; + color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]}; + background: inherit; + border: none; + border-radius: inherit; + padding: 8px 12px; + outline: 0; +`, +); + +const StyledButton = styled('button')( + ({ theme }) => ` + display: flex; + flex-flow: row nowrap; + justify-content: center; + align-items: center; + appearance: none; + padding: 0; + width: 19px; + height: 19px; + font-family: system-ui, sans-serif; + font-size: 0.875rem; + box-sizing: border-box; + line-height: 1.2; + background: ${theme.palette.mode === 'dark' ? grey[900] : '#fff'}; + border: 0; + color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]}; + + transition-property: all; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 120ms; + + &:hover { + background: ${theme.palette.mode === 'dark' ? grey[800] : grey[50]}; + border-color: ${theme.palette.mode === 'dark' ? grey[600] : grey[300]}; + cursor: pointer; + } + + &.${numberInputClasses.incrementButton} { + grid-column: 2/3; + grid-row: 1/2; + } + + &.${numberInputClasses.decrementButton} { + grid-column: 2/3; + grid-row: 2/3; + } +`, +); diff --git a/docs/data/base/components/number-input/NumberInputBasic/system/index.tsx b/docs/data/base/components/number-input/NumberInputBasic/system/index.tsx new file mode 100644 index 00000000000000..132d4bf1db664e --- /dev/null +++ b/docs/data/base/components/number-input/NumberInputBasic/system/index.tsx @@ -0,0 +1,153 @@ +import * as React from 'react'; +import NumberInput, { + NumberInputProps, + numberInputClasses, +} from '@mui/base/Unstable_NumberInput'; +import { styled } from '@mui/system'; + +const CustomNumberInput = React.forwardRef(function CustomNumberInput( + props: NumberInputProps, + ref: React.ForwardedRef, +) { + return ( + + ); +}); + +export default function NumberInputBasic() { + const [value, setValue] = React.useState(); + return ( + setValue(val)} + /> + ); +} + +const blue = { + 100: '#DAECFF', + 200: '#80BFFF', + 400: '#3399FF', + 500: '#007FFF', + 600: '#0072E5', +}; + +const grey = { + 50: '#F3F6F9', + 100: '#E7EBF0', + 200: '#E0E3E7', + 300: '#CDD2D7', + 400: '#B2BAC2', + 500: '#A0AAB4', + 600: '#6F7E8C', + 700: '#3E5060', + 800: '#2D3843', + 900: '#1A2027', +}; + +const StyledInputRoot = styled('div')( + ({ theme }) => ` + font-family: IBM Plex Sans, sans-serif; + font-weight: 400; + border-radius: 8px; + color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]}; + background: ${theme.palette.mode === 'dark' ? grey[900] : '#fff'}; + border: 1px solid ${theme.palette.mode === 'dark' ? grey[700] : grey[200]}; + box-shadow: 0px 2px 2px ${theme.palette.mode === 'dark' ? grey[900] : grey[50]}; + display: grid; + grid-template-columns: 1fr 19px; + grid-template-rows: 1fr 1fr; + overflow: hidden; + + + &.${numberInputClasses.focused} { + border-color: ${blue[400]}; + box-shadow: 0 0 0 3px ${theme.palette.mode === 'dark' ? blue[500] : blue[200]}; + } + + &:hover { + border-color: ${blue[400]}; + } + + // firefox + &:focus-visible { + outline: 0; + } +`, +); + +const StyledInputElement = styled('input')( + ({ theme }) => ` + font-size: 0.875rem; + font-family: inherit; + font-weight: 400; + line-height: 1.5; + grid-column: 1/2; + grid-row: 1/3; + color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]}; + background: inherit; + border: none; + border-radius: inherit; + padding: 8px 12px; + outline: 0; +`, +); + +const StyledButton = styled('button')( + ({ theme }) => ` + display: flex; + flex-flow: row nowrap; + justify-content: center; + align-items: center; + appearance: none; + padding: 0; + width: 19px; + height: 19px; + font-family: system-ui, sans-serif; + font-size: 0.875rem; + box-sizing: border-box; + line-height: 1.2; + background: ${theme.palette.mode === 'dark' ? grey[900] : '#fff'}; + border: 0; + color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]}; + + transition-property: all; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 120ms; + + &:hover { + background: ${theme.palette.mode === 'dark' ? grey[800] : grey[50]}; + border-color: ${theme.palette.mode === 'dark' ? grey[600] : grey[300]}; + cursor: pointer; + } + + &.${numberInputClasses.incrementButton} { + grid-column: 2/3; + grid-row: 1/2; + } + + &.${numberInputClasses.decrementButton} { + grid-column: 2/3; + grid-row: 2/3; + } +`, +); diff --git a/docs/data/base/components/number-input/NumberInputBasic/system/index.tsx.preview b/docs/data/base/components/number-input/NumberInputBasic/system/index.tsx.preview new file mode 100644 index 00000000000000..a2ecd626c6645c --- /dev/null +++ b/docs/data/base/components/number-input/NumberInputBasic/system/index.tsx.preview @@ -0,0 +1,6 @@ + setValue(val)} +/> \ No newline at end of file diff --git a/docs/data/base/components/number-input/NumberInputBasic/tailwind/index.js b/docs/data/base/components/number-input/NumberInputBasic/tailwind/index.js new file mode 100644 index 00000000000000..6ec1ea01da471a --- /dev/null +++ b/docs/data/base/components/number-input/NumberInputBasic/tailwind/index.js @@ -0,0 +1,48 @@ +import * as React from 'react'; +import NumberInput from '@mui/base/Unstable_NumberInput'; +import clsx from 'clsx'; + +const CustomNumberInput = React.forwardRef(function CustomNumberInput(props, ref) { + return ( + ({ + className: clsx( + 'grid grid-cols-[1fr_19px] grid-rows-2 overflow-hidden rounded-lg text-slate-900 dark:text-slate-300 border border-solid bg-white dark:bg-slate-900 hover:border-violet-400 dark:hover:border-violet-400 focus-visible:outline-0 ', + ownerState.focused + ? 'border-violet-400 dark:border-violet-400 shadow-lg shadow-outline-purple dark:shadow-outline-purple' + : 'border-slate-300 dark:border-slate-600 shadow-md shadow-slate-100 dark:shadow-slate-900', + ), + }), + input: { + className: + 'col-start-1 col-end-2 row-start-1 row-end-3 text-sm leading-normal text-slate-900 bg-inherit border-0 rounded-[inherit] dark:text-slate-300 px-3 py-2 outline-0 focus-visible:outline-0 focus-visible:outline-none', + }, + incrementButton: { + children: '▴', + className: + 'font-[system-ui] flex flex-row flex-nowrap justify-center items-center appearance-none p-0 w-[19px] h-[19px] text-sm box-border leading-[1.2] border-0 bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-300 transition-all duration-[120ms] hover:cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800 border-slate-300 dark:border-slate-600 col-start-2 col-end-3 row-start-1 row-end-2', + }, + decrementButton: { + children: '▾', + className: + 'font-[system-ui] flex flex-row flex-nowrap justify-center items-center appearance-none p-0 w-[19px] h-[19px] text-sm box-border leading-[1.2] border-0 bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-300 transition-all duration-[120ms] hover:cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800 border-slate-300 dark:border-slate-600 col-start-2 col-end-3 row-start-2 row-end-3', + }, + }} + {...props} + ref={ref} + /> + ); +}); + +export default function NumberInputBasic() { + const [value, setValue] = React.useState(); + return ( + setValue(val)} + /> + ); +} diff --git a/docs/data/base/components/number-input/NumberInputBasic/tailwind/index.tsx b/docs/data/base/components/number-input/NumberInputBasic/tailwind/index.tsx new file mode 100644 index 00000000000000..f2f2266e13df1b --- /dev/null +++ b/docs/data/base/components/number-input/NumberInputBasic/tailwind/index.tsx @@ -0,0 +1,54 @@ +import * as React from 'react'; +import NumberInput, { + NumberInputProps, + NumberInputOwnerState, +} from '@mui/base/Unstable_NumberInput'; +import clsx from 'clsx'; + +const CustomNumberInput = React.forwardRef(function CustomNumberInput( + props: NumberInputProps, + ref: React.ForwardedRef, +) { + return ( + ({ + className: clsx( + 'grid grid-cols-[1fr_19px] grid-rows-2 overflow-hidden rounded-lg text-slate-900 dark:text-slate-300 border border-solid bg-white dark:bg-slate-900 hover:border-violet-400 dark:hover:border-violet-400 focus-visible:outline-0 ', + ownerState.focused + ? 'border-violet-400 dark:border-violet-400 shadow-lg shadow-outline-purple dark:shadow-outline-purple' + : 'border-slate-300 dark:border-slate-600 shadow-md shadow-slate-100 dark:shadow-slate-900', + ), + }), + input: { + className: + 'col-start-1 col-end-2 row-start-1 row-end-3 text-sm leading-normal text-slate-900 bg-inherit border-0 rounded-[inherit] dark:text-slate-300 px-3 py-2 outline-0 focus-visible:outline-0 focus-visible:outline-none', + }, + incrementButton: { + children: '▴', + className: + 'font-[system-ui] flex flex-row flex-nowrap justify-center items-center appearance-none p-0 w-[19px] h-[19px] text-sm box-border leading-[1.2] border-0 bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-300 transition-all duration-[120ms] hover:cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800 border-slate-300 dark:border-slate-600 col-start-2 col-end-3 row-start-1 row-end-2', + }, + decrementButton: { + children: '▾', + className: + 'font-[system-ui] flex flex-row flex-nowrap justify-center items-center appearance-none p-0 w-[19px] h-[19px] text-sm box-border leading-[1.2] border-0 bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-300 transition-all duration-[120ms] hover:cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800 border-slate-300 dark:border-slate-600 col-start-2 col-end-3 row-start-2 row-end-3', + }, + }} + {...props} + ref={ref} + /> + ); +}); + +export default function NumberInputBasic() { + const [value, setValue] = React.useState(); + return ( + setValue(val)} + /> + ); +} diff --git a/docs/data/base/components/number-input/NumberInputBasic/tailwind/index.tsx.preview b/docs/data/base/components/number-input/NumberInputBasic/tailwind/index.tsx.preview new file mode 100644 index 00000000000000..a2ecd626c6645c --- /dev/null +++ b/docs/data/base/components/number-input/NumberInputBasic/tailwind/index.tsx.preview @@ -0,0 +1,6 @@ + setValue(val)} +/> \ No newline at end of file diff --git a/docs/data/base/components/number-input/NumberInputIntroduction.js b/docs/data/base/components/number-input/NumberInputIntroduction.js new file mode 100644 index 00000000000000..a86b1eedc1b6cb --- /dev/null +++ b/docs/data/base/components/number-input/NumberInputIntroduction.js @@ -0,0 +1,142 @@ +import * as React from 'react'; +import NumberInput, { numberInputClasses } from '@mui/base/Unstable_NumberInput'; +import { styled } from '@mui/system'; + +const CustomNumberInput = React.forwardRef(function CustomNumberInput(props, ref) { + return ( + + ); +}); + +export default function NumberInputIntroduction() { + return ( + + ); +} + +const blue = { + 100: '#DAECFF', + 200: '#b6daff', + 400: '#3399FF', + 500: '#007FFF', + 600: '#0072E5', + 900: '#003A75', +}; + +const grey = { + 50: '#f6f8fa', + 100: '#eaeef2', + 200: '#d0d7de', + 300: '#afb8c1', + 400: '#8c959f', + 500: '#6e7781', + 600: '#57606a', + 700: '#424a53', + 800: '#32383f', + 900: '#24292f', +}; + +const StyledInputRoot = styled('div')( + ({ theme }) => ` + font-family: IBM Plex Sans, sans-serif; + font-weight: 400; + border-radius: 8px; + color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]}; + background: ${theme.palette.mode === 'dark' ? grey[900] : '#fff'}; + border: 1px solid ${theme.palette.mode === 'dark' ? grey[700] : grey[200]}; + box-shadow: 0px 2px 24px ${theme.palette.mode === 'dark' ? blue[900] : blue[100]}; + display: grid; + grid-template-columns: 1fr 19px; + grid-template-rows: 1fr 1fr; + overflow: hidden; + + + &.${numberInputClasses.focused} { + border-color: ${blue[400]}; + box-shadow: 0 0 0 3px ${theme.palette.mode === 'dark' ? blue[600] : blue[200]}; + } + + &:hover { + border-color: ${blue[400]}; + } + + // firefox + &:focus-visible { + outline: 0; + } +`, +); + +const StyledInputElement = styled('input')( + ({ theme }) => ` + font-size: 0.875rem; + font-family: inherit; + font-weight: 400; + line-height: 1.5; + grid-column: 1/2; + grid-row: 1/3; + color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]}; + background: inherit; + border: none; + border-radius: inherit; + padding: 8px 12px; + outline: 0; +`, +); + +const StyledButton = styled('button')( + ({ theme }) => ` + display: flex; + flex-flow: row nowrap; + justify-content: center; + align-items: center; + appearance: none; + padding: 0; + width: 19px; + height: 19px; + font-family: system-ui, sans-serif; + font-size: 0.875rem; + box-sizing: border-box; + line-height: 1.2; + background: ${theme.palette.mode === 'dark' ? grey[900] : '#fff'}; + border: 0; + color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]}; + + transition-property: all; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 120ms; + + &:hover { + background: ${theme.palette.mode === 'dark' ? grey[800] : grey[50]}; + border-color: ${theme.palette.mode === 'dark' ? grey[600] : grey[300]}; + cursor: pointer; + } + + &.${numberInputClasses.incrementButton} { + grid-column: 2/3; + grid-row: 1/2; + } + + &.${numberInputClasses.decrementButton} { + grid-column: 2/3; + grid-row: 2/3; + } +`, +); diff --git a/docs/data/base/components/number-input/NumberInputIntroduction.tsx b/docs/data/base/components/number-input/NumberInputIntroduction.tsx new file mode 100644 index 00000000000000..6405133fe5624d --- /dev/null +++ b/docs/data/base/components/number-input/NumberInputIntroduction.tsx @@ -0,0 +1,148 @@ +import * as React from 'react'; +import NumberInput, { + NumberInputProps, + numberInputClasses, +} from '@mui/base/Unstable_NumberInput'; +import { styled } from '@mui/system'; + +const CustomNumberInput = React.forwardRef(function CustomNumberInput( + props: NumberInputProps, + ref: React.ForwardedRef, +) { + return ( + + ); +}); + +export default function NumberInputIntroduction() { + return ( + + ); +} + +const blue = { + 100: '#DAECFF', + 200: '#b6daff', + 400: '#3399FF', + 500: '#007FFF', + 600: '#0072E5', + 900: '#003A75', +}; + +const grey = { + 50: '#f6f8fa', + 100: '#eaeef2', + 200: '#d0d7de', + 300: '#afb8c1', + 400: '#8c959f', + 500: '#6e7781', + 600: '#57606a', + 700: '#424a53', + 800: '#32383f', + 900: '#24292f', +}; + +const StyledInputRoot = styled('div')( + ({ theme }) => ` + font-family: IBM Plex Sans, sans-serif; + font-weight: 400; + border-radius: 8px; + color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]}; + background: ${theme.palette.mode === 'dark' ? grey[900] : '#fff'}; + border: 1px solid ${theme.palette.mode === 'dark' ? grey[700] : grey[200]}; + box-shadow: 0px 2px 24px ${theme.palette.mode === 'dark' ? blue[900] : blue[100]}; + display: grid; + grid-template-columns: 1fr 19px; + grid-template-rows: 1fr 1fr; + overflow: hidden; + + + &.${numberInputClasses.focused} { + border-color: ${blue[400]}; + box-shadow: 0 0 0 3px ${theme.palette.mode === 'dark' ? blue[600] : blue[200]}; + } + + &:hover { + border-color: ${blue[400]}; + } + + // firefox + &:focus-visible { + outline: 0; + } +`, +); + +const StyledInputElement = styled('input')( + ({ theme }) => ` + font-size: 0.875rem; + font-family: inherit; + font-weight: 400; + line-height: 1.5; + grid-column: 1/2; + grid-row: 1/3; + color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]}; + background: inherit; + border: none; + border-radius: inherit; + padding: 8px 12px; + outline: 0; +`, +); + +const StyledButton = styled('button')( + ({ theme }) => ` + display: flex; + flex-flow: row nowrap; + justify-content: center; + align-items: center; + appearance: none; + padding: 0; + width: 19px; + height: 19px; + font-family: system-ui, sans-serif; + font-size: 0.875rem; + box-sizing: border-box; + line-height: 1.2; + background: ${theme.palette.mode === 'dark' ? grey[900] : '#fff'}; + border: 0; + color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]}; + + transition-property: all; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 120ms; + + &:hover { + background: ${theme.palette.mode === 'dark' ? grey[800] : grey[50]}; + border-color: ${theme.palette.mode === 'dark' ? grey[600] : grey[300]}; + cursor: pointer; + } + + &.${numberInputClasses.incrementButton} { + grid-column: 2/3; + grid-row: 1/2; + } + + &.${numberInputClasses.decrementButton} { + grid-column: 2/3; + grid-row: 2/3; + } +`, +); diff --git a/docs/data/base/components/number-input/NumberInputIntroduction.tsx.preview b/docs/data/base/components/number-input/NumberInputIntroduction.tsx.preview new file mode 100644 index 00000000000000..fa6c5c5dc81d85 --- /dev/null +++ b/docs/data/base/components/number-input/NumberInputIntroduction.tsx.preview @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/data/base/components/number-input/QuantityInput/css/index.js b/docs/data/base/components/number-input/QuantityInput/css/index.js new file mode 100644 index 00000000000000..40b2207eaa063c --- /dev/null +++ b/docs/data/base/components/number-input/QuantityInput/css/index.js @@ -0,0 +1,152 @@ +import * as React from 'react'; +import NumberInput from '@mui/base/Unstable_NumberInput'; +import { useTheme } from '@mui/system'; +import RemoveIcon from '@mui/icons-material/Remove'; +import AddIcon from '@mui/icons-material/Add'; + +const CustomNumberInput = React.forwardRef(function CustomNumberInput(props, ref) { + return ( + , + className: 'btn', + }, + incrementButton: { + children: , + className: 'btn increment', + }, + }} + {...props} + ref={ref} + /> + ); +}); + +export default function QuantityInput() { + return ( + + + + + ); +} + +const cyan = { + 50: '#E9F8FC', + 100: '#BDEBF4', + 200: '#99D8E5', + 300: '#66BACC', + 400: '#1F94AD', + 500: '#0D5463', + 600: '#094855', + 700: '#063C47', + 800: '#043039', + 900: '#022127', +}; + +const grey = { + 50: '#F3F6F9', + 100: '#E7EBF0', + 200: '#E0E3E7', + 300: '#CDD2D7', + 400: '#B2BAC2', + 500: '#A0AAB4', + 600: '#6F7E8C', + 700: '#3E5060', + 800: '#2D3843', + 900: '#1A2027', +}; + +function useIsDarkMode() { + const theme = useTheme(); + return theme.palette.mode === 'dark'; +} + +function Styles() { + // Replace this with your app logic for determining dark mode + const isDarkMode = useIsDarkMode(); + + return ( + + ); +} diff --git a/docs/data/base/components/number-input/QuantityInput/css/index.tsx b/docs/data/base/components/number-input/QuantityInput/css/index.tsx new file mode 100644 index 00000000000000..b5ea9808e6beb4 --- /dev/null +++ b/docs/data/base/components/number-input/QuantityInput/css/index.tsx @@ -0,0 +1,155 @@ +import * as React from 'react'; +import NumberInput, { NumberInputProps } from '@mui/base/Unstable_NumberInput'; +import { useTheme } from '@mui/system'; +import RemoveIcon from '@mui/icons-material/Remove'; +import AddIcon from '@mui/icons-material/Add'; + +const CustomNumberInput = React.forwardRef(function CustomNumberInput( + props: NumberInputProps, + ref: React.ForwardedRef, +) { + return ( + , + className: 'btn', + }, + incrementButton: { + children: , + className: 'btn increment', + }, + }} + {...props} + ref={ref} + /> + ); +}); + +export default function QuantityInput() { + return ( + + + + + ); +} + +const cyan = { + 50: '#E9F8FC', + 100: '#BDEBF4', + 200: '#99D8E5', + 300: '#66BACC', + 400: '#1F94AD', + 500: '#0D5463', + 600: '#094855', + 700: '#063C47', + 800: '#043039', + 900: '#022127', +}; + +const grey = { + 50: '#F3F6F9', + 100: '#E7EBF0', + 200: '#E0E3E7', + 300: '#CDD2D7', + 400: '#B2BAC2', + 500: '#A0AAB4', + 600: '#6F7E8C', + 700: '#3E5060', + 800: '#2D3843', + 900: '#1A2027', +}; + +function useIsDarkMode() { + const theme = useTheme(); + return theme.palette.mode === 'dark'; +} + +function Styles() { + // Replace this with your app logic for determining dark mode + const isDarkMode = useIsDarkMode(); + + return ( + + ); +} diff --git a/docs/data/base/components/number-input/QuantityInput/css/index.tsx.preview b/docs/data/base/components/number-input/QuantityInput/css/index.tsx.preview new file mode 100644 index 00000000000000..47c4378dc87168 --- /dev/null +++ b/docs/data/base/components/number-input/QuantityInput/css/index.tsx.preview @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/docs/data/base/components/number-input/QuantityInput/system/index.js b/docs/data/base/components/number-input/QuantityInput/system/index.js new file mode 100644 index 00000000000000..82380339434fa6 --- /dev/null +++ b/docs/data/base/components/number-input/QuantityInput/system/index.js @@ -0,0 +1,138 @@ +import * as React from 'react'; +import NumberInput from '@mui/base/Unstable_NumberInput'; +import { styled } from '@mui/system'; +import RemoveIcon from '@mui/icons-material/Remove'; +import AddIcon from '@mui/icons-material/Add'; + +const CustomNumberInput = React.forwardRef(function CustomNumberInput(props, ref) { + return ( + , + className: 'increment', + }, + decrementButton: { + children: , + }, + }} + {...props} + ref={ref} + /> + ); +}); + +export default function QuantityInput() { + return ; +} + +const blue = { + 100: '#daecff', + 200: '#b6daff', + 300: '#66b2ff', + 400: '#3399ff', + 500: '#007fff', + 600: '#0072e5', + 800: '#004c99', +}; + +const grey = { + 50: '#f6f8fa', + 100: '#eaeef2', + 200: '#d0d7de', + 300: '#afb8c1', + 400: '#8c959f', + 500: '#6e7781', + 600: '#57606a', + 700: '#424a53', + 800: '#32383f', + 900: '#24292f', +}; + +const StyledInputRoot = styled('div')( + ({ theme }) => ` + font-family: IBM Plex Sans, sans-serif; + font-weight: 400; + color: ${theme.palette.mode === 'dark' ? grey[300] : grey[500]}; + + display: flex; + flex-flow: row nowrap; + justify-content: center; + align-items: center; +`, +); + +const StyledInput = styled('input')( + ({ theme }) => ` + font-size: 0.875rem; + font-family: inherit; + font-weight: 400; + line-height: 1.375; + color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]}; + background: ${theme.palette.mode === 'dark' ? grey[900] : '#fff'}; + border: 1px solid ${theme.palette.mode === 'dark' ? grey[700] : grey[200]}; + border-radius: 4px; + margin: 0 4px; + padding: 10px 12px; + outline: 0; + min-width: 0; + width: 4rem; + text-align: center; + + &:hover { + border-color: ${blue[400]}; + } + + &:focus { + border-color: ${blue[400]}; + box-shadow: 0 0 0 3px ${theme.palette.mode === 'dark' ? blue[500] : blue[200]}; + } + + &:focus-visible { + outline: 0; + } +`, +); + +const StyledButton = styled('button')( + ({ theme }) => ` + font-family: IBM Plex Sans, sans-serif; + font-size: 0.875rem; + box-sizing: border-box; + line-height: 1.5; + border: 0; + border-radius: 999px; + color: ${theme.palette.mode === 'dark' ? blue[300] : blue[600]}; + background: transparent; + + width: 40px; + height: 40px; + display: flex; + flex-flow: row nowrap; + justify-content: center; + align-items: center; + + transition-property: all; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 120ms; + + &:hover { + background: ${theme.palette.mode === 'dark' ? blue[800] : blue[100]}; + cursor: pointer; + } + + &:focus-visible { + outline: 0; + } + + &.increment { + order: 1; + } +`, +); diff --git a/docs/data/base/components/number-input/QuantityInput/system/index.tsx b/docs/data/base/components/number-input/QuantityInput/system/index.tsx new file mode 100644 index 00000000000000..bbf9bc8b9fa5d7 --- /dev/null +++ b/docs/data/base/components/number-input/QuantityInput/system/index.tsx @@ -0,0 +1,141 @@ +import * as React from 'react'; +import NumberInput, { NumberInputProps } from '@mui/base/Unstable_NumberInput'; +import { styled } from '@mui/system'; +import RemoveIcon from '@mui/icons-material/Remove'; +import AddIcon from '@mui/icons-material/Add'; + +const CustomNumberInput = React.forwardRef(function CustomNumberInput( + props: NumberInputProps, + ref: React.ForwardedRef, +) { + return ( + , + className: 'increment', + }, + decrementButton: { + children: , + }, + }} + {...props} + ref={ref} + /> + ); +}); + +export default function QuantityInput() { + return ; +} + +const blue = { + 100: '#daecff', + 200: '#b6daff', + 300: '#66b2ff', + 400: '#3399ff', + 500: '#007fff', + 600: '#0072e5', + 800: '#004c99', +}; + +const grey = { + 50: '#f6f8fa', + 100: '#eaeef2', + 200: '#d0d7de', + 300: '#afb8c1', + 400: '#8c959f', + 500: '#6e7781', + 600: '#57606a', + 700: '#424a53', + 800: '#32383f', + 900: '#24292f', +}; + +const StyledInputRoot = styled('div')( + ({ theme }) => ` + font-family: IBM Plex Sans, sans-serif; + font-weight: 400; + color: ${theme.palette.mode === 'dark' ? grey[300] : grey[500]}; + + display: flex; + flex-flow: row nowrap; + justify-content: center; + align-items: center; +`, +); + +const StyledInput = styled('input')( + ({ theme }) => ` + font-size: 0.875rem; + font-family: inherit; + font-weight: 400; + line-height: 1.375; + color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]}; + background: ${theme.palette.mode === 'dark' ? grey[900] : '#fff'}; + border: 1px solid ${theme.palette.mode === 'dark' ? grey[700] : grey[200]}; + border-radius: 4px; + margin: 0 4px; + padding: 10px 12px; + outline: 0; + min-width: 0; + width: 4rem; + text-align: center; + + &:hover { + border-color: ${blue[400]}; + } + + &:focus { + border-color: ${blue[400]}; + box-shadow: 0 0 0 3px ${theme.palette.mode === 'dark' ? blue[500] : blue[200]}; + } + + &:focus-visible { + outline: 0; + } +`, +); + +const StyledButton = styled('button')( + ({ theme }) => ` + font-family: IBM Plex Sans, sans-serif; + font-size: 0.875rem; + box-sizing: border-box; + line-height: 1.5; + border: 0; + border-radius: 999px; + color: ${theme.palette.mode === 'dark' ? blue[300] : blue[600]}; + background: transparent; + + width: 40px; + height: 40px; + display: flex; + flex-flow: row nowrap; + justify-content: center; + align-items: center; + + transition-property: all; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 120ms; + + &:hover { + background: ${theme.palette.mode === 'dark' ? blue[800] : blue[100]}; + cursor: pointer; + } + + &:focus-visible { + outline: 0; + } + + &.increment { + order: 1; + } +`, +); diff --git a/docs/data/base/components/number-input/QuantityInput/system/index.tsx.preview b/docs/data/base/components/number-input/QuantityInput/system/index.tsx.preview new file mode 100644 index 00000000000000..d28010b2207aa6 --- /dev/null +++ b/docs/data/base/components/number-input/QuantityInput/system/index.tsx.preview @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/data/base/components/number-input/QuantityInput/tailwind/index.js b/docs/data/base/components/number-input/QuantityInput/tailwind/index.js new file mode 100644 index 00000000000000..5ff3f216ffeef9 --- /dev/null +++ b/docs/data/base/components/number-input/QuantityInput/tailwind/index.js @@ -0,0 +1,37 @@ +import * as React from 'react'; +import NumberInput from '@mui/base/Unstable_NumberInput'; +import RemoveIcon from '@mui/icons-material/Remove'; +import AddIcon from '@mui/icons-material/Add'; + +const CustomNumberInput = React.forwardRef(function CustomNumberInput(props, ref) { + return ( + , + className: + 'order-1 text-sm box-border leading-normal border-0 rounded-full bg-transparent w-10 h-10 flex flex-row flex-nowrap justify-center items-center transition-all duration-[120ms] focus-visible:outline-0 hover:cursor-pointer hover:bg-purple-100 dark:hover:bg-purple-800', + }, + decrementButton: { + children: , + className: + 'text-sm box-border leading-normal border-0 rounded-full bg-transparent w-10 h-10 flex flex-row flex-nowrap justify-center items-center transition-all duration-[120ms] focus-visible:outline-0 hover:cursor-pointer hover:bg-purple-100 dark:hover:bg-purple-800', + }, + }} + {...props} + ref={ref} + /> + ); +}); + +export default function QuantityInput() { + return ; +} diff --git a/docs/data/base/components/number-input/QuantityInput/tailwind/index.tsx b/docs/data/base/components/number-input/QuantityInput/tailwind/index.tsx new file mode 100644 index 00000000000000..867f0ba4be7bb1 --- /dev/null +++ b/docs/data/base/components/number-input/QuantityInput/tailwind/index.tsx @@ -0,0 +1,40 @@ +import * as React from 'react'; +import NumberInput, { NumberInputProps } from '@mui/base/Unstable_NumberInput'; +import RemoveIcon from '@mui/icons-material/Remove'; +import AddIcon from '@mui/icons-material/Add'; + +const CustomNumberInput = React.forwardRef(function CustomNumberInput( + props: NumberInputProps, + ref: React.ForwardedRef, +) { + return ( + , + className: + 'order-1 text-sm box-border leading-normal border-0 rounded-full bg-transparent w-10 h-10 flex flex-row flex-nowrap justify-center items-center transition-all duration-[120ms] focus-visible:outline-0 hover:cursor-pointer hover:bg-purple-100 dark:hover:bg-purple-800', + }, + decrementButton: { + children: , + className: + 'text-sm box-border leading-normal border-0 rounded-full bg-transparent w-10 h-10 flex flex-row flex-nowrap justify-center items-center transition-all duration-[120ms] focus-visible:outline-0 hover:cursor-pointer hover:bg-purple-100 dark:hover:bg-purple-800', + }, + }} + {...props} + ref={ref} + /> + ); +}); + +export default function QuantityInput() { + return ; +} diff --git a/docs/data/base/components/number-input/QuantityInput/tailwind/index.tsx.preview b/docs/data/base/components/number-input/QuantityInput/tailwind/index.tsx.preview new file mode 100644 index 00000000000000..d28010b2207aa6 --- /dev/null +++ b/docs/data/base/components/number-input/QuantityInput/tailwind/index.tsx.preview @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/data/base/components/number-input/UseNumberInput.js b/docs/data/base/components/number-input/UseNumberInput.js new file mode 100644 index 00000000000000..b4ae5cd8ca3a6b --- /dev/null +++ b/docs/data/base/components/number-input/UseNumberInput.js @@ -0,0 +1,170 @@ +import * as React from 'react'; +import useNumberInput from '@mui/base/unstable_useNumberInput'; +import { styled } from '@mui/system'; +import { unstable_useForkRef as useForkRef } from '@mui/utils'; + +const CustomNumberInput = React.forwardRef(function CustomNumberInput(props, ref) { + const { + getRootProps, + getInputProps, + getIncrementButtonProps, + getDecrementButtonProps, + focused, + } = useNumberInput(props); + + const inputProps = getInputProps(); + + // Make sure that both the forwarded ref and the ref returned from the getInputProps are applied on the input element + inputProps.ref = useForkRef(inputProps.ref, ref); + + return ( + + + + + + + + + + + + + + + + ); +}); + +export default function UseNumberInput() { + return ( + + ); +} + +const blue = { + 100: '#DAECFF', + 200: '#b6daff', + 400: '#3399FF', + 500: '#007FFF', + 600: '#0072E5', +}; + +const grey = { + 50: '#f6f8fa', + 100: '#eaeef2', + 200: '#d0d7de', + 300: '#afb8c1', + 400: '#8c959f', + 500: '#6e7781', + 600: '#57606a', + 700: '#424a53', + 800: '#32383f', + 900: '#24292f', +}; + +const StyledInputRoot = styled('div')( + ({ theme }) => ` + font-family: IBM Plex Sans, sans-serif; + font-size: 0.875rem; + font-weight: 400; + line-height: 1.5; + + display: grid; + grid-template-columns: 24px 1fr; + grid-template-rows: 1fr 1fr; + column-gap: 8px; + padding: 6px; + + border-radius: 6px; + border-style: solid; + border-width: 1px; + + color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]}; + background: ${theme.palette.mode === 'dark' ? grey[900] : grey[50]}; + + border-color: ${theme.palette.mode === 'dark' ? grey[700] : grey[200]}; + box-shadow: 0px 2px 2px ${theme.palette.mode === 'dark' ? grey[900] : grey[50]}; + + &.focused { + border-color: ${blue[400]}; + box-shadow: + inset 0 0 0 1px ${blue[400]}, + 0px 2px 2px ${theme.palette.mode === 'dark' ? grey[900] : grey[50]}; + + & button:hover { + background: ${blue[400]}; + } + } + `, +); + +const StyledInputElement = styled('input')` + grid-column: 2/3; + grid-row: 1/3; + background: none; + border: 0; + outline: 0; + padding: 0; +`; + +const StyledStepperButton = styled('button')( + ({ theme }) => ` + width: 1.5rem; + height: 1rem; + display: flex; + flex-flow: row nowrap; + justify-content: center; + align-items: center; + font-size: 0.875rem; + box-sizing: border-box; + border: 0; + padding: 0; + + & > svg { + transform: scale(0.8); + } + + &.increment, + &.decrement { + &:hover { + cursor: pointer; + background: ${blue[400]}; + color: ${grey[50]}; + } + + background: ${theme.palette.mode === 'dark' ? grey[600] : grey[200]}; + color: ${theme.palette.mode === 'dark' ? grey[200] : grey[900]}; + } + + &.increment { + grid-column: 1/2; + grid-row: 1/2; + border-top-left-radius: 4px; + border-top-right-radius: 4px; + } + + &.decrement { + grid-column: 1/2; + grid-row: 2/3; + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + } + `, +); diff --git a/docs/data/base/components/number-input/UseNumberInput.tsx b/docs/data/base/components/number-input/UseNumberInput.tsx new file mode 100644 index 00000000000000..aa3ccb008b187f --- /dev/null +++ b/docs/data/base/components/number-input/UseNumberInput.tsx @@ -0,0 +1,175 @@ +import * as React from 'react'; +import useNumberInput, { + UseNumberInputParameters, +} from '@mui/base/unstable_useNumberInput'; +import { styled } from '@mui/system'; +import { unstable_useForkRef as useForkRef } from '@mui/utils'; + +const CustomNumberInput = React.forwardRef(function CustomNumberInput( + props: UseNumberInputParameters & React.InputHTMLAttributes, + ref: React.ForwardedRef, +) { + const { + getRootProps, + getInputProps, + getIncrementButtonProps, + getDecrementButtonProps, + focused, + } = useNumberInput(props); + + const inputProps = getInputProps(); + + // Make sure that both the forwarded ref and the ref returned from the getInputProps are applied on the input element + inputProps.ref = useForkRef(inputProps.ref, ref); + + return ( + + + + + + + + + + + + + + + + ); +}); + +export default function UseNumberInput() { + return ( + + ); +} + +const blue = { + 100: '#DAECFF', + 200: '#b6daff', + 400: '#3399FF', + 500: '#007FFF', + 600: '#0072E5', +}; + +const grey = { + 50: '#f6f8fa', + 100: '#eaeef2', + 200: '#d0d7de', + 300: '#afb8c1', + 400: '#8c959f', + 500: '#6e7781', + 600: '#57606a', + 700: '#424a53', + 800: '#32383f', + 900: '#24292f', +}; + +const StyledInputRoot: React.ElementType = styled('div')( + ({ theme }) => ` + font-family: IBM Plex Sans, sans-serif; + font-size: 0.875rem; + font-weight: 400; + line-height: 1.5; + + display: grid; + grid-template-columns: 24px 1fr; + grid-template-rows: 1fr 1fr; + column-gap: 8px; + padding: 6px; + + border-radius: 6px; + border-style: solid; + border-width: 1px; + + color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]}; + background: ${theme.palette.mode === 'dark' ? grey[900] : grey[50]}; + + border-color: ${theme.palette.mode === 'dark' ? grey[700] : grey[200]}; + box-shadow: 0px 2px 2px ${theme.palette.mode === 'dark' ? grey[900] : grey[50]}; + + &.focused { + border-color: ${blue[400]}; + box-shadow: + inset 0 0 0 1px ${blue[400]}, + 0px 2px 2px ${theme.palette.mode === 'dark' ? grey[900] : grey[50]}; + + & button:hover { + background: ${blue[400]}; + } + } + `, +); + +const StyledInputElement = styled('input')` + grid-column: 2/3; + grid-row: 1/3; + background: none; + border: 0; + outline: 0; + padding: 0; +`; + +const StyledStepperButton = styled('button')( + ({ theme }) => ` + width: 1.5rem; + height: 1rem; + display: flex; + flex-flow: row nowrap; + justify-content: center; + align-items: center; + font-size: 0.875rem; + box-sizing: border-box; + border: 0; + padding: 0; + + & > svg { + transform: scale(0.8); + } + + &.increment, + &.decrement { + &:hover { + cursor: pointer; + background: ${blue[400]}; + color: ${grey[50]}; + } + + background: ${theme.palette.mode === 'dark' ? grey[600] : grey[200]}; + color: ${theme.palette.mode === 'dark' ? grey[200] : grey[900]}; + } + + &.increment { + grid-column: 1/2; + grid-row: 1/2; + border-top-left-radius: 4px; + border-top-right-radius: 4px; + } + + &.decrement { + grid-column: 1/2; + grid-row: 2/3; + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + } + `, +); diff --git a/docs/data/base/components/number-input/UseNumberInput.tsx.preview b/docs/data/base/components/number-input/UseNumberInput.tsx.preview new file mode 100644 index 00000000000000..fa6c5c5dc81d85 --- /dev/null +++ b/docs/data/base/components/number-input/UseNumberInput.tsx.preview @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/data/base/components/number-input/UseNumberInputCompact/css/index.js b/docs/data/base/components/number-input/UseNumberInputCompact/css/index.js new file mode 100644 index 00000000000000..606d202b86713a --- /dev/null +++ b/docs/data/base/components/number-input/UseNumberInputCompact/css/index.js @@ -0,0 +1,183 @@ +import * as React from 'react'; +import useNumberInput from '@mui/base/unstable_useNumberInput'; +import { useTheme } from '@mui/system'; +import { unstable_useForkRef as useForkRef } from '@mui/utils'; + +const CompactNumberInput = React.forwardRef(function CompactNumberInput(props, ref) { + const { + getRootProps, + getInputProps, + getIncrementButtonProps, + getDecrementButtonProps, + } = useNumberInput(props); + + const inputProps = getInputProps(); + + inputProps.ref = useForkRef(inputProps.ref, ref); + + return ( +
+ + + +
+ ); +}); + +export default function UseNumberInputCompact() { + const [value, setValue] = React.useState(); + + return ( +
+ setValue(val)} + className="my-input" + /> +
Current value: {value ?? ' '}
+ + +
+ ); +} + +const cyan = { + 50: '#E9F8FC', + 100: '#BDEBF4', + 200: '#99D8E5', + 300: '#66BACC', + 400: '#1F94AD', + 500: '#0D5463', + 600: '#094855', + 700: '#063C47', + 800: '#043039', + 900: '#022127', +}; + +const grey = { + 50: '#F3F6F9', + 100: '#E7EBF0', + 200: '#E0E3E7', + 300: '#CDD2D7', + 400: '#B2BAC2', + 500: '#A0AAB4', + 600: '#6F7E8C', + 700: '#3E5060', + 800: '#2D3843', + 900: '#1A2027', +}; + +function useIsDarkMode() { + const theme = useTheme(); + return theme.palette.mode === 'dark'; +} + +function Styles() { + // Replace this with your app logic for determining dark mode + const isDarkMode = useIsDarkMode(); + + return ( + + ); +} diff --git a/docs/data/base/components/number-input/UseNumberInputCompact/css/index.tsx b/docs/data/base/components/number-input/UseNumberInputCompact/css/index.tsx new file mode 100644 index 00000000000000..417aa536ca9d81 --- /dev/null +++ b/docs/data/base/components/number-input/UseNumberInputCompact/css/index.tsx @@ -0,0 +1,190 @@ +import * as React from 'react'; +import useNumberInput, { + UseNumberInputParameters, +} from '@mui/base/unstable_useNumberInput'; +import { useTheme } from '@mui/system'; +import { unstable_useForkRef as useForkRef } from '@mui/utils'; + +const CompactNumberInput = React.forwardRef(function CompactNumberInput( + props: Omit, 'onChange'> & + UseNumberInputParameters, + ref: React.ForwardedRef, +) { + const { + getRootProps, + getInputProps, + getIncrementButtonProps, + getDecrementButtonProps, + } = useNumberInput(props); + + const inputProps = getInputProps(); + + inputProps.ref = useForkRef(inputProps.ref, ref); + + return ( +
+ + + +
+ ); +}); + +export default function UseNumberInputCompact() { + const [value, setValue] = React.useState(); + + return ( +
+ setValue(val)} + className="my-input" + /> + +
Current value: {value ?? ' '}
+ + +
+ ); +} + +const cyan = { + 50: '#E9F8FC', + 100: '#BDEBF4', + 200: '#99D8E5', + 300: '#66BACC', + 400: '#1F94AD', + 500: '#0D5463', + 600: '#094855', + 700: '#063C47', + 800: '#043039', + 900: '#022127', +}; + +const grey = { + 50: '#F3F6F9', + 100: '#E7EBF0', + 200: '#E0E3E7', + 300: '#CDD2D7', + 400: '#B2BAC2', + 500: '#A0AAB4', + 600: '#6F7E8C', + 700: '#3E5060', + 800: '#2D3843', + 900: '#1A2027', +}; + +function useIsDarkMode() { + const theme = useTheme(); + return theme.palette.mode === 'dark'; +} + +function Styles() { + // Replace this with your app logic for determining dark mode + const isDarkMode = useIsDarkMode(); + + return ( + + ); +} diff --git a/docs/data/base/components/number-input/UseNumberInputCompact/css/index.tsx.preview b/docs/data/base/components/number-input/UseNumberInputCompact/css/index.tsx.preview new file mode 100644 index 00000000000000..e586cf96949b12 --- /dev/null +++ b/docs/data/base/components/number-input/UseNumberInputCompact/css/index.tsx.preview @@ -0,0 +1,12 @@ + setValue(val)} + className="my-input" +/> + +
Current value: {value ?? ' '}
+ + \ No newline at end of file diff --git a/docs/data/base/components/number-input/UseNumberInputCompact/system/index.js b/docs/data/base/components/number-input/UseNumberInputCompact/system/index.js new file mode 100644 index 00000000000000..322e5ffb804839 --- /dev/null +++ b/docs/data/base/components/number-input/UseNumberInputCompact/system/index.js @@ -0,0 +1,164 @@ +import * as React from 'react'; +import useNumberInput from '@mui/base/unstable_useNumberInput'; +import { styled } from '@mui/system'; +import { unstable_useForkRef as useForkRef } from '@mui/utils'; + +const CompactNumberInput = React.forwardRef(function CompactNumberInput(props, ref) { + const { + getRootProps, + getInputProps, + getIncrementButtonProps, + getDecrementButtonProps, + } = useNumberInput(props); + + const inputProps = getInputProps(); + + inputProps.ref = useForkRef(inputProps.ref, ref); + + return ( + + + + + + + + + + + + + + + + ); +}); + +export default function UseNumberInputCompact() { + const [value, setValue] = React.useState(); + + return ( + + setValue(val)} + /> +
Current value: {value ?? ' '}
+
+ ); +} + +const blue = { + 100: '#DAECFF', + 200: '#80BFFF', + 400: '#3399FF', + 500: '#007FFF', + 600: '#0072E5', +}; + +const grey = { + 50: '#F3F6F9', + 100: '#E7EBF0', + 200: '#E0E3E7', + 300: '#CDD2D7', + 400: '#B2BAC2', + 500: '#A0AAB4', + 600: '#6F7E8C', + 700: '#3E5060', + 800: '#2D3843', + 900: '#1A2027', +}; + +const StyledInputRoot = styled('div')( + ({ theme }) => ` + display: grid; + grid-template-columns: 2.5rem; + grid-template-rows: 2rem 2rem; + grid-template-areas: + "increment" + "decrement"; + row-gap: 1px; + border-radius: 0.5rem; + border-style: solid; + border-width: 1px; + color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]}; + background: ${theme.palette.mode === 'dark' ? grey[800] : grey[200]}; + border-color: ${theme.palette.mode === 'dark' ? grey[800] : grey[200]}; + overflow: auto; + + &:hover { + border-color: ${blue[500]}; + } + `, +); + +const HiddenInput = styled('input')` + visibility: hidden; + position: absolute; +`; + +const StyledStepperButton = styled('button')( + ({ theme }) => ` + display: flex; + flex-flow: nowrap; + justify-content: center; + align-items: center; + + font-size: 0.875rem; + box-sizing: border-box; + border: 0; + padding: 0; + color: inherit; + background: ${theme.palette.mode === 'dark' ? grey[900] : grey[50]}; + + &:hover { + cursor: pointer; + background: ${blue[500]}; + color: ${grey[50]}; + } + + &:focus-visible { + outline: 0; + background: ${blue[500]}; + color: ${theme.palette.mode === 'dark' ? grey[300] : grey[50]}; + } + + &.increment { + grid-area: increment; + border-top-left-radius: 0.35rem; + border-top-right-radius: 0.35rem; + } + + &.decrement { + grid-area: decrement; + border-bottom-left-radius: 0.35rem; + border-bottom-right-radius: 0.35rem; + } +`, +); + +const Layout = styled('div')` + display: flex; + flex-flow: row nowrap; + align-items: center; + column-gap: 2rem; +`; diff --git a/docs/data/base/components/number-input/UseNumberInputCompact/system/index.tsx b/docs/data/base/components/number-input/UseNumberInputCompact/system/index.tsx new file mode 100644 index 00000000000000..8ca35b81556ee9 --- /dev/null +++ b/docs/data/base/components/number-input/UseNumberInputCompact/system/index.tsx @@ -0,0 +1,171 @@ +import * as React from 'react'; +import useNumberInput, { + UseNumberInputParameters, +} from '@mui/base/unstable_useNumberInput'; +import { styled } from '@mui/system'; +import { unstable_useForkRef as useForkRef } from '@mui/utils'; + +const CompactNumberInput = React.forwardRef(function CompactNumberInput( + props: Omit, 'onChange'> & + UseNumberInputParameters, + ref: React.ForwardedRef, +) { + const { + getRootProps, + getInputProps, + getIncrementButtonProps, + getDecrementButtonProps, + } = useNumberInput(props); + + const inputProps = getInputProps(); + + inputProps.ref = useForkRef(inputProps.ref, ref); + + return ( + + + + + + + + + + + + + + + + ); +}); + +export default function UseNumberInputCompact() { + const [value, setValue] = React.useState(); + + return ( + + setValue(val)} + /> + +
Current value: {value ?? ' '}
+
+ ); +} + +const blue = { + 100: '#DAECFF', + 200: '#80BFFF', + 400: '#3399FF', + 500: '#007FFF', + 600: '#0072E5', +}; + +const grey = { + 50: '#F3F6F9', + 100: '#E7EBF0', + 200: '#E0E3E7', + 300: '#CDD2D7', + 400: '#B2BAC2', + 500: '#A0AAB4', + 600: '#6F7E8C', + 700: '#3E5060', + 800: '#2D3843', + 900: '#1A2027', +}; + +const StyledInputRoot = styled('div')( + ({ theme }) => ` + display: grid; + grid-template-columns: 2.5rem; + grid-template-rows: 2rem 2rem; + grid-template-areas: + "increment" + "decrement"; + row-gap: 1px; + border-radius: 0.5rem; + border-style: solid; + border-width: 1px; + color: ${theme.palette.mode === 'dark' ? grey[300] : grey[900]}; + background: ${theme.palette.mode === 'dark' ? grey[800] : grey[200]}; + border-color: ${theme.palette.mode === 'dark' ? grey[800] : grey[200]}; + overflow: auto; + + &:hover { + border-color: ${blue[500]}; + } + `, +); + +const HiddenInput = styled('input')` + visibility: hidden; + position: absolute; +`; + +const StyledStepperButton = styled('button')( + ({ theme }) => ` + display: flex; + flex-flow: nowrap; + justify-content: center; + align-items: center; + + font-size: 0.875rem; + box-sizing: border-box; + border: 0; + padding: 0; + color: inherit; + background: ${theme.palette.mode === 'dark' ? grey[900] : grey[50]}; + + &:hover { + cursor: pointer; + background: ${blue[500]}; + color: ${grey[50]}; + } + + &:focus-visible { + outline: 0; + background: ${blue[500]}; + color: ${theme.palette.mode === 'dark' ? grey[300] : grey[50]}; + } + + &.increment { + grid-area: increment; + border-top-left-radius: 0.35rem; + border-top-right-radius: 0.35rem; + } + + &.decrement { + grid-area: decrement; + border-bottom-left-radius: 0.35rem; + border-bottom-right-radius: 0.35rem; + } +`, +); + +const Layout = styled('div')` + display: flex; + flex-flow: row nowrap; + align-items: center; + column-gap: 2rem; +`; diff --git a/docs/data/base/components/number-input/UseNumberInputCompact/system/index.tsx.preview b/docs/data/base/components/number-input/UseNumberInputCompact/system/index.tsx.preview new file mode 100644 index 00000000000000..bc55f0dc37f866 --- /dev/null +++ b/docs/data/base/components/number-input/UseNumberInputCompact/system/index.tsx.preview @@ -0,0 +1,11 @@ + + setValue(val)} + /> + +
Current value: {value ?? ' '}
+
\ No newline at end of file diff --git a/docs/data/base/components/number-input/UseNumberInputCompact/tailwind/index.js b/docs/data/base/components/number-input/UseNumberInputCompact/tailwind/index.js new file mode 100644 index 00000000000000..cbb316b0965f40 --- /dev/null +++ b/docs/data/base/components/number-input/UseNumberInputCompact/tailwind/index.js @@ -0,0 +1,88 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import useNumberInput from '@mui/base/unstable_useNumberInput'; +import { unstable_useForkRef as useForkRef } from '@mui/utils'; +import clsx from 'clsx'; + +const CompactNumberInput = React.forwardRef(function CompactNumberInput(props, ref) { + const { className, ...rest } = props; + const { + getRootProps, + getInputProps, + getIncrementButtonProps, + getDecrementButtonProps, + } = useNumberInput(rest); + + const inputProps = getInputProps(); + + inputProps.ref = useForkRef(inputProps.ref, ref); + + return ( +
+ + + +
+ ); +}); + +CompactNumberInput.propTypes = { + className: PropTypes.string, +}; + +export default function UseNumberInputCompact() { + const [value, setValue] = React.useState(); + + return ( +
+ setValue(val)} + /> +
Current value: {value ?? ' '}
+
+ ); +} diff --git a/docs/data/base/components/number-input/UseNumberInputCompact/tailwind/index.tsx b/docs/data/base/components/number-input/UseNumberInputCompact/tailwind/index.tsx new file mode 100644 index 00000000000000..db825abe928e6b --- /dev/null +++ b/docs/data/base/components/number-input/UseNumberInputCompact/tailwind/index.tsx @@ -0,0 +1,90 @@ +import * as React from 'react'; +import useNumberInput, { + UseNumberInputParameters, +} from '@mui/base/unstable_useNumberInput'; +import { unstable_useForkRef as useForkRef } from '@mui/utils'; +import clsx from 'clsx'; + +const CompactNumberInput = React.forwardRef(function CompactNumberInput( + props: Omit, 'onChange'> & + UseNumberInputParameters, + ref: React.ForwardedRef, +) { + const { className, ...rest } = props; + const { + getRootProps, + getInputProps, + getIncrementButtonProps, + getDecrementButtonProps, + } = useNumberInput(rest); + + const inputProps = getInputProps(); + + inputProps.ref = useForkRef(inputProps.ref, ref); + + return ( +
+ + + +
+ ); +}); + +export default function UseNumberInputCompact() { + const [value, setValue] = React.useState(); + + return ( +
+ setValue(val)} + /> + +
Current value: {value ?? ' '}
+
+ ); +} diff --git a/docs/data/base/components/number-input/UseNumberInputCompact/tailwind/index.tsx.preview b/docs/data/base/components/number-input/UseNumberInputCompact/tailwind/index.tsx.preview new file mode 100644 index 00000000000000..4dcd66b1bc1aac --- /dev/null +++ b/docs/data/base/components/number-input/UseNumberInputCompact/tailwind/index.tsx.preview @@ -0,0 +1,9 @@ + setValue(val)} +/> + +
Current value: {value ?? ' '}
\ No newline at end of file diff --git a/docs/data/base/components/number-input/number-input.md b/docs/data/base/components/number-input/number-input.md new file mode 100644 index 00000000000000..3fd2115b77585b --- /dev/null +++ b/docs/data/base/components/number-input/number-input.md @@ -0,0 +1,157 @@ +--- +productId: base-ui +title: React Number Input component and hook +components: NumberInput +hooks: useNumberInput +githubLabel: 'component: NumberInput' +--- + +# Number Input + +

The Number Input component provides users with a field for integer values, and buttons to increment or decrement the value.

+ +{{"component": "modules/components/ComponentLinkHeader.js", "design": false}} + +{{"component": "modules/components/ComponentPageTabs.js"}} + +## Introduction + +A number input is a UI element that accepts numeric values from the user. +Base UI's Number Input component is a customizable replacement for the native HTML `` that solves common usability issues of the native counterpart, such as: + +- Inconsistencies across browsers in the appearance and behavior of the stepper buttons +- Allowing certain non-numeric characters ('e', '+', '-', '.') and silently discarding others +- Incompatibilities with assistive technologies and limited accessibility features + +See [this article](https://technology.blog.gov.uk/2020/02/24/why-the-gov-uk-design-system-team-changed-the-input-type-for-numbers/) by the GOV.UK Design System team for a more detailed explanation. + +{{"demo": "NumberInputIntroduction.js", "defaultCodeOpen": false, "bg": "gradient"}} + +## Component + +### Usage + +After [installation](/base-ui/getting-started/quickstart/#installation), you can start building with this component using the following basic elements: + +```jsx +import NumberInput from '@mui/base/Unstable_NumberInput'; + +export default function MyApp() { + return ; +} +``` + +### Basics + +The following demo shows how to create a number input component, apply some styling, and write the latest value to a state variable using the `onChange` prop: + +{{"demo": "NumberInputBasic"}} + +The `min` and `max` props can be used to define a range of accepted values. You can pass only one of them to define an open-ended range. + +```tsx + + + // Open-ended + +``` + +The `step` prop can be used to defined the granularity of the change in value when incrementing or decrementing. For example, if `min={0}` and `step={2}`, valid values for the component would be 0, 2, 4… since the value can only be changed in increments of 2. + +```tsx +// valid values: 0, 2, 4, 6, 8... + +``` + +When the input field is in focus, you can enter values that fall outside the valid range. The value will be clamped based on `min`, `max` and `step` once the input field is blurred. + +Holding down the Shift key when interacting with the stepper buttons applies a multipler (default 10x) to the value change of each step. + +This can be customized with the `shiftMultiplier` prop. In the following snippet, if Shift is held when clicking the increment button, the value will change from 0 to 5, then to 10 etc. + +```tsx + +``` + +Here's another demo of a Number Input with fully customized styles: + +{{"demo": "QuantityInput", "defaultCodeOpen": false}} + +### Anatomy + +The Base UI Number Input component consists of 4 slots: + +- `root`: a outer `
` containing the other interior slots +- `input`: an `` element +- `incrementButton`: a `
+``` + +### Slot props + +:::info +The following props are available on all non-utility Base components. +See [Usage](/base-ui/getting-started/usage/) for full details. +::: + +Use the `slots` prop to override the root slot or any interior slots: + +```jsx + +``` + +Use the `slotProps` prop to pass custom props to internal slots. +The following code snippet: + +- applies a CSS class called `my-num-input` to the input slot, +- and passes a `direction` prop to the `CustomButton` components in the increment and decrement button slots + +```jsx + +``` + +## Hook + +```js +import useNumberInput from '@mui/base/unstable_useNumberInput'; +``` + +The `useNumberInput` hook lets you apply the functionality of a number input to a fully custom component. +It returns props to be placed on the custom component, along with fields representing the component's internal state. + +Hooks _do not_ support [slot props](#slot-props), but they do support [customization props](#customization). + +:::info +Hooks give you the most room for customization, but require more work to implement. +With hooks, you can take full control over how your component is rendered, and define all the custom props and CSS classes you need. + +You may not need to use hooks unless you find that you're limited by the customization options of their component counterparts—for instance, if your component requires significantly different [structure](#anatomy). +::: + +Here's an example of a custom component built using the `useNumberInput` hook with all the required props: + +{{"demo": "UseNumberInput.js", "defaultCodeOpen": false}} + +Here's an example of a "compact" number input component using the hook that only consists of the stepper buttons. +In this demo, `onChange` is used to write the latest value of the component to a state variable. + +{{"demo": "UseNumberInputCompact", "defaultCodeOpen": false}} diff --git a/docs/data/base/pages.ts b/docs/data/base/pages.ts index ac8a24e1875487..07c34afea6ba4f 100644 --- a/docs/data/base/pages.ts +++ b/docs/data/base/pages.ts @@ -25,6 +25,7 @@ const pages: readonly MuiPage[] = [ { pathname: '/base-ui/react-button', title: 'Button' }, { pathname: '/base-ui/react-checkbox', title: 'Checkbox', planned: true }, { pathname: '/base-ui/react-input', title: 'Input' }, + { pathname: '/base-ui/react-number-input', title: 'Number Input' }, { pathname: '/base-ui/react-radio-button', title: 'Radio Button', planned: true }, { pathname: '/base-ui/react-rating', title: 'Rating', planned: true }, { pathname: '/base-ui/react-select', title: 'Select' }, diff --git a/docs/data/base/pagesApi.js b/docs/data/base/pagesApi.js index 87fb37f2e44ef1..caf0be66448658 100644 --- a/docs/data/base/pagesApi.js +++ b/docs/data/base/pagesApi.js @@ -24,6 +24,10 @@ module.exports = [ { pathname: '/base-ui/react-menu/components-api/#menu-item', title: 'MenuItem' }, { pathname: '/base-ui/react-modal/components-api/#modal', title: 'Modal' }, { pathname: '/base-ui/react-no-ssr/components-api/#no-ssr', title: 'NoSsr' }, + { + pathname: '/base-ui/react-number-input/components-api/#number-input', + title: 'NumberInput', + }, { pathname: '/base-ui/react-select/components-api/#option', title: 'Option' }, { pathname: '/base-ui/react-select/components-api/#option-group', @@ -68,6 +72,10 @@ module.exports = [ title: 'useMenuButton', }, { pathname: '/base-ui/react-menu/hooks-api/#use-menu-item', title: 'useMenuItem' }, + { + pathname: '/base-ui/react-number-input/hooks-api/#use-number-input', + title: 'useNumberInput', + }, { pathname: '/base-ui/react-select/hooks-api/#use-option', title: 'useOption' }, { pathname: '/base-ui/react-select/hooks-api/#use-select', title: 'useSelect' }, { pathname: '/base-ui/react-slider/hooks-api/#use-slider', title: 'useSlider' }, diff --git a/docs/pages/base-ui/api/number-input.json b/docs/pages/base-ui/api/number-input.json new file mode 100644 index 00000000000000..ab15ad7756169b --- /dev/null +++ b/docs/pages/base-ui/api/number-input.json @@ -0,0 +1,53 @@ +{ + "props": { + "defaultValue": { "type": { "name": "any" } }, + "disabled": { "type": { "name": "bool" } }, + "error": { "type": { "name": "bool" } }, + "id": { "type": { "name": "string" } }, + "max": { "type": { "name": "number" } }, + "min": { "type": { "name": "number" } }, + "onChange": { + "type": { "name": "func" }, + "signature": { + "type": "function(event: React.FocusEvent | React.PointerEvent | React.KeyboardEvent, value: number | undefined) => void", + "describedArgs": ["event", "value"] + } + }, + "onInputChange": { + "type": { "name": "func" }, + "signature": { + "type": "function(event: React.ChangeEvent) => void", + "describedArgs": ["event"] + } + }, + "readOnly": { "type": { "name": "bool" }, "default": "false" }, + "required": { "type": { "name": "bool" } }, + "shiftMultiplier": { "type": { "name": "number" } }, + "slotProps": { + "type": { + "name": "shape", + "description": "{ decrementButton?: func
| object, incrementButton?: func
| object, input?: func
| object, root?: func
| object }" + }, + "default": "{}" + }, + "slots": { + "type": { + "name": "shape", + "description": "{ decrementButton?: elementType, incrementButton?: elementType, input?: elementType, root?: elementType }" + }, + "default": "{}", + "additionalPropsInfo": { "slotsApi": true } + }, + "step": { "type": { "name": "number" } }, + "value": { "type": { "name": "any" } } + }, + "name": "NumberInput", + "styles": { "classes": [], "globalClasses": {}, "name": null }, + "spread": true, + "muiName": "MuiNumberInput", + "forwardsRefTo": "HTMLDivElement", + "filename": "/packages/mui-base/src/Unstable_NumberInput/NumberInput.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/pages/base-ui/api/use-number-input.json b/docs/pages/base-ui/api/use-number-input.json new file mode 100644 index 00000000000000..03508578d30d7e --- /dev/null +++ b/docs/pages/base-ui/api/use-number-input.json @@ -0,0 +1,120 @@ +{ + "parameters": { + "defaultValue": { "type": { "name": "unknown", "description": "unknown" } }, + "disabled": { "type": { "name": "boolean", "description": "boolean" } }, + "error": { "type": { "name": "boolean", "description": "boolean" } }, + "inputId": { "type": { "name": "string", "description": "string" } }, + "inputRef": { + "type": { + "name": "React.Ref<HTMLInputElement>", + "description": "React.Ref<HTMLInputElement>" + } + }, + "max": { "type": { "name": "number", "description": "number" } }, + "min": { "type": { "name": "number", "description": "number" } }, + "onBlur": { + "type": { + "name": "(event?: React.FocusEvent) => void", + "description": "(event?: React.FocusEvent) => void" + } + }, + "onChange": { + "type": { + "name": "(event: React.FocusEvent<HTMLInputElement> | React.PointerEvent | React.KeyboardEvent, value: number | undefined) => void", + "description": "(event: React.FocusEvent<HTMLInputElement> | React.PointerEvent | React.KeyboardEvent, value: number | undefined) => void" + } + }, + "onClick": { + "type": { "name": "React.MouseEventHandler", "description": "React.MouseEventHandler" } + }, + "onFocus": { + "type": { "name": "React.FocusEventHandler", "description": "React.FocusEventHandler" } + }, + "onInputChange": { + "type": { + "name": "React.ChangeEventHandler<HTMLInputElement>", + "description": "React.ChangeEventHandler<HTMLInputElement>" + } + }, + "readOnly": { "type": { "name": "boolean", "description": "boolean" }, "default": "false" }, + "required": { "type": { "name": "boolean", "description": "boolean" } }, + "shiftMultiplier": { "type": { "name": "number", "description": "number" } }, + "step": { "type": { "name": "number", "description": "number" } }, + "value": { "type": { "name": "unknown", "description": "unknown" } } + }, + "returnValue": { + "disabled": { + "type": { "name": "boolean", "description": "boolean" }, + "default": "false", + "required": true + }, + "error": { + "type": { "name": "boolean", "description": "boolean" }, + "default": "false", + "required": true + }, + "focused": { + "type": { "name": "boolean", "description": "boolean" }, + "default": "false", + "required": true + }, + "formControlContext": { + "type": { + "name": "FormControlState | undefined", + "description": "FormControlState | undefined" + }, + "required": true + }, + "getDecrementButtonProps": { + "type": { + "name": "<TOther extends Record<string, any> = {}>(externalProps?: TOther) => UseNumberInputDecrementButtonSlotProps<TOther>", + "description": "<TOther extends Record<string, any> = {}>(externalProps?: TOther) => UseNumberInputDecrementButtonSlotProps<TOther>" + }, + "required": true + }, + "getIncrementButtonProps": { + "type": { + "name": "<TOther extends Record<string, any> = {}>(externalProps?: TOther) => UseNumberInputIncrementButtonSlotProps<TOther>", + "description": "<TOther extends Record<string, any> = {}>(externalProps?: TOther) => UseNumberInputIncrementButtonSlotProps<TOther>" + }, + "required": true + }, + "getInputProps": { + "type": { + "name": "<TOther extends Record<string, any> = {}>(externalProps?: TOther) => UseNumberInputInputSlotProps<TOther>", + "description": "<TOther extends Record<string, any> = {}>(externalProps?: TOther) => UseNumberInputInputSlotProps<TOther>" + }, + "required": true + }, + "getRootProps": { + "type": { + "name": "<TOther extends Record<string, any> = {}>(externalProps?: TOther) => UseNumberInputRootSlotProps<TOther>", + "description": "<TOther extends Record<string, any> = {}>(externalProps?: TOther) => UseNumberInputRootSlotProps<TOther>" + }, + "required": true + }, + "inputValue": { + "type": { "name": "string | undefined", "description": "string | undefined" }, + "required": true + }, + "isDecrementDisabled": { + "type": { "name": "boolean", "description": "boolean" }, + "default": "false", + "required": true + }, + "isIncrementDisabled": { + "type": { "name": "boolean", "description": "boolean" }, + "default": "false", + "required": true + }, + "required": { + "type": { "name": "boolean", "description": "boolean" }, + "default": "false", + "required": true + }, + "value": { "type": { "name": "unknown", "description": "unknown" }, "required": true } + }, + "name": "useNumberInput", + "filename": "/packages/mui-base/src/unstable_useNumberInput/useNumberInput.ts", + "demos": "" +} diff --git a/docs/pages/base-ui/react-number-input/[docsTab]/index.js b/docs/pages/base-ui/react-number-input/[docsTab]/index.js new file mode 100644 index 00000000000000..389fe3c553f5fb --- /dev/null +++ b/docs/pages/base-ui/react-number-input/[docsTab]/index.js @@ -0,0 +1,48 @@ +import * as React from 'react'; +import MarkdownDocs from 'docs/src/modules/components/MarkdownDocsV2'; +import AppFrame from 'docs/src/modules/components/AppFrame'; +import * as pageProps from 'docs/data/base/components/number-input/number-input.md?@mui/markdown'; +import mapApiPageTranslations from 'docs/src/modules/utils/mapApiPageTranslations'; +import NumberInputApiJsonPageContent from '../../api/number-input.json'; +import useNumberInputApiJsonPageContent from '../../api/use-number-input.json'; + +export default function Page(props) { + const { userLanguage, ...other } = props; + return ; +} + +Page.getLayout = (page) => { + return {page}; +}; + +export const getStaticPaths = () => { + return { + paths: [{ params: { docsTab: 'components-api' } }, { params: { docsTab: 'hooks-api' } }], + fallback: false, // can also be true or 'blocking' + }; +}; + +export const getStaticProps = () => { + const NumberInputApiReq = require.context( + 'docs/translations/api-docs-base/number-input', + false, + /number-input.*.json$/, + ); + const NumberInputApiDescriptions = mapApiPageTranslations(NumberInputApiReq); + + const useNumberInputApiReq = require.context( + 'docs/translations/api-docs/use-number-input', + false, + /use-number-input.*.json$/, + ); + const useNumberInputApiDescriptions = mapApiPageTranslations(useNumberInputApiReq); + + return { + props: { + componentsApiDescriptions: { NumberInput: NumberInputApiDescriptions }, + componentsApiPageContents: { NumberInput: NumberInputApiJsonPageContent }, + hooksApiDescriptions: { useNumberInput: useNumberInputApiDescriptions }, + hooksApiPageContents: { useNumberInput: useNumberInputApiJsonPageContent }, + }, + }; +}; diff --git a/docs/pages/base-ui/react-number-input/index.js b/docs/pages/base-ui/react-number-input/index.js new file mode 100644 index 00000000000000..124fb94411844c --- /dev/null +++ b/docs/pages/base-ui/react-number-input/index.js @@ -0,0 +1,13 @@ +import * as React from 'react'; +import MarkdownDocs from 'docs/src/modules/components/MarkdownDocsV2'; +import AppFrame from 'docs/src/modules/components/AppFrame'; +import * as pageProps from 'docs/data/base/components/number-input/number-input.md?@mui/markdown'; + +export default function Page(props) { + const { userLanguage, ...other } = props; + return ; +} + +Page.getLayout = (page) => { + return {page}; +}; diff --git a/docs/src/modules/components/ComponentsApiContent.js b/docs/src/modules/components/ComponentsApiContent.js index 061c8537860e18..6671b57e4033f2 100644 --- a/docs/src/modules/components/ComponentsApiContent.js +++ b/docs/src/modules/components/ComponentsApiContent.js @@ -109,11 +109,30 @@ export default function ComponentsApiContent(props) { slotGuideLink = '/base-ui/guides/overriding-component-structure/'; } - const source = filename - .replace(/\/packages\/mui(-(.+?))?\/src/, (match, dash, pkg) => `@mui/${pkg}`) + // convert paths like `/packages/mui-base/src/Input...` to `@mui/base/Input...` + const packageAndFilename = filename.replace( + /\/packages\/mui(-(.+?))?\/src/, + (match, dash, pkg) => `@mui/${pkg}`, + ); + + const source = packageAndFilename // convert things like `/Table/Table.js` to `` .replace(/\/([^/]+)\/\1\.(js|tsx)$/, ''); + const defaultImportName = pageContent.name; + let defaultImportPath = `${source}/${defaultImportName}`; + let namedImportPath = source; + let namedImportName = defaultImportName; + + if (/Unstable_/.test(source)) { + defaultImportPath = source.replace(/\/[^/]*$/, ''); + namedImportPath = packageAndFilename + .replace(/Unstable_/, '') + .replace(/\/([^/]+)\/\1\.(js|tsx)$/, ''); + + namedImportName = `Unstable_${defaultImportName} as ${defaultImportName}`; + } + // The `ref` is forwarded to the root element. let refHint = t('api-docs.refRootElement'); if (forwardsRefTo == null) { @@ -146,9 +165,9 @@ export default function ComponentsApiContent(props) { diff --git a/docs/src/modules/components/HooksApiContent.js b/docs/src/modules/components/HooksApiContent.js index b8063707519649..3fef27c35dfaa2 100644 --- a/docs/src/modules/components/HooksApiContent.js +++ b/docs/src/modules/components/HooksApiContent.js @@ -63,6 +63,12 @@ export default function HooksApiContent(props) { const hookNameKebabCase = kebabCase(hookName); + let defaultImportName = hookName; + + if (/unstable_/.test(filename)) { + defaultImportName = `unstable_${hookName} as ${hookName}`; + } + return ( @@ -72,7 +78,7 @@ export default function HooksApiContent(props) { code={` import ${hookName} from '${source.split('/').slice(0, -1).join('/')}'; // ${t('or')} -import { ${hookName} } from '${source.split('/').slice(0, 2).join('/')}';`} +import { ${defaultImportName} } from '${source.split('/').slice(0, 2).join('/')}';`} language="jsx" /> diff --git a/docs/translations/api-docs-base/number-input-unstyled/number-input-unstyled.json b/docs/translations/api-docs-base/number-input-unstyled/number-input-unstyled.json new file mode 100644 index 00000000000000..4d8c3b82671253 --- /dev/null +++ b/docs/translations/api-docs-base/number-input-unstyled/number-input-unstyled.json @@ -0,0 +1,19 @@ +{ + "componentDescription": "", + "propDescriptions": { + "component": "The component used for the root node. Either a string to use a HTML element or a component.", + "defaultValue": "The default value. Use when the component is not controlled.", + "disabled": "If true, the component is disabled. The prop defaults to the value (false) inherited from the parent FormControl component.", + "error": "If true, the input will indicate an error by setting the aria-invalid attribute on the input and the Mui-error class on the root element.", + "id": "The id of the input element.", + "max": "The maximum value.", + "min": "The minimum value.", + "onValueChange": "Callback fired after the value is clamped and changes. Called with undefined when the value is unset.", + "required": "If true, the input element is required. The prop defaults to the value (false) inherited from the parent FormControl component.", + "slotProps": "The props used for each slot inside the NumberInput.", + "slots": "The components used for each slot inside the InputBase. Either a string to use a HTML element or a component. See Slots API below for more details.", + "step": "The amount that the value changes on each increment or decrement.", + "value": "The current value. Use when the component is controlled." + }, + "classDescriptions": {} +} diff --git a/docs/translations/api-docs-base/number-input/number-input.json b/docs/translations/api-docs-base/number-input/number-input.json new file mode 100644 index 00000000000000..b588e42ddadeb0 --- /dev/null +++ b/docs/translations/api-docs-base/number-input/number-input.json @@ -0,0 +1,44 @@ +{ + "componentDescription": "", + "propDescriptions": { + "defaultValue": { + "description": "The default value. Use when the component is not controlled." + }, + "disabled": { + "description": "If true, the component is disabled. The prop defaults to the value (false) inherited from the parent FormControl component." + }, + "error": { + "description": "If true, the input will indicate an error by setting the aria-invalid attribute on the input and the Mui-error class on the root element." + }, + "id": { "description": "The id of the input element." }, + "max": { "description": "The maximum value." }, + "min": { "description": "The minimum value." }, + "onChange": { + "description": "Callback fired after the value is clamped and changes - when the input is blurred or when the stepper buttons are triggered. Called with undefined when the value is unset.", + "typeDescriptions": { + "event": "The event source of the callback", + "value": "The new value of the component" + } + }, + "onInputChange": { + "description": "Callback fired when the input value changes after each keypress, before clamping is applied. Note that event.target.value may contain values that fall outside of min and max or are otherwise "invalid".", + "typeDescriptions": { "event": "The event source of the callback." } + }, + "readOnly": { + "description": "If true, the input element becomes read-only. The stepper buttons remain active, with the addition that they are now keyboard focusable." + }, + "required": { + "description": "If true, the input element is required. The prop defaults to the value (false) inherited from the parent FormControl component." + }, + "shiftMultiplier": { + "description": "Multiplier applied to step if the shift key is held while incrementing or decrementing the value. Defaults to 10." + }, + "slotProps": { "description": "The props used for each slot inside the NumberInput." }, + "slots": { + "description": "The components used for each slot inside the InputBase. Either a string to use a HTML element or a component." + }, + "step": { "description": "The amount that the value changes on each increment or decrement." }, + "value": { "description": "The current value. Use when the component is controlled." } + }, + "classDescriptions": {} +} diff --git a/docs/translations/api-docs/use-number-input/use-number-input.json b/docs/translations/api-docs/use-number-input/use-number-input.json new file mode 100644 index 00000000000000..575cb5d734d751 --- /dev/null +++ b/docs/translations/api-docs/use-number-input/use-number-input.json @@ -0,0 +1,66 @@ +{ + "hookDescription": "", + "parametersDescriptions": { + "defaultValue": { + "description": "The default value. Use when the component is not controlled." + }, + "disabled": { + "description": "If true, the component is disabled.\nThe prop defaults to the value (false) inherited from the parent FormControl component." + }, + "error": { + "description": "If true, the input will indicate an error by setting the aria-invalid attribute.\nThe prop defaults to the value (false) inherited from the parent FormControl component." + }, + "inputId": { "description": "The id attribute of the input element." }, + "inputRef": { "description": "The ref of the input element." }, + "max": { "description": "The maximum value." }, + "min": { "description": "The minimum value." }, + "onChange": { + "description": "Callback fired after the value is clamped and changes - when the input is blurred or when\nthe stepper buttons are triggered.\nCalled with undefined when the value is unset." + }, + "onInputChange": { + "description": "Callback fired when the input value changes after each keypress, before clamping is applied.\nNote that event.target.value may contain values that fall outside of min and max or\nare otherwise "invalid"." + }, + "readOnly": { + "description": "If true, the input element becomes read-only. The stepper buttons remain active,\nwith the addition that they are now keyboard focusable." + }, + "required": { + "description": "If true, the input element is required.\nThe prop defaults to the value (false) inherited from the parent FormControl component." + }, + "shiftMultiplier": { + "description": "Multiplier applied to step if the shift key is held while incrementing\nor decrementing the value. Defaults to 10." + }, + "step": { "description": "The amount that the value changes on each increment or decrement." }, + "value": { "description": "The current value. Use when the component is controlled." } + }, + "returnValueDescriptions": { + "disabled": { "description": "If true, the component will be disabled." }, + "error": { + "description": "If true, the input will indicate an error by setting the aria-invalid attribute." + }, + "focused": { "description": "If true, the input will be focused." }, + "formControlContext": { + "description": "Return value from the useFormControlContext hook." + }, + "getDecrementButtonProps": { + "description": "Resolver for the decrement button slot's props." + }, + "getIncrementButtonProps": { + "description": "Resolver for the increment button slot's props." + }, + "getInputProps": { "description": "Resolver for the input slot's props." }, + "getRootProps": { "description": "Resolver for the root slot's props." }, + "inputValue": { + "description": "The dirty value of the input element when it is in focus." + }, + "isDecrementDisabled": { + "description": "If true, the decrement button will be disabled.\ne.g. when the value is already at min" + }, + "isIncrementDisabled": { + "description": "If true, the increment button will be disabled.\ne.g. when the value is already at max" + }, + "required": { + "description": "If true, the input will indicate that it's required." + }, + "value": { "description": "The clamped value of the input element." } + } +} diff --git a/docs/translations/translations.json b/docs/translations/translations.json index e4cdd4d1e8ef6f..8e574f1b2f6c19 100644 --- a/docs/translations/translations.json +++ b/docs/translations/translations.json @@ -237,6 +237,7 @@ "/base-ui/react-button": "Button", "/base-ui/react-checkbox": "Checkbox", "/base-ui/react-input": "Input", + "/base-ui/react-number-input": "Number Input", "/base-ui/react-radio-button": "Radio Button", "/base-ui/react-rating": "Rating", "/base-ui/react-select": "Select", @@ -277,6 +278,7 @@ "/base-ui/react-menu/components-api/#menu-item": "MenuItem", "/base-ui/react-modal/components-api/#modal": "Modal", "/base-ui/react-no-ssr/components-api/#no-ssr": "NoSsr", + "/base-ui/react-number-input/components-api/#number-input": "NumberInput", "/base-ui/react-select/components-api/#option": "Option", "/base-ui/react-select/components-api/#option-group": "OptionGroup", "/base-ui/react-popper/components-api/#popper": "Popper", @@ -300,6 +302,7 @@ "/base-ui/react-menu/hooks-api/#use-menu": "useMenu", "/base-ui/react-menu/hooks-api/#use-menu-button": "useMenuButton", "/base-ui/react-menu/hooks-api/#use-menu-item": "useMenuItem", + "/base-ui/react-number-input/hooks-api/#use-number-input": "useNumberInput", "/base-ui/react-select/hooks-api/#use-option": "useOption", "/base-ui/react-select/hooks-api/#use-select": "useSelect", "/base-ui/react-slider/hooks-api/#use-slider": "useSlider", diff --git a/package.json b/package.json index ebe6061728deb2..728f0121e3c8e3 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,7 @@ "@slack/bolt": "^3.13.2", "@testing-library/dom": "^9.3.1", "@testing-library/react": "^14.0.0", + "@testing-library/user-event": "^14.4.3", "@types/chai": "^4.3.5", "@types/chai-dom": "^1.11.0", "@types/enzyme": "^3.10.13", diff --git a/packages/mui-base/src/Unstable_NumberInput/NumberInput.test.tsx b/packages/mui-base/src/Unstable_NumberInput/NumberInput.test.tsx new file mode 100644 index 00000000000000..fe8bb2f8b69c54 --- /dev/null +++ b/packages/mui-base/src/Unstable_NumberInput/NumberInput.test.tsx @@ -0,0 +1,436 @@ +import * as React from 'react'; +import { expect } from 'chai'; +import { spy } from 'sinon'; +import userEvent from '@testing-library/user-event'; +import { act, createMount, createRenderer, describeConformanceUnstyled } from 'test/utils'; +import NumberInput, { + numberInputClasses, + NumberInputOwnerState, +} from '@mui/base/Unstable_NumberInput'; + +describe('', () => { + const mount = createMount(); + const { render } = createRenderer(); + + describeConformanceUnstyled(, () => ({ + inheritComponent: 'div', + render, + mount, + refInstanceof: window.HTMLDivElement, + testComponentPropWith: 'div', + muiName: 'MuiNumberInput', + slots: { + root: { + expectedClassName: numberInputClasses.root, + }, + input: { + expectedClassName: numberInputClasses.input, + testWithElement: 'input', + }, + incrementButton: { + expectedClassName: numberInputClasses.incrementButton, + testWithElement: 'button', + }, + decrementButton: { + expectedClassName: numberInputClasses.decrementButton, + testWithElement: 'button', + }, + }, + skip: ['componentProp'], + })); + + it('should be able to attach input ref passed through props', () => { + const inputRef = React.createRef(); + const { getByTestId } = render( + , + ); + + expect(inputRef.current).to.deep.equal(getByTestId('input')); + }); + + it('passes ownerState to all the slots', () => { + interface SlotProps { + ownerState: NumberInputOwnerState; + children?: React.ReactNode; + } + + const CustomComponent = React.forwardRef( + ({ ownerState, children }: SlotProps, ref: React.Ref) => { + return ( +
+ {children} +
+ ); + }, + ); + + const slots = { + root: CustomComponent, + input: CustomComponent, + decrementButton: CustomComponent, + incrementButton: CustomComponent, + }; + + const { getAllByTestId } = render(); + const renderedComponents = getAllByTestId('custom'); + + expect(renderedComponents.length).to.equal(4); + for (let i = 0; i < renderedComponents.length; i += 1) { + expect(renderedComponents[i]).to.have.attribute('data-disabled', 'true'); + expect(renderedComponents[i]).to.have.attribute('data-focused', 'false'); + expect(renderedComponents[i]).to.have.attribute('data-readonly', 'true'); + expect(renderedComponents[i]).to.have.attribute('data-decrementdisabled', 'true'); + expect(renderedComponents[i]).to.have.attribute('data-incrementdisabled', 'true'); + } + }); + + describe('step buttons', () => { + it('clicking the increment and decrement buttons changes the value', async () => { + const user = userEvent.setup(); + const handleChange = spy(); + + const { getByTestId } = render( + , + ); + + const input = getByTestId('input') as HTMLInputElement; + const incrementButton = getByTestId('increment-btn'); + const decrementButton = getByTestId('decrement-btn'); + + await user.click(incrementButton); + expect(handleChange.args[0][1]).to.equal(11); + expect(input.value).to.equal('11'); + + await user.click(decrementButton); + await user.click(decrementButton); + expect(handleChange.callCount).to.equal(3); + expect(handleChange.args[2][1]).to.equal(9); + expect(input.value).to.equal('9'); + }); + + it('clicking the increment and decrement buttons changes the value based on shiftMultiplier if the Shift key is held', async () => { + const user = userEvent.setup(); + const handleChange = spy(); + + const { getByTestId } = render( + , + ); + + const input = getByTestId('input') as HTMLInputElement; + const incrementButton = getByTestId('increment-btn'); + const decrementButton = getByTestId('decrement-btn'); + + // press Shift key without releasing it + await user.keyboard('{Shift>}'); + await user.click(incrementButton); + await user.click(incrementButton); + expect(handleChange.args[1][1]).to.equal(30); + expect(input.value).to.equal('30'); + + await user.click(decrementButton); + expect(handleChange.args[2][1]).to.equal(25); + expect(handleChange.callCount).to.equal(3); + expect(input.value).to.equal('25'); + }); + + it('clicking on the stepper buttons will focus the input', async () => { + const user = userEvent.setup(); + + const { getByTestId } = render( + , + ); + + const input = getByTestId('input') as HTMLInputElement; + const incrementButton = getByTestId('increment-btn'); + const decrementButton = getByTestId('decrement-btn'); + + expect(document.activeElement).to.equal(document.body); + + await user.click(incrementButton); + + expect(document.activeElement).to.equal(input); + + act(() => { + input.blur(); + }); + + expect(document.activeElement).to.equal(document.body); + + await user.click(decrementButton); + + expect(document.activeElement).to.equal(input); + }); + }); + + describe('keyboard interaction', () => { + it('ArrowUp and ArrowDown changes the value', async () => { + const user = userEvent.setup(); + const handleChange = spy(); + + const { getByTestId } = render( + , + ); + + const input = getByTestId('input') as HTMLInputElement; + + await user.click(input); + + await user.keyboard('[ArrowUp]'); + await user.keyboard('[ArrowUp]'); + expect(handleChange.callCount).to.equal(2); + expect(handleChange.args[1][1]).to.equal(12); + expect(input.value).to.equal('12'); + + await user.keyboard('[ArrowDown]'); + expect(handleChange.callCount).to.equal(3); + expect(handleChange.args[2][1]).to.equal(11); + expect(input.value).to.equal('11'); + }); + + it('ArrowUp and ArrowDown changes the value based on a custom step', async () => { + const user = userEvent.setup(); + const handleChange = spy(); + + const { getByTestId } = render( + , + ); + + const input = getByTestId('input') as HTMLInputElement; + + await user.click(input); + + await user.keyboard('[ArrowUp]'); + await user.keyboard('[ArrowUp]'); + expect(handleChange.args[1][1]).to.equal(20); + expect(input.value).to.equal('20'); + + await user.keyboard('[ArrowDown]'); + expect(handleChange.args[2][1]).to.equal(15); + expect(handleChange.callCount).to.equal(3); + expect(input.value).to.equal('15'); + }); + + it('ArrowUp and ArrowDown changes the value based on shiftMultiplier if the Shift key is held', async () => { + const user = userEvent.setup(); + const handleChange = spy(); + + const { getByTestId } = render( + , + ); + + const input = getByTestId('input') as HTMLInputElement; + + await user.click(input); + + await user.keyboard('{Shift>}[ArrowUp]/'); + expect(handleChange.callCount).to.equal(1); + expect(handleChange.args[0][1]).to.equal(25); + expect(input.value).to.equal('25'); + + await user.keyboard('{Shift>}[ArrowDown][ArrowDown]{/Shift}'); + expect(handleChange.args[2][1]).to.equal(15); + expect(handleChange.callCount).to.equal(3); + expect(input.value).to.equal('15'); + }); + + it('PageUp and PageDown changes the value based on shiftMultiplier', async () => { + const user = userEvent.setup(); + const handleChange = spy(); + + const { getByTestId } = render( + , + ); + + const input = getByTestId('input') as HTMLInputElement; + + await user.click(input); + + await user.keyboard('[PageUp]'); + expect(handleChange.args[0][1]).to.equal(25); + expect(input.value).to.equal('25'); + + await user.keyboard('[PageDown][PageDown]'); + expect(handleChange.args[2][1]).to.equal(15); + expect(handleChange.callCount).to.equal(3); + expect(input.value).to.equal('15'); + }); + + it('sets value to max when Home is pressed', async () => { + const user = userEvent.setup(); + const handleChange = spy(); + + const { getByTestId } = render( + , + ); + + const input = getByTestId('input') as HTMLInputElement; + + await user.click(input); + + await user.keyboard('[Home]'); + expect(handleChange.args[0][1]).to.equal(50); + expect(input.value).to.equal('50'); + }); + + it('sets value to min when End is pressed', async () => { + const user = userEvent.setup(); + const handleChange = spy(); + + const { getByTestId } = render( + , + ); + + const input = getByTestId('input') as HTMLInputElement; + + await user.click(input); + + await user.keyboard('[End]'); + expect(handleChange.args[0][1]).to.equal(1); + expect(input.value).to.equal('1'); + }); + + it('sets value to min when the input has no value and ArrowUp is pressed', async () => { + const user = userEvent.setup(); + const handleChange = spy(); + + const { getByTestId } = render( + , + ); + + const input = getByTestId('input') as HTMLInputElement; + + await user.click(input); + + await user.keyboard('[ArrowUp]'); + expect(handleChange.args[0][1]).to.equal(5); + expect(input.value).to.equal('5'); + }); + + it('sets value to max when the input has no value and ArrowDown is pressed', async () => { + const user = userEvent.setup(); + const handleChange = spy(); + + const { getByTestId } = render( + , + ); + + const input = getByTestId('input') as HTMLInputElement; + + await user.click(input); + + await user.keyboard('[ArrowDown]'); + expect(handleChange.args[0][1]).to.equal(9); + expect(input.value).to.equal('9'); + }); + + it('only includes the input element in the tab order', async () => { + const user = userEvent.setup(); + + const { getByTestId } = render( + , + ); + + const input = getByTestId('input') as HTMLInputElement; + expect(document.activeElement).to.equal(document.body); + + await user.keyboard('[Tab]'); + expect(document.activeElement).to.equal(input); + + await user.keyboard('[Tab]'); + expect(document.activeElement).to.equal(document.body); + }); + }); +}); diff --git a/packages/mui-base/src/Unstable_NumberInput/NumberInput.tsx b/packages/mui-base/src/Unstable_NumberInput/NumberInput.tsx new file mode 100644 index 00000000000000..3d540dc6eb2c41 --- /dev/null +++ b/packages/mui-base/src/Unstable_NumberInput/NumberInput.tsx @@ -0,0 +1,297 @@ +'use client'; +import * as React from 'react'; +import PropTypes from 'prop-types'; +import { OverridableComponent } from '@mui/types'; +import { getNumberInputUtilityClass } from './numberInputClasses'; +import useNumberInput from '../unstable_useNumberInput'; +import { + NumberInputOwnerState, + NumberInputProps, + NumberInputRootSlotProps, + NumberInputInputSlotProps, + NumberInputIncrementButtonSlotProps, + NumberInputDecrementButtonSlotProps, + NumberInputTypeMap, +} from './NumberInput.types'; +import composeClasses from '../composeClasses'; +import { EventHandlers, useSlotProps, WithOptionalOwnerState } from '../utils'; +import { useClassNamesOverride } from '../utils/ClassNameConfigurator'; + +const useUtilityClasses = (ownerState: NumberInputOwnerState) => { + const { + disabled, + error, + focused, + readOnly, + formControlContext, + isIncrementDisabled, + isDecrementDisabled, + } = ownerState; + + const slots = { + root: [ + 'root', + disabled && 'disabled', + error && 'error', + focused && 'focused', + readOnly && 'readOnly', + Boolean(formControlContext) && 'formControl', + ], + input: ['input', disabled && 'disabled', readOnly && 'readOnly'], + incrementButton: ['incrementButton', isIncrementDisabled && 'disabled'], + decrementButton: ['decrementButton', isDecrementDisabled && 'disabled'], + }; + + return composeClasses(slots, useClassNamesOverride(getNumberInputUtilityClass)); +}; + +/** + * + * Demos: + * + * - [Number Input](https://mui.com/base-ui/react-number-input/) + * + * API: + * + * - [NumberInput API](https://mui.com/base-ui/react-number-input/components-api/#number-input) + */ +const NumberInput = React.forwardRef(function NumberInput( + props: NumberInputProps, + forwardedRef: React.ForwardedRef, +) { + const { + className, + defaultValue, + disabled, + error, + id, + max, + min, + onBlur, + onInputChange, + onFocus, + onChange, + placeholder, + required, + readOnly = false, + shiftMultiplier, + step, + value, + slotProps = {}, + slots = {}, + ...rest + } = props; + + const { + getRootProps, + getInputProps, + getIncrementButtonProps, + getDecrementButtonProps, + focused, + error: errorState, + disabled: disabledState, + formControlContext, + isIncrementDisabled, + isDecrementDisabled, + } = useNumberInput({ + min, + max, + step, + shiftMultiplier, + defaultValue, + disabled, + error, + onFocus, + onInputChange, + onBlur, + onChange, + required, + readOnly, + value, + inputId: id, + }); + + const ownerState: NumberInputOwnerState = { + ...props, + disabled: disabledState, + error: errorState, + focused, + readOnly, + formControlContext, + isIncrementDisabled, + isDecrementDisabled, + }; + + const classes = useUtilityClasses(ownerState); + + const propsForwardedToInputSlot = { + placeholder, + }; + + const Root = slots.root ?? 'div'; + const rootProps: WithOptionalOwnerState = useSlotProps({ + elementType: Root, + getSlotProps: getRootProps, + externalSlotProps: slotProps.root, + externalForwardedProps: rest, + additionalProps: { + ref: forwardedRef, + }, + ownerState, + className: [classes.root, className], + }); + + const Input = slots.input ?? 'input'; + const inputProps: WithOptionalOwnerState = useSlotProps({ + elementType: Input, + getSlotProps: (otherHandlers: EventHandlers) => + getInputProps({ ...otherHandlers, ...propsForwardedToInputSlot }), + externalSlotProps: slotProps.input, + ownerState, + className: classes.input, + }); + + const IncrementButton = slots.incrementButton ?? 'button'; + const incrementButtonProps: WithOptionalOwnerState = + useSlotProps({ + elementType: IncrementButton, + getSlotProps: getIncrementButtonProps, + externalSlotProps: slotProps.incrementButton, + ownerState, + className: classes.incrementButton, + }); + + const DecrementButton = slots.decrementButton ?? 'button'; + const decrementButtonProps: WithOptionalOwnerState = + useSlotProps({ + elementType: DecrementButton, + getSlotProps: getDecrementButtonProps, + externalSlotProps: slotProps.decrementButton, + ownerState, + className: classes.decrementButton, + }); + + return ( + + + + + + ); +}) as OverridableComponent; + +NumberInput.propTypes /* remove-proptypes */ = { + // ----------------------------- Warning -------------------------------- + // | These PropTypes are generated from the TypeScript type definitions | + // | To update them edit TypeScript types and run "yarn proptypes" | + // ---------------------------------------------------------------------- + /** + * @ignore + */ + children: PropTypes.node, + /** + * @ignore + */ + className: PropTypes.string, + /** + * The default value. Use when the component is not controlled. + */ + defaultValue: PropTypes.any, + /** + * If `true`, the component is disabled. + * The prop defaults to the value (`false`) inherited from the parent FormControl component. + */ + disabled: PropTypes.bool, + /** + * If `true`, the `input` will indicate an error by setting the `aria-invalid` attribute on the input and the `Mui-error` class on the root element. + */ + error: PropTypes.bool, + /** + * The id of the `input` element. + */ + id: PropTypes.string, + /** + * The maximum value. + */ + max: PropTypes.number, + /** + * The minimum value. + */ + min: PropTypes.number, + /** + * @ignore + */ + onBlur: PropTypes.func, + /** + * Callback fired after the value is clamped and changes - when the `input` is blurred or when + * the stepper buttons are triggered. + * Called with `undefined` when the value is unset. + * + * @param {React.FocusEvent|React.PointerEvent|React.KeyboardEvent} event The event source of the callback + * @param {number|undefined} value The new value of the component + */ + onChange: PropTypes.func, + /** + * @ignore + */ + onFocus: PropTypes.func, + /** + * Callback fired when the `input` value changes after each keypress, before clamping is applied. + * Note that `event.target.value` may contain values that fall outside of `min` and `max` or + * are otherwise "invalid". + * + * @param {React.ChangeEvent} event The event source of the callback. + */ + onInputChange: PropTypes.func, + /** + * @ignore + */ + placeholder: PropTypes.string, + /** + * If `true`, the `input` element becomes read-only. The stepper buttons remain active, + * with the addition that they are now keyboard focusable. + * @default false + */ + readOnly: PropTypes.bool, + /** + * If `true`, the `input` element is required. + * The prop defaults to the value (`false`) inherited from the parent FormControl component. + */ + required: PropTypes.bool, + /** + * Multiplier applied to `step` if the shift key is held while incrementing + * or decrementing the value. Defaults to `10`. + */ + shiftMultiplier: PropTypes.number, + /** + * The props used for each slot inside the NumberInput. + * @default {} + */ + slotProps: PropTypes.shape({ + decrementButton: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + incrementButton: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + input: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + root: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + }), + /** + * The components used for each slot inside the InputBase. + * Either a string to use a HTML element or a component. + * @default {} + */ + slots: PropTypes.shape({ + decrementButton: PropTypes.elementType, + incrementButton: PropTypes.elementType, + input: PropTypes.elementType, + root: PropTypes.elementType, + }), + /** + * The amount that the value changes on each increment or decrement. + */ + step: PropTypes.number, + /** + * The current value. Use when the component is controlled. + */ + value: PropTypes.any, +} as any; + +export default NumberInput; diff --git a/packages/mui-base/src/Unstable_NumberInput/NumberInput.types.ts b/packages/mui-base/src/Unstable_NumberInput/NumberInput.types.ts new file mode 100644 index 00000000000000..513ba18a0a3b1d --- /dev/null +++ b/packages/mui-base/src/Unstable_NumberInput/NumberInput.types.ts @@ -0,0 +1,104 @@ +import { Simplify } from '@mui/types'; +import { FormControlState } from '../FormControl'; +import { + UseNumberInputParameters, + UseNumberInputRootSlotProps, + UseNumberInputIncrementButtonSlotProps, + UseNumberInputDecrementButtonSlotProps, +} from '../unstable_useNumberInput/useNumberInput.types'; +import { PolymorphicProps, SlotComponentProps } from '../utils'; + +export interface NumberInputRootSlotPropsOverrides {} +export interface NumberInputInputSlotPropsOverrides {} +export interface NumberInputStepperButtonSlotPropsOverrides {} + +export type NumberInputOwnProps = Omit & { + /** + * If `true`, the `input` will indicate an error by setting the `aria-invalid` attribute on the input and the `Mui-error` class on the root element. + */ + error?: boolean; + /** + * The id of the `input` element. + */ + id?: string; + /** + * The props used for each slot inside the NumberInput. + * @default {} + */ + slotProps?: { + root?: SlotComponentProps<'div', NumberInputRootSlotPropsOverrides, NumberInputOwnerState>; + input?: SlotComponentProps<'input', NumberInputInputSlotPropsOverrides, NumberInputOwnerState>; + incrementButton?: SlotComponentProps< + 'button', + NumberInputStepperButtonSlotPropsOverrides, + NumberInputOwnerState + >; + decrementButton?: SlotComponentProps< + 'button', + NumberInputStepperButtonSlotPropsOverrides, + NumberInputOwnerState + >; + }; + /** + * The components used for each slot inside the InputBase. + * Either a string to use a HTML element or a component. + * @default {} + */ + slots?: { + root?: React.ElementType; + input?: React.ElementType; + incrementButton?: React.ElementType; + decrementButton?: React.ElementType; + }; +}; + +export interface NumberInputTypeMap< + AdditionalProps = {}, + RootComponentType extends React.ElementType = 'div', +> { + props: AdditionalProps & NumberInputOwnProps; + defaultComponent: RootComponentType; +} + +export type NumberInputProps< + RootComponentType extends React.ElementType = NumberInputTypeMap['defaultComponent'], +> = PolymorphicProps, RootComponentType>; + +export type NumberInputOwnerState = Simplify< + NumberInputOwnProps & { + formControlContext: FormControlState | undefined; + focused: boolean; + isIncrementDisabled: boolean; + isDecrementDisabled: boolean; + } +>; + +export type NumberInputRootSlotProps = Simplify< + UseNumberInputRootSlotProps & { + ownerState: NumberInputOwnerState; + className?: string; + children?: React.ReactNode; + ref?: React.Ref; + } +>; + +export type NumberInputInputSlotProps = Simplify< + Omit & { + id: string | undefined; + ownerState: NumberInputOwnerState; + placeholder: string | undefined; + ref: React.Ref; + } +>; + +export type NumberInputIncrementButtonSlotProps = Simplify< + UseNumberInputIncrementButtonSlotProps & { + ownerState: NumberInputOwnerState; + } +>; + +export type NumberInputDecrementButtonSlotProps = Simplify< + UseNumberInputDecrementButtonSlotProps & { + ownerState: NumberInputOwnerState; + } +>; diff --git a/packages/mui-base/src/Unstable_NumberInput/index.ts b/packages/mui-base/src/Unstable_NumberInput/index.ts new file mode 100644 index 00000000000000..b83be49446103e --- /dev/null +++ b/packages/mui-base/src/Unstable_NumberInput/index.ts @@ -0,0 +1,7 @@ +'use client'; +export { default } from './NumberInput'; + +export { default as numberInputClasses } from './numberInputClasses'; +export * from './numberInputClasses'; + +export * from './NumberInput.types'; diff --git a/packages/mui-base/src/Unstable_NumberInput/numberInputClasses.ts b/packages/mui-base/src/Unstable_NumberInput/numberInputClasses.ts new file mode 100644 index 00000000000000..e7373f4cc8efa0 --- /dev/null +++ b/packages/mui-base/src/Unstable_NumberInput/numberInputClasses.ts @@ -0,0 +1,49 @@ +import generateUtilityClass from '../generateUtilityClass'; +import generateUtilityClasses from '../generateUtilityClasses'; + +export interface NumberInputClasses { + /** Class name applied to the root element. */ + root: string; + /** Class name applied to the root element if the component is a descendant of `FormControl`. */ + formControl: string; + /** Class name applied to the root element if `startAdornment` is provided. */ + // TODO: adornedStart: string; + /** Class name applied to the root element if `endAdornment` is provided. */ + // TODO: adornedEnd: string; + /** Class name applied to the root element if the component is focused. */ + focused: string; + /** Class name applied to the root element if `disabled={true}`. */ + disabled: string; + /** State class applied to the root element if `readOnly={true}`. */ + readOnly: string; + /** State class applied to the root element if `error={true}`. */ + error: string; + /** Class name applied to the input element. */ + input: string; + /** Class name applied to the increment button element. */ + incrementButton: string; + /** Class name applied to the decrement button element. */ + decrementButton: string; +} + +export type NumberInputClassKey = keyof NumberInputClasses; + +export function getNumberInputUtilityClass(slot: string): string { + return generateUtilityClass('MuiNumberInput', slot); +} + +const numberInputClasses: NumberInputClasses = generateUtilityClasses('MuiNumberInput', [ + 'root', + 'formControl', + 'focused', + 'disabled', + 'readOnly', + 'error', + 'input', + 'incrementButton', + 'decrementButton', + // 'adornedStart', + // 'adornedEnd', +]); + +export default numberInputClasses; diff --git a/packages/mui-base/src/index.d.ts b/packages/mui-base/src/index.d.ts index ea9553b3d37094..8be49ed6cf0f8c 100644 --- a/packages/mui-base/src/index.d.ts +++ b/packages/mui-base/src/index.d.ts @@ -38,6 +38,9 @@ export * from './Modal'; export { default as NoSsr } from './NoSsr'; +export { default as Unstable_NumberInput } from './Unstable_NumberInput'; +export * from './Unstable_NumberInput'; + export { default as OptionGroup } from './OptionGroup'; export * from './OptionGroup'; @@ -104,6 +107,9 @@ export * from './useMenuButton'; export { default as useMenuItem } from './useMenuItem'; export * from './useMenuItem'; +export { default as unstable_useNumberInput } from './unstable_useNumberInput'; +export * from './unstable_useNumberInput'; + export { default as useOption } from './useOption'; export * from './useOption'; diff --git a/packages/mui-base/src/index.js b/packages/mui-base/src/index.js index 4535ace2218e14..b7ad15d40d5940 100644 --- a/packages/mui-base/src/index.js +++ b/packages/mui-base/src/index.js @@ -35,6 +35,9 @@ export * from './Modal'; export { default as NoSsr } from './NoSsr'; +export { default as Unstable_NumberInput } from './Unstable_NumberInput'; +export * from './Unstable_NumberInput'; + export { default as OptionGroup } from './OptionGroup'; export * from './OptionGroup'; @@ -97,6 +100,9 @@ export * from './useMenuButton'; export { default as useMenuItem } from './useMenuItem'; export * from './useMenuItem'; +export { default as unstable_useNumberInput } from './unstable_useNumberInput'; +export * from './unstable_useNumberInput'; + export { default as useOption } from './useOption'; export * from './useOption'; diff --git a/packages/mui-base/src/unstable_useNumberInput/index.ts b/packages/mui-base/src/unstable_useNumberInput/index.ts new file mode 100644 index 00000000000000..6984f2df6e29b2 --- /dev/null +++ b/packages/mui-base/src/unstable_useNumberInput/index.ts @@ -0,0 +1,4 @@ +'use client'; +export { default } from './useNumberInput'; + +export * from './useNumberInput.types'; diff --git a/packages/mui-base/src/unstable_useNumberInput/useNumberInput.test.tsx b/packages/mui-base/src/unstable_useNumberInput/useNumberInput.test.tsx new file mode 100644 index 00000000000000..d8b5de3b45351e --- /dev/null +++ b/packages/mui-base/src/unstable_useNumberInput/useNumberInput.test.tsx @@ -0,0 +1,274 @@ +import { expect } from 'chai'; +import { spy } from 'sinon'; +import * as React from 'react'; +import userEvent from '@testing-library/user-event'; +import { createRenderer, screen } from 'test/utils'; +import useNumberInput, { UseNumberInputParameters } from './index'; + +describe('useNumberInput', () => { + const { render } = createRenderer(); + const invokeUseNumberInput = (props: UseNumberInputParameters) => { + const ref = React.createRef>(); + function TestComponent() { + const numberInputDefinition = useNumberInput(props); + React.useImperativeHandle(ref, () => numberInputDefinition, [numberInputDefinition]); + return null; + } + + render(); + + return ref.current!; + }; + + it('should return correct ARIA attributes', () => { + const INPUT_ID = 'TestInput'; + + const props: UseNumberInputParameters = { + inputId: INPUT_ID, + value: 50, + min: 10, + max: 100, + disabled: true, + }; + + const { getInputProps, getIncrementButtonProps, getDecrementButtonProps } = + invokeUseNumberInput(props); + const inputProps = getInputProps(); + const incrementButtonProps = getIncrementButtonProps(); + const decrementButtonProps = getDecrementButtonProps(); + + expect(inputProps['aria-valuenow']).to.equal(50); + expect(inputProps['aria-valuemin']).to.equal(10); + expect(inputProps['aria-valuemax']).to.equal(100); + expect(inputProps['aria-disabled']).to.equal(true); + + expect(decrementButtonProps.tabIndex).to.equal(-1); + expect(decrementButtonProps['aria-controls']).to.equal(INPUT_ID); + expect(decrementButtonProps['aria-disabled']).to.equal(true); + + expect(incrementButtonProps.tabIndex).to.equal(-1); + expect(incrementButtonProps['aria-controls']).to.equal(INPUT_ID); + expect(incrementButtonProps['aria-disabled']).to.equal(true); + }); + + it('should accept defaultValue in uncontrolled mode', () => { + const props: UseNumberInputParameters = { + defaultValue: 100, + disabled: true, + required: true, + }; + + const { getInputProps } = invokeUseNumberInput(props); + const inputProps = getInputProps(); + + expect(inputProps.value).to.equal(100); + expect(inputProps.required).to.equal(true); + }); + + describe('prop: onInputChange', () => { + it('should call onInputChange accordingly when inputting valid characters', async () => { + const user = userEvent.setup(); + const handleInputChange = spy(); + function NumberInput() { + const { getInputProps } = useNumberInput({ onInputChange: handleInputChange }); + + return ; + } + render(); + + const input = screen.getByTestId('test-input') as HTMLInputElement; + + await user.click(input); + + await user.keyboard('-12'); + + expect(handleInputChange.callCount).to.equal(3); + expect(handleInputChange.args[2][0].target.value).to.equal('-12'); + expect(input.value).to.equal('-12'); + }); + + it('should not change the input value when inputting invalid characters', async () => { + const user = userEvent.setup(); + const handleInputChange = spy(); + function NumberInput() { + const { getInputProps } = useNumberInput({ onInputChange: handleInputChange }); + + return ; + } + render(); + + const input = screen.getByTestId('test-input') as HTMLInputElement; + + await user.click(input); + + await user.keyboard('-5a'); + + expect(handleInputChange.callCount).to.equal(3); + expect(input.value).to.equal('-5'); + }); + }); + + describe('prop: onChange', () => { + it('should call onChange when the input is blurred', async () => { + const user = userEvent.setup(); + const handleChange = spy(); + function NumberInput() { + const { getInputProps } = useNumberInput({ onChange: handleChange }); + + return ; + } + render(); + + const input = screen.getByTestId('test-input'); + + await user.click(input); + + await user.keyboard('34'); + + expect(handleChange.callCount).to.equal(0); + + await user.keyboard('[Tab]'); + expect(document.activeElement).to.equal(document.body); + + expect(handleChange.callCount).to.equal(1); + }); + + it('should call onChange with a value within max', async () => { + const user = userEvent.setup(); + const handleChange = spy(); + function NumberInput() { + const { getInputProps } = useNumberInput({ + onChange: handleChange, + max: 5, + }); + + return ; + } + render(); + + const input = screen.getByTestId('test-input'); + + await user.click(input); + + await user.keyboard('9'); + + await user.keyboard('[Tab]'); + expect(document.activeElement).to.equal(document.body); + + expect(handleChange.args[0][1]).to.equal(5); + }); + + it('should call onChange with a value within min', async () => { + const user = userEvent.setup(); + const handleChange = spy(); + function NumberInput() { + const { getInputProps } = useNumberInput({ + onChange: handleChange, + min: 5, + }); + + return ; + } + render(); + + const input = screen.getByTestId('test-input'); + + await user.click(input); + + await user.keyboard('-9'); + + await user.keyboard('[Tab]'); + expect(document.activeElement).to.equal(document.body); + + expect(handleChange.args[0][1]).to.equal(5); + }); + + it('should call onChange with a value based on a custom step', async () => { + const user = userEvent.setup(); + const handleChange = spy(); + function NumberInput() { + const { getInputProps } = useNumberInput({ + onChange: handleChange, + min: 0, + step: 5, + }); + + return ; + } + render(); + + const input = screen.getByTestId('test-input'); + + await user.click(input); + + await user.keyboard('4'); + + await user.keyboard('[Tab]'); + expect(document.activeElement).to.equal(document.body); + + expect(handleChange.args[0][1]).to.equal(5); + }); + + it('should call onChange with undefined when the value is cleared', async () => { + const user = userEvent.setup(); + const handleChange = spy(); + function NumberInput() { + const { getInputProps } = useNumberInput({ + onChange: handleChange, + }); + + return ; + } + render(); + + const input = screen.getByTestId('test-input') as HTMLInputElement; + + await user.click(input); + + await user.keyboard('9'); + + expect(input.value).to.equal('9'); + + await user.keyboard('[Backspace]'); + + expect(input.value).to.equal(''); + + await user.keyboard('[Tab]'); + expect(document.activeElement).to.equal(document.body); + + expect(handleChange.callCount).to.equal(1); + expect(handleChange.args[0][1]).to.equal(undefined); + }); + + it('should call onChange with undefined when input value is -', async () => { + const user = userEvent.setup(); + const handleChange = spy(); + function NumberInput() { + const { getInputProps } = useNumberInput({ + onChange: handleChange, + }); + + return ; + } + render(); + + const input = screen.getByTestId('test-input') as HTMLInputElement; + + await user.click(input); + + await user.keyboard('-5'); + + expect(input.value).to.equal('-5'); + + await user.keyboard('[Backspace]'); + + expect(input.value).to.equal('-'); + + await user.keyboard('[Tab]'); + expect(document.activeElement).to.equal(document.body); + + expect(handleChange.callCount).to.equal(1); + expect(handleChange.args[0][1]).to.equal(undefined); + }); + }); +}); diff --git a/packages/mui-base/src/unstable_useNumberInput/useNumberInput.ts b/packages/mui-base/src/unstable_useNumberInput/useNumberInput.ts new file mode 100644 index 00000000000000..754b9db595845c --- /dev/null +++ b/packages/mui-base/src/unstable_useNumberInput/useNumberInput.ts @@ -0,0 +1,377 @@ +'use client'; +import * as React from 'react'; +import MuiError from '@mui/utils/macros/MuiError.macro'; +import { unstable_useForkRef as useForkRef, unstable_useId as useId } from '@mui/utils'; +import { FormControlState, useFormControlContext } from '../FormControl'; +import { + UseNumberInputParameters, + UseNumberInputRootSlotProps, + UseNumberInputInputSlotProps, + UseNumberInputIncrementButtonSlotProps, + UseNumberInputDecrementButtonSlotProps, + UseNumberInputReturnValue, +} from './useNumberInput.types'; +import { clamp, isNumber } from './utils'; +import extractEventHandlers from '../utils/extractEventHandlers'; + +type StepDirection = 'up' | 'down'; + +const STEP_KEYS = ['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown']; + +const SUPPORTED_KEYS = [...STEP_KEYS, 'Home', 'End']; + +function parseInput(v: string): string { + return v ? String(v.trim()) : String(v); +} + +/** + * + * Demos: + * + * - [Number Input](https://mui.com/base-ui/react-number-input/#hook) + * + * API: + * + * - [useNumberInput API](https://mui.com/base-ui/react-number-input/hooks-api/#use-number-input) + */ +export default function useNumberInput( + parameters: UseNumberInputParameters, +): UseNumberInputReturnValue { + const { + min, + max, + step, + shiftMultiplier = 10, + defaultValue: defaultValueProp, + disabled: disabledProp = false, + error: errorProp = false, + onBlur, + onInputChange, + onFocus, + onChange, + required: requiredProp = false, + readOnly: readOnlyProp = false, + value: valueProp, + inputRef: inputRefProp, + inputId: inputIdProp, + } = parameters; + + // TODO: make it work with FormControl + const formControlContext: FormControlState | undefined = useFormControlContext(); + + const { current: isControlled } = React.useRef(valueProp != null); + + const handleInputRefWarning = React.useCallback((instance: HTMLElement) => { + if (process.env.NODE_ENV !== 'production') { + if (instance && instance.nodeName !== 'INPUT' && !instance.focus) { + console.error( + [ + 'MUI: You have provided a `slots.input` to the input component', + 'that does not correctly handle the `ref` prop.', + 'Make sure the `ref` prop is called with a HTMLInputElement.', + ].join('\n'), + ); + } + } + }, []); + + const inputRef = React.useRef(null); + const handleInputRef = useForkRef(inputRef, inputRefProp, handleInputRefWarning); + + const inputId = useId(inputIdProp); + + const [focused, setFocused] = React.useState(false); + + // the "final" value + const [value, setValue] = React.useState(valueProp ?? defaultValueProp); + // the (potentially) dirty or invalid input value + const [dirtyValue, setDirtyValue] = React.useState( + value ? String(value) : undefined, + ); + + React.useEffect(() => { + if (!formControlContext && disabledProp && focused) { + setFocused(false); + + onBlur?.(); + } + }, [formControlContext, disabledProp, focused, onBlur]); + + const handleFocus = + (otherHandlers: Record | undefined>) => + (event: React.FocusEvent) => { + otherHandlers.onFocus?.(event); + + if (event.defaultPrevented) { + return; + } + + if (formControlContext && formControlContext.onFocus) { + formControlContext?.onFocus?.(); + } + setFocused(true); + }; + + const handleValueChange = + () => + ( + event: React.FocusEvent | React.PointerEvent | React.KeyboardEvent, + val: number | undefined, + ) => { + let newValue; + + if (val === undefined) { + newValue = val; + setDirtyValue(''); + } else { + newValue = clamp(val, min, max, step); + setDirtyValue(String(newValue)); + } + + setValue(newValue); + + if (isNumber(newValue)) { + onChange?.(event, newValue); + } else { + onChange?.(event, undefined); + } + }; + + const handleInputChange = + (otherHandlers: Record | undefined>) => + (event: React.ChangeEvent) => { + if (!isControlled && event.target === null) { + throw new MuiError( + 'MUI: Expected valid input target. ' + + 'Did you use a custom `slots.input` and forget to forward refs? ' + + 'See https://mui.com/r/input-component-ref-interface for more info.', + ); + } + + formControlContext?.onChange?.(event); + + otherHandlers.onInputChange?.(event); + + const val = parseInput(event.currentTarget.value); + + if (val === '' || val === '-') { + setDirtyValue(val); + setValue(undefined); + } + + if (val.match(/^-?\d+?$/)) { + setDirtyValue(val); + setValue(parseInt(val, 10)); + } + }; + + const handleBlur = + (otherHandlers: Record | undefined>) => + (event: React.FocusEvent) => { + const val = parseInput(event.currentTarget.value); + + otherHandlers.onBlur?.(event); + + if (val === '' || val === '-') { + handleValueChange()(event, undefined); + } else { + handleValueChange()(event, parseInt(val, 10)); + } + + if (formControlContext && formControlContext.onBlur) { + formControlContext.onBlur(); + } + + setFocused(false); + }; + + const handleClick = + (otherHandlers: Record>) => + (event: React.MouseEvent) => { + if (inputRef.current && event.currentTarget === event.target) { + inputRef.current.focus(); + } + + otherHandlers.onClick?.(event); + }; + + const handleStep = + (direction: StepDirection) => (event: React.PointerEvent | React.KeyboardEvent) => { + let newValue; + + if (isNumber(value)) { + const multiplier = + event.shiftKey || + (event as React.KeyboardEvent).key === 'PageUp' || + (event as React.KeyboardEvent).key === 'PageDown' + ? shiftMultiplier + : 1; + newValue = { + up: value + (step ?? 1) * multiplier, + down: value - (step ?? 1) * multiplier, + }[direction]; + } else { + // no value + newValue = { + up: min ?? 0, + down: max ?? 0, + }[direction]; + } + handleValueChange()(event, newValue); + }; + + const handleKeyDown = + (otherHandlers: Record | undefined>) => + (event: React.KeyboardEvent) => { + otherHandlers.onKeyDown?.(event); + + if (event.defaultPrevented) { + return; + } + + if (SUPPORTED_KEYS.includes(event.key)) { + event.preventDefault(); + } + + if (STEP_KEYS.includes(event.key)) { + const direction = { + ArrowUp: 'up', + ArrowDown: 'down', + PageUp: 'up', + PageDown: 'down', + }[event.key] as StepDirection; + + handleStep(direction)(event); + } + + if (event.key === 'Home' && isNumber(max)) { + handleValueChange()(event, max); + } + + if (event.key === 'End' && isNumber(min)) { + handleValueChange()(event, min); + } + }; + + const getRootProps = = {}>( + externalProps: TOther = {} as TOther, + ): UseNumberInputRootSlotProps => { + const propsEventHandlers = extractEventHandlers(parameters, [ + 'onBlur', + 'onInputChange', + 'onFocus', + 'onChange', + ]); + + const externalEventHandlers = { ...propsEventHandlers, ...extractEventHandlers(externalProps) }; + + return { + ...externalProps, + ...externalEventHandlers, + onClick: handleClick(externalEventHandlers), + }; + }; + + const getInputProps = = {}>( + externalProps: TOther = {} as TOther, + ): UseNumberInputInputSlotProps => { + const externalEventHandlers = { + onBlur, + onFocus, + ...extractEventHandlers(externalProps, ['onInputChange']), + }; + + const mergedEventHandlers = { + ...externalProps, + ...externalEventHandlers, + onFocus: handleFocus(externalEventHandlers), + onChange: handleInputChange({ ...externalEventHandlers, onInputChange }), + onBlur: handleBlur(externalEventHandlers), + onKeyDown: handleKeyDown(externalEventHandlers), + }; + + const displayValue = (focused ? dirtyValue : value) ?? ''; + + return { + ...mergedEventHandlers, + type: 'text', + id: inputId, + 'aria-invalid': errorProp || undefined, + defaultValue: undefined, + ref: handleInputRef, + value: displayValue as number | undefined, + 'aria-valuenow': displayValue as number | undefined, + 'aria-valuetext': String(displayValue), + 'aria-valuemin': min, + 'aria-valuemax': max, + autoComplete: 'off', + autoCorrect: 'off', + spellCheck: 'false', + required: requiredProp, + readOnly: readOnlyProp, + 'aria-disabled': disabledProp, + disabled: disabledProp, + }; + }; + + const handleStepperButtonMouseDown = (event: React.PointerEvent) => { + event.preventDefault(); + + if (inputRef.current) { + inputRef.current.focus(); + } + }; + + const stepperButtonCommonProps = { + 'aria-controls': inputId, + tabIndex: -1, + }; + + const isIncrementDisabled = + disabledProp || (isNumber(value) ? value >= (max ?? Number.MAX_SAFE_INTEGER) : false); + + const getIncrementButtonProps = = {}>( + externalProps: TOther = {} as TOther, + ): UseNumberInputIncrementButtonSlotProps => { + return { + ...externalProps, + ...stepperButtonCommonProps, + disabled: isIncrementDisabled, + 'aria-disabled': isIncrementDisabled, + onMouseDown: handleStepperButtonMouseDown, + onClick: handleStep('up'), + }; + }; + + const isDecrementDisabled = + disabledProp || (isNumber(value) ? value <= (min ?? Number.MIN_SAFE_INTEGER) : false); + + const getDecrementButtonProps = = {}>( + externalProps: TOther = {} as TOther, + ): UseNumberInputDecrementButtonSlotProps => { + return { + ...externalProps, + ...stepperButtonCommonProps, + disabled: isDecrementDisabled, + 'aria-disabled': isDecrementDisabled, + onMouseDown: handleStepperButtonMouseDown, + onClick: handleStep('down'), + }; + }; + + return { + disabled: disabledProp, + error: errorProp, + focused, + formControlContext, + getInputProps, + getIncrementButtonProps, + getDecrementButtonProps, + getRootProps, + required: requiredProp, + value: focused ? dirtyValue : value, + isIncrementDisabled, + isDecrementDisabled, + inputValue: dirtyValue, + }; +} diff --git a/packages/mui-base/src/unstable_useNumberInput/useNumberInput.types.ts b/packages/mui-base/src/unstable_useNumberInput/useNumberInput.types.ts new file mode 100644 index 00000000000000..6415fc75732421 --- /dev/null +++ b/packages/mui-base/src/unstable_useNumberInput/useNumberInput.types.ts @@ -0,0 +1,227 @@ +import * as React from 'react'; +import { FormControlState } from '../FormControl'; + +export type UseNumberInputChangeHandler = ( + e: React.KeyboardEvent, + value: number | null, +) => void; + +export interface UseNumberInputParameters { + /** + * The minimum value. + */ + min?: number; + /** + * The maximum value. + */ + max?: number; + /** + * The amount that the value changes on each increment or decrement. + */ + step?: number; + /** + * Multiplier applied to `step` if the shift key is held while incrementing + * or decrementing the value. Defaults to `10`. + */ + shiftMultiplier?: number; + /** + * The default value. Use when the component is not controlled. + */ + defaultValue?: unknown; + /** + * If `true`, the component is disabled. + * The prop defaults to the value (`false`) inherited from the parent FormControl component. + */ + disabled?: boolean; + /** + * If `true`, the `input` will indicate an error by setting the `aria-invalid` attribute. + * The prop defaults to the value (`false`) inherited from the parent FormControl component. + */ + error?: boolean; + onBlur?: (event?: React.FocusEvent) => void; + onClick?: React.MouseEventHandler; + /** + * Callback fired when the `input` value changes after each keypress, before clamping is applied. + * Note that `event.target.value` may contain values that fall outside of `min` and `max` or + * are otherwise "invalid". + * + * @param {React.ChangeEvent} event The event source of the callback. + */ + onInputChange?: React.ChangeEventHandler; + onFocus?: React.FocusEventHandler; + /** + * Callback fired after the value is clamped and changes - when the `input` is blurred or when + * the stepper buttons are triggered. + * Called with `undefined` when the value is unset. + * + * @param {React.FocusEvent|React.PointerEvent|React.KeyboardEvent} event The event source of the callback + * @param {number|undefined} value The new value of the component + */ + onChange?: ( + event: React.FocusEvent | React.PointerEvent | React.KeyboardEvent, + value: number | undefined, + ) => void; + /** + * The `id` attribute of the input element. + */ + inputId?: string; + /** + * The ref of the input element. + */ + inputRef?: React.Ref; + /** + * If `true`, the `input` element is required. + * The prop defaults to the value (`false`) inherited from the parent FormControl component. + */ + required?: boolean; + /** + * If `true`, the `input` element becomes read-only. The stepper buttons remain active, + * with the addition that they are now keyboard focusable. + * @default false + */ + readOnly?: boolean; + /** + * The current value. Use when the component is controlled. + */ + value?: unknown; +} + +export interface UseNumberInputRootSlotOwnProps { + onClick: React.MouseEventHandler | undefined; +} + +export type UseNumberInputRootSlotProps = Omit< + TOther, + keyof UseNumberInputRootSlotOwnProps | 'onBlur' | 'onInputChange' | 'onFocus' +> & + UseNumberInputRootSlotOwnProps; + +export interface UseNumberInputInputSlotOwnProps { + defaultValue: number | undefined; + id: string | undefined; + ref: React.RefCallback | null; + value: number | undefined; + role?: React.AriaRole; + 'aria-disabled': React.AriaAttributes['aria-disabled']; + 'aria-valuemax': React.AriaAttributes['aria-valuemax']; + 'aria-valuemin': React.AriaAttributes['aria-valuemin']; + 'aria-valuenow': React.AriaAttributes['aria-valuenow']; + 'aria-valuetext': React.AriaAttributes['aria-valuetext']; + tabIndex?: number; + onBlur: React.FocusEventHandler; + onChange: React.ChangeEventHandler; + onFocus: React.FocusEventHandler; + required: boolean; + disabled: boolean; +} + +export type UseNumberInputInputSlotProps = Omit< + TOther, + keyof UseNumberInputInputSlotOwnProps +> & + UseNumberInputInputSlotOwnProps; + +export interface UseNumberInputIncrementButtonSlotOwnProps { + 'aria-controls': React.AriaAttributes['aria-controls']; + 'aria-disabled': React.AriaAttributes['aria-disabled']; + disabled: boolean; + tabIndex?: number; +} + +export type UseNumberInputIncrementButtonSlotProps = Omit< + TOther, + keyof UseNumberInputIncrementButtonSlotOwnProps +> & + UseNumberInputIncrementButtonSlotOwnProps; + +export interface UseNumberInputDecrementButtonSlotOwnProps { + 'aria-controls': React.AriaAttributes['aria-controls']; + 'aria-disabled': React.AriaAttributes['aria-disabled']; + disabled: boolean; + tabIndex?: number; +} + +export type UseNumberInputDecrementButtonSlotProps = Omit< + TOther, + keyof UseNumberInputDecrementButtonSlotOwnProps +> & + UseNumberInputDecrementButtonSlotOwnProps; + +export interface UseNumberInputReturnValue { + /** + * If `true`, the component will be disabled. + * @default false + */ + disabled: boolean; + /** + * If `true`, the `input` will indicate an error by setting the `aria-invalid` attribute. + * @default false + */ + error: boolean; + /** + * If `true`, the `input` will be focused. + * @default false + */ + focused: boolean; + /** + * Return value from the `useFormControlContext` hook. + */ + formControlContext: FormControlState | undefined; + /** + * Resolver for the decrement button slot's props. + * @param externalProps props for the decrement button slot + * @returns props that should be spread on the decrement button slot + */ + getDecrementButtonProps: = {}>( + externalProps?: TOther, + ) => UseNumberInputDecrementButtonSlotProps; + /** + * Resolver for the increment button slot's props. + * @param externalProps props for the increment button slot + * @returns props that should be spread on the increment button slot + */ + getIncrementButtonProps: = {}>( + externalProps?: TOther, + ) => UseNumberInputIncrementButtonSlotProps; + /** + * Resolver for the input slot's props. + * @param externalProps props for the input slot + * @returns props that should be spread on the input slot + */ + getInputProps: = {}>( + externalProps?: TOther, + ) => UseNumberInputInputSlotProps; + /** + * Resolver for the root slot's props. + * @param externalProps props for the root slot + * @returns props that should be spread on the root slot + */ + getRootProps: = {}>( + externalProps?: TOther, + ) => UseNumberInputRootSlotProps; + /** + * If `true`, the `input` will indicate that it's required. + * @default false + */ + required: boolean; + /** + * The clamped `value` of the `input` element. + */ + value: unknown; + /** + * The dirty `value` of the `input` element when it is in focus. + */ + inputValue: string | undefined; + /** + * If `true`, the increment button will be disabled. + * e.g. when the `value` is already at `max` + * @default false + */ + isIncrementDisabled: boolean; + /** + * If `true`, the decrement button will be disabled. + * e.g. when the `value` is already at `min` + * @default false + */ + isDecrementDisabled: boolean; +} diff --git a/packages/mui-base/src/unstable_useNumberInput/utils.test.ts b/packages/mui-base/src/unstable_useNumberInput/utils.test.ts new file mode 100644 index 00000000000000..213294e133bf95 --- /dev/null +++ b/packages/mui-base/src/unstable_useNumberInput/utils.test.ts @@ -0,0 +1,50 @@ +import { expect } from 'chai'; +import { clamp, isNumber } from './utils'; + +describe('utils', () => { + it('clamp: clamps a value based on min and max', () => { + expect(clamp(1, 2, 4)).to.equal(2); + expect(clamp(5, 2, 4)).to.equal(4); + expect(clamp(-5, -1, 5)).to.equal(-1); + }); + + it('clamp: clamps a value between min and max and on a valid step', () => { + expect(clamp(2, -15, 15, 3)).to.equal(3); + expect(clamp(-1, -15, 15, 3)).to.equal(0); + expect(clamp(5, -15, 15, 3)).to.equal(6); + expect(clamp(-5, -15, 15, 3)).to.equal(-6); + expect(clamp(-55, -15, 15, 3)).to.equal(-15); + expect(clamp(57, -15, 15, 3)).to.equal(15); + expect(clamp(3, -20, 20, 5)).to.equal(5); + expect(clamp(2, -20, 20, 5)).to.equal(0); + expect(clamp(8, -20, 20, 5)).to.equal(10); + expect(clamp(-7, -20, 20, 5)).to.equal(-5); + }); + + it('isNumber: rejects NaN', () => { + expect(isNumber(NaN)).to.equal(false); + }); + + it('isNumber: rejects Infinity', () => { + expect(isNumber(Infinity)).to.equal(false); + expect(isNumber(-Infinity)).to.equal(false); + }); + + it('isNumber: rejects falsy values', () => { + expect(isNumber('')).to.equal(false); + expect(isNumber(undefined)).to.equal(false); + expect(isNumber(null)).to.equal(false); + }); + + it('isNumber: accepts positive and negative integers', () => { + expect(isNumber(10)).to.equal(true); + expect(isNumber(7)).to.equal(true); + expect(isNumber(-20)).to.equal(true); + expect(isNumber(-333)).to.equal(true); + }); + + it('isNumber: accepts 0', () => { + expect(isNumber(0)).to.equal(true); + expect(isNumber(-0)).to.equal(true); + }); +}); diff --git a/packages/mui-base/src/unstable_useNumberInput/utils.ts b/packages/mui-base/src/unstable_useNumberInput/utils.ts new file mode 100644 index 00000000000000..c20907be5b683a --- /dev/null +++ b/packages/mui-base/src/unstable_useNumberInput/utils.ts @@ -0,0 +1,34 @@ +function simpleClamp( + val: number, + min: number = Number.MIN_SAFE_INTEGER, + max: number = Number.MAX_SAFE_INTEGER, +): number { + return Math.max(min, Math.min(val, max)); +} + +export function clamp( + val: number, + min: number = Number.MIN_SAFE_INTEGER, + max: number = Number.MAX_SAFE_INTEGER, + stepProp: number = NaN, +): number { + if (Number.isNaN(stepProp)) { + return simpleClamp(val, min, max); + } + + const step = stepProp || 1; + + const remainder = val % step; + + const positivity = Math.sign(remainder); + + if (Math.abs(remainder) > step / 2) { + return simpleClamp(val + positivity * (step - Math.abs(remainder)), min, max); + } + + return simpleClamp(val - positivity * Math.abs(remainder), min, max); +} + +export function isNumber(val: unknown): val is number { + return typeof val === 'number' && !Number.isNaN(val) && Number.isFinite(val); +} diff --git a/packages/mui-utils/src/composeClasses/composeClasses.ts b/packages/mui-utils/src/composeClasses/composeClasses.ts index cf049e73c5d15f..259ed7a05534c3 100644 --- a/packages/mui-utils/src/composeClasses/composeClasses.ts +++ b/packages/mui-utils/src/composeClasses/composeClasses.ts @@ -6,7 +6,7 @@ export default function composeClasses( const output: Record = {} as any; Object.keys(slots).forEach( - // `Objet.keys(slots)` can't be wider than `T` because we infer `T` from `slots`. + // `Object.keys(slots)` can't be wider than `T` because we infer `T` from `slots`. // @ts-expect-error https://github.com/microsoft/TypeScript/pull/12253#issuecomment-263132208 (slot: ClassKey) => { output[slot] = slots[slot] diff --git a/yarn.lock b/yarn.lock index c8a83c3ce29895..49a89b40a7ae2d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2999,6 +2999,11 @@ "@testing-library/dom" "^9.0.0" "@types/react-dom" "^18.0.0" +"@testing-library/user-event@^14.4.3": + version "14.4.3" + resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.4.3.tgz#af975e367743fa91989cd666666aec31a8f50591" + integrity sha512-kCUc5MEwaEMakkO5x7aoD+DLi02ehmEM2QCGWvNqAS1dV/fAvORWEjnjsEIvml59M7Y5kCkWN6fCCyPOe8OL6Q== + "@theme-ui/color-modes@^0.16.0": version "0.16.0" resolved "https://registry.yarnpkg.com/@theme-ui/color-modes/-/color-modes-0.16.0.tgz#7e137b7b17be56a4620e90d9a68d6c32cc97e92e"