Vue3로 접근성을 고려한 Form(양식)을 개발하는 방법의 기초를 학습해 봅시다.
전체 소스 코드 보기
https://github.com/Code-Pop/Vue-3-Forms/tree/l9-end
양식(form)의 기초
1. HTML에서 양식의 기본 동작은 브라우저 탐색(navigation) 이벤트를 트리거하여 지정된 URL로 많은 데이터를 보내는 것입니다.
- Axios와 같은 라이브러리를 사용하지 않으면 HTML 양식 제출시 브라우저가 완전히 새로운 페이지를 로드하게 합니다다
- 스크린 리더가 <form> 요소 내 콘텐츠를 처리할 때 종종 "양식 모드"로 전환됩니다.
- 이것은 accesibility 기능 사용하는 사용자에게 양식을 탐색할 때 더 나은 경험을 제공하며 이를 수행하지 않을 이유가 없습니다.
- 제출 버튼 클릭 or 탭 + 클릭 or 탭 + Enter
- 아무 필드에 포커싱 한 후 Enter 키를 누르면, 스크린 리더는 type="submit" 타입이 있는 버튼을 찾습니다.
그렇다면 우리는 양식을 제출하려는 사용자의 시도를 어떻게 올바르게 파악할까요?
The submit event
사용자가 래핑된 양식 태그 내에서 양식을 제출할 때마다 이 양식 태그는 우리가 리스닝 할 수 있는 submit 이벤트를 내보냅니다(emit).
클릭 리스너를 양식의 제출 버튼에 설정하는 것이 일반적이지만 우리는 이를 피해야 합니다.
대신 양식(form)태그의 이벤트를 리스닝 합니다.
[...]
<form @submit.prevent="sendForm">
[...]
폼 컴포넌트 설계
이벤트의 추상화 : modelValue
폼에는 사용자 입력을 받을 수 있는 input 태그가 존재하며, 해당 input의 타입은 매우 다양합니다.
아마 지금까지 보지 못한 것들이 있을 수 있습니다.
- button
- checkbox
- color
- date
- datetime-local
- file
- hidden
- image
- month
- number
- password
- radio
- range
- reset
- search
- submit
- tel
- text
- time
- url
- week
이 인풋들은 전부 사용자 입력을 받아 상태로 저장하는 역할을 수행하지만,
찾아보면 checkbox와 같이 독특한 속성(checked)를 사용하는 경우도 있으며,
실제로 인풋은 아니지만 인풋처럼 (textarea, select) 동작하는 경우가 있습니다.
이와 같이 특정 입력에 대해 특정 변수와 데이터, 상태를 동기화하는 것을 매우 쉽게 해주는 v-model이라는 개념이 있습니다.
위 글을 읽어보면 아시겠지만, v-model은 단방향 데이터 플로우의 문법적 설탕에 불과하며,
모든 컴포넌트에 해당 로직을 일반화하여 적용할 수 있습니다.
따라서 모든 양식 내부의 입력 역할을 하는 컴포넌트에 대해 아래와 같은 작업을 수행합니다.
- 라벨 추가
- v-model 기능 추가
- input에 해당하는 요소들을 리액티브하게 만들기
BaseCheckbox
사용자는 해당 변수가 checked에 바인딩 되는지, value에 바인딩 되는지 관계없이 v-model만 사용합니다.
<template>
<input
type="checkbox"
:checked="modelValue"
@change="$emit('update:modelValue', $event.target.checked)"
class="field"
/>
<label v-if="label">{{ label }}</label>
</template>
<script>
export default {
props: {
label: {
type: String,
default: ''
},
modelValue: {
type: Boolean,
default: false
}
}
}
</script>
BaseInput
가장 일반적인 사용자 입력을 위한 인풋입니다.
입력은 숫자가 될 수도 있고 문자가 될 수도 있습니다.
<template>
<label :for="uuid" v-if="label">{{ label }}</label>
<input
v-bind="$attrs"
:value="modelValue"
:placeholder="label"
@input="$emit('update:modelValue', $event.target.value)"
class="field"
:id="uuid"
:aria-describedby="error ? `${uuid}-error` : null"
:aria-invalid="error ? true : null"
>
<p
v-if="error"
class="errorMessage"
:id="`${uuid}-error`"
aria-live="assertive"
>
{{ error }}
</p>
</template>
<script>
import UniqueID from '../features/UniqueID'
export default {
props: {
label: {
type: String,
default: ''
},
modelValue: {
type: [String, Number],
default: ''
},
error: {
type: String,
default: ''
}
},
setup () {
const uuid = UniqueID().getID()
return {
uuid
}
}
}
</script>
BaseRadio
사용자는 해당 변수가 checked에 바인딩 되는지, value에 바인딩 되는지 관계없이 v-model만 사용합니다.
<template>
<input
type="radio"
:checked="modelValue === value"
:value="value"
v-bind="$attrs"
@change="$emit('update:modelValue', value)"
/>
<label v-if="label">{{ label }}</label>
</template>
<script>
export default {
props: {
label: {
type: String,
default: ''
},
modelValue: {
type: [String, Number],
default: ''
},
value: {
type: [String, Number],
required: true
}
}
}
</script>
BaseRadioGroup
이전의 BaseRadio 컴포넌트를 재활용 합니다.
수직, 수평으로 쌓는 옵션 prop(vertical)을 추가합니다.
<template>
<component
v-for="option in options"
:key="option.value"
:is="vertical ? 'div' : 'span'"
:class="{
'horizontal': !vertical
}"
>
<BaseRadio
:label="option.label"
:value="option.value"
:modelValue="modelValue"
:name="name"
@update:modelValue="$emit('update:modelValue', $event)"
/>
</component>
</template>
<script>
export default {
props: {
options: {
type: Array,
required: true
},
name: {
type: String,
required: true
},
modelValue: {
type: [String, Number],
required: true
},
vertical: {
type: Boolean,
default: false
}
}
}
</script>
<style scoped>
.horizontal {
margin-right: 20px;
}
</style>
props vs slot
위와 같이 option list를 데이터로 받아 렌더링 하는 것의 장점은
레이아웃(마크업)과 스타일 관심사를 list, group 컴포넌트로 추상화 할 수 있다는 것입니다.
단점은? 내부 컴포넌트의 레이아웃(마크업, 스타일)의 커스터마이징이 매우 어렵다는 점입니다.
해당 API를 사용하는 개발자가 마크업(레이아웃), 스타일 친화적인 개발자라면
UI 컴포넌트는 유연하게(slot 기반) 가져가는 것이 좋습니다. (ex : headlessui의 조합형 컴포넌트)
또한 wrapper 컴포넌트는 내부의 마크업 보다는 외부의 레이아웃(스타일, 마크업), 상태관리를 담당하면 좋습니다.
해당 API를 사용하는 개발자가 주로 데이터 바인딩, 인터랙션만 담당한다면 콜백과 이벤트 위주의 프롭만 제공하는 것이 좋습니다.
이 경우 API 사용자가 마크업(ex : 탭 수직 방향 설정하기)과 특점 돔 요소에 이벤트 바인딩을 직접 할 수 있도록 하는게 옳은가는 생각해 봐야 할 문제입니다.
prop으로 레이아웃(마크업), 스타일을 추상화 하게 되면 하단 컴포넌트의 참조를 전달하는 것이 어려워집니다.
prop만으로 완결된 컴포넌트를 제공할 수 있다면 best입니다.
하지만 만약 도메인, 비즈니스 로직 개발자가 ui에 직접 트랜지션, 포커싱 등의 효과를 줘야 한다면 ref로 해당 돔에 직접 접근하지 못하면 힘들 수 있습니다.
react의 imperativeHandle이나 vue의 expose, scoped slot을 활용하는 방법이 있습니다만, 컴포넌트가 수정되어야 합니다.
따라서 props을 이용한 구성과 slot을 이용한 마크업, 스타일의 노출 사이의 적절한 균형이 필요합니다.
BaseSelect
select는 별도의 태그가 존재합니다.
사용자는 해당 컴포넌트가 어떤 태그로 구현되어있는지 관계없이 사용할 수 있습니다.
<template>
<label v-if="label">{{ label }}</label>
<select
class="field"
:value="modelValue"
v-bind="{
...$attrs,
onChange: ($event) => { $emit('update:modelValue', $event.target.value) }
}"
>
<option
v-for="option in options"
:value="option"
:key="option"
:selected="option === modelValue"
>{{ option }}</option>
</select>
</template>
<script>
export default {
props: {
label: {
type: String,
default: ''
},
modelValue: {
type: [String, Number],
default: ''
},
options: {
type: Array,
required: true
}
}
}
</script>
Putting all together
위에서 만든 컴포넌트들을 조합하면 아래와 같은 모양이 됩니다.
composition API로 바꾸는 것도 간단하니 option API인 상태로 두겠습니다.
<template>
<div>
<h1>Create an event</h1>
<form @submit.prevent="sendForm">
<BaseSelect
:options="categories"
v-model="event.category"
label="Select a category"
/>
<fieldset>
<legend>Name & describe your event</legend>
<BaseInput
v-model="event.title"
label="Title"
type="text"
error="This input has an error!"
/>
<BaseInput
v-model="event.description"
label="Description"
type="text"
/>
</fieldset>
<fieldset>
<legend>Where is your event?</legend>
<BaseInput
v-model="event.location"
label="Location"
type="text"
/>
</fieldset>
<fieldset>
<legend>Pets</legend>
<p>Are pets allowed?</p>
<div>
<BaseRadioGroup
v-model="event.pets"
name="pets"
:options="petOptions"
/>
</div>
</fieldset>
<fieldset>
<legend>Extras</legend>
<div>
<BaseCheckbox
v-model="event.extras.catering"
label="Catering"
/>
</div>
<div>
<BaseCheckbox
v-model="event.extras.music"
label="Live music"
/>
</div>
</fieldset>
<button type="submit">Submit</button>
</form>
<pre>{{ event }}</pre>
</div>
</template>
<script>
import axios from 'axios'
export default {
data () {
return {
categories: [
'sustainability',
'nature',
'animal welfare',
'housing',
'education',
'food',
'community'
],
event: {
category: '',
title: '',
description: '',
location: '',
pets: 1,
extras: {
catering: false,
music: false
}
},
petOptions: [
{ label: 'Yes', value: 1 },
{ label: 'No', value: 0 }
]
}
},
methods: {
sendForm (e) {
axios.post('https://my-json-server.typicode.com/Code-Pop/Vue-3-Forms/events', this.event)
.then(function (response) {
console.log('Response', response)
})
.catch(function (err) {
console.log('Error', err)
})
}
}
}
</script>
<style>
fieldset {
border: 0;
margin: 0;
padding: 0;
}
legend {
font-size: 28px;
font-weight: 700;
margin-top: 20px;
}
</style>
위 폼에서 설명되지 않는 내용들을 아래에서 추가로 논의하겠습니다.
Basic accesibility
접근성은 앱 개발을 완료한 후 수행해야 하는 부수적인 작업이 아닙니다.
개발 프로세스의 일부로 해결해야 하는 주요 관심사입니다.
적절한 type 지정
양식에는 전체 컨텍스트를 지배하는 가장 중요한 input이 존재합니다.
input은 type을 이용하여 다양한 형태로 전환이 가능합니다.
Fieldset과 Legend
양식에서는 일반적으로 입력을 논리적으로 그룹화합니다.
예를 들어 일반적으로 사용자에게 이름, 성, 전화번호와 같은 개인 데이터를 먼저 요청하도록 양식을 코딩합니다.
나중에 다른 섹션에서 배송 주소를 요청할 수 있습니다.
항상 fieldset 요소 내에서 양식의 섹션을 래핑해야 합니다. 그러면 그 내부의 입력이 논리적으로 그룹화됩니다.
그런 다음 fieldset의 첫 번째 요소는 해당 특정 fieldset에 대한 제목을 제공하는 legend 요소가 됩니다.
어떤 이유로 양식에 legend를 표시하지 않으려는 경우(일반적으로 디자인상의 이유 때문에)
표시되는 화면 외부에 절대 위치를 지정할 수 있습니다.
📃 SimpleForm.vue
<template>
<div>
<h1>Create an event</h1>
<form @submit.prevent="sendForm">
<BaseSelect
:options="categories"
v-model="event.category"
label="Select a category"
/>
<fieldset>
<legend>Name & describe your event</legend>
<BaseInput
v-model="event.title"
label="Title"
type="text"
/>
<BaseInput
v-model="event.description"
label="Description"
type="text"
/>
</fieldset>
<fieldset>
<legend>Where is your event?</legend>
<BaseInput
v-model="event.location"
label="Location"
type="text"
/>
</fieldset>
<fieldset>
<legend>Pets</legend>
<p>Are pets allowed?</p>
<div>
<BaseRadioGroup
v-model="event.pets"
name="pets"
:options="petOptions"
/>
</div>
</fieldset>
<fieldset>
<legend>Extras</legend>
<div>
<BaseCheckbox
v-model="event.extras.catering"
label="Catering"
/>
</div>
<div>
<BaseCheckbox
v-model="event.extras.music"
label="Live music"
/>
</div>
</fieldset>
<button type="submit">Submit</button>
</form>
<pre>{{ event }}</pre>
</div>
</template>
fieldset과 legend에도 스타일을 지정할 수 있습니다.
<style>
fieldset {
border: 0;
margin: 0;
padding: 0;
}
legend {
font-size: 28px;
font-weight: 700;
margin-top: 20px;
}
</style>
FireFox의 접근성 도구를 사용해 봅시다.
접근성 탭을 확인하면 이제 스크린 리더가가 양식의 논리적 그룹화를 어떻게 이해하는지 확인할 수 있습니다.
플레이스홀더에 의존하지 않기
몇 년 전에 등장한 인기 있는 디자인 패턴은 요소가 기대하는 콘텐츠 타입을 설명하기 위해 input의 placeholder를 사용하는 것이었습니다.
- 플레이스홀더는 사용자가 필드에 입력을 시작할 때마다 사라지므로 사용자는 해당 필드가 예상하는 내용을 기억해야 합니다.
- 또한 일부 사용자는 플레이스홀더가 있는 필드와 콘텐츠가 미리 채워져있거나, 이미 채워진 필드를 구분하는 데 문제가 있을 수 있습니다.
각 스크린 리더는 플레이스홀더 속성을 다르게 취급할 수 있지만 올바르게 설정된 레이블이 있는 한 큰 문제가 되지 않습니다.
Labels
그러나 시력이 좋지 않은 사용자에게는 이것이 분명하지 않습니다.
우리는 아직 이 두 HTML 요소를 연결하지 않았으며 이는 스크린 리더가 만들 수 없는 가정입니다.
입력 요소를 레이블과 연결하는 몇 가지 방법이 있습니다.
첫 번째 방법은 레이블 요소 내부에 입력을 중첩하는 것입니다.
<label>
Title
<input />
</label>
<template>
<label :for="uuid" v-if="label">{{ label }}</label>
<input
v-bind="$attrs"
:value="modelValue"
:placeholder="label"
@input="$emit('update:modelValue', $event.target.value)"
class="field"
:id="uuid"
>
</template>
접근 가능한 에러
폼 개발 시 자주 간과되는 또 하나의 요소입니다.
바로 에러 메세지와의 연계입니다.
📃 BaseInput.vue
<p
v-if="error"
class="errorMessage"
:id="`${uuid}-error`"
>
{{ error }}
</p>
해당 정보는 조건부로 화면에 나타나기 떄문에, 변경 시마다 스크린 리더가 독자에게 알려줄 수 있도록 해야 합니다.
- aria-live="assertive" 속성 추가(추천)
- role="alert" 추가
📃 BaseInput.vue
<p
v-if="error"
class="errorMessage"
:id="`${uuid}-error`"
aria-live="assertive"
>
{{ error }}
</p>
명시적인 입력 상태
보통 많은 양식은 유효하지 않은 입력 주변에 빨간색 테두리를 추가합니다.
시각을 사용할 수 없는 사용자는 해당 정보를 판단할 수 없습니다.
사용자에게 더 나은 피드백을 제공하기 위해 잘못된 입력 상태에 대해서도 스크린 리더에 알려야 합니다.
aria-invalid 속성을 입력에 추가하고 error props 값에 따라 켜고 끕니다.
입력이 유효하면 해당 속성이 입력 요소에 추가되지 않도록 null이 됩니다.
<input
v-bind="$attrs"
:value="modelValue"
:placeholder="label"
@input="$emit('update:modelValue', $event.target.value)"
class="field"
:id="uuid"
:aria-describedby="error ? `${uuid}-error` : null"
:aria-invalid="error ? true : null"
>
브라우저로 돌아가 Firefox의 접근성 도구를 사용하여 input을 검사하면
input의 활성 상태에 "유효하지 않음" 상태가 추가되었음을 알 수 있습니다.
제출 버튼을 비활성화 하지 마세요
양식이 유효하지 않은 경우 제출 버튼의 disabled 속성을 true로 설정하여 사용자가 양식을 제출할 수 없도록 하는 것이 맞나요?
클릭할 수 없다는 것을 전달하기 위해 다른 색상으로 버튼의 스타일을 지정할 수도 있습니다.
여기에는 큰 문제가 있습니다.
스크린 리더에 의존하는 사용자가 양식 내부를 탭으로 이동할 때 스크린 리더는 버튼을 완전히 무시합니다.
이 경우 사용자는 버튼을 발견하지 못할 수 있으며,이것은 분명히 매우 혼란스러울 수 있습니다.
제출 버튼을 비활성화 하는 대신 대신 제출 버튼 클릭 시 양식이 유효한지 확인하기 위해 모든 검사를 수행하는 것이 좋습니다.
모든 것이 정상이면 양식을 정상적으로 제출합니다.
문제가 있는 경우 방금 배운 방법을 사용하여 양식에 필요한 오류를 설정합니다.
참고
https://www.vuemastery.com/courses/vue3-forms/forms-introduction
더 강력한 form 만들기 : vee-validate
'FrontEnd' 카테고리의 다른 글
[프론트엔드 커리어] 프론트엔드 엔지니어의 가성비 곡선 (0) | 2023.01.05 |
---|---|
[Vue3] defineProps를 컴포저블에서 사용할 수 없는 이유 (0) | 2023.01.05 |
[번역] layout 성능 정확하게 측정하기 (0) | 2023.01.01 |
Vue3 컴포넌트 디자인 패턴 : Renderess Component / Compound Component (0) | 2022.12.31 |
[객체지향설계] 객체지향 설계 및 분석 : UML과 Use Case 다이어그램 (0) | 2022.12.30 |