[DOC] skills: update odoo-javascript skill documentation
This commit is contained in:
parent
158933e96e
commit
162ba0b570
6 changed files with 875 additions and 1 deletions
245
.claude/skills/odoo-web-design/references/accessibility.md
Normal file
245
.claude/skills/odoo-web-design/references/accessibility.md
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
# 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue