Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[base-ui][useNumberInput] Integrate useNumberInput with useControllableReducer #40206

Merged
merged 32 commits into from
Feb 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
03b26d8
Integrate useNumberInput with useControllableReducer
mj12albert Dec 14, 2023
8bcf895
Update demos
mj12albert Dec 14, 2023
05c6326
Fix tests and TS
mj12albert Dec 14, 2023
a6d1640
Fix proptypes
mj12albert Dec 14, 2023
1bc2022
Tweak test
mj12albert Dec 14, 2023
8c5b69b
inputValue can no longer be null
mj12albert Dec 14, 2023
34d3eaa
inputChange actions do not directly change the value
mj12albert Dec 14, 2023
4d08fca
Update tests
mj12albert Dec 14, 2023
808a36c
Fix tests
mj12albert Dec 14, 2023
836b125
Update comments
mj12albert Dec 15, 2023
0e00664
Update types
mj12albert Dec 15, 2023
58acda1
value is always directly returned by useNumberInput
mj12albert Dec 15, 2023
dea150a
Add a test
mj12albert Dec 15, 2023
0775d7b
Fix stateComparers
mj12albert Dec 15, 2023
bd8cca8
Blur only calls onChange if the value has changed
mj12albert Dec 15, 2023
04b2d0d
Tidy up imports
mj12albert Dec 15, 2023
d7bdbc5
Merge remote-tracking branch 'upstream/master' into base/number-input…
mj12albert Dec 28, 2023
a27fac0
Merge remote-tracking branch 'upstream/master' into base/number-input…
mj12albert Dec 29, 2023
57b8259
Update initial step behavior when min and max are absent
mj12albert Dec 29, 2023
de0c038
Doc update
mj12albert Dec 29, 2023
a16d67f
Add controlled mode test
mj12albert Dec 29, 2023
d400dde
Reset inputValue on focus when controlled
mj12albert Dec 29, 2023
d54da2d
Move resetInputValue to useEffect
mj12albert Dec 29, 2023
f661961
Consider value prop when setting reducer initialState
mj12albert Dec 29, 2023
e768801
Merge remote-tracking branch 'upstream/master' into base/number-input…
mj12albert Dec 29, 2023
6a892cf
Merge remote-tracking branch 'upstream/master' into base/number-input…
mj12albert Jan 2, 2024
c72faa7
Merge remote-tracking branch 'upstream/master' into base/number-input…
mj12albert Jan 11, 2024
66c2867
Merge remote-tracking branch 'upstream/master' into base/number-input…
mj12albert Jan 12, 2024
432ae2a
Merge remote-tracking branch 'upstream/master' into base/number-input…
mj12albert Jan 18, 2024
26ae3e4
Merge remote-tracking branch 'upstream/master' into base/number-input…
mj12albert Jan 26, 2024
fcdb632
Merge remote-tracking branch 'upstream/master' into base/number-input…
mj12albert Feb 2, 2024
38ae823
Merge remote-tracking branch 'upstream/master' into base/number-input…
mj12albert Feb 5, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ const NumberInput = React.forwardRef(function CustomNumberInput(props, ref) {
});

export default function NumberInputBasic() {
const [value, setValue] = React.useState();
const [value, setValue] = React.useState(null);
return (
<NumberInput
aria-label="Demo number input"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ const NumberInput = React.forwardRef(function CustomNumberInput(
});

export default function NumberInputBasic() {
const [value, setValue] = React.useState<number | undefined>();
const [value, setValue] = React.useState<number | null>(null);
return (
<NumberInput
aria-label="Demo number input"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Unstable_NumberInput as BaseNumberInput } from '@mui/base/Unstable_Numb
import clsx from 'clsx';

export default function NumberInputBasic() {
const [value, setValue] = React.useState();
const [value, setValue] = React.useState(null);
return (
<NumberInput
aria-label="Demo number input"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
import clsx from 'clsx';

export default function NumberInputBasic() {
const [value, setValue] = React.useState<number | undefined>();
const [value, setValue] = React.useState<number | null>(null);
return (
<NumberInput
aria-label="Demo number input"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Unstable_NumberInput as BaseNumberInput } from '@mui/base/Unstable_Numb
import clsx from 'clsx';

export default function NumberInputIntroduction() {
const [value, setValue] = React.useState();
const [value, setValue] = React.useState(null);
return (
<NumberInput
aria-label="Demo number input"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
import clsx from 'clsx';

export default function NumberInputIntroduction() {
const [value, setValue] = React.useState<number | undefined>();
const [value, setValue] = React.useState<number | null>(null);
return (
<NumberInput
aria-label="Demo number input"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const CompactNumberInput = React.forwardRef(function CompactNumberInput(props, r
});

export default function UseNumberInputCompact() {
const [value, setValue] = React.useState();
const [value, setValue] = React.useState(null);

return (
<Layout>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import ArrowDropUpRoundedIcon from '@mui/icons-material/ArrowDropUpRounded';
import ArrowDropDownRoundedIcon from '@mui/icons-material/ArrowDropDownRounded';

const CompactNumberInput = React.forwardRef(function CompactNumberInput(
props: Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> &
props: Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value'> &
UseNumberInputParameters,
ref: React.ForwardedRef<HTMLInputElement>,
) {
Expand Down Expand Up @@ -38,7 +38,7 @@ const CompactNumberInput = React.forwardRef(function CompactNumberInput(
});

export default function UseNumberInputCompact() {
const [value, setValue] = React.useState<number | undefined>();
const [value, setValue] = React.useState<number | null>(null);

return (
<Layout>
Expand Down
2 changes: 1 addition & 1 deletion docs/data/base/components/number-input/number-input.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ onChange: (
) => void;
```

It's called when the `<input>` element is blurred, or when the stepper buttons are clicked, after the value has been clamped based on the [`min`, `max`](#minimum-and-maximum), or [`step`](#incremental-steps) props.
It's called when the `<input>` element is blurred if the value has changed, or when the stepper buttons are clicked. This is after the value has been clamped based on the [`min`, `max`](#minimum-and-maximum), or [`step`](#incremental-steps) props.

:::info
When using the component, `onChange` can only be passed as a prop on the component—not through `slotProps`.
Expand Down
4 changes: 2 additions & 2 deletions docs/pages/base-ui/api/number-input.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"props": {
"defaultValue": { "type": { "name": "any" } },
"defaultValue": { "type": { "name": "number" } },
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

"disabled": { "type": { "name": "bool" } },
"endAdornment": { "type": { "name": "node" } },
"error": { "type": { "name": "bool" } },
Expand Down Expand Up @@ -42,7 +42,7 @@
},
"startAdornment": { "type": { "name": "node" } },
"step": { "type": { "name": "number" } },
"value": { "type": { "name": "number" } }
"value": { "type": { "name": "number" }, "default": "null" }
},
"name": "NumberInput",
"imports": [
Expand Down
25 changes: 16 additions & 9 deletions docs/pages/base-ui/api/use-number-input.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
{
"parameters": {
"defaultValue": { "type": { "name": "unknown", "description": "unknown" } },
"componentName": {
"type": { "name": "string", "description": "string" },
"default": "'useNumberInput'"
},
"defaultValue": { "type": { "name": "number | null", "description": "number | null" } },
"disabled": { "type": { "name": "boolean", "description": "boolean" } },
"error": { "type": { "name": "boolean", "description": "boolean" } },
"inputId": { "type": { "name": "string", "description": "string" } },
Expand All @@ -20,8 +24,8 @@
},
"onChange": {
"type": {
"name": "(event: React.FocusEvent&lt;HTMLInputElement&gt; | React.PointerEvent | React.KeyboardEvent, value: number | undefined) =&gt; void",
"description": "(event: React.FocusEvent&lt;HTMLInputElement&gt; | React.PointerEvent | React.KeyboardEvent, value: number | undefined) =&gt; void"
"name": "(event: React.FocusEvent&lt;HTMLInputElement&gt; | React.PointerEvent | React.KeyboardEvent, value: number | null) =&gt; void",
"description": "(event: React.FocusEvent&lt;HTMLInputElement&gt; | React.PointerEvent | React.KeyboardEvent, value: number | null) =&gt; void"
}
},
"onClick": {
Expand All @@ -40,7 +44,10 @@
"required": { "type": { "name": "boolean", "description": "boolean" } },
"shiftMultiplier": { "type": { "name": "number", "description": "number" } },
"step": { "type": { "name": "number", "description": "number" } },
"value": { "type": { "name": "number", "description": "number" } }
"value": {
"type": { "name": "number | null", "description": "number | null" },
"default": "null"
}
},
"returnValue": {
"disabled": {
Expand Down Expand Up @@ -93,10 +100,7 @@
},
"required": true
},
"inputValue": {
"type": { "name": "string | undefined", "description": "string | undefined" },
"required": true
},
"inputValue": { "type": { "name": "string", "description": "string" }, "required": true },
"isDecrementDisabled": {
"type": { "name": "boolean", "description": "boolean" },
"default": "false",
Expand All @@ -112,7 +116,10 @@
"default": "false",
"required": true
},
"value": { "type": { "name": "unknown", "description": "unknown" }, "required": true }
"value": {
"type": { "name": "number | null", "description": "number | null" },
"required": true
}
},
"name": "useNumberInput",
"filename": "/packages/mui-base/src/unstable_useNumberInput/useNumberInput.ts",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
{
"hookDescription": "",
"parametersDescriptions": {
"componentName": {
"description": "The name of the component using useNumberInput. For debugging purposes."
},
"defaultValue": {
"description": "The default value. Use when the component is not controlled."
},
Expand Down
90 changes: 74 additions & 16 deletions packages/mui-base/src/Unstable_NumberInput/NumberInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -497,32 +497,64 @@ describe('<NumberInput />', () => {
expect(input.value).to.equal('1');
});

it('sets value to min when the input has no value and ArrowUp is pressed', async () => {
const handleChange = spy();
describe('when the input has no value and ArrowUp is pressed', () => {
it('sets value to min if min is provided', async () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't quite how the native control works - it kinda assumes that no value = 0 (so if min = 0, pressing the ArrowUp sets the control to 1.

Copy link
Member Author

@mj12albert mj12albert Jan 2, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@michaldudak This was intentional ~ I think it's a worthwhile improvement from the native behavior 😬

If a range is defined with min and/or max in a cleared NumberInput, it feels like a better UX to start incrementing from min on arrow up, and start decrementing from the max on arrow down

If arrow down starts on the min value, a 2nd arrow down will do nothing, and I feel its more natural to tap the same arrow 2-3 times – e.g. increment from min to min+3, or decrement from max to max-3 – rather than "down-up-up-up"

And luckily since the native number input isn't used in the wild that much, it shouldn't be something a typical web user would need to "unlearn", what do you think?

PS: Did a quick check as well, react-aria(react-spectrum) also implemented this behavior – sandbox - but mantine and Chakra do not

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BTW I would love to get @colmtuite's opinion on this as well ~

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes I agree @mj12albert. When min or max is set, it should increment/decrement from those values imo.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All right, then. Let's get it merged!

const handleChange = spy();

const { getByRole } = render(<NumberInput min={5} onChange={handleChange} />);
const { getByRole } = render(<NumberInput min={5} onChange={handleChange} />);

const input = getByRole('textbox') as HTMLInputElement;
const input = getByRole('textbox') as HTMLInputElement;

await userEvent.click(input);
await userEvent.keyboard('[ArrowUp]');
await userEvent.click(input);
await userEvent.keyboard('[ArrowUp]');

expect(handleChange.args[0][1]).to.equal(5);
expect(input.value).to.equal('5');
});

it('sets value to 1 if min is not provided', async () => {
const handleChange = spy();

const { getByRole } = render(<NumberInput onChange={handleChange} />);

expect(handleChange.args[0][1]).to.equal(5);
expect(input.value).to.equal('5');
const input = getByRole('textbox') as HTMLInputElement;

await userEvent.click(input);
await userEvent.keyboard('[ArrowUp]');

expect(handleChange.args[0][1]).to.equal(1);
expect(input.value).to.equal('1');
});
});

it('sets value to max when the input has no value and ArrowDown is pressed', async () => {
const handleChange = spy();
describe('when the input has no value and ArrowDown is pressed', () => {
it('sets value to max when max is provided', async () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't match the native behavior either - the min or -1 value should be used here (see the first demo in https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/number)

const handleChange = spy();

const { getByRole } = render(<NumberInput max={9} onChange={handleChange} />);
const { getByRole } = render(<NumberInput max={9} onChange={handleChange} />);

const input = getByRole('textbox') as HTMLInputElement;
const input = getByRole('textbox') as HTMLInputElement;

await userEvent.click(input);
await userEvent.keyboard('[ArrowDown]');
await userEvent.click(input);
await userEvent.keyboard('[ArrowDown]');

expect(handleChange.args[0][1]).to.equal(9);
expect(input.value).to.equal('9');
expect(handleChange.args[0][1]).to.equal(9);
expect(input.value).to.equal('9');
});

it('sets value to -1 when max is not provided', async () => {
const handleChange = spy();

const { getByRole } = render(<NumberInput onChange={handleChange} />);

const input = getByRole('textbox') as HTMLInputElement;

await userEvent.click(input);
await userEvent.keyboard('[ArrowDown]');

expect(handleChange.args[0][1]).to.equal(-1);
expect(input.value).to.equal('-1');
});
});

it('only includes the input element in the tab order', async () => {
Expand Down Expand Up @@ -556,4 +588,30 @@ describe('<NumberInput />', () => {
expect(getByTestId('adornment')).not.to.equal(null);
});
});

it('Should update NumberInput value when value prop is set through side effects', async () => {
function App() {
const [value, setValue] = React.useState(10);

return (
<div>
<button data-testid="button" onClick={() => setValue(20)}>
Enable
</button>
<NumberInput value={value} />
</div>
);
}
const { getByRole, getByTestId } = render(<App />);

const input = getByRole('textbox') as HTMLInputElement;
const button = getByTestId('button') as HTMLButtonElement;

act(() => {
fireEvent.click(button);
});

await userEvent.click(input);
expect(input.value).to.equal('20');
});
});
4 changes: 3 additions & 1 deletion packages/mui-base/src/Unstable_NumberInput/NumberInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ const NumberInput = React.forwardRef(function NumberInput(
readOnly,
value,
inputId: id,
componentName: 'NumberInput',
});

const ownerState: NumberInputOwnerState = {
Expand Down Expand Up @@ -204,7 +205,7 @@ NumberInput.propTypes /* remove-proptypes */ = {
/**
* The default value. Use when the component is not controlled.
*/
defaultValue: PropTypes.any,
defaultValue: PropTypes.number,
/**
* If `true`, the component is disabled.
* The prop defaults to the value (`false`) inherited from the parent FormControl component.
Expand Down Expand Up @@ -306,6 +307,7 @@ NumberInput.propTypes /* remove-proptypes */ = {
step: PropTypes.number,
/**
* The current value. Use when the component is controlled.
* @default null
*/
value: PropTypes.number,
} as any;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,34 +5,45 @@ export const NumberInputActionTypes = {
decrement: 'numberInput:decrement',
decrementToMin: 'numberInput:decrementToMin',
incrementToMax: 'numberInput:incrementToMax',
resetInputValue: 'numberInput:resetInputValue',
} as const;

interface NumberInputClampAction {
type: typeof NumberInputActionTypes.clamp;
event: React.FocusEvent<HTMLInputElement>;
inputValue: string;
}

interface NumberInputInputChangeAction {
type: typeof NumberInputActionTypes.inputChange;
event: React.ChangeEvent<HTMLInputElement>;
inputValue: string;
}

interface NumberInputIncrementAction {
type: typeof NumberInputActionTypes.increment;
event: React.PointerEvent | React.KeyboardEvent;
applyMultiplier: boolean;
}

interface NumberInputDecrementAction {
type: typeof NumberInputActionTypes.decrement;
event: React.PointerEvent | React.KeyboardEvent;
applyMultiplier: boolean;
}

interface NumberInputIncrementToMaxAction {
type: typeof NumberInputActionTypes.incrementToMax;
event: React.KeyboardEvent;
}

interface NumberInputDecrementToMinAction {
type: typeof NumberInputActionTypes.decrementToMin;
event: React.KeyboardEvent;
}

interface NumberInputResetInputValueAction {
type: typeof NumberInputActionTypes.resetInputValue;
}

export type NumberInputAction =
Expand All @@ -41,4 +52,5 @@ export type NumberInputAction =
| NumberInputIncrementAction
| NumberInputDecrementAction
| NumberInputIncrementToMaxAction
| NumberInputDecrementToMinAction;
| NumberInputDecrementToMinAction
| NumberInputResetInputValueAction;
Loading
Loading