Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
75 changes: 75 additions & 0 deletions content/intro-to-storybook/react/ko/accessibility-testing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
---
title: '접근성 테스트'
tocTitle: '접근성 테스트'
description: '워크플로우에 접근성 테스트를 통합하는 방법을 배워보세요'
---

지금까지 우리는 시각적 테스트와 그 기능에 중점을 두고, 점차 복잡도를 높여가며 UI 컴포넌트를 구축하는 것에 집중해 왔습니다. 하지만 아직 UI 개발에 중요한 한 측면을 다루지 않았습니다. 바로 접근성입니다.

## 왜 접근성(A11y)일까요?

접근성은 모든 사용자가 신체적 능력에 관계없이 컴포넌트와 효과적으로 상호작용할 수 있도록 보장합니다. 여기에는 시각, 청각, 운동 또는 인지 장애가 있는 사용자가 포함됩니다. 접근성은 올바른 일일 뿐만 아니라 법적 요구 사항과 업계 표준에 따라 점점 더 의무화되고 있습니다. 이러한 요구 사항을 고려할 때, 우리는 UI 컴포넌트의 접근성을 개발 초기부터 자주 테스트해야 합니다.

## Storybook으로 접근성 문제 해결하기

Storybook은 컴포넌트의 접근성을 테스트하는 데 도움을 주는 [접근성 애드온](https://storybook.js.org/addons/@storybook/addon-a11y) (A11y)을 제공합니다. [axe-core](https://github.com/dequelabs/axe-core)를 기반으로 하며, [WCAG 문제의 최대 57%](https://www.deque.com/blog/automated-testing-study-identifies-57-percent-of-digital-accessibility-issues/)를 포착할 수 있습니다.

어떻게 작동하는지 봅시다! 애드온을 설치하려면 다음 명령을 실행하세요:

```shell
yarn exec storybook add @storybook/addon-a11y
```

<div class="aside">

💡 Storybook의 `add` 명령은 애드온의 설치와 설정을 자동화합니다. 사용 가능한 다른 명령어에 대해 자세히 알아보려면 [공식 문서](https://storybook.js.org/docs/api/cli-options)를 참조하세요.

</div>

스토리북을 재시작하여 UI에서 새로운 애드온이 활성화되었는지 확인하세요.

![Task accessibility issue in Storybook](/intro-to-storybook/accessibility-issue-task-react-9-0.png)

스토리들을 순환하면서, 애드온이 테스트 상태 중 하나에서 접근성 문제를 발견한 것을 볼 수 있습니다.[**"색상 대비"**](https://dequeuniversity.com/rules/axe/4.10/color-contrast?application=axeAPI)위반은 기본적으로 작업 제목 색과 배경 색 간의 대비가 충분하지 않다는 의미입니다. 애플리케이션의 CSS(`src/index.css`)에서 텍스트 색상을 더 어두운 회색으로 변경하여 간단히 해결할 수 있습니다.

```diff:title=src/index.css
.list-item.TASK_ARCHIVED input[type="text"] {
- color: #a0aec0;
+ color: #4a5568;
text-decoration: line-through;
}
```

됐습니다! 우리는 UI 접근성 확보의 첫 걸음을 뗐습니다. 하지만 이게 우리가 해야할 일의 끝은 아닙니다. 접근성을 유지하는것은 지속적인 과정이며 우리는 애플리케이션의 발전하고 UI가 복잡해짐에 따라 새로운 접근성 문제나 회귀 현상이 발생하지 않도록 모니터링해야 합니다.

## Chromatic을 이용한 접근성 테스트

스토리북의 접근성 애드온을 사용하면 개발 중에 접근성 문제를 테스트하고 즉각적인 피드백을 받을 수 있습니다. 하지만 접근성 문제를 추적하는 것은 쉽지 않은 일이며, 어떤 문제를 먼저 해결할지 우선순위를 정하는 데에도 노력이 필요할 수 있습니다. 바로 이 때 Chromatic이 도움을 줄 수 있습니다. 이미 보았듯이, Chromatic은 [시각적 테스트](/intro-to-storybook/react/ko/test/)를 통해 회귀를 방지하는 데 도움을 주었습니다. 이제 [접근성 테스트 기능](https://www.chromatic.com/docs/accessibility)을 사용하여 UI가 접근성을 유지하도록 하고, 실수로 새로운 위반 사항이 발생하지 않도록 할 것입니다.

### 접근성 테스트 활성화하기

Chromatic 프로젝트로 이동하여 **Manage** 페이지로 이동합니다. **Enable** 버튼을 클릭하여 해당 프로젝트의 접근성 테스트를 활성화합니다.

![Chromatic accessibility tests enabled](/intro-to-storybook/chromatic-a11y-tests-enabled.png)

### 접근성 테스트 실행하기

이제 접근성 테스트를 활성화 했고 CSS의 색상 대비 문제를 해결했으니, 변경 사항을 푸시하여 새로운 Chromatic 빌드를 실행해 봅시다.

```shell:clipboard=false
git add .
git commit -m "Fix color contrast accessibility violation"
git push
```

Chromatic이 실행되면, 향후 진행할 테스트들의 기준점이 될 [접근성 기준선](https://www.chromatic.com/docs/accessibility/#what-is-an-accessibility-baseline)을 설정합니다. 이를 통해 우리는 새로운 회귀 현상을 일으키지 않으면서 효과적으로 우선순위에 따라 해결할 수 있습니다.

<!--

TODO: Follow up with Design for an updated asset
- Needs a React and non-React version to ensure parity with the tutorial
-->

![Chromatic build with accessibility tests](/intro-to-storybook/chromatic-build-a11y-tests-react.png)

이제 우리는 성공적으로 개발의 모든 단계에서 UI가 접근성을 유지할 수 있는 워크플로우를 구축했습니다. Storybook이 개발 과정에서 발생하는 접근성 문제를 발견하는데 도움을 준다면, Chromatic은 접근성 회귀 현상을 추적하고 점진적으로 해결해나가기 쉽게 만들어줍니다.
153 changes: 60 additions & 93 deletions content/intro-to-storybook/react/ko/composite-component.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,32 @@ Taskbox는 핀으로 고정된 task를 일반 task 위에 배치하여 강조합

## 설정하기

복합 컴포넌트는 기본 컴포넌트와 크게 다르지 않습니다. `TaskList` 컴포넌트와 그에 해당하는 스토리 파일을 만들어보겠습니다. `src/components/TaskList.jsx` 와 `src/components/TaskList.stories.jsx`를 생성해 주세요.
복합 컴포넌트는 기본 컴포넌트와 크게 다르지 않습니다. `TaskList` 컴포넌트와 그에 해당하는 스토리 파일을 만들어보겠습니다. `src/components/TaskList.tsx` 와 `src/components/TaskList.stories.tsx`를 생성해 주세요.

우선 `TaskList`의 대략적인 구현부터 시작하겠습니다. 이전의 `Task` 컴포넌트를 가져온 후, 속성과 액션을 입력값으로 전달해 주세요.

```jsx:title=src/components/TaskList.jsx
```tsx:title=src/components/TaskList.tsx
import type { TaskData } from '../types';

import Task from './Task';

export default function TaskList({ loading, tasks, onPinTask, onArchiveTask }) {
type TaskListProps = {
/** Checks if it's in loading state */
loading?: boolean;
/** The list of tasks */
tasks: TaskData[];
/** Event to change the task to pinned */
onPinTask: (id: string) => void;
/** Event to change the task to archived */
onArchiveTask: (id: string) => void;
};

export default function TaskList({
loading = false,
tasks,
onPinTask,
onArchiveTask,
}: TaskListProps) {
const events = {
onPinTask,
onArchiveTask,
Expand All @@ -42,7 +60,7 @@ export default function TaskList({ loading, tasks, onPinTask, onArchiveTask }) {

return (
<div className="list-items">
{tasks.map(task => (
{tasks.map((task) => (
<Task key={task.id} task={task} {...events} />
))}
</div>
Expand All @@ -52,25 +70,30 @@ export default function TaskList({ loading, tasks, onPinTask, onArchiveTask }) {

그리고, 스토리 파일 안에 `TaskList`의 테스트 상태값들을 만들어 보세요.

```jsx:title=src/components/TaskList.stories.jsx
```tsx:title=src/components/TaskList.stories.tsx
import type { Meta, StoryObj } from '@storybook/react-vite';

import TaskList from './TaskList';

import * as TaskStories from './Task.stories';

export default {
const meta = {
component: TaskList,
title: 'TaskList',
decorators: [(story) => <div style={{ margin: '3rem' }}>{story()}</div>],
tags: ['autodocs'],
tags: ["autodocs"],
args: {
...TaskStories.ActionsData,
},
};
} satisfies Meta<typeof TaskList>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Default = {
export const Default: Story = {
args: {
// Shaping the stories through args composition.
// The data was inherited from the Default story in Task.stories.jsx.
// The data was inherited from the Default story in Task.stories.tsx.
tasks: [
{ ...TaskStories.Default.args.task, id: '1', title: 'Task 1' },
{ ...TaskStories.Default.args.task, id: '2', title: 'Task 2' },
Expand All @@ -82,7 +105,7 @@ export const Default = {
},
};

export const WithPinnedTasks = {
export const WithPinnedTasks: Story = {
args: {
tasks: [
...Default.args.tasks.slice(0, 5),
Expand All @@ -91,14 +114,14 @@ export const WithPinnedTasks = {
},
};

export const Loading = {
export const Loading: Story = {
args: {
tasks: [],
loading: true,
},
};

export const Empty = {
export const Empty: Story = {
args: {
// Shaping the stories through args composition.
// Inherited data coming from the Loading story.
Expand All @@ -122,7 +145,7 @@ export const Empty = {

<video autoPlay muted playsInline loop>
<source
src="/intro-to-storybook/inprogress-tasklist-states-7-0.mp4"
src="/intro-to-storybook/inprogress-tasklist-states-9-0.mp4"
type="video/mp4"
/>
</video>
Expand All @@ -131,10 +154,28 @@ export const Empty = {

우리의 컴포넌트는 아직 기본 뼈대만을 갖추었지만, 앞으로 작업하게 될 스토리에 대한 아이디어를 얻었습니다. `.list-items` 래퍼(wrapper)가 지나치게 단순하다고 생각할 수도 있습니다. 맞습니다! 대부분의 경우에 우리는 단지 래퍼(wrapper)를 추가하기 위해서 새로운 컴포넌트를 만들지 않습니다. 하지만 `TaskList` 컴포넌트의 **진정한 복잡성**은 `withPinnedTasks`, `loading` 그리고 `empty`에서 드러날 것입니다.

```jsx:title=src/components/TaskList.jsx
```tsx:title=src/components/TaskList.tsx
import type { TaskData } from '../types';

import Task from './Task';

export default function TaskList({ loading, tasks, onPinTask, onArchiveTask }) {
type TaskListProps = {
/** Checks if it's in loading state */
loading?: boolean;
/** The list of tasks */
tasks: TaskData[];
/** Event to change the task to pinned */
onPinTask: (id: string) => void;
/** Event to change the task to archived */
onArchiveTask: (id: string) => void;
};

export default function TaskList({
loading = false,
tasks,
onPinTask,
onArchiveTask,
}: TaskListProps) {
const events = {
onPinTask,
onArchiveTask,
Expand Down Expand Up @@ -172,8 +213,8 @@ export default function TaskList({ loading, tasks, onPinTask, onArchiveTask }) {
}

const tasksInOrder = [
...tasks.filter((t) => t.state === 'TASK_PINNED'),
...tasks.filter((t) => t.state !== 'TASK_PINNED'),
...tasks.filter((t) => t.state === "TASK_PINNED"),
...tasks.filter((t) => t.state !== "TASK_PINNED"),
];
return (
<div className="list-items">
Expand All @@ -189,87 +230,13 @@ export default function TaskList({ loading, tasks, onPinTask, onArchiveTask }) {

<video autoPlay muted playsInline loop>
<source
src="/intro-to-storybook/finished-tasklist-states-7-0.mp4"
src="/intro-to-storybook/finished-tasklist-states-9-0.mp4""
type="video/mp4"
/>
</video>

목록에서 핀으로 고정된 task의 위치를 확인해 주세요. 핀으로 고정된 task를 사용자를 위해 목록의 맨 위에 위치하도록 우선순위를 부여합니다.

## 데이터 요구사항 및 props

컴포넌트가 커질수록 입력에 필요한 데이터 요구사항도 함께 커집니다. `TaskList`에서 prop의 요구사항을 정의해봅시다. `Task`는 하위 컴포넌트이기 때문에 렌더링에 필요한 적합한 형태의 데이터를 제공해야 합니다. 시간 절약을 위해서 `Task`에서 사용한 `propTypes`를 재사용하겠습니다.

```diff:title=src/components/TaskList.jsx
+ import PropTypes from 'prop-types';

import Task from './Task';

export default function TaskList({ loading, tasks, onPinTask, onArchiveTask }) {
const events = {
onPinTask,
onArchiveTask,
};
const LoadingRow = (
<div className="loading-item">
<span className="glow-checkbox" />
<span className="glow-text">
<span>Loading</span> <span>cool</span> <span>state</span>
</span>
</div>
);
if (loading) {
return (
<div className="list-items" data-testid="loading" key={"loading"}>
{LoadingRow}
{LoadingRow}
{LoadingRow}
{LoadingRow}
{LoadingRow}
{LoadingRow}
</div>
);
}
if (tasks.length === 0) {
return (
<div className="list-items" key={"empty"} data-testid="empty">
<div className="wrapper-message">
<span className="icon-check" />
<p className="title-message">You have no tasks</p>
<p className="subtitle-message">Sit back and relax</p>
</div>
</div>
);
}

const tasksInOrder = [
...tasks.filter((t) => t.state === 'TASK_PINNED'),
...tasks.filter((t) => t.state !== 'TASK_PINNED'),
];
return (
<div className="list-items">
{tasksInOrder.map((task) => (
<Task key={task.id} task={task} {...events} />
))}
</div>
);
}

+ TaskList.propTypes = {
+ /** Checks if it's in loading state */
+ loading: PropTypes.bool,
+ /** The list of tasks */
+ tasks: PropTypes.arrayOf(Task.propTypes.task).isRequired,
+ /** Event to change the task to pinned */
+ onPinTask: PropTypes.func,
+ /** Event to change the task to archived */
+ onArchiveTask: PropTypes.func,
+ };
+ TaskList.defaultProps = {
+ loading: false,
+ };
```

<div class="aside">
💡 깃(Git)에 변경된 사항을 commit하는 것을 잊지 마세요!
</div>
Loading
Loading