diff --git a/tutorials/html/13-accessibility/accessible-buttons.mdx b/tutorials/html/13-accessibility/accessible-buttons.mdx index e69de29bb..a3f067158 100644 --- a/tutorials/html/13-accessibility/accessible-buttons.mdx +++ b/tutorials/html/13-accessibility/accessible-buttons.mdx @@ -0,0 +1,594 @@ +--- +id: html-accessible-buttons +title: "Accessible Buttons" +sidebar_label: "Accessible Buttons" +sidebar_position: 11 +slug: /html/accessibility/accessible-buttons +description: "Learn how to build buttons that work for everyone: use the real button element, set the type, name icon buttons, make toggle and disclosure buttons with aria-pressed and aria-expanded, handle disabled and loading states, and avoid fake buttons made from div and span. Includes a toggle lab, a disabled-button lab, and a live button checker." +keywords: + - accessible buttons + - button element + - aria-pressed + - aria-expanded + - aria-disabled + - disabled button + - toggle button + - icon button + - div as button + - learn HTML + - CodeHarborHub HTML +tags: [html, web-development, beginner, accessibility, buttons, aria, keyboard, a11y, frontend] +--- + +import {useState} from 'react'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; +import BrowserOnly from '@docusaurus/BrowserOnly'; +import AdsComponent from '@site/src/components/AdsComponent'; +import BrowserWindow from '@site/src/components/BrowserWindow'; + +export function ToggleLab() { + const [muted, setMuted] = useState(false); + const [open, setOpen] = useState(false); + const [changeLabel, setChangeLabel] = useState(false); + const label = changeLabel ? (muted ? "Unmute" : "Mute") : "Mute"; + const toggleSpoken = label + ", toggle button, " + (muted ? "pressed" : "not pressed"); + const discSpoken = "Delivery details, button, " + (open ? "expanded" : "collapsed"); + return ( +
+

+ +

+
+

A toggle button (on or off)

+ +

A screen reader may say: {toggleSpoken}

+ {changeLabel ?

Problem: when the label changes and the state is announced, people hear "Unmute, pressed", and cannot tell whether sound is on or off.

:

The label stays the same, and the state carries the change. That is the correct pattern.

} +
+
+

A disclosure button (show or hide a panel)

+ + +

A screen reader may say: {discSpoken}

+
+

+ Press each button with Enter or Space. The exact words differ between screen readers (some say "toggle button", some say "pressed", some say "checked"), so the lines above are examples, not exact output. The rule stays the same: a stable label, and a changing state. +

+
+ ); +} + +export function DisabledLab() { + const [mode, setMode] = useState("native"); + const [agree, setAgree] = useState(false); + const [message, setMessage] = useState(""); + const [where, setWhere] = useState("nowhere yet"); + const onFocus = (event) => setWhere(event.target.getAttribute("data-name") || event.target.tagName.toLowerCase()); + const place = () => { + if (mode === "aria" && !agree) { + setMessage("Please accept the terms before placing your order."); + return; + } + setMessage("Order placed. (This is a demo, so nothing was sent.)"); + }; + return ( +
+
+ How should the unavailable button work? + + +
+

+ +

+

{agree ? "Ready when you are." : "Accept the terms to place your order."}

+

+ {" "} + +

+

{message}

+

Focus last landed on: {where}

+

+ Leave the box unticked, click the checkbox, then press Tab. With native disabled, focus skips straight past the Place order button, so a keyboard user may never learn it exists or why it does not work. With aria-disabled, focus lands on it, the reason is read out (it is connected with aria-describedby), and pressing it explains the problem. The trade-off: you must block the action yourself, and make it look unavailable. Both ways can work, as long as the reason is visible near the button. +

+
+ ); +} + +export const buttonSamples = { + messy: '
\n \n \n
\n
Save
\nShare\nMenu\n\n\n\n', + fixed: '
\n \n \n
\n\n\n\n\n\nHelp\n', +}; + +export function buttonTextOf(node) { + if (node.nodeType === 3) return node.textContent; + if (node.nodeType !== 1) return ""; + if (node.getAttribute("aria-hidden") === "true" || node.hasAttribute("hidden")) return ""; + if (node.tagName.toLowerCase() === "img") return node.getAttribute("alt") || ""; + let out = ""; + node.childNodes.forEach((child) => { out += " " + buttonTextOf(child) + " "; }); + return out; +} + +export function buttonNameOf(el) { + const label = (el.getAttribute("aria-label") || "").trim(); + if (label) return label; + const tag = el.tagName.toLowerCase(); + const type = (el.getAttribute("type") || "").toLowerCase(); + if (tag === "input") { + const value = (el.getAttribute("value") || "").trim(); + if (value) return value; + if (type === "image") return (el.getAttribute("alt") || "").trim(); + return type === "submit" ? "Submit" : type === "reset" ? "Reset" : ""; + } + return buttonTextOf(el).replace(/\s+/g, " ").trim(); +} + +export function ButtonCheckerInner() { + const [which, setWhich] = useState("messy"); + const [html, setHtml] = useState(buttonSamples.messy); + const doc = new DOMParser().parseFromString("" + html, "text/html"); + const selector = 'button, input[type="button"], input[type="submit"], input[type="reset"], input[type="image"], [role="button"], [onclick]:not(a):not(button):not(input):not(select):not(textarea):not(summary), a[href="#"], a[href^="javascript:"]'; + const elements = Array.from(doc.querySelectorAll(selector)); + const rows = elements.map((el) => { + const tag = el.tagName.toLowerCase(); + const issues = []; + const notes = []; + const real = tag === "button" || tag === "input"; + const name = buttonNameOf(el); + if (!real) { + if (tag === "a") issues.push("A link used as a button. If it does an action, use a button."); + else if (el.getAttribute("role") === "button") issues.push("A made-up button (role=button on a " + tag + "). Use a real button element."); + else issues.push("A click handler on a " + tag + ". It is not announced as a button, and keyboards cannot use it. Use a button."); + if (tag !== "a" && !el.hasAttribute("tabindex")) issues.push("Not focusable: there is no tabindex, so keyboard users cannot reach it."); + } + if (tag === "button" && !el.hasAttribute("type") && el.closest("form")) issues.push("No type inside a form. It will submit the form. Add type=\"button\" or type=\"submit\" on purpose."); + if (!name) issues.push("No accessible name."); + const tabindex = parseInt(el.getAttribute("tabindex") || "0", 10); + if (tabindex > 0) issues.push("A positive tabindex changes the focus order. Use 0 or -1 only."); + if (tag === "button" && el.querySelector("a[href], button, input, select, textarea")) issues.push("An interactive element inside a button is not allowed, and confuses keyboards and screen readers."); + if (el.hasAttribute("disabled")) notes.push("Disabled: it is skipped by Tab. Say why nearby, or consider aria-disabled so people can find it."); + return {tag, name, issues, notes}; + }); + const bad = rows.filter((row) => row.issues.length > 0).length; + const choose = (value) => { setWhich(value); setHtml(buttonSamples[value]); }; + return ( +
+
+ Which page do you want to check? + + +
+

+
+ \n', + fixed: '

\n \n \n \n \n \n \n \n \n
\n Delivery\n \n \n
\n \n \n \n
', +}; + +export function fieldNameOf(el, doc) { + const clean = (value) => (value || "").replace(/\s+/g, " ").trim(); + const ids = clean(el.getAttribute("aria-labelledby")); + if (ids) { + const text = clean(ids.split(" ").map((id) => { const target = doc.getElementById(id); return target ? target.textContent : ""; }).join(" ")); + if (text) return {name: text, source: "aria-labelledby", labelText: text}; + } + const aria = clean(el.getAttribute("aria-label")); + if (aria) return {name: aria, source: "aria-label", labelText: ""}; + const id = el.getAttribute("id"); + if (id) { + const found = Array.from(doc.querySelectorAll("label")).find((item) => item.getAttribute("for") === id); + if (found) return {name: clean(found.textContent), source: "label", labelText: clean(found.textContent)}; + } + const wrap = el.closest("label"); + if (wrap) { + const copy = wrap.cloneNode(true); + copy.querySelectorAll("input, select, textarea").forEach((control) => control.remove()); + return {name: clean(copy.textContent), source: "label", labelText: clean(copy.textContent)}; + } + if (clean(el.getAttribute("title"))) return {name: clean(el.getAttribute("title")), source: "title", labelText: ""}; + if (clean(el.getAttribute("placeholder"))) return {name: clean(el.getAttribute("placeholder")), source: "placeholder", labelText: ""}; + return {name: "", source: "none", labelText: ""}; +} + +export function FormCheckerInner() { + const [which, setWhich] = useState("messy"); + const [html, setHtml] = useState(formSamples.messy); + const doc = new DOMParser().parseFromString("" + html, "text/html"); + const controls = Array.from(doc.querySelectorAll("input:not([type=hidden]):not([type=submit]):not([type=button]):not([type=reset]):not([type=image]), select, textarea")); + const general = []; + const ids = {}; + Array.from(doc.querySelectorAll("[id]")).forEach((el) => { ids[el.id] = (ids[el.id] || 0) + 1; }); + Object.keys(ids).forEach((id) => { if (ids[id] > 1) general.push("The id \"" + id + "\" is used " + ids[id] + " times. Ids must be unique, or labels connect to the wrong field."); }); + Array.from(doc.querySelectorAll("label[for]")).forEach((label) => { if (!doc.getElementById(label.getAttribute("for"))) general.push("A label points to the id \"" + label.getAttribute("for") + "\", which does not exist."); }); + if (doc.querySelector("form") && !doc.querySelector('button:not([type="button"]):not([type="reset"]), input[type="submit"], input[type="image"]')) general.push("The form has no submit button."); + const groups = {}; + controls.forEach((el) => { + const type = (el.getAttribute("type") || "").toLowerCase(); + if ((type === "radio" || type === "checkbox") && el.getAttribute("name")) { + const key = type + ":" + el.getAttribute("name"); + groups[key] = groups[key] || []; + groups[key].push(el); + } + }); + Object.keys(groups).forEach((key) => { + const list = groups[key]; + if (list.length > 1 && !list.some((el) => el.closest("fieldset"))) general.push("The " + key.split(":")[0] + " group \"" + key.split(":")[1] + "\" is not inside a fieldset with a legend."); + }); + const rows = controls.map((el) => { + const tag = el.tagName.toLowerCase(); + const type = (el.getAttribute("type") || (tag === "input" ? "text" : tag)).toLowerCase(); + const info = fieldNameOf(el, doc); + const hint = ((el.getAttribute("name") || "") + " " + (el.getAttribute("id") || "") + " " + info.name).toLowerCase(); + const ac = (el.getAttribute("autocomplete") || "").toLowerCase(); + const issues = []; + const notes = []; + if (info.source === "none") issues.push("No label or name."); + if (info.source === "placeholder") issues.push("Only a placeholder, which is not a real label."); + if (info.source === "title") issues.push("Only a title attribute. Use a visible label."); + if (info.source === "aria-label") notes.push("Named with aria-label only. A visible label is better for everyone."); + if (type === "text" && /e-?mail/.test(hint)) issues.push("This looks like an email field. Use type=\"email\"."); + if (type === "text" && /(phone|tel|mobile)/.test(hint)) issues.push("This looks like a phone field. Use type=\"tel\"."); + const expected = type === "email" || /e-?mail/.test(hint) ? "email" : type === "tel" || /(phone|tel\b|mobile)/.test(hint) ? "tel" : type === "password" ? "password" : /(^|[^a-z])(full )?name/.test(hint) ? "name" : ""; + if (expected && ac === "") issues.push("Add an autocomplete value (for example " + (expected === "password" ? "new-password or current-password" : expected) + ")."); + if (expected && ac === "off") issues.push("autocomplete=\"off\" blocks autofill and password managers, and fails WCAG 1.3.5 for personal data."); + if (el.hasAttribute("required") && !/(required|mandatory|\*)/i.test(info.labelText || "")) notes.push("Say that the field is required in visible words, such as (required), not only with the attribute."); + if (el.getAttribute("aria-invalid") === "true" && !el.getAttribute("aria-describedby")) issues.push("Marked invalid, but no error message is connected with aria-describedby."); + if (/return\s+false/i.test(el.getAttribute("onpaste") || "")) issues.push("Blocks pasting. This breaks password managers and fails WCAG 3.3.8."); + return {tag, type, name: info.name, source: info.source, issues, notes}; + }); + const bad = rows.filter((row) => row.issues.length > 0).length + general.length; + const choose = (value) => { setWhich(value); setHtml(formSamples[value]); }; + return ( +
+
+ Which form do you want to check? + + +
+

+
+