{"id":"l-a11y-2-3","courseId":"web-accessibility","lang":"en","kind":"lesson","summary":"Build a checkout form where every field has a label, related options are grouped, browsers can autofill, and errors are announced and fixable without sight.","html":"<p>Ticketly loses a quarter of its customers at checkout, and the analytics can’t say why. Run the form with a screen reader and the reason is obvious: the fields announce “edit text, edit text, edit text”, the ticket-type radios announce “Standing, radio button” with no question, and when payment fails a red border appears that nobody without sight can perceive. Forms are where accessibility turns directly into money.</p>\n<h2 id=\"sec-1\" tabindex=\"-1\">Every field gets a label</h2>\n<p>A <code dir=\"ltr\">&lt;label&gt;</code> connected to an input does three things: it gives the field its accessible name, it shows sighted users what to type even after they start typing, and it makes the label text clickable, which enlarges the target.</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"html\" data-index=\"0\"><div class=\"code-head\"><span class=\"code-title\">ticketly/checkout.html</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">&#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">label</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> for</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"full-name\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">>Full name&#x3C;/</span><span style=\"color:#116329;--shiki-dark:#7EE787\">label</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">></span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">&#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">input</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> id</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"full-name\"</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> name</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"fullName\"</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> autocomplete</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"name\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">></span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">&#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">label</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">></span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  Email</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  &#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">input</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> type</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"email\"</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> name</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"email\"</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> autocomplete</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"email\"</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> required</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">></span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">&#x3C;/</span><span style=\"color:#116329;--shiki-dark:#7EE787\">label</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">></span></span></code></pre></div>\n<p>Both forms work: <code dir=\"ltr\">for</code> pointing at an <code dir=\"ltr\">id</code>, or the input nested inside the label. You can confirm the link in the console: <code dir=\"ltr\">document.querySelector('#full-name').labels.length</code> should be 1.</p>\n<aside class=\"callout callout-mistake\" role=\"note\"><p class=\"callout-title\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3\"/><path d=\"M12 9v4\"/><path d=\"M12 17h.01\"/></svg><span><span class=\"callout-kind\">Common mistake</span><span class=\"callout-sep\" aria-hidden=\"true\"> · </span>Placeholder as label</span></p>\n<p><code dir=\"ltr\">placeholder=&quot;Email&quot;</code> looks tidy, but it disappears the moment the user types, so anyone who gets distracted mid-field has lost the question. Its default grey usually fails contrast, and not every assistive technology treats it as a name. Use a visible label; keep placeholders for example formats, or skip them.</p>\n</aside>\n<h2 id=\"sec-2\" tabindex=\"-1\">Group related choices</h2>\n<p>Radio buttons and checkboxes have two layers of meaning: each option, and the question they answer together. <code dir=\"ltr\">&lt;fieldset&gt;</code> and <code dir=\"ltr\">&lt;legend&gt;</code> carry the question:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"html\" data-index=\"1\"><div class=\"code-head\"><span class=\"code-title\">HTML</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">&#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">fieldset</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">></span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  &#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">legend</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">>Ticket type&#x3C;/</span><span style=\"color:#116329;--shiki-dark:#7EE787\">legend</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">></span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  &#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">label</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">>&#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">input</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> type</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"radio\"</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> name</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"ticket\"</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> value</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"standing\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">> Standing&#x3C;/</span><span style=\"color:#116329;--shiki-dark:#7EE787\">label</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">></span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  &#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">label</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">>&#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">input</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> type</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"radio\"</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> name</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"ticket\"</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> value</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"seated\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">> Seated&#x3C;/</span><span style=\"color:#116329;--shiki-dark:#7EE787\">label</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">></span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  &#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">label</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">>&#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">input</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> type</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"radio\"</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> name</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"ticket\"</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> value</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"vip\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">> VIP lounge&#x3C;/</span><span style=\"color:#116329;--shiki-dark:#7EE787\">label</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">></span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">&#x3C;/</span><span style=\"color:#116329;--shiki-dark:#7EE787\">fieldset</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">></span></span></code></pre></div>\n<p>When focus enters the group, NVDA reads “Ticket type, grouping, Standing, radio button, not checked, 1 of 3”. Use the same pattern for a billing address block or a set of “notify me by” checkboxes.</p>\n<h2 id=\"sec-3\" tabindex=\"-1\">Help people and browsers fill it in</h2>\n<p>Two attributes save a lot of typing, which matters most to people with motor or memory impairments.</p>\n<p>The right <code dir=\"ltr\">type</code> brings up the right keyboard on phones and gives basic validation: <code dir=\"ltr\">type=&quot;email&quot;</code>, <code dir=\"ltr\">type=&quot;tel&quot;</code>, <code dir=\"ltr\">inputmode=&quot;numeric&quot;</code> for a card number.</p>\n<p>The <code dir=\"ltr\">autocomplete</code> attribute declares what the field is for, so the browser can fill it from saved data. WCAG 1.3.5 Identify Input Purpose (AA) requires it for fields about the user. Common tokens: <code dir=\"ltr\">name</code>, <code dir=\"ltr\">given-name</code>, <code dir=\"ltr\">family-name</code>, <code dir=\"ltr\">email</code>, <code dir=\"ltr\">tel</code>, <code dir=\"ltr\">street-address</code>, <code dir=\"ltr\">postal-code</code>, <code dir=\"ltr\">country</code>, <code dir=\"ltr\">cc-name</code>, <code dir=\"ltr\">cc-number</code>, <code dir=\"ltr\">cc-exp</code>, <code dir=\"ltr\">cc-csc</code>, <code dir=\"ltr\">username</code>, <code dir=\"ltr\">current-password</code>, <code dir=\"ltr\">new-password</code>.</p>\n<p>Mark required fields with the <code dir=\"ltr\">required</code> attribute, which screen readers announce, and with visible text such as “(required)”, not only an asterisk. And don’t ask twice: if the user typed their email at step 1, prefill it at step 3 (3.3.7 Redundant Entry).</p>\n<h2 id=\"sec-4\" tabindex=\"-1\">Errors people can find and fix</h2>\n<p>This is where most forms fail. A good error has four parts, and the diagram shows how they connect:</p>\n<figure class=\"diagram\"><svg class=\"diagram-svg\" focusable=\"false\" viewBox=\"0 0 680 230\" role=\"img\" aria-labelledby=\"l-a11y-2-3-t1\"><defs><marker id=\"arrow-l-a11y-2-3-1\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\"><path d=\"M0 0 L10 5 L0 10 z\" class=\"d-arrowhead\"/></marker></defs>\n  <title id=\"l-a11y-2-3-t1\">A label element gives the input its name through the for attribute. The input carries aria-invalid true, and aria-describedby points to a hint and an error message, which become its description. Focus moves to the input after a failed submit.</title>\n  <rect class=\"d-box\" x=\"20\" y=\"20\" width=\"170\" height=\"50\" rx=\"8\"/>\n  <text class=\"d-code\" x=\"105\" y=\"50\" text-anchor=\"middle\">&lt;label for&gt;</text>\n  <rect class=\"d-box-primary\" x=\"250\" y=\"80\" width=\"190\" height=\"70\" rx=\"8\"/>\n  <text class=\"d-code\" x=\"345\" y=\"110\" text-anchor=\"middle\">&lt;input&gt;</text>\n  <text class=\"d-code\" x=\"345\" y=\"134\" text-anchor=\"middle\">aria-invalid=\"true\"</text>\n  <rect class=\"d-box\" x=\"500\" y=\"20\" width=\"160\" height=\"50\" rx=\"8\"/>\n  <text class=\"d-label\" x=\"580\" y=\"50\" text-anchor=\"middle\">Hint text</text>\n  <rect class=\"d-box-warn\" x=\"500\" y=\"150\" width=\"160\" height=\"50\" rx=\"8\"/>\n  <text class=\"d-label\" x=\"580\" y=\"180\" text-anchor=\"middle\">Error message</text>\n  <path class=\"d-arrow\" d=\"M190 45 L250 95\" marker-end=\"url(#arrow-l-a11y-2-3-1)\"/>\n  <text class=\"d-label-muted\" x=\"190\" y=\"90\">name</text>\n  <path class=\"d-arrow\" d=\"M500 45 L440 95\" marker-end=\"url(#arrow-l-a11y-2-3-1)\"/>\n  <path class=\"d-arrow\" d=\"M500 175 L440 135\" marker-end=\"url(#arrow-l-a11y-2-3-1)\"/>\n  <text class=\"d-label-muted\" x=\"455\" y=\"80\">described by</text>\n  <text class=\"d-label-muted\" x=\"345\" y=\"200\" text-anchor=\"middle\">focus lands here on error</text>\n</svg><figcaption>The parts of an accessible field</figcaption></figure>\n<ol>\n<li><strong>Say which field</strong> is wrong, in text (3.3.1 Error Identification).</li>\n<li><strong>Say how to fix it</strong> (3.3.3 Error Suggestion): “Enter an email address like name@example.com”, not “Invalid input”.</li>\n<li><strong>Connect it programmatically</strong>: <code dir=\"ltr\">aria-invalid=&quot;true&quot;</code> on the field and <code dir=\"ltr\">aria-describedby</code> pointing at the message.</li>\n<li><strong>Move focus</strong> to the first invalid field (or to an error summary at the top for long forms) so the user lands on the problem.</li>\n</ol>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"html\" data-index=\"2\"><div class=\"code-head\"><span class=\"code-title\">HTML</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">&#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">label</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> for</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"email\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">>Email&#x3C;/</span><span style=\"color:#116329;--shiki-dark:#7EE787\">label</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">></span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">&#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">input</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> id</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"email\"</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> type</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"email\"</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> autocomplete</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"email\"</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> required</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">></span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">&#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">p</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> id</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"email-error\"</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> class</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"error\"</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> hidden</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">>&#x3C;/</span><span style=\"color:#116329;--shiki-dark:#7EE787\">p</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">></span></span></code></pre></div>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"js\" data-index=\"3\"><div class=\"code-head\"><span class=\"code-title\">ticketly/checkout.js</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">const</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> form</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> document.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">querySelector</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'#checkout'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">const</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> email</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> form.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">querySelector</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'#email'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">const</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> error</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> form.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">querySelector</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'#email-error'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">form.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">addEventListener</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'submit'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, (</span><span style=\"color:#953800;--shiki-dark:#FFA657\">event</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> {</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  if</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> (email.value.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">includes</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'@'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)) {</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">    email.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">removeAttribute</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'aria-invalid'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">    email.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">removeAttribute</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'aria-describedby'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">    error.hidden </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> true</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">    return</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  }</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  event.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">preventDefault</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">();</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  error.textContent </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> 'Enter an email address like name@example.com'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  error.hidden </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> false</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  email.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">setAttribute</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'aria-invalid'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'true'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  email.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">setAttribute</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'aria-describedby'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'email-error'</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  email.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">focus</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">();</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">});</span></span></code></pre></div>\n<p>The form uses <code dir=\"ltr\">novalidate</code> in Ticketly’s markup (<code dir=\"ltr\">&lt;form id=&quot;checkout&quot; novalidate&gt;</code>) so this custom message replaces the browser’s built-in bubble, which varies between browsers and disappears quickly. That is a common choice; keeping native validation is also fine if you are happy with its messages.</p>\n<aside class=\"callout callout-tip\" role=\"note\"><p class=\"callout-title\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5\"/><path d=\"M9 18h6\"/><path d=\"M10 22h4\"/></svg><span><span class=\"callout-kind\">Tip</span><span class=\"callout-sep\" aria-hidden=\"true\"> · </span>Remove the description when the error is fixed</span></p>\n<p>An element referenced by <code dir=\"ltr\">aria-describedby</code> is read even when it is hidden. If you leave the reference in place after the user fixes the field, they keep hearing an old error. Remove the attribute (or empty the message) when the field becomes valid, as the code above does.</p>\n</aside>\n<p>For a long form with several errors, add an <strong>error summary</strong> at the top: a heading such as “There are 2 problems”, followed by a list of links, each pointing to the field it describes (<code dir=\"ltr\">&lt;a href=&quot;#email&quot;&gt;Enter an email address&lt;/a&gt;</code>). Move focus to the summary after a failed submit. Users hear how many problems there are, and each link takes them straight to a field.</p>\n<p>Validate on submit or when the user leaves a field, not on every keystroke. Shouting “invalid email” while someone is still typing the first letters is noise for everyone and a flood of announcements for screen reader users.</p>\n<p>Ticketly’s forms now speak clearly. The last piece of Section 2 covers content that isn’t text at all: images, icons and the data tables on the pricing page.</p>\n","toc":[{"id":"sec-1","text":"Every field gets a label","level":2},{"id":"sec-2","text":"Group related choices","level":2},{"id":"sec-3","text":"Help people and browsers fill it in","level":2},{"id":"sec-4","text":"Errors people can find and fix","level":2}],"takeaways":["Every input needs a programmatic label; a placeholder disappears on typing and is not a reliable name.","Group radio buttons and checkboxes in a fieldset whose legend asks the question.","Autocomplete tokens such as name, email and cc-number let browsers fill fields and satisfy 1.3.5 Identify Input Purpose.","On an error, set aria-invalid, connect the message with aria-describedby, describe how to fix it, and move focus to the first invalid field."],"further":[{"title":"W3C WAI: Forms Tutorial","url":"https://www.w3.org/WAI/tutorials/forms/"},{"title":"MDN: HTML autocomplete attribute","url":"https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/autocomplete"},{"title":"MDN: The Fieldset element","url":"https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/fieldset"}],"quiz":[{"q":"<p>What does a screen reader announce when focus reaches this field?</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"html\" data-index=\"0\"><div class=\"code-head\"><span class=\"code-title\">HTML</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">&#x3C;</span><span style=\"color:#116329;--shiki-dark:#7EE787\">input</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> type</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"email\"</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> id</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"email\"</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> placeholder</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"Email\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">></span></span></code></pre></div>\n","options":[{"text":"“Email, edit text” in most browsers, until the user types, and then the cue on screen is gone","why":"Correct. Browsers fall back to the placeholder as a name, but once the user types the visual hint vanishes, and some tools don’t use it. A real label fixes both."},{"text":"“Edit text” with no name in every browser and screen reader","why":"Most browsers do use the placeholder as a last-resort name. The problem is that it is fragile and disappears visually, not that it is always silent."},{"text":"“Email, required, edit text”","why":"Nothing marks the field as required. That needs the required attribute."}],"answer":0},{"q":"<p>Ticketly asks “Ticket type” with three radio buttons. How should the question be marked up?</p>","options":[{"text":"A paragraph with the question text above the radios","why":"A paragraph isn’t programmatically tied to the radios, so moving into the group doesn’t announce the question."},{"text":"A fieldset containing the radios, with a legend that says “Ticket type”","why":"Correct. Screen readers announce the legend when focus enters the group, so users hear “Ticket type, grouping, Standing, radio button, 1 of 3”."},{"text":"An aria-label=“Ticket type” on each radio button","why":"That replaces each option’s own name, so every radio would be announced as “Ticket type” and users couldn’t tell them apart."}],"answer":1},{"q":"<p>After a failed submit, which change helps a screen reader user most?</p>","options":[{"text":"Turning the invalid field’s border red","why":"Colour alone isn’t perceivable to screen reader users or many color-blind users, and it fails 1.4.1 Use of Color."},{"text":"Showing a toast “Form has errors” for three seconds","why":"A short-lived toast far from the field is easy to miss and doesn’t say which field is wrong or how to fix it."},{"text":"Setting aria-invalid on the field, linking the message with aria-describedby, and moving focus to the field","why":"Correct. Focus lands on the problem, and the screen reader reads “Email, invalid entry, Enter an email address like name@example.com”."}],"answer":2},{"q":"<p>Which autocomplete value is correct for the cardholder’s name on Ticketly’s payment step?</p>","options":[{"text":"<code dir=\"ltr\">autocomplete=&quot;name&quot;</code>","why":"name is for the user’s own full name. It may fill correctly by luck, but the token for the name on a card is more specific."},{"text":"<code dir=\"ltr\">autocomplete=&quot;cc-name&quot;</code>","why":"Correct. cc-name is the token for the name printed on the payment card, alongside cc-number, cc-exp and cc-csc."},{"text":"<code dir=\"ltr\">autocomplete=&quot;on&quot;</code>","why":"on lets the browser guess but declares no purpose, so it doesn’t meet 1.3.5 Identify Input Purpose."}],"answer":1}],"exercise":{"kind":"web","mode":"dom","starter":{"html":"<form id=\"checkout\" novalidate>\n  <input id=\"full-name\" name=\"fullName\" placeholder=\"Full name\">\n  <input id=\"email\" name=\"email\" type=\"text\" placeholder=\"Email\">\n  <p id=\"email-error\" class=\"error\" hidden></p>\n\n  <p class=\"question\">Ticket type</p>\n  <input type=\"radio\" name=\"ticket\" value=\"standing\" id=\"t-standing\"> Standing\n  <input type=\"radio\" name=\"ticket\" value=\"seated\" id=\"t-seated\"> Seated\n\n  <button id=\"pay\">Pay now</button>\n</form>","css":"form { display: grid; gap: 6px; max-width: 320px; }\ninput:not([type=\"radio\"]) { font: inherit; padding: 6px 8px; border: 1px solid #77708f; border-radius: 6px; }\nfieldset { border: 1px solid #c9c4dc; border-radius: 8px; padding: 8px 12px; }\n.error { color: #b3261e; margin: 0; }\n[aria-invalid=\"true\"] { border-color: #b3261e; border-width: 2px; }\nbutton { justify-self: start; font: inherit; padding: 8px 16px; border-radius: 8px; border: 0; background: #5b3cc4; color: #fff; }\n","js":"const email = document.querySelector('#email');\nconst error = document.querySelector('#email-error');\n\n// The playground blocks real form submission, so validation runs on the\n// submit button's click. That click also fires when Enter is pressed in a field.\ndocument.querySelector('#pay').addEventListener('click', (event) => {\n  event.preventDefault();\n  if (email.value.includes('@')) {\n    error.hidden = true;\n    return;\n  }\n  error.textContent = 'Enter an email address like name@example.com';\n  error.hidden = false;\n});\n"},"solution":{"html":"<form id=\"checkout\" novalidate>\n  <label for=\"full-name\">Full name</label>\n  <input id=\"full-name\" name=\"fullName\" autocomplete=\"name\">\n  <label for=\"email\">Email</label>\n  <input id=\"email\" name=\"email\" type=\"email\" autocomplete=\"email\">\n  <p id=\"email-error\" class=\"error\" hidden></p>\n\n  <fieldset>\n    <legend>Ticket type</legend>\n    <label><input type=\"radio\" name=\"ticket\" value=\"standing\" id=\"t-standing\"> Standing</label>\n    <label><input type=\"radio\" name=\"ticket\" value=\"seated\" id=\"t-seated\"> Seated</label>\n  </fieldset>\n\n  <button id=\"pay\">Pay now</button>\n</form>","css":"form { display: grid; gap: 6px; max-width: 320px; }\ninput:not([type=\"radio\"]) { font: inherit; padding: 6px 8px; border: 1px solid #77708f; border-radius: 6px; }\nfieldset { border: 1px solid #c9c4dc; border-radius: 8px; padding: 8px 12px; }\n.error { color: #b3261e; margin: 0; }\n[aria-invalid=\"true\"] { border-color: #b3261e; border-width: 2px; }\nbutton { justify-self: start; font: inherit; padding: 8px 16px; border-radius: 8px; border: 0; background: #5b3cc4; color: #fff; }\n","js":"const email = document.querySelector('#email');\nconst error = document.querySelector('#email-error');\n\n// The playground blocks real form submission, so validation runs on the\n// submit button's click. That click also fires when Enter is pressed in a field.\ndocument.querySelector('#pay').addEventListener('click', (event) => {\n  event.preventDefault();\n  if (email.value.includes('@')) {\n    error.hidden = true;\n    email.removeAttribute('aria-invalid');\n    email.removeAttribute('aria-describedby');\n    return;\n  }\n  error.textContent = 'Enter an email address like name@example.com';\n  error.hidden = false;\n  email.setAttribute('aria-invalid', 'true');\n  email.setAttribute('aria-describedby', 'email-error');\n  email.focus();\n});\n"},"checks":[{"id":"labels","test":"const lbl = (el) => [...(el.labels || [])].map((l) => l.textContent).join(' ').replace(/\\s+/g, ' ').trim(); const fields = $$('input'); return fields.length === 4 && fields.every((f) => lbl(f).length > 0) && /full name/i.test(lbl($('#full-name'))) && /email/i.test(lbl($('#email'))) && /standing/i.test(lbl($('#t-standing'))) && /seated/i.test(lbl($('#t-seated')));"},{"id":"fieldset","test":"const radios = $$('input[type=\"radio\"]'); const fs = radios[0] && radios[0].closest('fieldset'); if (!fs || !radios.every((r) => fs.contains(r))) return false; const lg = fs.querySelector('legend'); return !!lg && fs.firstElementChild === lg && /ticket type/i.test(lg.textContent);"},{"id":"autocomplete","test":"return $('#full-name').getAttribute('autocomplete') === 'name' && $('#email').getAttribute('autocomplete') === 'email' && $('#email').type === 'email';"},{"id":"error-linked","test":"const email = $('#email'); await input(email, 'samir.example.com'); await click('#pay'); if (email.getAttribute('aria-invalid') !== 'true') return false; const ids = (email.getAttribute('aria-describedby') || '').split(/\\s+/).filter(Boolean); return ids.some((id) => { const el = document.getElementById(id); return el && !el.hidden && /enter an email/i.test(el.textContent); });"},{"id":"error-focus","test":"return document.activeElement === $('#email');"},{"id":"error-clears","test":"const email = $('#email'); await input(email, 'samir@example.com'); await click('#pay'); if (email.getAttribute('aria-invalid') === 'true') return false; const ids = (email.getAttribute('aria-describedby') || '').split(/\\s+/).filter(Boolean); return !ids.some((id) => { const el = document.getElementById(id); return el && /enter an email/i.test(el.textContent) && !el.hidden; }) && $('#email-error').hidden;"}],"title":"Repair Ticketly's checkout form","prompt":"<p>The checkout form relies on placeholders, floats a question above unlabelled radios, and shows an email error that only sighted users notice. Fix it:</p>\n<ol>\n<li>Give <code dir=\"ltr\">#full-name</code>, <code dir=\"ltr\">#email</code> and both radios a real <code dir=\"ltr\">&lt;label&gt;</code> (“Full name”, “Email”, “Standing”, “Seated”).</li>\n<li>Group the radios in a <code dir=\"ltr\">&lt;fieldset&gt;</code> whose <code dir=\"ltr\">&lt;legend&gt;</code> is “Ticket type”.</li>\n<li>Add <code dir=\"ltr\">autocomplete=&quot;name&quot;</code> and <code dir=\"ltr\">autocomplete=&quot;email&quot;</code>, and make the email field <code dir=\"ltr\">type=&quot;email&quot;</code>.</li>\n<li>In the script, when the email is invalid: set <code dir=\"ltr\">aria-invalid=&quot;true&quot;</code>, point <code dir=\"ltr\">aria-describedby</code> at <code dir=\"ltr\">#email-error</code>, and move focus to the email field. When it becomes valid, remove both attributes.</li>\n</ol>\n","hints":["Either <label for=\"email\">Email</label> before the input, or wrap the radio in <label> … Standing</label>.","The legend must be the first child of the fieldset.","In the invalid branch: email.setAttribute(‘aria-invalid’, ‘true’); email.setAttribute(‘aria-describedby’, ‘email-error’); email.focus();"],"explanation":"<p>Labels give every field a name that survives typing, and the fieldset lets screen readers announce “Ticket type, grouping” before the options. The autocomplete tokens let browsers and password managers fill the form, which is what 1.3.5 asks for. The script now connects the error in three ways: <code dir=\"ltr\">aria-invalid</code> says the field is wrong, <code dir=\"ltr\">aria-describedby</code> makes the message its description, and <code dir=\"ltr\">focus()</code> takes the user straight to it, so NVDA reads “Email, edit text, invalid entry, Enter an email address like name@example.com”. Removing both attributes on success stops the old error being read again, because a referenced element is announced even while hidden.</p>\n","checkLabels":{"labels":"All four inputs have labels with the right text","fieldset":"The radios sit in a fieldset whose first child is the legend “Ticket type”","autocomplete":"Name and email have autocomplete tokens, and email is type=“email”","error-linked":"An invalid email sets aria-invalid and links the visible error with aria-describedby","error-focus":"Focus moves to the email field after the failed attempt","error-clears":"A valid email removes aria-invalid and hides the error"}},"words":735,"readMinutes":4}