-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #15 from ReoHakase/11/add-checkbox
チェックボックス`<Checkbox>`, `<PrefectureCheckbox>`を実装した
- Loading branch information
Showing
6 changed files
with
546 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,114 @@ | ||
import type { Meta, StoryObj } from '@storybook/react'; | ||
import { expect, fn, userEvent, within } from '@storybook/test'; | ||
import { Checkbox } from './Checkbox'; | ||
import { css } from 'styled-system/css'; | ||
|
||
type Story = StoryObj<typeof Checkbox>; | ||
|
||
const meta: Meta<typeof Checkbox> = { | ||
component: Checkbox, | ||
tags: ['autodocs'], | ||
args: { | ||
id: 'example', | ||
'aria-labelledby': 'example-label', | ||
onChange: fn(), | ||
checked: undefined, | ||
}, | ||
decorators: [ | ||
// a11yテストでラベルが存在しないエラーを防ぐために<label>を追加する。ストーリー内では見えない方が都合がいいので、スクリーンリーダーにしか見えないようにする。 | ||
(Story) => ( | ||
<> | ||
<Story /> | ||
<label id="example-label" htmlFor="example" className={css({ srOnly: true })}> | ||
Example | ||
</label> | ||
</> | ||
), | ||
], | ||
argTypes: { | ||
id: { | ||
description: 'チェックボックスのID', | ||
control: { | ||
type: 'text', | ||
}, | ||
}, | ||
'aria-labelledby': { | ||
description: | ||
'ユーザーに向けた実際のラベルのID。独自の見た目を持つチェックボックスの実装に`<label>`を用いているため、スクリーンリーダーが読み上げるべきものを区別するために必要', | ||
control: { | ||
type: 'text', | ||
}, | ||
}, | ||
checked: { | ||
description: 'チェックボックスの値', | ||
control: { | ||
type: 'boolean', | ||
}, | ||
}, | ||
disabled: { | ||
description: 'チェックボックスを無効にする', | ||
control: { | ||
type: 'boolean', | ||
}, | ||
}, | ||
defaultChecked: { | ||
description: 'チェックボックスの初期値', | ||
control: { | ||
type: 'boolean', | ||
}, | ||
}, | ||
}, | ||
}; | ||
|
||
export default meta; | ||
|
||
export const Default: Story = { | ||
args: { | ||
checked: true, | ||
}, | ||
play: async ({ args, canvasElement }) => { | ||
const canvas = within(canvasElement); | ||
const checkbox = canvas.getByRole('checkbox'); | ||
|
||
checkbox.click(); | ||
|
||
checkbox.focus(); | ||
await userEvent.keyboard('[Space][Space][Space]', { delay: 100 }); | ||
|
||
// onChangeが1回呼び出されたことを確認 | ||
expect(args.onChange).toHaveBeenCalledTimes(4); | ||
}, | ||
}; | ||
|
||
export const Unchecked: Story = { | ||
args: { | ||
checked: false, | ||
}, | ||
play: Default.play, | ||
}; | ||
|
||
export const Disabled: Story = { | ||
args: { | ||
disabled: true, | ||
}, | ||
play: async ({ args, canvasElement }) => { | ||
const canvas = within(canvasElement); | ||
const checkbox = canvas.getByRole('checkbox'); | ||
|
||
checkbox.click(); | ||
|
||
checkbox.focus(); | ||
await userEvent.keyboard('[Space][Space][Space]', { delay: 100 }); | ||
|
||
// onChangeが1回呼び出されたことを確認 | ||
expect(args.onChange).toHaveBeenCalledTimes(0); | ||
}, | ||
}; | ||
|
||
export const CheckedDisabled: Story = { | ||
args: { | ||
checked: true, | ||
disabled: true, | ||
}, | ||
play: Disabled.play, | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,82 @@ | ||
import { Check } from 'lucide-react'; | ||
import { ComponentPropsWithoutRef, forwardRef, ReactNode } from 'react'; | ||
import { css, cx } from 'styled-system/css'; | ||
|
||
export type CheckboxProps = Omit<ComponentPropsWithoutRef<'input'>, 'type'> & { | ||
id: string; // <label>で擬似的なチェックボックスを作るために必要 | ||
'aria-labelledby': string; // 実際にラベルテキストを含む<label>のidを必ず指定する | ||
}; | ||
|
||
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(({ id, className, ...props }, ref): ReactNode => { | ||
return ( | ||
<> | ||
<label | ||
htmlFor={id} | ||
// a11yテストで、labelが複数存在するエラーを防ぐためにaria-hiddenを指定する。実際のlabelのidをaria-labelledbyで指定する | ||
// @see https://dequeuniversity.com/rules/axe/4.9/form-field-multiple-labels | ||
aria-hidden="true" | ||
className={cx( | ||
css({ | ||
display: 'flex', | ||
justifyContent: 'center', | ||
alignItems: 'center', | ||
w: '8', | ||
h: '8', | ||
rounded: 'sm', | ||
border: '1px solid', | ||
borderColor: 'keyplate.6', | ||
bg: 'keyplate.2', | ||
cursor: 'pointer', | ||
'&:has(+ input:focus-visible)': { | ||
ringColor: 'cyan.9', | ||
ringWidth: '2', | ||
outlineStyle: 'solid', | ||
ringOffset: '2px', | ||
}, | ||
'&:has(+ input:checked)': { | ||
bg: 'primary.3', | ||
borderColor: 'primary.9', | ||
color: 'primary.11', | ||
}, | ||
'&:has(+ input:disabled)': { | ||
bg: 'keyplate.4', | ||
color: 'keyplate.11', | ||
cursor: 'not-allowed', | ||
}, | ||
'&:has(+ input:checked:disabled)': { | ||
bg: 'keyplate.4', | ||
color: 'keyplate.11', | ||
borderColor: 'keyplate.9', | ||
}, | ||
_hover: { | ||
bg: 'keyplate.3', | ||
}, | ||
}), | ||
className, | ||
)} | ||
> | ||
<Check | ||
className={css({ | ||
'label:has(+ input:not(:checked)) &': { | ||
display: 'none', | ||
}, | ||
})} | ||
/> | ||
</label> | ||
<input | ||
id={id} | ||
type="checkbox" | ||
ref={ref} | ||
className={css({ | ||
appearance: 'none', | ||
pos: 'absolute', | ||
top: '0', | ||
left: '0', | ||
pointerEvents: 'none', | ||
})} | ||
{...props} | ||
/> | ||
</> | ||
); | ||
}); | ||
Checkbox.displayName = 'Checkbox'; |
158 changes: 158 additions & 0 deletions
158
.../web/src/features/navigation/components/PrefectureCheckbox/PrefectureCheckbox.stories.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,158 @@ | ||
import { action } from '@storybook/addon-actions'; | ||
import type { Meta, StoryObj } from '@storybook/react'; | ||
import { expect, fn, userEvent, within } from '@storybook/test'; | ||
import { PrefectureCheckbox } from './PrefectureCheckbox'; | ||
import { css } from 'styled-system/css'; | ||
|
||
type Story = StoryObj<typeof PrefectureCheckbox>; | ||
|
||
const meta: Meta<typeof PrefectureCheckbox> = { | ||
component: PrefectureCheckbox, | ||
tags: ['autodocs'], | ||
args: { | ||
prefCode: '1', | ||
prefLocale: '北海道', | ||
onChange: fn(), | ||
}, | ||
decorators: [ | ||
// a11yテストでラベルが存在しないエラーを防ぐために見えない<label>を追加 | ||
(Story) => ( | ||
<> | ||
<Story /> | ||
<label htmlFor="example" className={css({ srOnly: true })}> | ||
Example | ||
</label> | ||
</> | ||
), | ||
], | ||
parameters: { | ||
nextjs: { | ||
appDirectory: true, | ||
navigation: { | ||
push: fn(async (...args: unknown[]) => action('nextRouter.push')(...args)), | ||
pathname: '/all', | ||
query: { | ||
prefCodes: '11,24', | ||
}, | ||
}, | ||
}, | ||
}, | ||
argTypes: { | ||
prefCode: { | ||
description: 'チェックボックスの都道府県コード', | ||
control: { | ||
type: 'text', | ||
}, | ||
}, | ||
prefLocale: { | ||
description: '都道府県名', | ||
control: { | ||
type: 'text', | ||
}, | ||
}, | ||
disabled: { | ||
description: 'チェックボックスを無効にする', | ||
control: { | ||
type: 'boolean', | ||
}, | ||
}, | ||
defaultChecked: { | ||
description: 'チェックボックスの初期値', | ||
control: { | ||
type: 'boolean', | ||
}, | ||
}, | ||
}, | ||
}; | ||
|
||
export default meta; | ||
|
||
export const Default: Story = { | ||
parameters: { | ||
nextjs: { | ||
navigation: { | ||
query: { | ||
prefCodes: '8,12,13,14', | ||
}, | ||
}, | ||
}, | ||
}, | ||
play: async ({ args, parameters, canvasElement }) => { | ||
const canvas = within(canvasElement); | ||
const checkbox = canvas.getByRole('checkbox'); | ||
const label = canvas.getByText(args.prefLocale); | ||
|
||
// チェックボックスがチェックされていないことを確認 | ||
expect(checkbox).not.toBeChecked(); | ||
|
||
// ラベルが表示されていることを確認 | ||
expect(label).toBeVisible(); | ||
|
||
checkbox.focus(); | ||
await userEvent.keyboard('[Space]', { delay: 100 }); | ||
|
||
// onChangeが1回呼び出されたことを確認 | ||
expect(args.onChange).toHaveBeenCalledTimes(1); | ||
// searchParamsのprefCodesに与えた都道府県コードが追加されたことを確認 | ||
expect(parameters.nextjs.navigation.push).toHaveBeenCalledTimes(1); | ||
expect(parameters.nextjs.navigation.push).toHaveBeenCalledWith('/all?prefCodes=1,8,12,13,14'); | ||
}, | ||
}; | ||
|
||
export const Checked: Story = { | ||
parameters: { | ||
nextjs: { | ||
navigation: { | ||
query: { | ||
prefCodes: '1,8,12,13,14', | ||
}, | ||
}, | ||
}, | ||
}, | ||
play: async ({ args, parameters, canvasElement }) => { | ||
const canvas = within(canvasElement); | ||
const checkbox = canvas.getByRole('checkbox'); | ||
const label = canvas.getByText(args.prefLocale); | ||
|
||
// チェックボックスがチェックされていることを確認 | ||
expect(checkbox).toBeChecked(); | ||
|
||
// ラベルが表示されていることを確認 | ||
expect(label).toBeVisible(); | ||
|
||
checkbox.focus(); | ||
await userEvent.keyboard('[Space]', { delay: 100 }); | ||
|
||
// onChangeが1回呼び出されたことを確認 | ||
expect(args.onChange).toHaveBeenCalledTimes(1); | ||
// searchParamsのprefCodesに与えた都道府県コードが削除されたことを確認 | ||
expect(parameters.nextjs.navigation.push).toHaveBeenCalledTimes(1); | ||
expect(parameters.nextjs.navigation.push).toHaveBeenCalledWith('/all?prefCodes=8,12,13,14'); | ||
}, | ||
}; | ||
|
||
export const Disabled: Story = { | ||
parameters: Default.parameters, | ||
args: { | ||
disabled: true, | ||
}, | ||
play: async ({ args, parameters, canvasElement }) => { | ||
const canvas = within(canvasElement); | ||
const checkbox = canvas.getByRole('checkbox'); | ||
|
||
checkbox.focus(); | ||
await userEvent.keyboard('[Space]', { delay: 100 }); | ||
|
||
// onChangeが呼び出されなかったことを確認 | ||
expect(args.onChange).toHaveBeenCalledTimes(0); | ||
expect(parameters.nextjs.navigation.push).toHaveBeenCalledTimes(0); | ||
}, | ||
}; | ||
|
||
export const DisabledChecked: Story = { | ||
parameters: Checked.parameters, | ||
args: { | ||
disabled: true, | ||
}, | ||
play: Disabled.play, | ||
}; |
Oops, something went wrong.