Skip to content

Commit

Permalink
Merge pull request #15 from ReoHakase/11/add-checkbox
Browse files Browse the repository at this point in the history
チェックボックス`<Checkbox>`, `<PrefectureCheckbox>`を実装した
  • Loading branch information
ReoHakase authored May 25, 2024
2 parents 16c3142 + a7e411e commit 101a389
Show file tree
Hide file tree
Showing 6 changed files with 546 additions and 0 deletions.
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"@pandacss/dev": "0.36.1",
"@radix-ui/colors": "3.0.0",
"@storybook/addon-a11y": "8.0.5",
"@storybook/addon-actions": "8.1.3",
"@storybook/addon-essentials": "8.0.5",
"@storybook/addon-interactions": "8.0.5",
"@storybook/addon-links": "8.0.5",
Expand Down
114 changes: 114 additions & 0 deletions apps/web/src/components/Checkbox/Checkbox.stories.tsx
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,
};
82 changes: 82 additions & 0 deletions apps/web/src/components/Checkbox/Checkbox.tsx
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';
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,
};
Loading

0 comments on commit 101a389

Please sign in to comment.