티스토리 뷰
14. 접근성 개선
14-1. 접근성이란?
장애가 있는 사용자를 포함하여 모든 사람이 사용할 수 있는 웹 애플리케이션을 설계하고 구현하는 것을 의미한다.
키보드 탐색, 시멘틱 HTML, 이미지, 색상, 비디오 등과 같은 많은 영역을 다루는 광범위한 주제이다.
14-2. Next.js에서 ESLint 접근성 플러그인 사용하기
기본적으로, Next.js는 접근성 문제를 초기에 파악하는 데 도움이 되는 eslint-plugin-jsx-a11y 플러그인이 포함되어 있다.
예를 들어, 이 플러그인은 대체 텍스트가 없는 이미지가 있는 경우 aria-* 및 role 속성을 잘못 사용하는 경우 등을 경고한다.
package.json 파일에 next link를 스크립트에 추가한다.
"scripts": {
"build": "next build",
"dev": "next dev",
"seed": "node -r dotenv/config ./scripts/seed.js",
"start": "next start",
"lint": "next lint"
},
이후 터미널에 npm run lint를 실행하면 메세지가 표시된다.
✔ No ESLint warnings or errors
여기서 alt 텍스트가 없는 이미지가 있으면 어떻게 될까?
/app/ui/invoices/table.tsx로 이동해 이미지의 alt 속성을 제거한다.
<Image
src={invoice.image_url}
className="rounded-full"
width={28}
height={28}
// alt={`${invoice.name}'s profile picture`} // 주석 처리
/>
다시 npm run lint를 실행하면 경고가 표시된다.
./app/ui/invoices/table.tsx
45:25 Warning: Image elements must have an alt prop,
either with meaningful text, or an empty string for decorative images. jsx-a11y/alt-text
Vercel에 애플리케이션을 배포하려고 하면 빌드 로그에도 경고가 표시된다.
이는 next lint 빌드 프로세스의 일부로 실행되기 때문이다.
따라서 애플리케이션을 배포하기 전에 로컬에서 lint를 실행하여 접근성 문제를 파악할 수 있다.
14-3. 폼 접근성 개선하기
폼의 접근성을 개선하기 위해 이미 세 가지 작업을 수행하고 있다.
- 시멘틱 HTML : <div> 대신 <input>, <option>과 같은 시멘틱 요소를 사용한다. 이를 통해 보조 기술(AT)은 input 요소에 집중하고 사용자에게 적절한 상황 정보를 제공하여 폼을 더 쉽게 탐색하고 이해할 수 있다.
- 라벨링 : <label> 및 htmlFor 속성을 포함하면 각 폼 필드에 설명 텍스트 레이블이 포함된다. 이는 컨텍스트를 제공하여 AT지원을 향상시키고 사용자가 레이블을 클릭하여 해당 입력 필드에 집중할 수 있게 함으로써 유용성을 향상시킨다.
- 초점 윤관석(Focus Outline) : 초점이 맞춰졌을 때 윤관선을 표시하도록 필드의 스타일이 적절하게 지정되었다. 이는 페이지의 활성 요소를 시각적으로 표시하여 키보드와 화면 판독기 사용자 모두가 폼의 위치를 이해하는 데 도움이 되므로 접근성에 매우 중요하다. tab을 눌러 확인할 수 있다.
이러한 방법은 많은 사용자가 폼에 더 쉽게 액세스할 수 있도록 하지만 폼 유효성 검사 및 오류는 해결되지 않는다.
14-4. 폼 유효성 검사
1. 클라이언트 사이드 검증
클라이언트에서 폼의 유효성을 검사할 수 있는 몇 가지 방법이 있다.
가장 간단한 방법은 폼의 <input> 및 <select> 요소에 required 속성을 추가해 브라우저에서 제공하는 폼 유효성 검사에 의존하는 것이다.
// app/ui/invoices/create-form.tsx
<input
id="amount"
name="amount"
type="number"
placeholder="Enter USD amount"
className="peer block w-full rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
required
/>
빈 값이 포함된 폼을 제출하려고 하면 브라우저에 경고가 표시된다.
일부 AT는 브라우저 유효성 검사를 지원하므로 이 접근 방식은 일반적으로 괜찮다.
14-5. 서버 사이드 검증
클라이언트 사이드 유효성 검사의 대안은 서버 사이드 검사이다. 위에서 추가한 required를 삭제하고 시작한다.
서버에서 폼의 유효성을 검사하여 다음을 수행할 수 있다.
- 데이터를 데이터베이스에 보내기 전에 데이터가 예상된 형식인지 확인
- 악의적인 유저가 클라이언트 사이드 유효성 검사를 우회하는 위험을 줄임
- 유효한 데이터로 간주되는 정보에 대한 하나의 진실된 소스를 확보
create-form.tsx 컴포넌트에서 react-dom으로부터 useFormState 훅을 가져온다.
useFormState는 훅이기 때문에 "use clinet" 선언을 사용해 클라이언트 컴포넌트로 변경한다.
'use client';
// ...
import { useFormState } from 'react-dom';
useFormState 훅은
- 두 개의 인자를 사용 : (action, initialState)
- 두 개의 값을 반환 : [state, dispatch] - 폼 state와 디스패치 함수(useReducer와 유사)
createInvoice 액션을 useFormState의 인자로 전달하고 <form action={}> 내부에서 dispatch를 호출한다.
// ...
import { useFormState } from 'react-dom';
export default function Form({ customers }: { customers: CustomerField[] }) {
const [state, dispatch] = useFormState(createInvoice, initialState);
return <form action={dispatch}>...</form>;
}
initialState는 2개의 빈 키인 message와 errors를 사용해 객체를 만든다.
// ...
export default function Form({ customers }: { customers: CustomerField[] }) {
const initialState = { message: null, errors: {} };
const [state, dispatch] = useFormState(createInvoice, initialState);
// ...
}
actions.ts 파일에서 Zod를 사용해 폼 데이터 유효성을 검사한다.
현재 발생하고 있는 오류를 해결하기 위해 FormSchema를 업데이트한다.
const FormSchema = z.object({
id: z.string(),
customerId: z.string({
invalid_type_error: 'Please select a customer.',
}),
amount: z.coerce
.number()
.gt(0, { message: 'Please enter an amount greater than $0.' }),
status: z.enum(['pending', 'paid'], {
invalid_type_error: 'Please select an invoice status.',
}),
date: z.string(),
});
- customerId - Zod는 이미 타입을 문자열로 예상해 고객 필드가 비어있으면 오류를 보내지만, 유저가 customer를 선택하지 않으면 메세지가 뜨도록 추가
- amount - amount의 타입을 강제로 문자열에서 숫자로 변환하므로 문자열이 비어있다면 기본값은 0이 됨. 따라서 Zod에 .gt( )함수를 사용해 항상 0보다 큰 amount를 원한다고 전함
- status - Zod는 "pending" 또는 "paid"를 예상하므로 status 필드가 비어있으면 오류를 발생시키지만, 유저가 status를 선택하지 않은 경우 메세지가 뜨도록 추가
다음으로 createInvoice 액션이 두 파라미터를 받도록 업데이트한다.
export type State = {
errors?: {
customerId?: string[];
amount?: string[];
status?: string[];
};
message?: string | null;
};
export async function createInvoice(prevState: State, formData: FormData) {
// ...
}
- formData - 이전과 같음
- prevState - useFormState 훅에서 전달한 state를 포함. 이 액션에서는 사용하지 않지만 필수 prop
Zod의 parse( ) 함수를 safeParse( )로 바꾼다.
- safeParse( ) - success 또는 error 필드를 포함하는 객체를 반환
- 로직을 try/catch 블록에 넣지 않고도 유효성 검사를 원활하게 처리하는 데 도움됨
export async function createInvoice(prevState: State, formData: FormData) {
// Validate form fields using Zod
const validatedFields = CreateInvoice.safeParse({
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
});
// ...
}
데이터베이스에 정보를 보내기 전에 폼 필드가 조건부로 올바르게 검증되었는지 확인한다.
export async function createInvoice(prevState: State, formData: FormData) {
const validatedFields = CreateInvoice.safeParse({
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
});
// 폼 유효성이 실패하면 일찍이 errors를 반환
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
message: 'Missing Fields. Failed to Create Invoice.',
};
}
// ...
}
validatedFields가 성공하지 못하면 Zod의 오류 메세지와 함께 이 함수를 초기에 반환한다.
마지막으로, try/catch 블록 외부에서 폼 유효성 검사를 별도로 처리하므로 데이터베이스 오류에 대해 특정 메세지를 반환할 수 있다.
⇩ 최종 코드
export async function createInvoice(prevState: State, formData: FormData) {
const validatedFields = CreateInvoice.safeParse({
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
});
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
message: 'Missing Fields. Failed to Create Invoice.',
};
}
const { customerId, amount, status } = validatedFields.data;
const amountInCents = amount * 100;
const date = new Date().toISOString().split('T')[0];
try {
await sql`
INSERT INTO invoices (customer_id, amount, status, date)
VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
`;
} catch (error) {
return {
message: 'Database Error: Failed to Create Invoice.',
};
}
revalidatePath('/dashboard/invoices');
redirect('/dashboard/invoices');
}
form 컴포넌트에 오류를 표시하자
create-form.tsx 컴포넌트로 돌아가 state를 사용해 오류에 접근한다.
각 특정 오류를 확인하는 삼항 연산자를 추가한다. customer 필드 뒤에 추가하자!
<form action={dispatch}>
<div className="rounded-md bg-gray-50 p-4 md:p-6">
{/* Customer Name */}
<div className="mb-4">
// ...
<div className="relative">
<select
id="customer"
name="customerId"
className="peer block w-full rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
defaultValue=""
aria-describedby="customer-error"
>
// ...
</select>
<UserCircleIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500" />
</div>
<div id="customer-error" aria-live="polite" aria-atomic="true">
{state.errors?.customerId &&
state.errors.customerId.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
</div>
// ...
</div>
</form>
- aria-describedby="customer-error" : select 요소와 오류 메세지 컨테이너 간의 관계를 설정
- id="customer-error"인 컨테이너가 select 요소를 설명함을 나타냄. 화면 판독기는 유저가 select 박스와 상호작용하여 오류를 알릴 때 이 설명을 읽음
- id="customer-error" : id 속성은 select input에 대한 오류 메세지를 보유하는 HTML 요소를 고유하게 식별함. aria-describedby가 관계를 설정하는 데 필요함
- aria-live="polite" : 스크린 리더는 div 내부의 오류가 업데이트되면 사용자에게 정중하게 알려야함
- 콘텐츠가 변경되면(ex. 유저가 오류를 수정하는 경우) 스크린 리더는 이 변경 사항을 알려주지만, 방해가 되지 않도록 유저가 아무것도 안하는 상태일 때만 알림
14-6. aria label 추가하기
나머지 폼 필드에 오류를 추가하자!
누락된 필드가 있는 경우 폼 하단에 메세지를 표시해야 한다.
⇩ 완성 코드
export default function Form({ customers }: { customers: CustomerField[] }) {
const initialState = { message: null, errors: {} };
const [state, dispatch] = useFormState(createInvoice, initialState);
return (
<form action={dispatch}>
<div className="rounded-md bg-gray-50 p-4 md:p-6">
{/* Customer Name */}
<div className="mb-4">
<label htmlFor="customer" className="mb-2 block text-sm font-medium">
Choose customer
</label>
<div className="relative">
<select
id="customer"
name="customerId"
className="peer block w-full cursor-pointer rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
defaultValue=""
aria-describedby="customer-error"
>
<option value="" disabled>
Select a customer
</option>
{customers.map((customer) => (
<option key={customer.id} value={customer.id}>
{customer.name}
</option>
))}
</select>
<UserCircleIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500" />
</div>
<div id="customer-error" aria-live="polite" aria-atomic="true">
{state.errors?.customerId &&
state.errors.customerId.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
</div>
{/* Invoice Amount */}
<div className="mb-4">
<label htmlFor="amount" className="mb-2 block text-sm font-medium">
Choose an amount
</label>
<div className="relative mt-2 rounded-md">
<div className="relative">
<input
id="amount"
name="amount"
type="number"
step="0.01"
placeholder="Enter USD amount"
className="peer block w-full rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
aria-describedby="amount-error"
// required
/>
<CurrencyDollarIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
</div>
<div id="amount-error" aria-live="polite" aria-atomic="true">
{state.errors?.amount &&
state.errors.amount.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
</div>
</div>
{/* Invoice Status */}
<fieldset>
<legend className="mb-2 block text-sm font-medium">
Set the invoice status
</legend>
<div className="rounded-md border border-gray-200 bg-white px-[14px] py-3">
<div className="flex gap-4">
<div className="flex items-center">
<input
id="pending"
name="status"
type="radio"
value="pending"
className="h-4 w-4 cursor-pointer border-gray-300 bg-gray-100 text-gray-600 focus:ring-2"
aria-describedby="status-error"
/>
<label
htmlFor="pending"
className="ml-2 flex cursor-pointer items-center gap-1.5 rounded-full bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-600"
>
Pending <ClockIcon className="h-4 w-4" />
</label>
</div>
<div className="flex items-center">
<input
id="paid"
name="status"
type="radio"
value="paid"
className="h-4 w-4 cursor-pointer border-gray-300 bg-gray-100 text-gray-600 focus:ring-2"
aria-describedby="status-error"
/>
<label
htmlFor="paid"
className="ml-2 flex cursor-pointer items-center gap-1.5 rounded-full bg-green-500 px-3 py-1.5 text-xs font-medium text-white"
>
Paid <CheckIcon className="h-4 w-4" />
</label>
</div>
</div>
</div>
<div id="status-error" aria-live="polite" aria-atomic="true">
{state.errors?.status &&
state.errors.status.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
</fieldset>
</div>
<div className="mt-6 flex justify-end gap-4">
<Link
href="/dashboard/invoices"
className="flex h-10 items-center rounded-lg bg-gray-100 px-4 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-200"
>
Cancel
</Link>
<Button type="submit">Create Invoice</Button>
</div>
</form>
);
}
+ 수정 폼 유효성 검사하기
edit-form.tsx 컴포넌트에 폼 유효성 검사를 추가하자!
- edit-form.tsx에 useFormState 추가하기
- Zod의 유효성 검사 오류를 처리하기 위해 updateInvoice 액션 편집하기
- 컴포넌트에 오류를 표시하고 aria label을 추가해 접근성 향상시키기
1. EditInvoiceForm 수정
빈 message와 errors 키를 가진 initialState를 만들어 useFormState에 넘겨준다.
반환된 dispatch 함수를 action에 넘겨준다.
export default function EditInvoiceForm({
invoice,
customers,
}: {
invoice: InvoiceForm;
customers: CustomerField[];
}) {
const initialState = { message: null, errors: {} };
const updateInvoiceWithId = updateInvoice.bind(null, invoice.id);
const [state, dispatch] = useFormState(updateInvoiceWithId, initialState);
return <form action={dispatch}></form>;
}
2. Server Action 수정
export async function updateInvoice(
id: string,
prevState: State,
formData: FormData,
) {
const validatedFields = UpdateInvoice.safeParse({
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
});
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
message: 'Missing Fields. Failed to Update Invoice.',
};
}
const { customerId, amount, status } = validatedFields.data;
const amountInCents = amount * 100;
try {
await sql`
UPDATE invoices
SET customer_id = ${customerId}, amount = ${amountInCents}, status = ${status}
WHERE id = ${id}
`;
} catch (error) {
return { message: 'Database Error: Failed to Update Invoice.' };
}
revalidatePath('/dashboard/invoices');
redirect('/dashboard/invoices');
}
3. aria label 추가
편집 폼의 경우 기존의 데이터로 각 필드가 채워지기 때문에 customer와 status는 빈 값이 들어오는 오류가 발생할 수 없다.
따라서 amount에 대한 aria label만 추가했다.
{/* Invoice Amount */}
<div className="mb-4">
<label htmlFor="amount" className="mb-2 block text-sm font-medium">
Choose an amount
</label>
<div className="relative mt-2 rounded-md">
<div className="relative">
<input
id="amount"
name="amount"
type="number"
step="0.01"
defaultValue={invoice.amount}
placeholder="Enter USD amount"
className="peer block w-full rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
aria-describedby="amount-error"
/>
<CurrencyDollarIcon className="pointer-events-none absolute left-3 top-1/2 h-[18px] w-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
</div>
<div id="amount-error" aria-live="polite" aria-atomic="true">
{state.errors?.amount &&
state.errors.amount.map((error: string) => (
<p className="mt-2 text-sm text-red-500" key={error}>
{error}
</p>
))}
</div>
</div>
</div>
'코딩 > Next.js' 카테고리의 다른 글
Next.js를 알아보자 - Customers 페이지 (0) | 2024.05.14 |
---|---|
Next.js를 알아보자 - 공식문서 따라하기(15~16장) (0) | 2024.05.12 |
Next.js를 알아보자 - 공식문서 따라하기(12~13장) (0) | 2024.05.09 |
Next.js를 알아보자 - 공식문서 따라하기(10~11장) (0) | 2024.05.09 |
Next.js를 알아보자 - 공식문서 따라하기(7~9장) (0) | 2024.05.08 |
- Total
- Today
- Yesterday
- 로컬 저장소
- 자바스크립트
- css
- 인증
- 코드스테이츠
- git 오류
- 개발
- React.JS
- 타입스크립트
- cdd
- 띵동코딩
- 정처기필기
- 상태관리
- 웹팩
- 프론트엔드
- 티스토리챌린지
- styled-component
- React
- HTML
- 데이터요청
- 프레임워크
- 클론코딩
- 오블완
- 보안
- useRef
- javascript
- Next.js
- nextjs
- 부트캠프
- 번들링
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | ||||
4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 |