Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,55 @@

## [Unreleased]

### Fixed — accessibility (#21)

Three gaps that predate 0.1.0 and were flagged during the #6 review, fixed
against both shipped themes before the theme-expansion epic copies the patterns
into three more.

- **The modal had no dialog semantics and no focus management.** It rendered a
plain `<div>`: a screen reader had no way to know a dialog opened, and
keyboard focus stayed behind it. It now carries `role="dialog"` and
`aria-modal="true"`, is named by its header via `aria-labelledby` (or by a new
`label` prop, defaulting to `"Dialog"`, when there is no header), moves focus
into itself on open, returns focus to whatever opened it on close, traps `Tab`
and `Shift+Tab` while open, and closes on `Escape`.

It stays a `<div>` rather than becoming a native `<dialog>`. `showModal()`
would give all of the above for free, but only where a theme's CSS is built
around it — it would make `cfModal` branch per theme, and the identical
cross-theme Alpine contract is what the theme work exists to protect. The
whole implementation is in `cf_ui_alpine.js`, once, so a new theme inherits it
by writing markup. See [`docs/accessibility.md`](docs/accessibility.md).

- **Tabs showed no active tab without JavaScript.** The active marker existed
only as an Alpine binding, so a JS-less page rendered every tab identically —
navigation worked (tabs are HTMX-driven), but nothing said which one you were
on. `CfTabs` / `<c-cf.tabs>` now take an `active` prop and server-render the
active class, `aria-selected`, `aria-controls`, and a roving `tabindex` from
it. The Alpine binding stays on top: the server value is the initial state,
not a replacement. Keyboard support follows the ARIA tabs pattern with manual
activation — arrows move focus, `Enter`/`Space` activates — because automatic
activation would fire an HTMX request on every arrow press.

- **`CfPanel`'s `open` prop was declared but never rendered.** An open panel
still emitted `x-cloak`, so with Alpine off it was hidden permanently. The
panel now renders its open state server-side, seeds Alpine from it rather than
resetting to closed, and its toggle is a real `<button type="button">` with
`aria-controls` and `aria-expanded`.

**New prop:** `CfPanel` takes an `id` (default `"panel"`), used for
`aria-controls="{id}-body"`. Two panels on one page need distinct `id`s or
they will emit duplicate element ids.

- Tabs and Panel receive their server state through `data-` attributes read in
`x-init`, never through an interpolated `x-data="cfTabs('{{ active }}')"`. The
value is request-controlled, and a template engine escapes an attribute
correctly but has no idea it is writing JavaScript source.

- `docs/accessibility.md` records the `<div>`-over-`<dialog>` decision and its
reasoning, so it is not relitigated per theme.

### Changed — BREAKING (#20)

The axis layer stated four properties it did not enforce. Enforcing them is
Expand Down
13 changes: 10 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,10 +235,10 @@ which is the exact failure it exists to prevent.
`{% cf_ui_body %}` loads `cf_ui_alpine.js` (before the Alpine CDN) which registers:

**Named components** — use `x-data="cfModal"` to bind from outside:
- `cfModal` — `open`, `toggle()`, `close()`, `initModal()`
- `cfModal` — `open`, `show()`, `toggle()`, `close()`, `initModal()`
- `cfNavbar` — `menuOpen`, `toggle()`
- `cfPanel` — `open`, `toggle()`
- `cfTabs` — `active`, `setActive(id)`
- `cfPanel` — `open`, `toggle()`, `initPanel()`
- `cfTabs` — `active`, `setActive(id)`, `initTabs()`, `onKeydown(e)`

**`$cf` global store** — cross-component messaging from any template:
```html
Expand All @@ -251,6 +251,13 @@ Opt out entirely:
{% cf_ui_body alpine=False %}
```

This file is also where the interactive components' accessibility lives — modal
focus trapping and restoration, `Escape` to close, the tabs roving tabindex. A
theme partial supplies classes and ARIA state; it never implements behavior, so
a new theme inherits all of it.

→ Full guide: [`docs/accessibility.md`](docs/accessibility.md)

---

## Escape Hatch
Expand Down
177 changes: 177 additions & 0 deletions docs/accessibility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
# Accessibility

What cf-ui guarantees about its interactive components, and — more usefully —
*where* those guarantees live, so a new theme inherits them instead of
reimplementing them.

The rule: **behavior is in `cf_ui_alpine.js`, state is in the template.** A
theme partial decides which classes express "selected" or "open". It never
decides what happens when you press `Tab`.

---

## Modal

### It is a `<div>`, on purpose

DaisyUI's canonical modal is a native `<dialog>`, and `showModal()` would hand
us focus trapping, `Escape`, the top layer, and the backdrop for free. cf-ui
uses a `<div>` with `role="dialog"` anyway.

The reason is not that `<dialog>` is worse — it is that Bulma has no `<dialog>`
convention, and the three themes in the expansion epic will not agree either.
Adopting it would make `cfModal` branch per theme, and the identical
cross-theme Alpine contract is exactly what the theme work protects. One
implementation covers every theme, present and future; that is the same trade
the composition axes make.

So everything `<dialog>` would have provided is implemented once, in
`cf_ui_alpine.js`:

| Guarantee | Where |
|---|---|
| `role="dialog"`, `aria-modal="true"` | theme partial (static markup) |
| Focus moves into the dialog on open | `cfModal.$watch('open')` → `_focusFirst()` |
| Focus returns to the trigger on close | `cfModal.close()` |
| Focus cannot leave while open | `cfModal._trapTab()` |
| `Escape` closes | `initModal()` keydown handler |

The trigger is **captured**, not declared: `show()` records
`document.activeElement`. Any opener works — a button, a link, a keyboard
shortcut, `Alpine.store('cf').modal.open(id)` — and no template has to name it.

#### Why `_focusFirst()` retries

`focus()` on an element that is not rendered yet is a silent no-op — on the
first tabbable child *and* on the `$el` fallback. So focus can only be taken
once the class that reveals the dialog has actually been committed, and nothing
guarantees that has happened by the time the watcher's `$nextTick` runs: Alpine
does not order the `:class` effect against it, and a theme is free to reveal
behind a transition. `_focusFirst()` therefore verifies the result and retries
per animation frame, bounded by `CF_FOCUS_ATTEMPTS`, rather than trusting one
flush's ordering. A retry that finds the dialog closed again bails out instead
of yanking focus back in.

This was not theoretical: it passed locally every run and failed on CI. The
regression test (`test_modal_takes_focus_even_when_the_reveal_is_late`) pins the
dialog hidden with a rule that outranks the reveal class, opens it, and lifts
the rule a couple of frames later — the race made deterministic.

### Labelling, and the `label` fallback

A dialog needs an accessible name. cf-ui picks one of two, never both:

* **With a header** — `aria-labelledby="{id}-title"`, pointing at the element
that renders the header slot.
* **Without a header** — `aria-label="{label}"`, where `label` is a prop that
defaults to `"Dialog"`.

An `aria-labelledby` aimed at an empty element is worse than no labelling: the
name resolves to the empty string and the reader announces nothing. That is why
the fallback exists and why the two are mutually exclusive.

```html
<!-- named by its header -->
<c-cf.modal id="confirm">
<c-slot name="header">Delete this file?</c-slot>
This cannot be undone.
</c-cf.modal>

<!-- no header: name it explicitly -->
<c-cf.modal id="prefs" label="Preferences">…</c-cf.modal>
```

```python
catalog.render("Cf:Modal", id="prefs", label="Preferences")
```

---

## Tabs

`active` is a **server-rendered** prop, not just an Alpine variable. Without it
— which is how cf-ui shipped through 0.1.x — a JS-less page rendered every tab
identically: navigation still worked (tabs are HTMX-driven), but nothing told
you which one you were on.

```html
<c-cf.tabs :tabs="tabs" active="overview" hx_target="tab-content" />
```

```python
catalog.render("Cf:Tabs", tabs=tabs, active="overview", hx_target="tab-content")
```

Server-rendered from that one prop: the theme's active class, `aria-selected`,
`aria-controls`, and the roving `tabindex`. The Alpine binding stays on top of
all four — the server value is the *initial* state, not a replacement for
client-side switching.

### Keyboard

Roving tabindex, **manual activation**: exactly one tab is in the page's tab
order; arrows move focus between tabs; `Enter` / `Space` activates. Automatic
activation (arrow = select) would fire an HTMX request on every arrow press.

| Key | Effect |
|---|---|
| `←` `→` `↑` `↓` | Move focus, wrapping at both ends |
| `Home` / `End` | First / last tab |
| `Enter` / `Space` | Activate the focused tab |

With no `active` prop, the **first** tab holds `tabindex="0"`. It has to: these
anchors carry no `href`, so `tabindex` is the only thing making them focusable
at all, and a widget where every tab is `-1` is unreachable by keyboard.

---

## Panel

`open` is server-rendered too. An open panel emits no `x-cloak`, so it is
readable with Alpine off; `initPanel()` then seeds Alpine from the same state
rather than resetting to closed.

The toggle is a real `<button type="button">` carrying `aria-controls` and a
server-rendered `aria-expanded`, with `:aria-expanded="open"` layered on top.
`aria-controls` points at `{id}-body` — which is why `CfPanel` takes an `id`,
and why two panels on one page need distinct ones.

A **closed** panel still cannot be opened without JS. Making that work needs
`<details>`/`<summary>`, which is a different component shape; it is tracked
separately rather than smuggled in here.

---

## Passing state into Alpine

New state crosses into Alpine through `data-` attributes, read in an `x-init`
hook — `data-cf-active` → `initTabs()`, `data-cf-open` → `initPanel()` — rather
than through an interpolated `x-data="cfTabs('{{ active }}')"`.

The value is request-controlled. A template engine escapes an *attribute*
correctly; it has no idea it is writing JavaScript source, so a single
apostrophe in `active` breaks out of the expression. The data-attribute route
has no such seam.

---

## Testing

Split deliberately, because #21 exists as a ticket largely because the earlier
tests could not have caught what they claimed to:

* `tests/unit/test_accessibility.py` — claims that *are* markup: a role, an
`aria-*` value, a server-rendered class. All four template sets, every case.
* `tests/e2e/` — claims that are behavior: where focus lands after open, where
it lands after close, that `Tab` cannot leave the dialog. Parameterized over
`js_on` / `js_off`.

`expect(role_is_dialog)` proves nothing about focus, and
`expect(tab).to_be_attached()` passes against markup nobody can use. Assert the
behavior.

One caveat that cost a CI cycle: a focus assertion has to **wait**, not sample.
`document.activeElement` read once, immediately after the reveal class lands,
races `_focusFirst()`'s retry loop — a fast machine wins it every time and a
loaded CI runner does not. Use `_wait_for_modal_focus(page)`, and press keys
that the dialog handles only after it returns.
Loading
Loading