Skip to content

Commit cb677dd

Browse files
authored
Apply @radix-ui/react-switch primitives to Switch component (#1002)
* chore(deps): add @radix-ui/react-switch * refactor(switch.types): apply SwitchPrimitiveProps * feat(switch): apply @radix-ui/react-switch primitives * fix(switch): align HTML element type for the sake of a11y * chore(switch.styled): add all: unset style for button * test(switch.stories): add Uncontrolled story * chore: fix stylelint * refactor(slider): change to named export * fix(switch.styled): fix typo - 'initial' to initial * test(switch.test): add a11y unit tests * fix(switch.styled): change data-disabled attr to disabled selector * feat(switch): add focus-visible style * test(switch.test): add userEvent unit tests * test(switch.test): add data attribute unit tests * docs(changeset): add changeset * test(form-control.stories): add Switch with FormControl * refactor(switch.types): improve SwitchProps from extending PrimitiveProps * test(switch.test): replace all getAllByRole to getByRole query
1 parent ecb6c06 commit cb677dd

File tree

10 files changed

+329
-72
lines changed

10 files changed

+329
-72
lines changed

.changeset/little-beans-cry.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@channel.io/bezier-react": minor
3+
---
4+
5+
Apply `@radix-ui/react-switch` primitives to `Switch` component
6+
7+
BREAKING CHANGE:
8+
9+
- `onClick` handler is now `React.MouseEventHandler<HTMLButtonElement>`.
10+
- `Switch` component is now `HTMLButtonElement`.

packages/bezier-react/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@
118118
"@babel/runtime": "^7.12.13",
119119
"@radix-ui/react-separator": "^1.0.0",
120120
"@radix-ui/react-slider": "^1.0.0",
121+
"@radix-ui/react-switch": "^1.0.1",
121122
"lodash-es": "^4.17.15",
122123
"react-resize-detector": "^7.1.1",
123124
"react-textarea-autosize": "^8.3.4",

packages/bezier-react/src/components/Forms/FormControl/FormControl.stories.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { TextField } from 'Components/Forms/Inputs/TextField'
1515
import { TextArea } from 'Components/Forms/Inputs/TextArea'
1616
import { Select } from 'Components/Forms/Inputs/Select'
1717
import { FormHelperText, FormErrorMessage } from 'Components/Forms/FormHelperText'
18+
import { Switch } from 'Components/Forms/Switch'
1819
import FormControl from './FormControl'
1920
import FormControlProps from './FormControl.types'
2021

@@ -121,6 +122,13 @@ const WithMultiFormTemplate: Story<FormControlProps> = (args) => (
121122
<FormHelperText>Description</FormHelperText>
122123
<FormErrorMessage>Error!</FormErrorMessage>
123124
</FormControl>
125+
126+
<FormControl {...args}>
127+
<FormLabel help="Lorem Ipsum">Label</FormLabel>
128+
<Switch />
129+
<FormHelperText>Description</FormHelperText>
130+
<FormErrorMessage>Error!</FormErrorMessage>
131+
</FormControl>
124132
</div>
125133
)
126134

packages/bezier-react/src/components/Forms/Switch/Switch.stories.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { Meta, Story } from '@storybook/react'
55

66
/* Internal dependencies */
77
import { getObjectFromEnum, getTitle } from 'Utils/storyUtils'
8-
import Switch from './Switch'
8+
import { Switch } from './Switch'
99
import type SwitchProps from './Switch.types'
1010
import { SwitchSize } from './Switch.types'
1111

@@ -29,8 +29,8 @@ export default {
2929
type: 'boolean',
3030
},
3131
},
32-
onClick: {
33-
action: 'onClick',
32+
onCheckedChange: {
33+
action: 'onCheckedChange',
3434
},
3535
},
3636
} as Meta
@@ -43,3 +43,9 @@ Primary.args = {
4343
checked: true,
4444
disabled: false,
4545
}
46+
47+
export const Uncontrolled = Template.bind({})
48+
Uncontrolled.args = {
49+
size: SwitchSize.M,
50+
disabled: false,
51+
}
Lines changed: 38 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/* Internal dependencies */
22
import { styled } from 'Foundation'
33
import DisabledOpacity from 'Constants/DisabledOpacity'
4+
import { focusedInputWrapperStyle } from 'Components/Forms/Inputs/mixins'
45
import type SwitchProps from './Switch.types'
56
import { SwitchSize } from './Switch.types'
67

@@ -21,42 +22,53 @@ const SWITCH_HANDLE_WIDTH_HEIGHT: Record<SwitchSize, number> = {
2122
[SwitchSize.S]: 14,
2223
}
2324

24-
interface WrapperProps extends Required<SwitchProps> {}
25+
interface SwitchRootProps extends SwitchProps {
26+
size: NonNullable<SwitchProps['size']>
27+
}
28+
29+
export const SwitchRoot = styled.button<SwitchRootProps>`
30+
all: unset;
2531
26-
export const Wrapper = styled.div<WrapperProps>`
2732
position: relative;
2833
2934
width: ${({ size }) => SWITCH_WIDTH[size]}px;
3035
height: ${({ size }) => SWITCH_HEIGHT[size]}px;
3136
32-
cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
37+
cursor: pointer;
3338
3439
${({ foundation }) => foundation?.rounding?.round12}
35-
background-color: ${({ checked, foundation }) => (
36-
checked
37-
? foundation?.theme?.['bgtxt-green-normal']
38-
: foundation?.theme?.['bg-black-dark']
39-
)};
40-
41-
opacity: ${({ disabled }) => (disabled ? DisabledOpacity : 'initial')};
42-
43-
&:hover {
44-
background-color: ${({ checked, foundation }) => (
45-
checked
46-
? foundation?.theme?.['bgtxt-green-dark']
47-
: foundation?.theme?.['bg-black-dark']
48-
)};
40+
41+
background-color: ${({ foundation }) => foundation?.theme?.['bg-black-dark']};
42+
43+
&[data-state='checked'] {
44+
background-color: ${({ foundation }) => foundation?.theme?.['bgtxt-green-normal']};
45+
46+
&:hover {
47+
background-color: ${({ foundation }) => foundation?.theme?.['bgtxt-green-dark']};
48+
}
49+
}
50+
51+
opacity: initial;
52+
53+
&:disabled {
54+
cursor: not-allowed;
55+
opacity: ${DisabledOpacity};
56+
}
57+
58+
&:focus-visible {
59+
${focusedInputWrapperStyle}
4960
}
5061
5162
${({ foundation }) => foundation?.transition?.getTransitionsCSS(['background-color', 'opacity'])};
5263
`
5364

54-
interface ContentProps extends SwitchProps {
65+
interface SwitchThumbProps extends SwitchProps {
5566
size: NonNullable<SwitchProps['size']>
56-
checked: NonNullable<SwitchProps['checked']>
5767
}
5868

59-
export const Content = styled.div<ContentProps>`
69+
export const SwitchThumb = styled.span<SwitchThumbProps>`
70+
all: unset;
71+
6072
position: absolute;
6173
top: ${PADDING}px;
6274
left: ${PADDING}px;
@@ -66,12 +78,12 @@ export const Content = styled.div<ContentProps>`
6678
${({ foundation }) => foundation?.rounding?.round12}
6779
${({ foundation }) => foundation?.elevation?.ev2()};
6880
background-color: ${({ foundation }) => foundation?.theme?.['bgtxt-absolute-white-dark']};
69-
70-
transform: ${({ checked, size }) => (
71-
checked
72-
? `translateX(${SWITCH_WIDTH[size] - SWITCH_HANDLE_WIDTH_HEIGHT[size] - (PADDING * 2)}px)`
73-
: 'initial'
74-
)};
81+
82+
transform: initial;
83+
84+
&[data-state='checked'] {
85+
transform: ${({ size }) => `translateX(${SWITCH_WIDTH[size] - SWITCH_HANDLE_WIDTH_HEIGHT[size] - (PADDING * 2)}px)`};
86+
}
7587
7688
${({ foundation }) => foundation?.transition?.getTransitionsCSS(['transform'])};
7789
`

packages/bezier-react/src/components/Forms/Switch/Switch.test.tsx

Lines changed: 151 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
/* External dependencies */
22
import React from 'react'
3-
import { fireEvent } from '@testing-library/react'
3+
import userEvent from '@testing-library/user-event'
44

55
/* Internal dependencies */
66
import { LightFoundation } from 'Foundation'
77
import DisabledOpacity from 'Constants/DisabledOpacity'
88
import { render } from 'Utils/testUtils'
9-
import Switch, { SWITCH_TEST_ID, SWITCH_HANDLE_TEST_ID } from './Switch'
9+
import {
10+
Switch,
11+
SWITCH_TEST_ID,
12+
SWITCH_HANDLE_TEST_ID,
13+
} from './Switch'
1014
import type SwitchProps from './Switch.types'
1115
import { SwitchSize } from './Switch.types'
1216

@@ -99,6 +103,7 @@ describe('Switch', () => {
99103
const switchComponent = getByTestId(SWITCH_TEST_ID)
100104

101105
expect(switchComponent).toHaveStyle('opacity: initial')
106+
expect(switchComponent).toHaveStyle('cursor: pointer')
102107
})
103108

104109
it('should render disabled Switch when disabled is true', () => {
@@ -108,31 +113,168 @@ describe('Switch', () => {
108113
const switchComponent = getByTestId(SWITCH_TEST_ID)
109114

110115
expect(switchComponent).toHaveStyle(`opacity: ${DisabledOpacity}`)
116+
expect(switchComponent).toHaveStyle('cursor: not-allowed')
111117
})
112118
})
113119

114-
describe('fire events', () => {
115-
it('should fire onClick event when Switch is clicked', () => {
120+
describe('data attribute', () => {
121+
describe('data-state', () => {
122+
it('should have data-state="checked" attribute when checked is true', () => {
123+
const { getByTestId } = renderComponent({
124+
checked: true,
125+
})
126+
const switchComponent = getByTestId(SWITCH_TEST_ID)
127+
128+
expect(switchComponent).toHaveAttribute('data-state', 'checked')
129+
})
130+
131+
it('should have data-state="unchecked" attribute when checked is false', () => {
132+
const { getByTestId } = renderComponent({
133+
checked: false,
134+
})
135+
const switchComponent = getByTestId(SWITCH_TEST_ID)
136+
137+
expect(switchComponent).toHaveAttribute('data-state', 'unchecked')
138+
})
139+
})
140+
141+
describe('data-disabled', () => {
142+
it('should have data-disabled attribute when disabled is true', () => {
143+
const { getByTestId } = renderComponent({
144+
disabled: true,
145+
})
146+
const switchComponent = getByTestId(SWITCH_TEST_ID)
147+
148+
expect(switchComponent).toHaveAttribute('data-disabled')
149+
})
150+
151+
it('should not have data-disabled attribute when disabled is false', () => {
152+
const { getByTestId } = renderComponent({
153+
disabled: false,
154+
})
155+
const switchComponent = getByTestId(SWITCH_TEST_ID)
156+
157+
expect(switchComponent).not.toHaveAttribute('data-disabled')
158+
})
159+
})
160+
})
161+
162+
describe('user interactions', () => {
163+
it('should change state when user clicks Switch', async () => {
164+
const user = userEvent.setup()
116165
const onClick = jest.fn()
166+
const onCheckedChange = jest.fn()
117167
const { getByTestId } = renderComponent({
168+
defaultChecked: false,
118169
onClick,
170+
onCheckedChange,
171+
})
172+
const switchComponent = getByTestId(SWITCH_TEST_ID)
173+
174+
await user.click(switchComponent)
175+
expect(switchComponent).toHaveAttribute('data-state', 'checked')
176+
expect(onClick).toHaveBeenCalledTimes(1)
177+
expect(onCheckedChange).toHaveBeenCalledTimes(1)
178+
await user.click(switchComponent)
179+
expect(switchComponent).toHaveAttribute('data-state', 'unchecked')
180+
expect(onClick).toHaveBeenCalledTimes(2)
181+
expect(onCheckedChange).toHaveBeenCalledTimes(2)
182+
})
183+
184+
it('should change state when user enters Space key on Switch', async () => {
185+
const user = userEvent.setup()
186+
const onCheckedChange = jest.fn()
187+
const { getByTestId } = renderComponent({
188+
defaultChecked: false,
189+
onCheckedChange,
190+
})
191+
const switchComponent = getByTestId(SWITCH_TEST_ID)
192+
193+
await user.tab()
194+
await user.keyboard('[Space]')
195+
expect(switchComponent).toHaveAttribute('data-state', 'checked')
196+
expect(onCheckedChange).toHaveBeenCalledTimes(1)
197+
await user.keyboard('[Space]')
198+
expect(switchComponent).toHaveAttribute('data-state', 'unchecked')
199+
expect(onCheckedChange).toHaveBeenCalledTimes(2)
200+
})
201+
202+
it('should change state when user enters Enter key on Switch', async () => {
203+
const user = userEvent.setup()
204+
const onCheckedChange = jest.fn()
205+
const { getByTestId } = renderComponent({
206+
defaultChecked: false,
207+
onCheckedChange,
119208
})
120209
const switchComponent = getByTestId(SWITCH_TEST_ID)
121210

122-
fireEvent.click(switchComponent)
123-
expect(onClick).toHaveBeenCalled()
211+
await user.tab()
212+
await user.keyboard('[Enter]')
213+
expect(switchComponent).toHaveAttribute('data-state', 'checked')
214+
expect(onCheckedChange).toHaveBeenCalledTimes(1)
215+
await user.keyboard('[Enter]')
216+
expect(switchComponent).toHaveAttribute('data-state', 'unchecked')
217+
expect(onCheckedChange).toHaveBeenCalledTimes(2)
124218
})
125219

126-
it('should not fire onClick event when disabled Switch is clicked', () => {
220+
it('should not change state when user clicks disabled Switch', async () => {
221+
const user = userEvent.setup()
127222
const onClick = jest.fn()
223+
const onCheckedChange = jest.fn()
128224
const { getByTestId } = renderComponent({
129-
onClick,
225+
defaultChecked: false,
130226
disabled: true,
227+
onClick,
228+
onCheckedChange,
131229
})
132230
const switchComponent = getByTestId(SWITCH_TEST_ID)
133231

134-
fireEvent.click(switchComponent)
232+
await user.click(switchComponent)
233+
expect(switchComponent).toHaveAttribute('data-state', 'unchecked')
135234
expect(onClick).not.toHaveBeenCalled()
235+
expect(onCheckedChange).not.toHaveBeenCalled()
236+
})
237+
})
238+
239+
describe('accessibility', () => {
240+
describe('role', () => {
241+
it('should render switch with "switch" role', () => {
242+
const { getByRole } = renderComponent()
243+
const switchComponent = getByRole('switch')
244+
245+
expect(switchComponent).toBeInTheDocument()
246+
})
247+
})
248+
249+
describe('aria-checked', () => {
250+
it('should be "true" when state is "on"', () => {
251+
const { getByRole } = renderComponent({
252+
checked: true,
253+
})
254+
const switchComponent = getByRole('switch')
255+
256+
expect(switchComponent).toHaveAttribute('aria-checked', 'true')
257+
})
258+
259+
it('should be "false" when state is "off"', () => {
260+
const { getByRole } = renderComponent({
261+
checked: false,
262+
})
263+
const switchComponent = getByRole('switch')
264+
265+
expect(switchComponent).toHaveAttribute('aria-checked', 'false')
266+
})
267+
})
268+
269+
describe('aria-disabled', () => {
270+
it('should have "true" value on "aria-disabled" attribute when disabled prop is true', () => {
271+
const { getByRole } = renderComponent({
272+
disabled: true,
273+
})
274+
const switchComponent = getByRole('switch')
275+
276+
expect(switchComponent).toHaveAttribute('aria-disabled', 'true')
277+
})
136278
})
137279
})
138280
})

0 commit comments

Comments
 (0)