Stage 5: Professional Skills

Web Accessibility · Lesson 03

Form Accessibility

Labels, error announcements and color contrast.

Web Accessibility3 min readFree lesson

Forms are where accessibility failures cost the most: a person cannot sign up, apply or pay. Most fixes are small and improve the experience for everyone.

Learning objectives

  • Associate every input with a label.
  • Group related controls with fieldset and legend.
  • Announce hints and errors with aria-describedby and live regions.
  • Meet contrast and focus requirements.

1. Labels

<!-- explicit: for + id -->
<label for="email">Email address</label>
<input id="email" name="email" type="email" autocomplete="email" />

<!-- implicit: input inside label -->
<label>
  Email address
  <input name="email" type="email" />
</label>

Clicking the label focuses the input, screen readers announce the name, and voice control can target it.

A placeholder is not a label: it disappears on typing, has poor contrast and is not reliably announced.

<!-- wrong -->
<input placeholder="Email" />

Icon-only inputs need a visually hidden label:

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

2. Groups

<fieldset>
  <legend>Preferred contact method</legend>

  <label><input type="radio" name="contact" value="email" /> Email</label>
  <label><input type="radio" name="contact" value="phone" /> Phone</label>
</fieldset>

fieldset + legend gives the group a name. Without it, a screen reader reads three unrelated radio buttons.

3. Hints and errors

<label for="phone">Phone number</label>
<input
  id="phone"
  name="phone"
  type="tel"
  autocomplete="tel"
  aria-describedby="phone-hint phone-error"
  aria-invalid="true"
/>
<p id="phone-hint">Include the country code.</p>
<p id="phone-error" role="alert">Enter a 10-digit number.</p>
  • aria-describedby links both the hint and the error.
  • aria-invalid="true" marks the field as failing.
  • role="alert" (or aria-live="assertive") announces the error when it appears.

Do not rely on red borders alone. Add text, and keep the message next to the field.

4. Error summary and focus

On submit:

  1. Collect all errors.
  2. Render a summary at the top with links to each field.
  3. Move focus to the summary.
  4. Keep the entered values.
<div role="alert" tabindex="-1" id="error-summary">
  <h2>There are 3 problems with this form</h2>
  <ul>
    <li><a href="#email">Email is required</a></li>
    <li><a href="#phone">Enter a 10-digit number</a></li>
    <li><a href="#terms">Accept the terms</a></li>
  </ul>
</div>

The summary is announced once, and the user can jump to each field. Individual inline messages remain for context.

5. Required fields

<label for="name">Full name <span aria-hidden="true">*</span></label>
<input id="name" name="name" required aria-required="true" />

If you mark required fields with an asterisk, explain it in text and hide the asterisk from screen readers. Better: mark optional fields instead, so the common case needs no explanation.

6. Contrast and focus

  • Body text: at least 4.5:1 against its background.
  • Large text (18.66 px bold or 24 px): at least 3:1.
  • UI components, borders and focus indicators: at least 3:1.
  • Never remove the focus outline without a visible replacement:
:focus-visible {
  outline: 3px solid #ea580c;
  outline-offset: 2px;
}
  • Touch targets should be at least 24×24 CSS px (44×44 is comfortable).
  • Do not disable zoom: never use user-scalable=no or maximum-scale=1.

7. Autocomplete

<input autocomplete="name" />
<input autocomplete="email" />
<input autocomplete="tel" />
<input autocomplete="street-address" />
<input autocomplete="one-time-code" />

Browsers and password managers fill forms correctly, which is itself an accessibility feature.

Common mistakes

  • Placeholder-only labels.
  • Errors shown only in red, with no text.
  • aria-invalid set but never cleared after a fix.
  • A role="alert" container that is inserted empty and filled later (it may not announce) — render it with the message in one update.
  • Removing focus outlines globally.
  • Disabling the submit button with no explanation of what is missing.

Practice

Build the accessible search form in the assignment. Then test it with a screen reader and a keyboard only.

Example files

Complete, runnable files used in this lesson. Copy them into your project and experiment.

accessible-form.htmlHTML
<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>접근성 회원가입 폼</title>
    <style>
        /* ── 기본 스타일 ── */
        *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
        body {
            font-family: 'Noto Sans KR', sans-serif;
            background: #f5f7fa;
            display: flex;
            justify-content: center;
            padding: 40px 16px;
            min-height: 100vh;
        }

        .form-wrapper {
            background: white;
            padding: 40px;
            border-radius: 12px;
            box-shadow: 0 4px 20px rgba(0,0,0,0.1);
            width: 100%;
            max-width: 480px;
            height: fit-content;
        }

        h1 { font-size: 1.6rem; margin-bottom: 8px; color: #1a1a2e; }
        .form-subtitle { color: #666; margin-bottom: 32px; }

        /* ── 포커스 스타일: outline 절대 제거 금지! ── */
        :focus-visible {
            outline: 3px solid #0066cc;
            outline-offset: 2px;
            border-radius: 3px;
        }

        /* ── 폼 필드 그룹 ── */
        .field-group { margin-bottom: 24px; }

        /* ── 레이블 스타일
           색상 대비: #333 / 흰 배경 = 12:1 ── */
        label {
            display: block;
            font-weight: 600;
            color: #333;
            margin-bottom: 6px;
            font-size: 0.95rem;
        }

        /* 필수 필드 표시: 색상만으로 표시하지 않고 텍스트도 함께 */
        .required-mark {
            color: #cc0000;
            margin-left: 4px;
            aria-hidden: true; /* 스크린리더에서 숨김 (aria-required로 이미 전달) */
        }

        /* ── 입력 필드
           테두리 색상 대비: #767676 / 흰 배경 = 4.5:1 (기준 충족) ── */
        input[type="text"],
        input[type="email"],
        input[type="password"] {
            width: 100%;
            padding: 12px 14px;
            border: 2px solid #767676; /* 대비율 4.5:1 */
            border-radius: 6px;
            font-size: 1rem;
            color: #1a1a1a;
            background: white;
            transition: border-color 0.2s;
        }
        input:hover { border-color: #444; }
        input:focus { border-color: #0066cc; }

        /* 에러 상태: 테두리 빨간색 + 배경 연분홍 */
        input[aria-invalid="true"] {
            border-color: #cc0000;
            background: #fff8f8;
        }

        /* ── 힌트 텍스트 (입력 필드 아래 안내문) ── */
        .hint-text {
            font-size: 0.85rem;
            color: #666;  /* 대비율 5.7:1 */
            margin-top: 6px;
        }

        /* ── 에러 메시지 ── */
        .error-msg {
            font-size: 0.875rem;
            color: #cc0000;  /* 대비율 5.9:1 */
            margin-top: 6px;
            display: flex;
            align-items: center;
            gap: 4px;
        }
        .error-msg[hidden] { display: none; }

        /* ── 비밀번호 강도 표시기 ── */
        .pw-strength { margin-top: 8px; }
        .pw-strength-bar {
            height: 6px;
            background: #eee;
            border-radius: 3px;
            overflow: hidden;
            margin-bottom: 4px;
        }
        .pw-strength-fill {
            height: 100%;
            border-radius: 3px;
            transition: width 0.3s, background 0.3s;
            width: 0%;
        }
        .pw-strength-text { font-size: 0.8rem; color: #666; }

        /* ── 체크박스 ── */
        .checkbox-group {
            display: flex;
            align-items: flex-start;
            gap: 10px;
            margin-bottom: 24px;
        }
        .checkbox-group input[type="checkbox"] {
            width: 20px;
            height: 20px;
            margin: 0;
            cursor: pointer;
            flex-shrink: 0;
            accent-color: #0066cc;
        }
        .checkbox-group label {
            font-weight: normal;
            cursor: pointer;
            margin-bottom: 0;
        }

        /* ── 제출 버튼 ── */
        .submit-btn {
            width: 100%;
            padding: 14px;
            background: #0066cc;
            color: white;
            border: none;
            border-radius: 6px;
            font-size: 1rem;
            font-weight: 600;
            cursor: pointer;
            transition: background 0.2s;
        }
        .submit-btn:hover { background: #0052a3; }
        .submit-btn:disabled {
            background: #aaa;
            cursor: not-allowed;
        }

        /* ── 성공 메시지 ── */
        .success-banner {
            background: #e8f5e9;
            border: 1px solid #4caf50;
            border-radius: 6px;
            padding: 16px;
            color: #1b5e20;
            font-weight: 600;
        }
        .success-banner[hidden] { display: none; }
    </style>
</head>
<body>
    <div class="form-wrapper">
        <h1>회원가입</h1>
        <p class="form-subtitle">
            <span aria-hidden="true">*</span>
            표시는 필수 입력 항목입니다.
        </p>

        <!-- ── 성공 메시지 (제출 후 표시)
             role="alert": 내용이 바뀌면 스크린리더가 즉시 읽음 ── -->
        <div id="success-msg" role="alert" class="success-banner" hidden>
            회원가입이 완료되었습니다! 환영합니다.
        </div>

        <!-- ── 폼
             novalidate: 브라우저 기본 유효성 검사 비활성화 (우리가 직접 처리)
             aria-label: 폼의 목적을 스크린리더에 알림 ── -->
        <form id="signup-form" novalidate aria-label="회원가입 폼">

            <!-- ── 이름 필드 ── -->
            <div class="field-group">
                <label for="name">
                    이름
                    <span class="required-mark" aria-hidden="true">*</span>
                </label>
                <input
                    type="text"
                    id="name"
                    name="name"
                    autocomplete="name"
                    required
                    aria-required="true"
                    aria-describedby="name-error"
                    aria-invalid="false"
                >
                <!-- 에러 메시지: hidden 속성으로 숨겼다가 에러 시 표시
                     role="alert": 표시되는 순간 스크린리더가 읽음 -->
                <p id="name-error" class="error-msg" role="alert" hidden>
                    <span aria-hidden="true">⚠</span>
                    이름을 입력해주세요.
                </p>
            </div>

            <!-- ── 이메일 필드 ── -->
            <div class="field-group">
                <label for="email">
                    이메일
                    <span class="required-mark" aria-hidden="true">*</span>
                </label>
                <input
                    type="email"
                    id="email"
                    name="email"
                    autocomplete="email"
                    required
                    aria-required="true"
                    aria-describedby="email-hint email-error"
                    aria-invalid="false"
                    placeholder="[email protected]"
                >
                <!-- aria-describedby에 hint와 error를 모두 연결
                     스크린리더는 두 요소를 모두 읽음 -->
                <p id="email-hint" class="hint-text">
                    예: [email protected]
                </p>
                <p id="email-error" class="error-msg" role="alert" hidden>
                    <span aria-hidden="true">⚠</span>
                    올바른 이메일 형식을 입력해주세요.
                </p>
            </div>

            <!-- ── 비밀번호 필드 ── -->
            <div class="field-group">
                <label for="password">
                    비밀번호
                    <span class="required-mark" aria-hidden="true">*</span>
                </label>
                <input
                    type="password"
                    id="password"
                    name="password"
                    autocomplete="new-password"
                    required
                    aria-required="true"
                    aria-describedby="pw-hint pw-strength-desc pw-error"
                    aria-invalid="false"
                    oninput="checkPasswordStrength(this.value)"
                >
                <p id="pw-hint" class="hint-text">
                    8자 이상, 대소문자 + 숫자 + 특수문자 포함
                </p>
                <!-- 비밀번호 강도: aria-live로 변경 시 자동 알림 -->
                <div class="pw-strength" aria-live="polite">
                    <div class="pw-strength-bar">
                        <div class="pw-strength-fill" id="pw-strength-fill"></div>
                    </div>
                    <!-- 스크린리더를 위한 텍스트 표현 -->
                    <p class="pw-strength-text" id="pw-strength-desc"></p>
                </div>
                <p id="pw-error" class="error-msg" role="alert" hidden>
                    <span aria-hidden="true">⚠</span>
                    비밀번호는 8자 이상이어야 합니다.
                </p>
            </div>

            <!-- ── 약관 동의 체크박스 ── -->
            <div class="checkbox-group">
                <input
                    type="checkbox"
                    id="agree-terms"
                    name="agree-terms"
                    required
                    aria-required="true"
                    aria-describedby="terms-error"
                >
                <label for="agree-terms">
                    <a href="/terms" target="_blank">이용약관</a> 및
                    <a href="/privacy" target="_blank">개인정보 처리방침</a>에 동의합니다
                    <span class="required-mark" aria-hidden="true">*</span>
                </label>
            </div>
            <p id="terms-error" class="error-msg" role="alert" hidden>
                <span aria-hidden="true">⚠</span>
                이용약관에 동의해주세요.
            </p>

            <button type="submit" class="submit-btn">가입하기</button>
        </form>
    </div>

    <script>
        // ── 비밀번호 강도 확인
        function checkPasswordStrength(value) {
            const fill = document.getElementById('pw-strength-fill');
            const desc = document.getElementById('pw-strength-desc');

            let strength = 0;
            if (value.length >= 8) strength++;
            if (/[A-Z]/.test(value) && /[a-z]/.test(value)) strength++;
            if (/[0-9]/.test(value)) strength++;
            if (/[^A-Za-z0-9]/.test(value)) strength++;

            const levels = [
                { label: '', color: '#eee', width: '0%' },
                { label: '매우 약함', color: '#cc0000', width: '25%' },
                { label: '약함',     color: '#ff8800', width: '50%' },
                { label: '보통',     color: '#ffcc00', width: '75%' },
                { label: '강함',     color: '#2e7d32', width: '100%' },
            ];

            const level = value.length === 0 ? 0 : Math.max(1, strength);
            fill.style.width = levels[level].width;
            fill.style.background = levels[level].color;
            // 스크린리더는 aria-live="polite"로 이 텍스트 변경을 읽음
            desc.textContent = value.length > 0 ? `비밀번호 강도: ${levels[level].label}` : '';
        }

        // ── 에러 표시/해제 헬퍼
        function showError(inputId, errorId, message) {
            const input = document.getElementById(inputId);
            const errorEl = document.getElementById(errorId);
            input.setAttribute('aria-invalid', 'true');
            errorEl.textContent = (message.includes('⚠') ? message : `⚠ ${message}`);
            errorEl.removeAttribute('hidden');
        }

        function clearError(inputId, errorId) {
            const input = document.getElementById(inputId);
            const errorEl = document.getElementById(errorId);
            input.setAttribute('aria-invalid', 'false');
            errorEl.setAttribute('hidden', '');
        }

        // ── 폼 제출 처리
        document.getElementById('signup-form').addEventListener('submit', function (e) {
            e.preventDefault();
            let hasError = false;

            const name  = document.getElementById('name').value.trim();
            const email = document.getElementById('email').value.trim();
            const pw    = document.getElementById('password').value;
            const terms = document.getElementById('agree-terms').checked;

            // 이름 검증
            if (!name) {
                showError('name', 'name-error', '이름을 입력해주세요.');
                hasError = true;
            } else {
                clearError('name', 'name-error');
            }

            // 이메일 검증
            const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
            if (!email || !emailRegex.test(email)) {
                showError('email', 'email-error', '올바른 이메일 형식을 입력해주세요.');
                hasError = true;
            } else {
                clearError('email', 'email-error');
            }

            // 비밀번호 검증
            if (pw.length < 8) {
                showError('password', 'pw-error', '비밀번호는 8자 이상이어야 합니다.');
                hasError = true;
            } else {
                clearError('password', 'pw-error');
            }

            // 약관 동의 검증
            if (!terms) {
                const termsError = document.getElementById('terms-error');
                termsError.removeAttribute('hidden');
                hasError = true;
            } else {
                document.getElementById('terms-error').setAttribute('hidden', '');
            }

            // 에러가 있으면 첫 번째 에러 필드로 포커스 이동
            if (hasError) {
                const firstInvalid = document.querySelector('[aria-invalid="true"]');
                if (firstInvalid) firstInvalid.focus();
                return;
            }

            // 성공 처리
            this.setAttribute('hidden', '');
            const successMsg = document.getElementById('success-msg');
            successMsg.removeAttribute('hidden');
            successMsg.focus(); // 포커스 이동으로 스크린리더가 즉시 읽음
        });
    </script>
</body>
</html>

Assignment

Assignment 03 — Accessible search form

Create search-form.html with a search and filter form.

Fields

  • Search query (text, required)
  • Category (select: All, Courses, Lectures, Blog)
  • Level (radio group: Beginner, Intermediate, Advanced)
  • Email for results (optional, type="email")
  • Terms checkbox (required)

Requirements

  1. Every field has a visible <label> connected with for/id.
  2. The radio group is wrapped in <fieldset> with a <legend>.
  3. The email field has a hint connected with aria-describedby.
  4. Validation runs on submit:
    • empty query → error
    • email present but invalid → error
    • terms unchecked → error
  5. Errors are rendered inline with role="alert" and the field gets aria-invalid="true".
  6. A summary at the top lists every error with links to the fields, and receives focus on submit.
  7. Fixing a field clears its error on the next submit (and removes aria-invalid).
  8. Focus style is visible: :focus-visible with a 3:1 contrast outline.
  9. Contrast of body text is at least 4.5:1.

Hints

<label for="q">Search</label>
<input id="q" name="q" type="search" required aria-describedby="q-error" />
<p id="q-error" role="alert" hidden>Enter a search term.</p>
const summary = document.getElementById("error-summary");
summary.hidden = false;
summary.focus();
:focus-visible {
  outline: 3px solid #ea580c;
  outline-offset: 2px;
}

Test

  • Submit empty: the summary is announced and focused.
  • Tab through the form: every control has a visible focus ring and a name.
  • NVDA/VoiceOver: the radio group is announced with its legend.
  • axe: no "label", "aria-describedby" or "color-contrast" violations.

Done when

  • No input relies on a placeholder for its name.
  • Errors are both visible and announced.
  • The form can be completed entirely by keyboard.
  • Zoom at 200% keeps every label and error readable.
Show the reference solution

Try the assignment yourself first — comparing your attempt with a working solution is where most of the learning happens.

search-form.htmlHTML
<!DOCTYPE html>
<!-- 개선 1: lang 속성 추가 → 스크린리더가 올바른 언어로 읽음 -->
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>접근성 검색 폼 — 개선 버전</title>
    <style>
        *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
        body { font-family: 'Noto Sans KR', sans-serif; padding: 40px; background: #f5f5f5; }

        h1 { margin-bottom: 24px; color: #1a1a2e; }

        .search-form { display: flex; gap: 8px; margin-bottom: 12px; max-width: 500px; }

        /* 개선 3: 색상 대비 충족
           input 테두리: #767676 / 흰 배경 = 4.5:1 (기준 충족) */
        input[type="search"] {
            flex: 1;
            padding: 12px 16px;
            border: 2px solid #767676; /* 대비율 4.5:1 */
            border-radius: 6px;
            font-size: 1rem;
            color: #1a1a1a;
        }

        /* 개선 4: 포커스 스타일 → outline: none 절대 사용 금지!
           키보드 사용자가 현재 위치를 알 수 있어야 함 */
        :focus-visible {
            outline: 3px solid #0066cc;
            outline-offset: 2px;
            border-radius: 3px;
        }

        /* 개선 5: 버튼 색상 대비
           버튼 텍스트: 흰색(#fff) / 파란 배경(#0055aa) = 5.7:1 (기준 충족) */
        button[type="submit"] {
            padding: 12px 20px;
            background: #0055aa;  /* 대비율 5.7:1 (기존 blue는 약 3.9:1로 기준 미달) */
            color: white;
            border: none;
            border-radius: 6px;
            font-size: 1rem;
            font-weight: 600;
            cursor: pointer;
            white-space: nowrap;
        }
        button[type="submit"]:hover { background: #003d8a; }

        /* 개선 6: 에러 메시지 색상 대비
           #cc0000(빨강) / 흰 배경 = 5.9:1 (기준 충족)
           기존 red는 대비율이 더 낮을 수 있음 → 명확한 색상 지정 */
        .error-msg {
            color: #cc0000;      /* 대비율 5.9:1 */
            font-size: 0.9rem;
            margin-top: 4px;
            display: flex;
            align-items: center;
            gap: 4px;
        }
        .error-msg[hidden] { display: none; }

        /* 검색 결과 영역 */
        .results-section { margin-top: 24px; max-width: 500px; }
        .results-section h2 { font-size: 1.1rem; margin-bottom: 12px; color: #333; }
        #results { list-style: none; }
        #results li {
            padding: 12px;
            background: white;
            border: 1px solid #ddd;
            border-radius: 6px;
            margin-bottom: 8px;
            color: #1a1a1a;
        }
        #results li:empty { display: none; }
    </style>
</head>
<body>
    <h1>상품 검색</h1>

    <!-- 개선 7: role="search" → 스크린리더가 "검색 랜드마크" 라고 알림 -->
    <form
        id="search-form"
        role="search"
        class="search-form-wrapper"
        onsubmit="handleSearch(event)"
        novalidate
    >
        <div class="search-form">

            <!-- 개선 1: aria-label로 레이블 제공
                 placeholder만 있으면 입력 후 레이블을 알 수 없음
                 aria-describedby로 에러 메시지 연결 -->
            <input
                type="search"
                id="search-input"
                name="q"
                aria-label="상품 검색어"
                aria-describedby="search-error"
                aria-invalid="false"
                placeholder="검색어를 입력하세요"
                autocomplete="off"
            >

            <!-- 개선 2: 아이콘만 있는 버튼에 aria-label 추가
                 기존: <button>🔍</button> → 스크린리더가 "돋보기" 또는 "버튼" 이라고 읽음
                 개선: aria-label="검색" → "검색 버튼" 이라고 명확하게 읽음 -->
            <button type="submit" aria-label="검색">
                <span aria-hidden="true">🔍</span> <!-- 아이콘은 aria-hidden으로 숨김 -->
                검색 <!-- 시각적 텍스트도 함께 표시 (더 명확함) -->
            </button>
        </div>

        <!-- 개선 3: 에러 메시지에 role="alert" 추가
             role="alert": 내용이 바뀌면 스크린리더가 즉시 읽음 (포커스 이동 없이)
             hidden 속성으로 숨겼다가 에러 시에만 표시 -->
        <p
            id="search-error"
            class="error-msg"
            role="alert"
            hidden
        >
            <span aria-hidden="true">⚠</span>
            검색어를 입력해주세요.
        </p>
    </form>

    <!-- 검색 결과 영역
         aria-live="polite": 결과가 업데이트되면 스크린리더가 읽음
         (polite: 현재 읽고 있는 것을 마친 후 읽음 / assertive: 즉시 끊고 읽음) -->
    <section class="results-section" aria-live="polite" aria-label="검색 결과">
        <!-- 검색 결과 수를 스크린리더에 알리는 요소 -->
        <p id="results-count" class="sr-only"></p>
        <ul id="results" aria-label="검색 결과 목록"></ul>
    </section>

    <!-- 시각적으로 숨기는 클래스 (스크린리더는 읽음) -->
    <style>
        .sr-only {
            position: absolute;
            width: 1px;
            height: 1px;
            padding: 0;
            margin: -1px;
            overflow: hidden;
            clip: rect(0, 0, 0, 0);
            white-space: nowrap;
            border: 0;
        }
    </style>

    <script>
        function handleSearch(e) {
            e.preventDefault();

            const input = document.getElementById('search-input');
            const errorEl = document.getElementById('search-error');
            const results = document.getElementById('results');
            const resultsCount = document.getElementById('results-count');

            const query = input.value.trim();

            // 에러 처리: 빈 검색어
            if (!query) {
                // aria-invalid: 입력값이 유효하지 않음을 스크린리더에 알림
                input.setAttribute('aria-invalid', 'true');
                // 에러 메시지 표시 (role="alert"로 자동 알림)
                errorEl.removeAttribute('hidden');
                input.focus(); // 포커스를 입력 필드로 이동
                return;
            }

            // 에러 해제
            input.setAttribute('aria-invalid', 'false');
            errorEl.setAttribute('hidden', '');

            // 검색 결과 표시 (모의 검색)
            const mockResults = [
                `"${query}" 노트북`,
                `"${query}" 관련 마우스`,
                `"${query}" 키보드 추천`,
            ];

            // aria-live 영역 업데이트 → 스크린리더가 자동으로 읽음
            results.innerHTML = mockResults
                .map(item => `<li>${item}</li>`)
                .join('');

            // 검색 결과 수를 스크린리더에 알림 (시각적으로는 숨김)
            resultsCount.textContent = `총 ${mockResults.length}개의 검색 결과가 있습니다.`;
        }
    </script>
</body>
</html>

Learn this lesson with a mentor

Work through the Build Your Homepage series on your own, or join a guided cohort at our Pitampura campus and online.

Call NowEnroll Now