fix(a11y): dialog semantics, focus management, server-rendered tab state (#21) - #27
Conversation
…ate (#21) Three gaps that predate 0.1.0, flagged in the #6 review and deferred so a theme PR would not widen into an accessibility PR. Fixed against both shipped themes now, because every pattern here is one the three themes in the expansion epic will copy — landing it after would mean writing it five times instead of two. Modal: role="dialog" + aria-modal, named by its header via aria-labelledby or by a new `label` prop when there is none, focus moved in on open, restored to the trigger on close, trapped while open, Escape to close. It stays a <div>; showModal() would give all of this for free but would make cfModal branch per theme, and the identical cross-theme Alpine contract is what the theme work protects. The whole implementation is in cf_ui_alpine.js, so a new theme inherits it by writing markup. Recorded in docs/accessibility.md. Tabs: `active` is now a prop, server-rendering the theme's active class plus aria-selected, aria-controls and a roving tabindex. The Alpine binding stays on top — server state is the initial value, not a replacement. Arrow/Home/End move focus, Enter/Space activates; automatic activation would fire an HTMX request on every arrow press. Panel: `open` was declared but never rendered, so an open panel still emitted x-cloak and stayed hidden without JS. Now server-rendered, with initPanel() seeding Alpine from it, and a real <button type="button"> carrying aria-controls and aria-expanded. Takes a new `id` prop for the aria-controls target. Server state crosses into Alpine through data- attributes read in x-init, never an interpolated x-data="cfTabs('{{ active }}')" — the value is request-controlled and a template engine cannot escape JavaScript source. Tests split along the line #21 draws: markup claims (roles, aria values, server-rendered classes) in tests/unit/test_accessibility.py across all four template sets; behavioral claims (where focus lands, that Tab cannot leave) in E2E over js_on/js_off. The old `expect(first_tab).to_be_attached()` js_off assertion is replaced with "exactly one tab carries the active class". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NhqNRBg83czKfr8L6FF5xf
Adversarial review — PR #27Reviewed the full diff (28 files, +1354/−61). Two findings, both confirmed by execution rather than by reading. Neither is a regression; both are places where the implementation is narrower than what the PR and its own docs claim. OverviewAdds ARIA semantics and keyboard behavior to The Finding 1 — an unmatched
|
`focus()` on an element that is not rendered yet is a silent no-op — on the first tabbable child and on the `$el` fallback alike. Alpine does not order the `:class` effect that reveals the dialog against the `open` watcher's `$nextTick`, so focus was being attempted while `display: none` still applied and nothing retried. Focus stayed on `<body>`, Escape never reached the dialog, and the trap had nothing to hold. `_focusFirst()` now verifies the result and retries per animation frame, bounded by `CF_FOCUS_ATTEMPTS`, bailing out if the dialog closed in the meantime. This also covers a theme that reveals behind a transition. Caught by CI, which failed three `js_on` E2E tests that passed locally on every run. The new regression test makes the race deterministic: it pins the dialog hidden with a rule outranking `is-active`, opens it, and lifts the rule two frames later. Verified non-vacuous — disabling the retry fails it with the same symptom CI reported. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NhqNRBg83czKfr8L6FF5xf
The retry landed in 99dc44c but CI still failed the same three tests, and the log showed why: the dialog was already `class="modal is-active"` — revealed — with focus still outside. So the reveal was not the problem; the assertion was. `_focusFirst()` retries across animation frames, so reading `document.activeElement` once, immediately after `to_have_class` returns, races that loop. A fast machine wins the race every time; a loaded CI runner loses it. `test_escape_closes_the_modal` failed downstream of the same thing — Escape is handled on the dialog, so a keypress sent while focus is still on <body> never reaches the handler and the modal stays open. Both suites now wait via `_wait_for_modal_focus()` before asserting focus or pressing a key the dialog handles. The retry remains necessary: without it focus never lands at all, so waiting alone would not help either. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NhqNRBg83czKfr8L6FF5xf
CI follow-up — now green on 3.11 / 3.12 / 3.13Three
Both changes are load-bearing — without the retry focus never lands at all, so waiting alone would not have helped either. The two findings from the review comment above are untouched and still open for your call. |
Closes #21. Phase 2 of epic #19.
Three gaps that predate 0.1.0, flagged during the #6 review and deferred so a theme PR would not widen into an accessibility PR. This is the phase that unblocks the theme-expansion epic (#25): every fix here is a pattern the three new themes copy, so landing it against the two existing themes means writing it twice instead of five times.
1. Modal — dialog semantics and focus management
role="dialog"andaria-modal="true"on all four modal templatesaria-labelledbypointing at the header slot's content, with a documentedaria-labelfallback when there is no headerEscapeclosescf_ui_alpine.js(cfModal/initModal()), not per templateLabelling picks exactly one of the two, never both — an
aria-labelledbyaimed at an empty element resolves to the empty string and announces nothing, which is worse than no labelling. The fallback is a newlabelprop defaulting to"Dialog".The trigger is captured, not declared:
show()recordsdocument.activeElement, so any opener works — a button, a link,Alpine.store('cf').modal.open(id)— and no template has to name it.The
<div>decision is recorded indocs/accessibility.mdwith its reasoning, per the issue, so it is not relitigated once the three new themes arrive.tests/unit/test_accessibility.py::test_no_modal_template_implements_focus_itselfasserts the "in Alpine, not per template" half structurally — no.focus()and nokeydownin any modal partial. Five themes carrying five slightly-different focus traps is the failure mode it exists to catch.2. Tabs — server-rendered active state
activeprop onCfTabs/<c-cf.tabs>and render the active class server-sidearia-selectedandaria-controls, server-rendered for the same reasonCfPanel'sopenprop — it had the same gapCfPaneldid have it, and worse:openwas declared in the{#def}and never rendered anywhere, so an open panel still emittedx-cloakand stayed hidden permanently without JS. It now renders its state,initPanel()seeds Alpine from it rather than resetting to closed, and it takes a newidprop for thearia-controlstarget.3. Remaining interactive components
CfPanel—aria-controls,aria-expanded(plus: the Bulma toggle was a<button>with notype, so it would submit an enclosing form)CfTabs— roving tabindexAudited
cfNavbarandcfNotificationtoo. Both are Alpine-only by nature — a collapsed mobile menu and a dismissed toast have no meaningful server state — and both already carry correct ARIA. No change.Roving tabindex uses manual activation: arrows move focus,
Enter/Spaceactivates. Automatic activation would fire an HTMX request on every arrow press. With noactiveprop the first tab holdstabindex="0"— these anchors carry nohref, so a widget where every tab is-1is unreachable by keyboard entirely.Testing
js_onandjs_offTabfrom the last focusable elementjs_offtab assertion from "attached" to "exactly one tab carries the active class"just check,just test-js, and the full E2E tier greenSplit along the line the issue draws. Markup claims — a role, an
aria-*value, a server-rendered class — live intests/unit/test_accessibility.py, run against all four template sets. Behavioral claims live in E2E.test_jinja_tabs_activatewas rewritten rather than extended: it asserted the first tab is not active, clicks it, asserts it is — which only held because no tab was ever active to begin with. The gallery now shipsactive="tab1", so it asserts a move, and thejs_offhalf has something real to check.The behavioral tests were mutation-tested
They were written after the implementation, so "they pass" proves nothing on its own. Disabling the focus management in
cf_ui_alpine.jsfails exactly the six tests that measure it. Disabling only theTabbranch — leaving focus-in, focus-restore, andEscapeintact — fails exactly the two trap tests and nothing else, so each assertion is measuring the thing it names.prek run --all-filesclean, run twice per the ruff-pin recurrence noted in the repo's CLAUDE.md.Scope notes
New
idprop onCfPanel.aria-controlsneeds a target id. Two panels on one page now need distinctids or they emit duplicate element ids. Called out in the changelog.The cotton E2E pages carry no Alpine. The E2E Django server is a bare
WSGIHandlerand serves no static files. That half exists to prove the new props survive<c-vars>— which the unit tier structurally cannot do, sincerender_to_stringbypasses the compiler — and focus behavior is asserted on the JinjaX galleries wherecf_ui_alpine.jsactually loads. WiringStaticFilesHandlerinto the E2E server would let the cotton half assert behavior too; left out as scope.Follow-ups, not folded in
<details>/<summary>, which is a different component shape, not an attribute change. Noted indocs/accessibility.md.setActive('{{ tab.id }}')and:class="{ 'is-active': active === '{{ tab.id }}' }"splice a value into evaluated JS. Predates this phase, and the newactiveprop deliberately avoids it (data-cf-active+initTabs()) rather than joining it. Worth a ticket.ruff format .rewrites Python snippets inside markdown, and one rewrite is wrong — it turns anINSTALLED_APPSline inprompts/migrate-django.mdinto a tuple. Nothing in CI,just format, or the prek hooks touches those paths, so nothing is broken today. Carried over from Axis layer: enforce every guarantee it states #20; wants anextend-exclude.🤖 Generated with Claude Code
https://claude.ai/code/session_01NhqNRBg83czKfr8L6FF5xf