Skip to content

Commit 2bbc791

Browse files
authored
use useFirstPaint to control animation in Checkbox and Radio (#2744)
* new useIsFirstRender useIsFirstRender rewritten and used in Checkbox and Radio * use isomorphic layout effect in useIsFirstRender * use ref in useIsFirstRender instead of state * refactor isFirstRender * rename useIsFirstRender to useIsAfterFirstPaint * replace useIsAfterFirstPaint with useFirstPaint hook add story * add description to story * clean up * clean up * update useFirstPaint story * use callback in useFirstPaint * fix example code * update Checkbox and Radio after last changes
1 parent 426ee7c commit 2bbc791

File tree

14 files changed

+292
-46
lines changed

14 files changed

+292
-46
lines changed

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@
3737
"meow": "^5.0.0",
3838
"patch-package": "^6.5.1",
3939
"path": "^0.12.7",
40-
"postinstall-postinstall": "^2.1.0"
40+
"postinstall-postinstall": "^2.1.0",
41+
"use-isomorphic-layout-effect": "^1.1.2"
4142
},
4243
"devDependencies": {
4344
"@babel/cli": "^7.8.3",

src/components/form-elements/checkbox/Checkbox.spec.tsx

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as React from 'react';
22
import Checkbox from './Checkbox';
3-
import {render} from '@testing-library/react';
3+
import {render, waitFor} from '@testing-library/react';
44
import userEvent from '@testing-library/user-event';
55
import {testA11y} from '../../../axe';
66

@@ -138,7 +138,7 @@ describe('<Checkbox />', () => {
138138
expect(checkboxInput.checked).toBe(false);
139139
});
140140

141-
it('it does not apply animation unless initial state has changed', () => {
141+
it('it does not apply animation unless initial state has changed after first render of DOM', async () => {
142142
const checkbox = renderCheckbox({
143143
defaultChecked: false,
144144
children: 'my label',
@@ -150,10 +150,16 @@ describe('<Checkbox />', () => {
150150

151151
expect(checkboxInput.checked).toBe(false);
152152
expect(iconWithAnimation.length).toBe(0);
153-
userEvent.click(checkbox.getByLabelText('my label'));
154-
expect(checkboxInput).toEqual(document.activeElement);
153+
setTimeout(() => {
154+
userEvent.click(checkbox.getByLabelText('my label'));
155+
});
156+
await waitFor(() => {
157+
expect(checkboxInput).toEqual(document.activeElement);
158+
});
155159
expect(checkboxInput.checked).toBe(true);
156-
expect(iconWithAnimation.length).toBe(1);
160+
await waitFor(() => {
161+
expect(iconWithAnimation.length).toBe(1);
162+
});
157163
});
158164

159165
describe('a11y', () => {

src/components/form-elements/checkbox/Checkbox.tsx

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {__DEV__, invariant} from '../../utils';
55
import Text from '../../text/Text';
66
import {CheckIcon, IndeterminateIcon} from './CheckboxIcon';
77
import ErrorMessage from '../ErrorMessage';
8+
import {useFirstPaint} from '../../utils/useFirstPaint';
89

910
type CheckboxColorType = 'dark' | 'light';
1011
type CheckboxLabelSizeType = 'medium' | 'small';
@@ -175,6 +176,7 @@ const Checkbox = ({
175176
'aria-labelledby': ariaLabelledBy,
176177
...props
177178
}: CheckboxPropsType) => {
179+
const checkboxIconRef = React.useRef<HTMLSpanElement>();
178180
const {current: checkboxId} = React.useRef(
179181
id === undefined || id === '' ? generateRandomString() : id
180182
);
@@ -184,27 +186,40 @@ const Checkbox = ({
184186
);
185187
const inputRef = React.useRef<HTMLInputElement>(null);
186188
const iconRef = React.useRef<SVGSVGElement | null>(null);
187-
const [isPristine, setIsPristine] = React.useState(true);
189+
const shouldAnimate = React.useRef(false);
190+
191+
useFirstPaint(() => {
192+
shouldAnimate.current = true;
193+
});
188194

189195
React.useEffect(() => {
190196
if (inputRef.current) inputRef.current.indeterminate = indeterminate;
191197
}, [inputRef, indeterminate]);
192198
React.useEffect(() => {
193199
if (isControlled && checked !== isChecked) {
194200
setIsChecked(checked);
195-
if (isPristine) setIsPristine(false);
201+
if (shouldAnimate.current && checkboxIconRef.current) {
202+
checkboxIconRef.current.classList.add(
203+
'sg-checkbox__icon--with-animation'
204+
);
205+
}
196206
}
197-
}, [checked, isControlled, isChecked, isPristine]);
207+
}, [checked, isControlled, isChecked]);
198208
const onInputChange = React.useCallback(
199209
e => {
200210
if (!isControlled) {
201211
setIsChecked(val => !val);
202-
if (isPristine) setIsPristine(false);
203212
}
204213

205214
if (onChange) onChange(e);
215+
216+
if (shouldAnimate.current && checkboxIconRef.current) {
217+
checkboxIconRef.current.classList.add(
218+
'sg-checkbox__icon--with-animation'
219+
);
220+
}
206221
},
207-
[onChange, isControlled, isPristine]
222+
[onChange, isControlled, checkboxIconRef]
208223
);
209224

210225
if (__DEV__) {
@@ -230,9 +245,7 @@ const Checkbox = ({
230245
'sg-checkbox__label--with-padding-bottom': description || errorMessage,
231246
[`sg-checkbox__label--${String(labelSize)}`]: labelSize,
232247
});
233-
const iconClass = classNames('sg-checkbox__icon', {
234-
'sg-checkbox__icon--with-animation': !isPristine, // Apply animation only when checkbox is not pristine
235-
});
248+
const iconClass = classNames('sg-checkbox__icon');
236249
const errorTextId = `${checkboxId}-errorText`;
237250
const descriptionId = `${checkboxId}-description`;
238251
const describedbyIds = React.useMemo(() => {
@@ -287,6 +300,7 @@ const Checkbox = ({
287300
<div className="sg-checkbox__icon-wrapper">
288301
<span
289302
className={iconClass} // This element is purely decorative so
303+
ref={checkboxIconRef}
290304
// we hide it for screen readers
291305
aria-hidden="true"
292306
>

src/components/form-elements/radio/Radio.spec.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as React from 'react';
22
import Radio from './Radio';
3-
import {render} from '@testing-library/react';
3+
import {render, waitFor} from '@testing-library/react';
44
import userEvent from '@testing-library/user-event';
55
import {testA11y} from '../../../axe';
66

@@ -104,7 +104,7 @@ describe('<Radio />', () => {
104104
expect(radioInput.checked).toBe(false);
105105
});
106106

107-
it('it does not apply animation unless initial state has changed', () => {
107+
it('it does not apply animation unless initial state has changed after first render of DOM', async () => {
108108
const radio = renderRadio({
109109
children: 'my label',
110110
});
@@ -115,8 +115,12 @@ describe('<Radio />', () => {
115115

116116
expect(radioInput.checked).toBe(false);
117117
expect(iconWithAnimation.length).toBe(0);
118-
userEvent.click(radio.getByLabelText('my label'));
119-
expect(radioInput).toEqual(document.activeElement);
118+
setTimeout(() => {
119+
userEvent.click(radio.getByLabelText('my label'));
120+
});
121+
await waitFor(() => {
122+
expect(radioInput).toEqual(document.activeElement);
123+
});
120124
expect(radioInput.checked).toBe(true);
121125
expect(iconWithAnimation.length).toBe(1);
122126
});

src/components/form-elements/radio/Radio.stories.mdx

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import Flex from '../../flex/Flex';
77
import Box from '../../box/Box';
88
import Headline from '../../text/Headline';
99
import Text from '../../text/Text';
10+
import Button from '../../buttons/Button';
1011

1112
import Radio from './Radio';
1213
import RadioGroup from './RadioGroup';
@@ -381,6 +382,38 @@ In the following example, className `sg-radio--custom-theme` was applied to all
381382
</Story>
382383
</Canvas>
383384

385+
<Canvas>
386+
<Story
387+
name="toggle without radio group"
388+
height="120px"
389+
args={{name: 'optionA', value: 'option-a'}}
390+
>
391+
{args => {
392+
const [value, setValue] = React.useState('option-a');
393+
return (
394+
<div>
395+
<div>
396+
<Button
397+
onClick={() =>
398+
setValue(value === 'option-a' ? 'option-b' : 'option-a')
399+
}
400+
size="s"
401+
>
402+
click to toggle active radio
403+
</Button>
404+
</div>
405+
<Radio value="option-a" checked={value === 'option-a'}>
406+
Option A
407+
</Radio>
408+
<Radio value="option-b" checked={value === 'option-b'}>
409+
Option B
410+
</Radio>
411+
</div>
412+
);
413+
}}
414+
</Story>
415+
</Canvas>
416+
384417
## Accessibility
385418

386419
<RadioA11y />

src/components/form-elements/radio/Radio.tsx

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import classNames from 'classnames';
88
import Text from '../../text/Text';
99
import generateRandomString from '../../../js/generateRandomString';
1010
import useRadioContext from './useRadioContext';
11+
import {useFirstPaint} from '../../utils/useFirstPaint';
1112

1213
export type RadioColorType = 'light' | 'dark';
1314
type RadioLabelSizeType = 'medium' | 'small';
@@ -160,26 +161,33 @@ const Radio = ({
160161
'aria-describedby': ariaDescribedBy,
161162
...props
162163
}: RadioPropsType) => {
164+
const circleRef = React.useRef<HTMLSpanElement>();
163165
const {current: radioId} = useRef(
164166
id === undefined || id === '' ? generateRandomString() : id
165167
);
166168
const radioGroupContext = useRadioContext();
167169
const isWithinRadioGroup = Boolean(
168170
radioGroupContext && Object.keys(radioGroupContext).length
169171
);
170-
const [isPristine, setIsPristine] = React.useState(true);
171-
const shouldAnimate =
172-
(isWithinRadioGroup && !radioGroupContext.isPristine) || !isPristine;
172+
const shouldAnimateRef = React.useRef(false);
173173
const isControlled = checked !== undefined || isWithinRadioGroup;
174174
let isChecked: boolean | undefined = undefined;
175175

176+
useFirstPaint(() => {
177+
shouldAnimateRef.current = true;
178+
});
179+
176180
if (isControlled) {
177181
// Radio can either be directly set as checked, or be controlled by a RadioGroup
178182
isChecked =
179183
checked !== undefined
180184
? checked
181185
: Boolean(radioGroupContext.selectedValue) &&
182186
radioGroupContext.selectedValue === value;
187+
188+
if (shouldAnimateRef.current && circleRef.current) {
189+
circleRef.current.classList.add('sg-radio__circle--with-animation');
190+
}
183191
}
184192

185193
const colorName = radioGroupContext.color || color;
@@ -203,23 +211,23 @@ const Radio = ({
203211
'sg-radio__label--with-padding-bottom': description,
204212
[`sg-radio__label--${String(labelSize)}`]: labelSize,
205213
});
206-
const circleClass = classNames('sg-radio__circle', {
207-
'sg-radio__circle--with-animation': shouldAnimate,
208-
});
214+
const circleClass = classNames('sg-radio__circle');
209215
const labelId = ariaLabelledBy || `${radioId}-label`;
210216
const isInvalid = invalid !== undefined ? invalid : radioGroupContext.invalid;
211217

212218
const onInputChange = e => {
213219
if (isWithinRadioGroup) {
214220
radioGroupContext.setLastFocusedValue(value);
215221
radioGroupContext.setSelectedValue(e, value);
216-
} else {
217-
setIsPristine(false);
218222
}
219223

220224
if (onChange) {
221225
onChange(e);
222226
}
227+
228+
if (circleRef.current && shouldAnimateRef.current) {
229+
circleRef.current.classList.add('sg-radio__circle--with-animation');
230+
}
223231
};
224232

225233
return (
@@ -242,6 +250,7 @@ const Radio = ({
242250
aria-invalid={isInvalid ? true : undefined}
243251
/>
244252
<span
253+
ref={circleRef}
245254
className={circleClass} // This element is purely decorative so
246255
// we hide it for screen readers
247256
aria-hidden="true"

src/components/form-elements/radio/RadioGroup.spec.tsx

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as React from 'react';
22
import RadioGroup from './RadioGroup';
33
import Radio from './Radio';
4-
import {render} from '@testing-library/react';
4+
import {render, waitFor} from '@testing-library/react';
55
import userEvent from '@testing-library/user-event';
66

77
describe('<RadioGroup />', () => {
@@ -67,7 +67,7 @@ describe('<RadioGroup />', () => {
6767
expect(onChange).not.toHaveBeenCalled();
6868
expect(radioGroup.getByLabelText('Option B')).not.toBeChecked();
6969
});
70-
it('checked radio can be changed on controlled radio group', () => {
70+
it('checked radio can be changed on controlled radio group', async () => {
7171
const {container, getByLabelText, rerender} = renderRadioGroup({
7272
name: 'option',
7373
value: 'option-a',
@@ -96,11 +96,10 @@ describe('<RadioGroup />', () => {
9696
</Radio>
9797
</RadioGroup>
9898
);
99-
expect(iconsWithAnimation.length).toBe(2);
10099
expect(getByLabelText('Option A')).not.toBeChecked();
101100
expect(getByLabelText('Option B')).toBeChecked();
102101
});
103-
it('it does not apply animation unless initial state has changed', () => {
102+
it('it does not apply animation unless initial state has changed after first render of DOM', async () => {
104103
const radioGroup = renderRadioGroup({
105104
name: 'option',
106105
value: 'option-a',
@@ -110,8 +109,11 @@ describe('<RadioGroup />', () => {
110109
);
111110

112111
expect(iconsWithAnimation.length).toBe(0);
113-
userEvent.click(radioGroup.getByLabelText('Option B'));
114-
expect(iconsWithAnimation.length).toBe(2);
112+
113+
requestAnimationFrame(() => {
114+
userEvent.click(radioGroup.getByLabelText('Option B'));
115+
});
116+
await waitFor(() => expect(iconsWithAnimation.length).toBe(2));
115117
});
116118
it('has an accessible name', () => {
117119
const onChange = jest.fn();
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import React from 'react';
2+
import {useFirstPaint} from './useFirstPaint';
3+
import './_use-first-paint-example.scss';
4+
import classNames from 'classnames';
5+
6+
export const Example = () => {
7+
const animatedElementRef = React.useRef<HTMLDivElement>();
8+
const shouldAnimateRef = React.useRef(false);
9+
const [isToggled, setIsToggled] = React.useState(true);
10+
const handleClick = React.useCallback(() => {
11+
setIsToggled(!isToggled);
12+
13+
if (shouldAnimateRef.current) {
14+
animatedElementRef.current.style.animationDuration = '';
15+
}
16+
}, [isToggled]);
17+
18+
React.useLayoutEffect(() => {
19+
animatedElementRef.current.style.animationDuration = '0ms';
20+
}, []);
21+
22+
useFirstPaint(() => {
23+
shouldAnimateRef.current = true;
24+
});
25+
26+
return (
27+
<div>
28+
<div style={{height: 300, width: 600}}>
29+
<div
30+
className={classNames('use-first-paint-example-box', {
31+
'use-first-paint-example-box--toggled': isToggled,
32+
})}
33+
onClick={handleClick}
34+
ref={animatedElementRef}
35+
>
36+
Click me
37+
</div>
38+
</div>
39+
</div>
40+
);
41+
};

0 commit comments

Comments
 (0)