[DOC] skills: update odoo-javascript skill documentation

This commit is contained in:
GitHub Copilot 2026-08-15 13:00:56 +02:00
parent 158933e96e
commit 162ba0b570
6 changed files with 875 additions and 1 deletions

View 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.

View file

@ -0,0 +1,160 @@
# CSS architecture
## File layout
```
static/src/css/
├── website_sale.css # index only: @import list, no rules of its own
├── base/
│ ├── variables.css # :root tokens — the ONLY file with hex literals
│ └── utilities.css # tiny single-purpose helpers, when Bootstrap lacks one
├── layout/
│ ├── pages.css # page shells, containers, backgrounds
│ └── header.css
├── components/ # one file per reusable component
│ ├── product-card.css
│ ├── order-card.css
│ ├── cart.css
│ ├── buttons.css
│ ├── quantity-control.css
│ ├── forms.css
│ ├── alerts.css
│ └── tag-filter.css
└── sections/ # page-specific composition only
├── products-grid.css
├── order-list.css
├── checkout.css
└── info-cards.css
```
Rules of placement:
- A rule that could appear on two pages is a **component**, never a section.
- A **section** file may only position and space components — if it restyles a component's internals,
the component is missing a modifier.
- Each component file owns its own media queries, at the bottom. There is no global responsive file
in a healthy tree; a legacy `layout/responsive.css` should shrink to zero as components are touched.
- `website_sale.css` contains imports and comments only.
## Naming
Prefix everything with `eskaera-` (the product name already used for page-level classes). Unprefixed
names like `.product-card`, `.cart-header`, `.qty-control` sit in the same namespace as Bootstrap,
Odoo core and every other installed addon — that collision is what forces `!important` later.
```
.eskaera-product-card /* block */
.eskaera-product-card__title /* element */
.eskaera-product-card--out-of-stock /* modifier */
.is-loading .is-active .has-error /* state, always paired with a block class */
```
`js-` prefix for hooks JS queries, and never style a `js-` class — that way markup can be restyled
without breaking behaviour, and behaviour can move without breaking styles.
When renaming existing classes, do it component by component: template + CSS + JS selectors + tests
in the same commit, then grep for the old name across `views/`, `static/src/`, and `README*`.
## Specificity — how to stop writing `!important`
Overriding Bootstrap globally (`.list-group-item { … }`, `.alert-warning { … }`) is a fight you win
only with `!important`, and it leaks into every other addon. Scope the override under the page root
instead: equal specificity plus one class always wins, no escape hatch needed.
```css
/* ❌ global override, needs !important to beat Bootstrap's own later rules */
.list-group-item {
border-radius: 0 !important;
}
/* ✅ scoped: higher specificity, no !important, no leakage */
.eskaera-checkout-page .list-group-item {
border-radius: 0;
}
```
Other rules:
- Max **3** levels of nesting in a selector. Deeper means the markup is doing the work the class
should do.
- No ID selectors for styling; IDs are for anchors and `aria-*` references.
- No element selectors outside `base/` (`div.product-card` locks the markup).
- `!important` is allowed in exactly two places: the `prefers-reduced-motion` reset, and a documented
override of a core rule you cannot scope — with a comment saying which rule and why.
## Ordering inside a file
```css
.eskaera-product-card {
/* 1. layout */
display: flex;
flex-direction: column;
/* 2. box */
padding: var(--ac-space-md);
border: 1px solid var(--ac-border-light);
border-radius: var(--ac-radius-lg);
/* 3. typography */
font-size: var(--ac-text-base);
color: var(--ac-text-primary);
/* 4. paint & motion */
background: var(--ac-surface);
box-shadow: var(--ac-shadow-sm);
transition: box-shadow var(--ac-transition-fast);
}
```
Then, in order: `:hover` (inside `@media (hover: hover)`), `:focus-visible`, `:active`, `:disabled`,
state classes, modifiers, media queries.
## Tokens
Full rules in `SKILL.md` §2. In short:
```css
:root {
/* alias Odoo/Bootstrap's unprefixed custom properties so theming still works */
--ac-color-primary: var(--primary, #007bff);
--ac-color-danger: var(--danger, #dc3545);
--ac-surface: var(--body-bg, #fff);
--ac-text-primary: var(--body-color, #1a202c);
--ac-border-light: var(--border-color, #e2e8f0);
/* own scales */
--ac-space-xs: 0.25rem;
--ac-space-sm: 0.5rem;
--ac-space-md: 1rem;
--ac-space-lg: 1.5rem;
--ac-space-xl: 2rem;
--ac-radius-sm: 0.25rem;
--ac-radius-md: 0.5rem;
--ac-radius-lg: 0.75rem;
--ac-shadow-sm: 0 2px 8px rgb(0 0 0 / 8%);
--ac-shadow-md: 0 4px 12px rgb(0 0 0 / 10%);
--ac-transition-fast: 200ms ease;
}
```
Migrating a legacy token set: add the `--ac-` token aliased to the old name, move consumers file by
file, delete the old token when `grep -rn "\-\-old-name" static/src/` is empty. Don't rename all at once.
## Anti-patterns seen in Odoo addons
| Smell | Fix |
| --- | --- |
| `!important` to beat Bootstrap | scope under the page root class |
| Same colour as `#28a745` in six files | one token in `variables.css` |
| `responsive.css` overriding twelve components | move each query into its component |
| `style="margin-top:20px"` in QWeb | utility class or component spacing |
| `.card .body .row .col span` | give the span a class |
| `px` for font sizes | `rem`, so browser zoom and user font size work |
| Colour hardcoded instead of `var(--primary)` | breaks the website theme editor silently |
| Both `@import` chain **and** manifest entries | double download; pick one (see SKILL.md §6) |
## Formatting
Prettier handles `.css` via pre-commit: `printWidth: 100`, `tabWidth: 4`. Run
`pre-commit run --all-files` (or `make format`) before committing; never hand-align properties, it
will be reformatted.

View file

@ -0,0 +1,96 @@
# The finish pass
What separates "it works" from "it looks designed". Go through this per screen, not per file.
## 1. Every interactive element has all its states
For each button, link, card, input, filter chip:
| State | Must be | Common miss |
| --- | --- | --- |
| default | on the token scale | — |
| `:hover` | inside `@media (hover: hover)` | sticks after tap on touch |
| `:focus-visible` | visible ring, 3:1 contrast | removed via `outline: none` |
| `:active` | perceptible press | missing entirely |
| `:disabled` | dimmed **and** `cursor: not-allowed`, still 4.5:1 if it carries text | unreadable grey |
| loading | spinner + `aria-busy`, size unchanged | layout jumps |
The element must not resize between states — animate `box-shadow`, `background`, `transform`, never
`padding`, `border-width` or `font-size`.
## 2. Vertical rhythm and spacing
- All spacing from `--ac-space-*`. A `margin: 13px` anywhere is a defect.
- Space belongs to **one** side consistently (block-end margins, or `gap` — pick one per container).
Prefer `gap` in flex/grid; it doesn't collapse and doesn't need `:last-child` cleanups.
- Related things sit closer than unrelated things. If the label is as far from its input as from the
previous field, the form reads as noise.
- Cards in a grid must be equal height (`align-items: stretch` + the price pinned with `margin-top: auto`).
## 3. Typography
- One scale (`--ac-text-*`), max 3 weights, max 2 sizes per card.
- Headings are structural (`h1``h2``h3`); size comes from a class, never from picking `h4` because
it looks right.
- `max-width: 65ch` on paragraphs of real prose.
- Numbers in tables: `font-variant-numeric: tabular-nums` and right-aligned so columns line up.
- Long product names truncate predictably: `text-overflow: ellipsis` with `title` attribute, or
`-webkit-line-clamp: 2` with a fixed `min-height` so cards don't stagger.
## 4. Colour
- Semantic, not decorative: success/danger/warning always the same hue for the same meaning.
- Status never colour-only — icon or text alongside (colour blindness, and grayscale printing).
- Max ~2 accent colours per screen. Everything else is surface, border, text.
- Check the rendered contrast, including: placeholder text, disabled buttons, badges on coloured
backgrounds, white text on `--warning` (this one almost always fails).
## 5. The states people forget
Every list, grid and total needs four designs, not one:
1. **Loading** — skeletons matched to the real layout (not a centred spinner that collapses the page).
2. **Empty** — heading, one sentence explaining why it's empty, and the button that fixes it.
"No products found" alone is a dead end; offer "Clear filters".
3. **Error** — what failed, in the user's language, and what to do now. Never a raw traceback or code.
4. **Partial / stale** — an order past its cutoff, an out-of-stock line, a price that changed. Say it
inline, next to the affected row, not only in a toast that disappears.
## 6. Feedback and motion
- Any action taken by the user gets a response within 100 ms — disable the button, show the spinner,
optimistic-update the quantity. Silence reads as "broken", and users double-submit.
- Toasts: `aria-live="polite"`, dismissible, never the only place an error is shown.
- Motion is 150250 ms and eases out. Anything above 400 ms feels slow; anything that moves the page
under the cursor is a bug.
- Everything animated respects `prefers-reduced-motion`.
## 7. Content and copy
- Sentence case for buttons and labels; imperative verbs ("Add to cart", not "Adding products").
- Prices always formatted server-side with the currency and locale (`i18nManager.formatCurrency`),
never string-concatenated in the template.
- Dates: locale-formatted, and relative only when the absolute date is also available (`title`).
- Nothing user-visible is a hardcoded English literal — templates get translatable text, JS gets
`window.i18nManager.get(key)`.
## 8. Cross-cutting checks before "done"
```
[ ] 320 px: no horizontal scroll on any page
[ ] 200 % zoom: nothing clipped or overlapping
[ ] Tab through the whole flow: visible focus, logical order, no trap
[ ] All actions reachable without a mouse, Esc closes overlays
[ ] No hex outside base/variables.css
[ ] No inline style="" in views/*.xml
[ ] No new !important (except the reduced-motion reset)
[ ] Every <img> has alt and width/height
[ ] Every icon-only control has aria-label
[ ] Empty / loading / error states exist for each list
[ ] es, eu and ca render without overflow (German-length strings break tight buttons)
[ ] pre-commit run --all-files is clean
[ ] Lighthouse a11y ≥ 95 on shop, cart, checkout
```
The last three matter as much as the rest: a layout tuned only against Spanish copy usually breaks in
Basque, and a11y regressions land silently.

View file

@ -0,0 +1,191 @@
# Responsive layout
## Breakpoints — Bootstrap 5, and only these
| Name | `min-width` | Typical device |
| --- | --- | --- |
| (base) | — | phones, 320575 px |
| `sm` | 576px | large phones |
| `md` | 768px | tablets |
| `lg` | 992px | small laptops |
| `xl` | 1200px | desktops |
| `xxl` | 1400px | large desktops |
Mobile-first, `min-width` only:
```css
/* ✅ base = mobile, enhance upwards */
.products-grid {
display: grid;
gap: var(--ac-space-md);
grid-template-columns: 1fr;
}
@media (min-width: 768px) {
.products-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 1200px) {
.products-grid {
grid-template-columns: repeat(4, 1fr);
}
}
```
```css
/* ❌ desktop-first bands: overlapping ranges, unclear cascade, private breakpoints */
@media (max-width: 1599px) and (min-width: 1400px) { … }
@media (max-width: 720px) { … }
```
## Prefer intrinsic layout — most grids need no media query at all
```css
.products-grid {
display: grid;
gap: var(--ac-space-md);
grid-template-columns: repeat(auto-fill, minmax(min(100%, 16rem), 1fr));
}
```
`min(100%, 16rem)` is what keeps it from overflowing at 320 px. Reach for a media query only when the
layout must genuinely *change* (sidebar moves, table becomes cards), not merely resize.
## Fluid type and space
```css
:root {
--ac-text-sm: clamp(0.875rem, 0.85rem + 0.1vw, 0.9375rem);
--ac-text-base: clamp(1rem, 0.96rem + 0.2vw, 1.125rem);
--ac-text-lg: clamp(1.25rem, 1.15rem + 0.5vw, 1.5rem);
--ac-text-xl: clamp(1.5rem, 1.3rem + 1vw, 2.25rem);
--ac-space-md: clamp(1rem, 0.9rem + 0.5vw, 1.5rem);
}
```
One continuous scale beats three step-changes at breakpoints. Never redefine a token inside a media
query to make it smaller.
Body copy: 1rem minimum, line-height ≥ 1.5, measure 4575 characters (`max-width: 65ch`).
## Touch targets and pointer
WCAG 2.2 AA (§2.5.8 Target Size, Minimum) asks for **24×24 px**. The familiar **44×44 px** is §2.5.5,
level **AAA** — treat it as the house rule for anything a thumb hits, applied by pointer type rather
than by viewport width: a 1024 px tablet needs the big target, a 1024 px laptop doesn't.
```css
.qty-control .btn,
.tag-filter-badge {
min-width: 2.25rem; /* 36px — comfortable with a mouse */
min-height: 2.25rem;
}
@media (pointer: coarse) {
.qty-control .btn,
.tag-filter-badge {
min-width: 2.75rem; /* 44px */
min-height: 2.75rem;
}
}
@media (hover: hover) and (pointer: fine) {
.product-card:hover {
transform: translateY(-2px);
box-shadow: var(--ac-shadow-md);
}
}
```
Hover effects belong inside `@media (hover: hover)` — on touch they stick after a tap and look broken.
## Tables → cards, without restructuring
```css
@media (max-width: 767.98px) {
.checkout-summary-table thead {
/* keep it in the a11y tree; hide visually only */
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.checkout-summary-table tr {
display: grid;
grid-template-columns: 1fr auto;
gap: var(--ac-space-xs);
padding: var(--ac-space-sm) 0;
border-bottom: 1px solid var(--ac-border-light);
}
}
```
This is the one place a `max-width` query is honest: it's an override of the desktop table. Keep the
markup semantic — never emit different HTML per viewport.
## Sticky bars (cart / checkout actions on mobile)
```css
.checkout-actions {
position: sticky;
bottom: 0;
z-index: var(--ac-z-sticky);
padding: var(--ac-space-sm);
padding-bottom: calc(var(--ac-space-sm) + env(safe-area-inset-bottom, 0px));
background: var(--ac-surface, #fff);
box-shadow: 0 -2px 8px rgb(0 0 0 / 8%);
}
```
`env(safe-area-inset-bottom)` keeps the button above the iOS home indicator. Sticky elements must not
eat more than ~20 % of a 568 px-tall viewport.
## Images
```xml
<img class="product-card-img" t-att-src="product['image_url']" t-att-alt="product['name']"
loading="lazy" decoding="async" width="400" height="400"/>
```
- `width`/`height` always — they reserve the box and prevent layout shift (CLS).
- `loading="lazy"` for everything below the fold; **never** on the LCP image (first product row,
hero) — that delays the largest paint.
- CSS: `max-width: 100%; height: auto;` plus `object-fit: cover` with a fixed `aspect-ratio` when
cards must align.
- Odoo serves resized variants: `/web/image/product.product/<id>/image_256` etc. Ask for the size you
render, not `image_1920` scaled down in CSS.
## Container and page rhythm
```css
.eskaera-page {
width: 100%;
max-width: 75rem;
margin-inline: auto;
padding-inline: clamp(1rem, 4vw, 2rem);
}
```
Use logical properties (`margin-inline`, `padding-block`, `inset-inline-start`) — Odoo auto-generates
an RTL stylesheet, and logical properties survive the flip without a second rule.
## Test matrix
| Width | Why |
| --- | --- |
| 320 px | smallest supported; the one that breaks first |
| 360 / 390 px | real Android / iPhone |
| 768 px | tablet portrait, first breakpoint |
| 1024 px | tablet landscape / small laptop |
| 1280 / 1440 px | desktop |
| 1280 px @ 200 % zoom | WCAG 1.4.4 reflow — equivalent to 640 px |
Run the stack (`docker-compose up -d`, http://localhost:8070) and check, per viewport: no horizontal
scroll, no clipped text, touch targets still ≥ 44 px on coarse pointers, sticky bars not covering
content, images not stretched.
DevTools device toolbar is enough for layout; check touch targets by actually tapping on a phone once
before calling it done.