Skip to content

Commit 2a0d9ba

Browse files
WesSouzaarturbien
authored andcommitted
chore(usecontrolledoruncontrolled): emulate React's uncontrolled warn if value is set without onChange or readOnly
1 parent 2177c67 commit 2a0d9ba

File tree

7 files changed

+83
-32
lines changed

7 files changed

+83
-32
lines changed

src/Checkbox/Checkbox.tsx

+4-2
Original file line numberDiff line numberDiff line change
@@ -207,8 +207,10 @@ const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
207207
ref
208208
) => {
209209
const [state, setState] = useControlledOrUncontrolled({
210-
value: checked,
211-
defaultValue: defaultChecked
210+
defaultValue: defaultChecked,
211+
onChange,
212+
readOnly: otherProps.readOnly ?? disabled,
213+
value: checked
212214
});
213215

214216
const handleChange = useCallback(

src/ColorInput/ColorInput.tsx

+4-2
Original file line numberDiff line numberDiff line change
@@ -128,8 +128,10 @@ const ColorInput = forwardRef<HTMLInputElement, ColorInputProps>(
128128
ref
129129
) => {
130130
const [valueDerived, setValueState] = useControlledOrUncontrolled({
131-
value,
132-
defaultValue
131+
defaultValue,
132+
onChange,
133+
readOnly: otherProps.readOnly ?? disabled,
134+
value
133135
});
134136

135137
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {

src/NumberInput/NumberInput.tsx

+9-4
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ type NumberInputProps = {
1414
disabled?: boolean;
1515
max?: number;
1616
min?: number;
17+
readOnly?: boolean;
1718
step?: number;
1819
onChange?: (newValue: number) => void;
1920
style?: React.CSSProperties;
@@ -105,6 +106,7 @@ const NumberInput = forwardRef<HTMLInputElement, NumberInputProps>(
105106
max,
106107
min,
107108
onChange,
109+
readOnly,
108110
step = 1,
109111
style,
110112
value,
@@ -114,8 +116,10 @@ const NumberInput = forwardRef<HTMLInputElement, NumberInputProps>(
114116
ref
115117
) => {
116118
const [valueDerived, setValueState] = useControlledOrUncontrolled({
117-
value,
118-
defaultValue
119+
defaultValue,
120+
onChange,
121+
readOnly,
122+
value
119123
});
120124

121125
const handleInputChange = useCallback(
@@ -169,6 +173,7 @@ const NumberInput = forwardRef<HTMLInputElement, NumberInputProps>(
169173
onChange={handleInputChange}
170174
disabled={disabled}
171175
type='number'
176+
readOnly={readOnly}
172177
ref={ref}
173178
fullWidth
174179
onBlur={onBlur}
@@ -177,15 +182,15 @@ const NumberInput = forwardRef<HTMLInputElement, NumberInputProps>(
177182
<StyledButton
178183
data-testid='increment'
179184
variant={variant}
180-
disabled={disabled}
185+
disabled={disabled || readOnly}
181186
onClick={stepUp}
182187
>
183188
<StyledButtonIcon invert />
184189
</StyledButton>
185190
<StyledButton
186191
data-testid='decrement'
187192
variant={variant}
188-
disabled={disabled}
193+
disabled={disabled || readOnly}
189194
onClick={stepDown}
190195
>
191196
<StyledButtonIcon />

src/Slider/Slider.spec.tsx

+28-17
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,20 @@ function createTouches(
1414
}
1515

1616
describe('<Slider />', () => {
17+
beforeAll(() => {
18+
jest
19+
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
20+
.mockImplementation(
21+
() =>
22+
({
23+
width: 100,
24+
height: 20,
25+
bottom: 20,
26+
left: 0
27+
} as DOMRect)
28+
);
29+
});
30+
1731
it('should call handlers', () => {
1832
const handleChange = jest.fn();
1933
const handleChangeCommitted = jest.fn();
@@ -76,7 +90,7 @@ describe('<Slider />', () => {
7690
});
7791

7892
it('defaults to horizontal orientation', () => {
79-
const { getByRole } = renderWithTheme(<Slider value={0} />);
93+
const { getByRole } = renderWithTheme(<Slider defaultValue={0} />);
8094

8195
expect(getByRole('slider')).toHaveAttribute(
8296
'aria-orientation',
@@ -86,7 +100,7 @@ describe('<Slider />', () => {
86100
it('should forward mouseDown', () => {
87101
const handleMouseDown = jest.fn();
88102
const { container } = renderWithTheme(
89-
<Slider onMouseDown={handleMouseDown} value={0} />
103+
<Slider onMouseDown={handleMouseDown} defaultValue={0} />
90104
);
91105
const slider = container.firstElementChild as HTMLElement;
92106
fireEvent.mouseDown(slider);
@@ -103,13 +117,6 @@ describe('<Slider />', () => {
103117
);
104118
const slider = container.firstElementChild as HTMLElement;
105119
// mocking containers size
106-
slider.getBoundingClientRect = () =>
107-
({
108-
width: 100,
109-
height: 20,
110-
bottom: 20,
111-
left: 0
112-
} as DOMRect);
113120
const thumb = getByRole('slider');
114121

115122
fireEvent.touchStart(
@@ -292,7 +299,7 @@ describe('<Slider />', () => {
292299
describe('prop: orientation', () => {
293300
it('when vertical, should render with aria-orientation attribute set to "vertical" ', () => {
294301
const { getByRole } = renderWithTheme(
295-
<Slider orientation='vertical' value={0} />
302+
<Slider orientation='vertical' defaultValue={0} />
296303
);
297304

298305
expect(getByRole('slider')).toHaveAttribute(
@@ -314,13 +321,17 @@ describe('<Slider />', () => {
314321
const slider = container.firstElementChild as HTMLElement;
315322

316323
// mocking containers size
317-
slider.getBoundingClientRect = () =>
318-
({
319-
width: 20,
320-
height: 100,
321-
bottom: 100,
322-
left: 0
323-
} as DOMRect);
324+
jest
325+
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
326+
.mockImplementation(
327+
() =>
328+
({
329+
width: 20,
330+
height: 100,
331+
bottom: 100,
332+
left: 0
333+
} as DOMRect)
334+
);
324335

325336
fireEvent.touchStart(
326337
slider,

src/Slider/Slider.tsx

+4-3
Original file line numberDiff line numberDiff line change
@@ -313,9 +313,10 @@ const Slider = forwardRef<HTMLDivElement, SliderProps>(
313313
) => {
314314
const Groove = variant === 'flat' ? StyledFlatGroove : StyledGroove;
315315
const vertical = orientation === 'vertical';
316-
const [valueDerived, setValueState] = useControlledOrUncontrolled({
317-
value,
318-
defaultValue: defaultValue ?? min
316+
const [valueDerived = min, setValueState] = useControlledOrUncontrolled({
317+
defaultValue,
318+
onChange: onChange ?? onChangeCommitted,
319+
value
319320
});
320321

321322
const {

src/TreeView/TreeView.tsx

+8-2
Original file line numberDiff line numberDiff line change
@@ -321,13 +321,19 @@ function TreeInner<T>(
321321
ref: React.ForwardedRef<HTMLUListElement>
322322
) {
323323
const [expandedInternal, setExpandedInternal] = useControlledOrUncontrolled({
324+
defaultValue: defaultExpanded,
325+
onChange: onNodeToggle,
326+
onChangePropName: 'onNodeToggle',
324327
value: expanded,
325-
defaultValue: defaultExpanded
328+
valuePropName: 'expanded'
326329
});
327330

328331
const [selectedInternal, setSelectedInternal] = useControlledOrUncontrolled({
332+
defaultValue: defaultSelected,
333+
onChange: onNodeSelect,
334+
onChangePropName: 'onNodeSelect',
329335
value: selected,
330-
defaultValue: defaultSelected
336+
valuePropName: 'selected'
331337
});
332338

333339
const toggleMenu = useCallback(

src/common/hooks/useControlledOrUncontrolled.ts

+26-2
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,20 @@
11
import React, { useState, useCallback } from 'react';
22

33
export default function useControlledOrUncontrolled<T>({
4+
defaultValue,
5+
onChange,
6+
onChangePropName = 'onChange',
7+
readOnly,
48
value,
5-
defaultValue
9+
valuePropName = 'value'
610
}: {
7-
value: T | undefined;
811
defaultValue: T;
12+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
13+
onChange?: (...args: any[]) => void;
14+
onChangePropName?: string;
15+
readOnly?: boolean;
16+
value: T | undefined;
17+
valuePropName?: string;
918
}): [T, (newValue: React.SetStateAction<T>) => void] {
1019
const isControlled = value !== undefined;
1120
const [controlledValue, setControlledValue] = useState(defaultValue);
@@ -17,5 +26,20 @@ export default function useControlledOrUncontrolled<T>({
1726
},
1827
[isControlled]
1928
);
29+
30+
// Because we provide `onChange` even to uncontrolled components, React's
31+
// default uncontrolled warning must be reimplemented. This also deals with
32+
// props that are different from `value`.
33+
if (isControlled && typeof onChange !== 'function' && !readOnly) {
34+
const message = `Warning: You provided a \`${valuePropName}\` prop to a component without an \`${onChangePropName}\` handler.${
35+
valuePropName === 'value'
36+
? `This will render a read-only field. If the field should be mutable use \`defaultValue\`. Otherwise, set either \`${onChangePropName}\` or \`readOnly\`.`
37+
: `This breaks the component state. You must provide an \`${onChangePropName}\` function that updates \`${valuePropName}\`.`
38+
}`;
39+
40+
// eslint-disable-next-line no-console
41+
console.warn(message);
42+
}
43+
2044
return [isControlled ? value : controlledValue, handleChangeIfUncontrolled];
2145
}

0 commit comments

Comments
 (0)