diff --git a/docs/data/base/components/number-input/UnstyledNumberInputIntroduction.js b/docs/data/base/components/number-input/UnstyledNumberInputIntroduction.js new file mode 100644 index 00000000000000..72eee5960b8715 --- /dev/null +++ b/docs/data/base/components/number-input/UnstyledNumberInputIntroduction.js @@ -0,0 +1,70 @@ +import * as React from 'react'; +import NumberInputUnstyled from '@mui/base/NumberInputUnstyled'; +import { styled } from '@mui/system'; + +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 StyledInputElement = styled('input')( + ({ theme }) => ` + width: 320px; + font-family: IBM Plex Sans, sans-serif; + font-size: 0.875rem; + font-weight: 400; + line-height: 1.5; + padding: 12px; + border-radius: 12px; + 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 4px 30px ${theme.palette.mode === 'dark' ? grey[900] : grey[200]}; + + &:hover { + border-color: ${blue[400]}; + } + + &:focus { + border-color: ${blue[400]}; + box-shadow: 0 0 0 3px ${theme.palette.mode === 'dark' ? blue[500] : blue[200]}; + } + + // firefox + &:focus-visible { + outline: 0; + } +`, +); + +const CustomNumberInput = React.forwardRef(function CustomNumberInput(props, ref) { + return ( + + ); +}); + +export default function UnstyledNumberInputIntroduction() { + return ( + + ); +} diff --git a/docs/data/base/components/number-input/UnstyledNumberInputIntroduction.tsx b/docs/data/base/components/number-input/UnstyledNumberInputIntroduction.tsx new file mode 100644 index 00000000000000..11f3be865d0716 --- /dev/null +++ b/docs/data/base/components/number-input/UnstyledNumberInputIntroduction.tsx @@ -0,0 +1,75 @@ +import * as React from 'react'; +import NumberInputUnstyled, { + NumberInputUnstyledProps, +} from '@mui/base/NumberInputUnstyled'; +import { styled } from '@mui/system'; + +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 StyledInputElement = styled('input')( + ({ theme }) => ` + width: 320px; + font-family: IBM Plex Sans, sans-serif; + font-size: 0.875rem; + font-weight: 400; + line-height: 1.5; + padding: 12px; + border-radius: 12px; + 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 4px 30px ${theme.palette.mode === 'dark' ? grey[900] : grey[200]}; + + &:hover { + border-color: ${blue[400]}; + } + + &:focus { + border-color: ${blue[400]}; + box-shadow: 0 0 0 3px ${theme.palette.mode === 'dark' ? blue[500] : blue[200]}; + } + + // firefox + &:focus-visible { + outline: 0; + } +`, +); + +const CustomNumberInput = React.forwardRef(function CustomNumberInput( + props: NumberInputUnstyledProps, + ref: React.ForwardedRef, +) { + return ( + + ); +}); + +export default function UnstyledNumberInputIntroduction() { + return ( + + ); +} diff --git a/docs/data/base/components/number-input/UnstyledNumberInputIntroduction.tsx.preview b/docs/data/base/components/number-input/UnstyledNumberInputIntroduction.tsx.preview new file mode 100644 index 00000000000000..fa6c5c5dc81d85 --- /dev/null +++ b/docs/data/base/components/number-input/UnstyledNumberInputIntroduction.tsx.preview @@ -0,0 +1 @@ + \ 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..674444d1ce4f68 --- /dev/null +++ b/docs/data/base/components/number-input/number-input.md @@ -0,0 +1,33 @@ +--- +product: base +title: Unstyled React Number Input component and hook +components: NumberInputUnstyled +# hooks: useNumberInput +githubLabel: 'component: NumberInput' +--- + +# Unstyled Number Input + +

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

+ +## Introduction + +🚧 + +{{"demo": "UnstyledNumberInputIntroduction.js", "defaultCodeOpen": false, "bg": "gradient"}} + +{{"component": "modules/components/ComponentLinkHeader.js", "design": false}} + +## Component + +### Usage + +After [installation](/base/getting-started/installation/), you can start building with this component using the following basic elements: + +```jsx +import NumberInputUnstyled from '@mui/base/NumberInputUnstyled'; + +export default function MyApp() { + return ; +} +``` diff --git a/docs/data/base/pages.ts b/docs/data/base/pages.ts index f872eb02102f38..d60ff299037bca 100644 --- a/docs/data/base/pages.ts +++ b/docs/data/base/pages.ts @@ -22,6 +22,7 @@ const pages = [ children: [ { pathname: '/base/react-button', title: 'Button' }, { pathname: '/base/react-input', title: 'Input' }, + { pathname: '/base/react-number-input', title: 'Number Input' }, { pathname: '/base/react-select', title: 'Select' }, { pathname: '/base/react-slider', title: 'Slider' }, { pathname: '/base/react-switch', title: 'Switch' }, diff --git a/docs/data/base/pagesApi.js b/docs/data/base/pagesApi.js index 57d013f9b79d5a..82860269b065d1 100644 --- a/docs/data/base/pagesApi.js +++ b/docs/data/base/pagesApi.js @@ -10,6 +10,7 @@ module.exports = [ { pathname: '/base/api/modal-unstyled' }, { pathname: '/base/api/multi-select-unstyled' }, { pathname: '/base/api/no-ssr' }, + { pathname: '/base/api/number-input-unstyled' }, { pathname: '/base/api/option-group-unstyled' }, { pathname: '/base/api/option-unstyled' }, { pathname: '/base/api/popper-unstyled' }, @@ -31,6 +32,7 @@ module.exports = [ { pathname: '/base/api/use-input' }, { pathname: '/base/api/use-menu' }, { pathname: '/base/api/use-menu-item' }, + { pathname: '/base/api/use-number-input' }, { pathname: '/base/api/use-option' }, { pathname: '/base/api/use-select' }, { pathname: '/base/api/use-slider' }, diff --git a/docs/pages/base/api/number-input-unstyled.js b/docs/pages/base/api/number-input-unstyled.js new file mode 100644 index 00000000000000..59c20cee5d43a2 --- /dev/null +++ b/docs/pages/base/api/number-input-unstyled.js @@ -0,0 +1,23 @@ +import * as React from 'react'; +import ApiPage from 'docs/src/modules/components/ApiPage'; +import mapApiPageTranslations from 'docs/src/modules/utils/mapApiPageTranslations'; +import jsonPageContent from './number-input-unstyled.json'; + +export default function Page(props) { + const { descriptions, pageContent } = props; + return ; +} + +Page.getInitialProps = () => { + const req = require.context( + 'docs/translations/api-docs/number-input-unstyled', + false, + /number-input-unstyled.*.json$/, + ); + const descriptions = mapApiPageTranslations(req); + + return { + descriptions, + pageContent: jsonPageContent, + }; +}; diff --git a/docs/pages/base/api/number-input-unstyled.json b/docs/pages/base/api/number-input-unstyled.json new file mode 100644 index 00000000000000..4565fdb962c3cd --- /dev/null +++ b/docs/pages/base/api/number-input-unstyled.json @@ -0,0 +1,20 @@ +{ + "props": { + "id": { "type": { "name": "string" } }, + "slotProps": { + "type": { "name": "shape", "description": "{ input?: func
| object }" }, + "default": "{}" + }, + "slots": { + "type": { "name": "shape", "description": "{ input?: elementType }" }, + "default": "{}" + } + }, + "name": "NumberInputUnstyled", + "styles": { "classes": [], "globalClasses": {}, "name": null }, + "spread": true, + "filename": "/packages/mui-base/src/NumberInputUnstyled/NumberInputUnstyled.tsx", + "inheritance": null, + "demos": "", + "cssComponent": false +} diff --git a/docs/pages/base/api/use-number-input.js b/docs/pages/base/api/use-number-input.js new file mode 100644 index 00000000000000..31fcb1734d4d62 --- /dev/null +++ b/docs/pages/base/api/use-number-input.js @@ -0,0 +1,23 @@ +import * as React from 'react'; +import HookApiPage from 'docs/src/modules/components/HookApiPage'; +import mapApiPageTranslations from 'docs/src/modules/utils/mapApiPageTranslations'; +import jsonPageContent from './use-number-input.json'; + +export default function Page(props) { + const { descriptions, pageContent } = props; + return ; +} + +Page.getInitialProps = () => { + const req = require.context( + 'docs/translations/api-docs/use-number-input', + false, + /use-number-input.*.json$/, + ); + const descriptions = mapApiPageTranslations(req); + + return { + descriptions, + pageContent: jsonPageContent, + }; +}; diff --git a/docs/pages/base/api/use-number-input.json b/docs/pages/base/api/use-number-input.json new file mode 100644 index 00000000000000..03660b232c11c5 --- /dev/null +++ b/docs/pages/base/api/use-number-input.json @@ -0,0 +1,37 @@ +{ + "parameters": { + "defaultValue": { "type": { "name": "unknown", "description": "unknown" } }, + "disabled": { "type": { "name": "boolean", "description": "boolean" } }, + "error": { "type": { "name": "boolean", "description": "boolean" } }, + "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": "React.FocusEventHandler", "description": "React.FocusEventHandler" } + }, + "onChange": { + "type": { + "name": "UseNumberInputChangeHandler", + "description": "UseNumberInputChangeHandler" + } + }, + "onClick": { + "type": { "name": "React.MouseEventHandler", "description": "React.MouseEventHandler" } + }, + "onFocus": { + "type": { "name": "React.FocusEventHandler", "description": "React.FocusEventHandler" } + }, + "required": { "type": { "name": "boolean", "description": "boolean" } }, + "step": { "type": { "name": "number", "description": "number" } }, + "value": { "type": { "name": "unknown", "description": "unknown" } } + }, + "returnValue": {}, + "name": "useNumberInput", + "filename": "/packages/mui-base/src/NumberInputUnstyled/useNumberInput.ts", + "demos": "
    " +} diff --git a/docs/pages/base/react-number-input.js b/docs/pages/base/react-number-input.js new file mode 100644 index 00000000000000..01bef2bfa30c7d --- /dev/null +++ b/docs/pages/base/react-number-input.js @@ -0,0 +1,7 @@ +import * as React from 'react'; +import MarkdownDocs from 'docs/src/modules/components/MarkdownDocs'; +import * as pageProps from 'docs/data/base/components/number-input/number-input.md?@mui/markdown'; + +export default function Page() { + return ; +} diff --git a/docs/translations/api-docs/number-input-unstyled/number-input-unstyled.json b/docs/translations/api-docs/number-input-unstyled/number-input-unstyled.json new file mode 100644 index 00000000000000..d271db44327172 --- /dev/null +++ b/docs/translations/api-docs/number-input-unstyled/number-input-unstyled.json @@ -0,0 +1,9 @@ +{ + "componentDescription": "", + "propDescriptions": { + "id": "The id of the input element.", + "slotProps": "The props used for each slot inside the Input.", + "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." + }, + "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..73535952943abe --- /dev/null +++ b/docs/translations/api-docs/use-number-input/use-number-input.json @@ -0,0 +1,10 @@ +{ + "hookDescription": "", + "parametersDescriptions": { + "defaultValue": "The default value. Use when the component is not controlled.", + "disabled": "If true, the component is disabled.\nThe 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.\nThe prop defaults to the value (false) inherited from the parent FormControl component.", + "required": "If true, the input element is required.\nThe prop defaults to the value (false) inherited from the parent FormControl component." + }, + "returnValueDescriptions": {} +} diff --git a/packages/mui-base/src/FormControlUnstyled/FormControlUnstyled.tsx b/packages/mui-base/src/FormControlUnstyled/FormControlUnstyled.tsx index 13468bcb940ad2..433e54c42c3123 100644 --- a/packages/mui-base/src/FormControlUnstyled/FormControlUnstyled.tsx +++ b/packages/mui-base/src/FormControlUnstyled/FormControlUnstyled.tsx @@ -121,6 +121,15 @@ const FormControlUnstyled = React.forwardRef(function FormControlUnstyled< setValue(event.target.value); onChange?.(event); }, + // TODO: make it like this? to work with SelectUnstyled as well + // onChange: (event: React.ChangeEvent, valueTwo?: unknown) => { + // console.group('FormControlUnstyledContext onChange'); + // console.log(event); + // console.log('valueTwo', valueTwo); + // console.groupEnd(); + // setValue(valueTwo ?? event.target.value); + // onChange?.(event, valueTwo); + // }, onFocus: () => { setFocused(true); }, diff --git a/packages/mui-base/src/NumberInputUnstyled/NumberInputUnstyled.test.tsx b/packages/mui-base/src/NumberInputUnstyled/NumberInputUnstyled.test.tsx new file mode 100644 index 00000000000000..19b6c06d501a72 --- /dev/null +++ b/packages/mui-base/src/NumberInputUnstyled/NumberInputUnstyled.test.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; +import { /* createMount, */ createRenderer /* , describeConformanceUnstyled */ } from 'test/utils'; +import { expect } from 'chai'; +import NumberInputUnstyled from '@mui/base/NumberInputUnstyled'; + +describe('', () => { + // const mount = createMount(); + const { render } = createRenderer(); + + // TODO: wow this looks complicated + // describeConformanceUnstyled(, () => ({ + // inheritComponent: 'div', + // render, + // mount, + // refInstanceof: window.HTMLDivElement, + // testComponentPropWith: 'div', + // muiName: 'MuiInput', + // slots: { + // root: { + // expectedClassName: '', + // }, + // input: { + // expectedClassName: '', + // testWithElement: 'input', + // }, + // }, + // })); + + it('should be able to attach input ref passed through props', () => { + const inputRef = React.createRef(); + const { getByRole } = render(); + + expect(inputRef.current).to.deep.equal(getByRole('spinbutton')); + }); +}); diff --git a/packages/mui-base/src/NumberInputUnstyled/NumberInputUnstyled.tsx b/packages/mui-base/src/NumberInputUnstyled/NumberInputUnstyled.tsx new file mode 100644 index 00000000000000..b3dd016a5ecf01 --- /dev/null +++ b/packages/mui-base/src/NumberInputUnstyled/NumberInputUnstyled.tsx @@ -0,0 +1,89 @@ +import * as React from 'react'; +import PropTypes from 'prop-types'; +import useNumberInput from './useNumberInput'; +import { + NumberInputUnstyledProps, + NumberInputUnstyledOwnerState, +} from './NumberInputUnstyled.types'; +import { EventHandlers, useSlotProps } from '../utils'; +/** + * + * Demos: + * + * - [hooks: useNumberInput](https://mui.com/base/react-number-input/) + * + * API: + * + * - [NumberInputUnstyled API](https://mui.com/base/api/number-input-unstyled/) + */ +const NumberInputUnstyled = React.forwardRef(function NumberInputUnstyled( + props: NumberInputUnstyledProps, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + forwardedRef: // TODO: this is for the root slot later + React.ForwardedRef, +) { + // set up ALL the props + const { id, slotProps = {}, slots = {} } = props; + + const { getInputProps } = useNumberInput(props); + + const ownerState: NumberInputUnstyledOwnerState = { + ...props, + type: 'text', + }; + + const propsToForward = { + id, + }; + + // define the root slot + + // root -> useSlotProps + + // define the input slot + const Input = slots.input ?? 'input'; + + const inputProps = useSlotProps({ + elementType: Input, + getSlotProps: (otherHandlers: EventHandlers) => + getInputProps({ ...otherHandlers, ...propsToForward }), + externalSlotProps: slotProps.input, + additionalProps: {}, + ownerState, + }); + + // input -> useSlotProps + return ; +}); + +NumberInputUnstyled.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, + /** + * The id of the `input` element. + */ + id: PropTypes.string, + /** + * The props used for each slot inside the Input. + * @default {} + */ + slotProps: PropTypes.shape({ + input: 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({ + input: PropTypes.elementType, + }), +} as any; + +export default NumberInputUnstyled; diff --git a/packages/mui-base/src/NumberInputUnstyled/NumberInputUnstyled.types.ts b/packages/mui-base/src/NumberInputUnstyled/NumberInputUnstyled.types.ts new file mode 100644 index 00000000000000..5f0822a84cc2d1 --- /dev/null +++ b/packages/mui-base/src/NumberInputUnstyled/NumberInputUnstyled.types.ts @@ -0,0 +1,52 @@ +import { OverrideProps, Simplify } from '@mui/types'; +import { UseNumberInputParameters } from './useNumberInput.types'; +import { SlotComponentProps } from '../utils'; + +export interface NumberInputUnstyledComponentsPropsOverrides {} + +export type NumberInputUnstyledOwnProps = {} & Omit & { + /** + * The id of the `input` element. + */ + id?: string; + /** + * The props used for each slot inside the Input. + * @default {} + */ + slotProps?: { + input?: SlotComponentProps< + 'input', + NumberInputUnstyledComponentsPropsOverrides, + NumberInputUnstyledOwnerState + >; + }; + /** + * 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; + }; + }; + +export interface NumberInputUnstyledTypeMap

    { + props: P & NumberInputUnstyledOwnProps; + defaultComponent: D; +} + +export type NumberInputUnstyledProps< + D extends React.ElementType = NumberInputUnstyledTypeMap['defaultComponent'], + P = {}, +> = OverrideProps, D> & { + component?: D; +}; + +export type NumberInputUnstyledOwnerState = Simplify< + Omit & { + // formControlContext: FormControlUnstyledState | undefined; + // focused: boolean; + type: React.InputHTMLAttributes['type'] | undefined; + } +>; diff --git a/packages/mui-base/src/NumberInputUnstyled/clamp.test.ts b/packages/mui-base/src/NumberInputUnstyled/clamp.test.ts new file mode 100644 index 00000000000000..7c9781b863eb01 --- /dev/null +++ b/packages/mui-base/src/NumberInputUnstyled/clamp.test.ts @@ -0,0 +1,23 @@ +import { expect } from 'chai'; +import clamp from './clamp'; + +describe('clamp', () => { + it('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('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); + }); +}); diff --git a/packages/mui-base/src/NumberInputUnstyled/clamp.ts b/packages/mui-base/src/NumberInputUnstyled/clamp.ts new file mode 100644 index 00000000000000..c96c80eddcad2c --- /dev/null +++ b/packages/mui-base/src/NumberInputUnstyled/clamp.ts @@ -0,0 +1,30 @@ +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 default 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); +} diff --git a/packages/mui-base/src/NumberInputUnstyled/index.ts b/packages/mui-base/src/NumberInputUnstyled/index.ts new file mode 100644 index 00000000000000..0fc14b5ffc9d2e --- /dev/null +++ b/packages/mui-base/src/NumberInputUnstyled/index.ts @@ -0,0 +1,7 @@ +export { default } from './NumberInputUnstyled'; + +export * from './NumberInputUnstyled.types'; + +export { default as useNumberInput } from './useNumberInput'; + +export * from './useNumberInput.types'; diff --git a/packages/mui-base/src/NumberInputUnstyled/useNumberInput.test.tsx b/packages/mui-base/src/NumberInputUnstyled/useNumberInput.test.tsx new file mode 100644 index 00000000000000..8740cf9d7c363e --- /dev/null +++ b/packages/mui-base/src/NumberInputUnstyled/useNumberInput.test.tsx @@ -0,0 +1,59 @@ +import { expect } from 'chai'; +import { spy } from 'sinon'; +import * as React from 'react'; +import { createRenderer, screen, act, fireEvent } 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!; + }; + + describe('getInputProps', () => { + it('should include the incoming uncontrolled props in the output', () => { + const props: UseNumberInputParameters = { + defaultValue: 100, + disabled: true, + required: true, + }; + + const { getInputProps } = invokeUseNumberInput(props); + const inputProps = getInputProps(); + + expect(inputProps.defaultValue).to.equal(100); + expect(inputProps.required).to.equal(true); + }); + + it('should call onChange if a change event is fired', () => { + const handleChange = spy(); + function NumberInput() { + const { getInputProps } = useNumberInput({ onChange: handleChange }); + + // TODO: how to make accept my custom onChange ?! + // @ts-ignore + return ; + } + render(); + + const input = screen.getByRole('spinbutton'); + + act(() => { + input.focus(); + fireEvent.change(document.activeElement!, { target: { value: 2 } }); + input.blur(); + }); + + expect(handleChange.callCount).to.equal(1); + }); + }); +}); diff --git a/packages/mui-base/src/NumberInputUnstyled/useNumberInput.ts b/packages/mui-base/src/NumberInputUnstyled/useNumberInput.ts new file mode 100644 index 00000000000000..98d44eafa476b2 --- /dev/null +++ b/packages/mui-base/src/NumberInputUnstyled/useNumberInput.ts @@ -0,0 +1,229 @@ +import * as React from 'react'; +import MuiError from '@mui/utils/macros/MuiError.macro'; +import { + // unstable_useControlled as useControlled, // TODO: do I need this? + unstable_useForkRef as useForkRef, +} from '@mui/utils'; +import { FormControlUnstyledState, useFormControlUnstyledContext } from '../FormControlUnstyled'; +import { + UseNumberInputParameters, + UseNumberInputInputSlotProps, + UseNumberInputChangeHandler, +} from './useNumberInput.types'; +import clamp from './clamp'; +import extractEventHandlers from '../utils/extractEventHandlers'; + +type EventHandlers = { + onBlur?: React.FocusEventHandler; + onChange?: UseNumberInputChangeHandler; + onFocus?: React.FocusEventHandler; +}; + +// TODO +// 1 - make a proper parser +// 2 - accept a parser (func) prop +const parseInput = (v: string): string => { + return v ? String(v.trim()) : String(v); +}; +/** + * + * API: + * + * - [useNumberInput API](https://mui.com/base/api/use-number-input/) + */ +export default function useNumberInput(parameters: UseNumberInputParameters) { + const { + // number + min, + max, + step, + // + defaultValue: defaultValueProp, + disabled: disabledProp = false, + error: errorProp = false, + onFocus, + onChange, + onBlur, + required: requiredProp = false, + value: valueProp, + inputRef: inputRefProp, + } = parameters; + + // TODO: make it work with FormControl + const formControlContext: FormControlUnstyledState | undefined = useFormControlUnstyledContext(); + + 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 [focused, setFocused] = React.useState(false); + + // the "final" value + const [value, setValue] = React.useState(valueProp ?? defaultValueProp); + // the (potentially) dirty or invalid input value + const [inputValue, setInputValue] = React.useState(undefined); + + React.useEffect(() => { + if (!formControlContext && disabledProp && focused) { + setFocused(false); + + // @ts-ignore + onBlur?.(); + } + }, [formControlContext, disabledProp, focused, onBlur]); + + const handleFocus = + (otherHandlers: EventHandlers) => (event: React.FocusEvent) => { + // Fix a bug with IE11 where the focus/blur events are triggered + // while the component is disabled. + if (formControlContext && formControlContext?.disabled) { + event.stopPropagation(); + return; + } + + otherHandlers.onFocus?.(event); + + if (formControlContext && formControlContext.onFocus) { + formControlContext?.onFocus?.(); + } + setFocused(true); + }; + + const handleChange = + (otherHandlers: EventHandlers) => + (event: React.FocusEvent, val: number | undefined) => { + // 1. clamp the number + // 2. setInputValue(clamped_value) + // 3. call onChange(event, returnValue) + + // console.log('handleChange', val); + + let newValue; + + if (val === undefined) { + newValue = val; + setInputValue(''); + } else { + newValue = clamp(val, min, max, step); + setInputValue(String(newValue)); + } + + setValue(newValue); + + formControlContext?.onChange?.(event /* newValue */); + // TODO: pass an (optional) "newValue" to formControlContext.onChange, this will make FormControlUnstyled work with SelectUnstyled too + + // @ts-ignore + otherHandlers.onChange?.(event, newValue); + }; + + const handleInputChange = () => (event: React.KeyboardEvent) => { + if (!isControlled) { + const element = event.target || inputRef.current; + if (element == 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.', + ); + } + } + + const val = parseInput(event.currentTarget.value); + + if (val === '' || val === '-') { + setInputValue(val); + setValue(undefined); + } + + if (val.match(/^-?\d+?$/)) { + setInputValue(val); + setValue(parseInt(val, 10)); + } + }; + + const handleBlur = + (otherHandlers: EventHandlers) => (event: React.FocusEvent) => { + const val = parseInput(event.currentTarget.value); + + if (val === '' || val === '-') { + handleChange(otherHandlers)(event, undefined); + } else { + handleChange(otherHandlers)(event, parseInt(val, 10)); + } + + otherHandlers.onBlur?.(event); + + if (formControlContext && formControlContext.onBlur) { + formControlContext.onBlur(); + } + + setFocused(false); + }; + + const getInputProps = = {}>( + externalProps: TOther = {} as TOther, + ): UseNumberInputInputSlotProps => { + const propsEventHandlers: EventHandlers = { + onBlur, + onChange, + onFocus, + }; + + const externalEventHandlers = { ...propsEventHandlers, ...extractEventHandlers(externalProps) }; + + const mergedEventHandlers = { + ...externalProps, + ...externalEventHandlers, + onFocus: handleFocus(externalEventHandlers), + // TODO: will I ever need the other handlers? + onChange: handleInputChange(/* externalEventHandlers */), + onBlur: handleBlur(externalEventHandlers), + }; + + return { + ...mergedEventHandlers, + // TODO: check to see if SR support is still weird + role: 'spinbutton', + defaultValue: defaultValueProp as string | number | readonly string[] | undefined, + ref: handleInputRef, + value: ((focused ? inputValue : value) ?? '') as + | string + | number + | readonly string[] + | undefined, + required: requiredProp, + disabled: disabledProp, + }; + }; + + return { + disabled: disabledProp, + error: errorProp, + focused, + formControlContext, + getInputProps, + // getIncrementButtonProps, + // getDecrementButtonProps, + // getRootProps, + required: requiredProp, + value: focused ? inputValue : value, + // private and could be thrown out later + inputValue, + }; +} diff --git a/packages/mui-base/src/NumberInputUnstyled/useNumberInput.types.ts b/packages/mui-base/src/NumberInputUnstyled/useNumberInput.types.ts new file mode 100644 index 00000000000000..039d5ce60fa670 --- /dev/null +++ b/packages/mui-base/src/NumberInputUnstyled/useNumberInput.types.ts @@ -0,0 +1,55 @@ +import * as React from 'react'; + +export type UseNumberInputChangeHandler = ( + e: React.KeyboardEvent, + value: number | null, +) => void; + +export interface UseNumberInputParameters { + // props for number specific features + min?: number; + max?: number; + step?: 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?: React.FocusEventHandler; + onClick?: React.MouseEventHandler; + onChange?: UseNumberInputChangeHandler; + onFocus?: React.FocusEventHandler; + 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; + value?: unknown; +} + +export interface UseNumberInputInputSlotOwnProps { + defaultValue: string | number | readonly string[] | undefined; + ref: React.Ref; + value: string | number | readonly string[] | undefined; + onBlur: React.FocusEventHandler; + onChange: UseNumberInputChangeHandler; + onFocus: React.FocusEventHandler; + required: boolean; + disabled: boolean; +} + +export type UseNumberInputInputSlotProps = Omit< + TOther, + keyof UseNumberInputInputSlotOwnProps +> & + UseNumberInputInputSlotOwnProps; diff --git a/packages/mui-base/src/index.d.ts b/packages/mui-base/src/index.d.ts index f36f6fd15b9760..b0ce4e5507f5c0 100644 --- a/packages/mui-base/src/index.d.ts +++ b/packages/mui-base/src/index.d.ts @@ -35,6 +35,9 @@ export * from './MultiSelectUnstyled'; export { default as NoSsr } from './NoSsr'; +export { default as NumberInputUnstyled } from './NumberInputUnstyled'; +export * from './NumberInputUnstyled'; + export { default as OptionGroupUnstyled } from './OptionGroupUnstyled'; export * from './OptionGroupUnstyled'; diff --git a/packages/mui-base/src/index.js b/packages/mui-base/src/index.js index 139e489e6a6a41..436ca486a9a65b 100644 --- a/packages/mui-base/src/index.js +++ b/packages/mui-base/src/index.js @@ -32,6 +32,9 @@ export * from './MultiSelectUnstyled'; export { default as NoSsr } from './NoSsr'; +export { default as NumberInputUnstyled } from './NumberInputUnstyled'; +export * from './NumberInputUnstyled'; + export { default as OptionGroupUnstyled } from './OptionGroupUnstyled'; export * from './OptionGroupUnstyled';