addons-cm/.claude/skills/odoo-web-design/references/accessibility.md

245 lines
9.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Accessibility patterns (QWeb + CSS + JS)
WCAG 2.2 AA. Snippets use this repo's conventions: 4-space indent, Bootstrap 5.3 utilities,
translatable attributes, `window.i18nManager.get(key)` for JS strings.
## Page skeleton
```xml
<t t-call="website.layout">
<a href="#eskaera-main" class="visually-hidden-focusable btn btn-primary m-2">
Skip to main content
</a>
<div id="wrap" class="eskaera-shop-page">
<main id="eskaera-main" tabindex="-1">
<h1 class="eskaera-page-title" t-esc="page_title"/>
...
</main>
</div>
</t>
```
- Exactly one `<h1>` per page; never skip heading levels to get a font size — that's what classes are for.
- `<main>`, `<nav aria-label="...">`, `<aside>` instead of anonymous `<div>`s.
- `tabindex="-1"` on the skip-link target so focus actually lands there.
## Buttons vs links
| Intent | Element |
| --- | --- |
| Navigates to a URL | `<a href="...">` |
| Changes state on this page | `<button type="button">` |
| Submits a form | `<button type="submit">` |
Never `<div t-on-click>` / `<span onclick>` — no keyboard, no role, no focus.
Icon-only:
```xml
<button type="button" class="btn btn-outline-secondary qty-decrease"
aria-label="Decrease quantity">
<i class="fa fa-minus" aria-hidden="true"/>
</button>
```
`aria-label` is auto-translated; `aria-hidden` on the icon stops the screen reader announcing
"minus icon" twice. Alternative when the label is long: visible `<span class="visually-hidden">`.
## Quantity stepper (live-announced)
```xml
<div class="qty-control" role="group" aria-label="Quantity">
<button type="button" class="btn qty-decrease" aria-label="Decrease quantity">
<i class="fa fa-minus" aria-hidden="true"/>
</button>
<input type="number" class="form-control qty-input"
t-att-value="line.qty" min="0" step="1"
aria-label="Quantity" inputmode="numeric"/>
<button type="button" class="btn qty-increase" aria-label="Increase quantity">
<i class="fa fa-plus" aria-hidden="true"/>
</button>
</div>
<p class="visually-hidden" aria-live="polite" id="qty_status"/>
```
```js
// after the server confirms the new qty
const status = document.getElementById("qty_status");
status.textContent = window.i18nManager.get("qty_updated_announcement");
```
Buttons must stay ≥ 24×24 px (WCAG 2.2 AA §2.5.8) everywhere, and ≥ 44×44 px under
`@media (pointer: coarse)` — including at 320 px width.
## Forms and errors
```xml
<div class="mb-3">
<label class="form-label" for="delivery_note">Delivery note</label>
<textarea id="delivery_note" name="delivery_note" class="form-control"
t-att-aria-invalid="'true' if errors.get('delivery_note') else None"
t-att-aria-describedby="'delivery_note_help delivery_note_error'
if errors.get('delivery_note')
else 'delivery_note_help'"/>
<small id="delivery_note_help" class="form-text">Optional, visible to the group manager.</small>
<p t-if="errors.get('delivery_note')" id="delivery_note_error"
class="invalid-feedback d-block" t-esc="errors['delivery_note']"/>
</div>
```
- The `for`/`id` pair is mandatory; wrapping alone is not enough for every AT.
- Error text is referenced by `aria-describedby`, and never colour-only — keep the message.
- The error summary at the top of a failed form should be focused on submit
(`container.focus()` on a `tabindex="-1"` heading).
- Required fields: `required` + a visible "(required)" or `*` with a legend explaining it.
## Product card — one link, no duplicates
```xml
<article class="product-card">
<img class="product-card-img" t-att-src="product['image_url']"
t-att-alt="product['name']" loading="lazy" width="400" height="400"/>
<h3 class="product-card-title">
<a t-att-href="product['url']" class="stretched-link" t-esc="product['name']"/>
</h3>
<p class="product-card-price">
<span class="visually-hidden">Price</span>
<span t-esc="product['price_formatted']"/>
</p>
</article>
```
- `stretched-link` makes the whole card clickable with a **single** link in the a11y tree — do not
also wrap the image in an `<a>`, that produces two identical stops.
- `width`/`height` reserve space (no layout shift); CSS `object-fit: cover` handles the ratio.
- Decorative product placeholder → `alt=""`.
## Filters as toggle buttons
```xml
<div class="tag-filter" role="group" aria-label="Filter by category">
<button t-foreach="categories" t-as="cat" t-key="cat['id']"
type="button" class="tag-filter-badge"
t-att-aria-pressed="'true' if cat['active'] else 'false'"
t-att-data-category-id="cat['id']">
<span t-esc="cat['name']"/>
</button>
</div>
```
JS flips `aria-pressed` together with the active class — the class alone announces nothing.
If the filters are a true multi-select set, `<fieldset><legend>` + checkboxes is better still.
## Search with results announcement
```xml
<label class="visually-hidden" for="product_search">Search products</label>
<input id="product_search" type="search" class="form-control"
placeholder="Search products…" autocomplete="off"
aria-describedby="search_results_count"/>
<p id="search_results_count" class="visually-hidden" aria-live="polite" aria-atomic="true"/>
```
Debounce ≥ 200 ms, then set the text once per settled query — announcing on every keystroke makes
the page unusable with a screen reader.
## Loading, infinite scroll, empty states
```xml
<div id="products_grid" class="products-grid" aria-busy="false"></div>
<p class="visually-hidden" aria-live="polite" id="load_status"/>
<button type="button" id="load_more" class="btn btn-outline-primary">Load more products</button>
```
- Set `aria-busy="true"` on the grid while fetching, back to `false` when done.
- Announce `"N more products loaded"` via `load_status`; don't move focus mid-scroll.
- **Always keep a real "Load more" button** alongside scroll-triggered loading — infinite scroll
alone is unreachable by keyboard and traps users away from the footer.
- Empty state is content, not a blank div: heading + explanation + the action that fixes it.
## Modals
Use Bootstrap's modal (focus trap, Esc, `aria-modal` come free) and give it a label:
```xml
<div class="modal fade" id="confirm_order_modal" tabindex="-1"
aria-labelledby="confirm_order_title" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title fs-5" id="confirm_order_title">Confirm your order</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"/>
</div>
</div>
</div>
</div>
```
Return focus to the trigger on close (Bootstrap does this if the trigger is a real button).
## Order tables
```xml
<table class="table checkout-summary-table">
<caption class="visually-hidden">Order lines</caption>
<thead>
<tr>
<th scope="col">Product</th>
<th scope="col" class="text-end">Quantity</th>
<th scope="col" class="text-end">Subtotal</th>
</tr>
</thead>
<tbody>
<tr t-foreach="lines" t-as="line" t-key="line['id']">
<th scope="row" t-esc="line['name']"/>
<td class="text-end" t-esc="line['qty']"/>
<td class="text-end" t-esc="line['subtotal_formatted']"/>
</tr>
</tbody>
</table>
```
Never use a table for layout, and never drop `<thead>`/`scope` when stacking to cards on mobile —
restyle, don't restructure.
## Focus and motion in CSS
```css
/* One ring, everywhere, on keyboard focus only. */
.eskaera-page :focus-visible {
outline: 2px solid var(--ac-color-focus, var(--focus-ring-color, #0d6efd));
outline-offset: 2px;
border-radius: var(--ac-radius-sm);
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
```
This is the one legitimate `!important` in the codebase — a user preference must beat every component.
## Multilingual
The addon runs es / eu / ca. `website.layout` sets `<html lang>`; when a string stays in another
language inside a translated page (a supplier name, a product origin), mark it: `<span lang="eu">`.
Never build a sentence by concatenating translated fragments — pass the whole string with `%s`
placeholders so translators can reorder.
## Verification
1. **Keyboard only**: Tab through the page. Visible focus at every stop, order matches the visual
order, no trap, every action reachable, Esc closes overlays.
2. **Zoom 200 %** and **320 px width**: no horizontal scroll, nothing clipped.
3. **Contrast**: check rendered colours, including disabled states and placeholder text.
4. **Screen reader** smoke test on the cart flow (Orca on Linux, NVDA on Windows).
5. Lighthouse/axe in DevTools catches the mechanical half — it does not catch focus order,
announcement quality, or a label that lies.