Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@
"meow": "^5.0.0",
"patch-package": "^6.5.1",
"path": "^0.12.7",
"postinstall-postinstall": "^2.1.0"
"postinstall-postinstall": "^2.1.0",
"use-isomorphic-layout-effect": "^1.1.2"
},
"devDependencies": {
"@babel/cli": "^7.8.3",
Expand Down
16 changes: 11 additions & 5 deletions src/components/form-elements/checkbox/Checkbox.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as React from 'react';
import Checkbox from './Checkbox';
import {render} from '@testing-library/react';
import {render, waitFor} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {testA11y} from '../../../axe';

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

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

expect(checkboxInput.checked).toBe(false);
expect(iconWithAnimation.length).toBe(0);
userEvent.click(checkbox.getByLabelText('my label'));
expect(checkboxInput).toEqual(document.activeElement);
setTimeout(() => {
userEvent.click(checkbox.getByLabelText('my label'));
});
await waitFor(() => {
expect(checkboxInput).toEqual(document.activeElement);
});
expect(checkboxInput.checked).toBe(true);
expect(iconWithAnimation.length).toBe(1);
await waitFor(() => {
expect(iconWithAnimation.length).toBe(1);
});
});

describe('a11y', () => {
Expand Down
27 changes: 19 additions & 8 deletions src/components/form-elements/checkbox/Checkbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {__DEV__, invariant} from '../../utils';
import Text from '../../text/Text';
import {CheckIcon, IndeterminateIcon} from './CheckboxIcon';
import ErrorMessage from '../ErrorMessage';
import {useFirstPaint} from '../../utils/useFirstPaint';

type CheckboxColorType = 'dark' | 'light';
type CheckboxLabelSizeType = 'medium' | 'small';
Expand Down Expand Up @@ -175,6 +176,7 @@ const Checkbox = ({
'aria-labelledby': ariaLabelledBy,
...props
}: CheckboxPropsType) => {
const checkboxIconRef = React.useRef<HTMLSpanElement>();
const {current: checkboxId} = React.useRef(
id === undefined || id === '' ? generateRandomString() : id
);
Expand All @@ -184,27 +186,37 @@ const Checkbox = ({
);
const inputRef = React.useRef<HTMLInputElement>(null);
const iconRef = React.useRef<SVGSVGElement | null>(null);
const [isPristine, setIsPristine] = React.useState(true);
const isFirstPaintRef = useFirstPaint();

React.useEffect(() => {
if (inputRef.current) inputRef.current.indeterminate = indeterminate;
}, [inputRef, indeterminate]);
React.useEffect(() => {
if (isControlled && checked !== isChecked) {
setIsChecked(checked);
if (isPristine) setIsPristine(false);

if (!isFirstPaintRef.current && checkboxIconRef.current) {
checkboxIconRef.current.classList.add(
'sg-checkbox__icon--with-animation'
);
}
}
}, [checked, isControlled, isChecked, isPristine]);
}, [checked, isControlled, isChecked, isFirstPaintRef]);
const onInputChange = React.useCallback(
e => {
if (!isControlled) {
setIsChecked(val => !val);
if (isPristine) setIsPristine(false);
}

if (onChange) onChange(e);

if (!isFirstPaintRef.current && checkboxIconRef.current) {
checkboxIconRef.current.classList.add(
'sg-checkbox__icon--with-animation'
);
}
},
[onChange, isControlled, isPristine]
[onChange, isControlled, checkboxIconRef, isFirstPaintRef]
);

if (__DEV__) {
Expand All @@ -230,9 +242,7 @@ const Checkbox = ({
'sg-checkbox__label--with-padding-bottom': description || errorMessage,
[`sg-checkbox__label--${String(labelSize)}`]: labelSize,
});
const iconClass = classNames('sg-checkbox__icon', {
'sg-checkbox__icon--with-animation': !isPristine, // Apply animation only when checkbox is not pristine
});
const iconClass = classNames('sg-checkbox__icon');
const errorTextId = `${checkboxId}-errorText`;
const descriptionId = `${checkboxId}-description`;
const describedbyIds = React.useMemo(() => {
Expand Down Expand Up @@ -287,6 +297,7 @@ const Checkbox = ({
<div className="sg-checkbox__icon-wrapper">
<span
className={iconClass} // This element is purely decorative so
ref={checkboxIconRef}
// we hide it for screen readers
aria-hidden="true"
>
Expand Down
12 changes: 8 additions & 4 deletions src/components/form-elements/radio/Radio.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as React from 'react';
import Radio from './Radio';
import {render} from '@testing-library/react';
import {render, waitFor} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {testA11y} from '../../../axe';

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

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

expect(radioInput.checked).toBe(false);
expect(iconWithAnimation.length).toBe(0);
userEvent.click(radio.getByLabelText('my label'));
expect(radioInput).toEqual(document.activeElement);
setTimeout(() => {
userEvent.click(radio.getByLabelText('my label'));
});
await waitFor(() => {
expect(radioInput).toEqual(document.activeElement);
});
expect(radioInput.checked).toBe(true);
expect(iconWithAnimation.length).toBe(1);
});
Expand Down
33 changes: 33 additions & 0 deletions src/components/form-elements/radio/Radio.stories.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import Flex from '../../flex/Flex';
import Box from '../../box/Box';
import Headline from '../../text/Headline';
import Text from '../../text/Text';
import Button from '../../buttons/Button';

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

<Canvas>
<Story
name="toggle without radio group"
height="120px"
args={{name: 'optionA', value: 'option-a'}}
>
{args => {
const [value, setValue] = React.useState('option-a');
return (
<div>
<div>
<Button
onClick={() =>
setValue(value === 'option-a' ? 'option-b' : 'option-a')
}
size="s"
>
click to toggle active radio
</Button>
</div>
<Radio value="option-a" checked={value === 'option-a'}>
Option A
</Radio>
<Radio value="option-b" checked={value === 'option-b'}>
Option B
</Radio>
</div>
);
}}
</Story>
</Canvas>

## Accessibility

<RadioA11y />
21 changes: 13 additions & 8 deletions src/components/form-elements/radio/Radio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import classNames from 'classnames';
import Text from '../../text/Text';
import generateRandomString from '../../../js/generateRandomString';
import useRadioContext from './useRadioContext';
import {useFirstPaint} from '../../utils/useFirstPaint';

export type RadioColorType = 'light' | 'dark';
type RadioLabelSizeType = 'medium' | 'small';
Expand Down Expand Up @@ -160,16 +161,15 @@ const Radio = ({
'aria-describedby': ariaDescribedBy,
...props
}: RadioPropsType) => {
const circleRef = React.useRef<HTMLSpanElement>();
const {current: radioId} = useRef(
id === undefined || id === '' ? generateRandomString() : id
);
const radioGroupContext = useRadioContext();
const isWithinRadioGroup = Boolean(
radioGroupContext && Object.keys(radioGroupContext).length
);
const [isPristine, setIsPristine] = React.useState(true);
const shouldAnimate =
(isWithinRadioGroup && !radioGroupContext.isPristine) || !isPristine;
const isFirstPaintRef = useFirstPaint();
const isControlled = checked !== undefined || isWithinRadioGroup;
let isChecked: boolean | undefined = undefined;

Expand All @@ -180,6 +180,10 @@ const Radio = ({
? checked
: Boolean(radioGroupContext.selectedValue) &&
radioGroupContext.selectedValue === value;

if (!isFirstPaintRef.current && circleRef.current) {
circleRef.current.classList.add('sg-radio__circle--with-animation');
}
}

const colorName = radioGroupContext.color || color;
Expand All @@ -203,23 +207,23 @@ const Radio = ({
'sg-radio__label--with-padding-bottom': description,
[`sg-radio__label--${String(labelSize)}`]: labelSize,
});
const circleClass = classNames('sg-radio__circle', {
'sg-radio__circle--with-animation': shouldAnimate,
});
const circleClass = classNames('sg-radio__circle');
const labelId = ariaLabelledBy || `${radioId}-label`;
const isInvalid = invalid !== undefined ? invalid : radioGroupContext.invalid;

const onInputChange = e => {
if (isWithinRadioGroup) {
radioGroupContext.setLastFocusedValue(value);
radioGroupContext.setSelectedValue(e, value);
} else {
setIsPristine(false);
}

if (onChange) {
onChange(e);
}

if (circleRef.current && !isFirstPaintRef.current) {
circleRef.current.classList.add('sg-radio__circle--with-animation');
}
};

return (
Expand All @@ -242,6 +246,7 @@ const Radio = ({
aria-invalid={isInvalid ? true : undefined}
/>
<span
ref={circleRef}
className={circleClass} // This element is purely decorative so
// we hide it for screen readers
aria-hidden="true"
Expand Down
14 changes: 8 additions & 6 deletions src/components/form-elements/radio/RadioGroup.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as React from 'react';
import RadioGroup from './RadioGroup';
import Radio from './Radio';
import {render} from '@testing-library/react';
import {render, waitFor} from '@testing-library/react';
import userEvent from '@testing-library/user-event';

describe('<RadioGroup />', () => {
Expand Down Expand Up @@ -67,7 +67,7 @@ describe('<RadioGroup />', () => {
expect(onChange).not.toHaveBeenCalled();
expect(radioGroup.getByLabelText('Option B')).not.toBeChecked();
});
it('checked radio can be changed on controlled radio group', () => {
it('checked radio can be changed on controlled radio group', async () => {
const {container, getByLabelText, rerender} = renderRadioGroup({
name: 'option',
value: 'option-a',
Expand Down Expand Up @@ -96,11 +96,10 @@ describe('<RadioGroup />', () => {
</Radio>
</RadioGroup>
);
expect(iconsWithAnimation.length).toBe(2);
expect(getByLabelText('Option A')).not.toBeChecked();
expect(getByLabelText('Option B')).toBeChecked();
});
it('it does not apply animation unless initial state has changed', () => {
it('it does not apply animation unless initial state has changed after first render of DOM', async () => {
const radioGroup = renderRadioGroup({
name: 'option',
value: 'option-a',
Expand All @@ -110,8 +109,11 @@ describe('<RadioGroup />', () => {
);

expect(iconsWithAnimation.length).toBe(0);
userEvent.click(radioGroup.getByLabelText('Option B'));
expect(iconsWithAnimation.length).toBe(2);

requestAnimationFrame(() => {
userEvent.click(radioGroup.getByLabelText('Option B'));
});
await waitFor(() => expect(iconsWithAnimation.length).toBe(2));
});
it('has an accessible name', () => {
const onChange = jest.fn();
Expand Down
36 changes: 36 additions & 0 deletions src/components/utils/UseFirstpaintExample.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import React from 'react';
import {useFirstPaint} from './useFirstPaint';
import './_use-first-paint-example.scss';
import classNames from 'classnames';

export const Example = () => {
const animatedElementRef = React.useRef<HTMLDivElement>();
const [isToggled, setIsToggled] = React.useState(true);
const handleClick = React.useCallback(() => {
setIsToggled(!isToggled);
}, [isToggled]);

React.useLayoutEffect(() => {
animatedElementRef.current.style.animationDuration = '0ms';
}, []);

useFirstPaint(() => {
animatedElementRef.current.style.animationDuration = '';
});

return (
<div>
<div style={{height: 300, width: 600}}>
<div
className={classNames('use-first-paint-example-box', {
'use-first-paint-example-box--toggled': isToggled,
})}
onClick={handleClick}
ref={animatedElementRef}
>
Click me
</div>
</div>
</div>
);
};
32 changes: 32 additions & 0 deletions src/components/utils/__cache.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<PageHeader type="utility">useFirstPaint</PageHeader>

`useFirstPaint` is a custom hook for detecting when component is before first DOM paint. This is useful for preventing unnecessary animations on initial render.

## Import

```jsx
import {useFirstPaint} from 'brainly-style-guide';
```

## Usage

```jsx
const animatedElementRef = React.useRef();
const [isToggled, setIsToggled] = React.useState(true);
const onClick = () => {
setIsToggled(!isToggled);
};

useFirstPaint(() => {
if (animatedElementRef) {
animatedElementRef.current.classList.add('with-animation')
}
});

return (
<div
ref={animatedElementRef}
click={onClick}
>toggle</>
);
```
Loading