Keyboard and focus problems axe cannot see: lost focus, single-key shortcuts, no focus in high contrast, board menus, headings #227

Closed
opened 2026-09-15 21:23:26 +00:00 by tiagoagueda · 0 comments
Owner

Accessibility debt that the browser suite's axe run cannot catch. Found in the 2026-09-15 code audit.

1. Focus drops to <body> after sorting, paging or switching theme (WCAG 2.4.3)

htmx 2 puts focus back after a swap only for an element with an id. These triggers have none:

  • the sort links (partials/table/head.html:119-122);
  • Previous and Next (pagination.html:6,17);
  • the theme button inside the account menu (core/partials/theme_switch.html:14,21).

After Enter, the next Tab starts again at the skip link.

Fix: stable ids (sort-{{ header.key }}, page-prev, page-next, theme-switch-button), plus an e2e test that tabs, presses Enter and checks where focus is.

2. Single-key shortcuts that cannot be turned off (WCAG 2.1.4, level A)

app.js:394-420 binds d (discard and next) and j (skip), and :443-465 binds / page-wide. They fire whenever focus is outside a field, on buttons and links too, and do not check event.isComposing. d discards straight away (jobs/capture_views.py:340-347).

Fix: a Keyboard shortcuts switch under Settings → Appearance (on by default is allowed if it can be turned off), or a modifier key. Ignore keys while an IME is composing.

3. Text fields have no focus indicator in forced-colours mode

field-input uses focus:outline-none (assets/css/app.css:249), which compiles to outline-style: none and overrides the base :focus-visible outline. The remaining cue, a border colour plus a box-shadow ring, is discarded in forced colours, and there is no forced-colors rule anywhere. This affects every input, select, textarea and table filter.

Fix: focus:outline-hidden (a transparent outline that forced colours make visible), or @media (forced-colors: active) { outline: 2px solid Highlight }. Add an e2e pass with Playwright forced_colors="active".

4. Board status menus

  • Every card's select is labelled just "Change status" (applications/partials/application_card.html:55-56).
  • data-autosubmit submits on change; in Chromium on Windows, arrowing through a closed select fires change at each step, writing a status change and a timeline event for every status passed (WCAG 3.2.2).
  • aria-describedby="board-drag-help" sits on a non-focusable <article>.
  • draggable="true" is set in the template, against the rule stated at app.js:1255.

Fix: name each select by its application; submit on Enter or blur, or with a visible Move button; move the describedby to the select; set draggable from the script.

5. In Settings and Server settings the h1 is the sidebar label

settings/base.html:11 and server/base.html:9 make the small "Settings" label the h1, and each page's title is an h2 at the same level as its sections. About 20 pages announce "Settings" when you jump to the heading.

Fix: make the sidebar label a nav aria-label or an h2, and the page title the h1. Add a check for exactly one h1 naming the page.

6. Column resizing

  • The handle is named "Widen {column}" although ArrowLeft narrows it.
  • Arrow presses are silent.
  • say() inserts a <caption role="status"> that replaces the table's accessible name.
  • pointercancel is not handled.

Fix: the ARIA splitter pattern (role="separator", aria-valuenow/min/max) with a neutral name, announcements from a region outside the table, and a pointercancel handler.

7. Destructive actions are inconsistent

  • Four button styles.
  • Revoking an API token and discarding a capture act at once, while deletions go through a confirmation page.
  • Two copied confirmation templates.
  • Cancel goes back via HTTP_REFERER.

Fix: .btn-danger and .btn-danger-ghost components, one rule (irreversible actions get a confirmation page; reversible ones act at once with an undo), and Cancel URLs passed by the view.

Accessibility debt that the browser suite's axe run cannot catch. Found in the 2026-09-15 code audit. ## 1. Focus drops to `<body>` after sorting, paging or switching theme (WCAG 2.4.3) htmx 2 puts focus back after a swap only for an element with an `id`. These triggers have none: - the sort links (`partials/table/head.html:119-122`); - *Previous* and *Next* (`pagination.html:6,17`); - the theme button inside the account menu (`core/partials/theme_switch.html:14,21`). After Enter, the next Tab starts again at the skip link. **Fix:** stable ids (`sort-{{ header.key }}`, `page-prev`, `page-next`, `theme-switch-button`), plus an e2e test that tabs, presses Enter and checks where focus is. ## 2. Single-key shortcuts that cannot be turned off (WCAG 2.1.4, level A) `app.js:394-420` binds `d` (discard and next) and `j` (skip), and `:443-465` binds `/` page-wide. They fire whenever focus is outside a field, on buttons and links too, and do not check `event.isComposing`. `d` discards straight away (`jobs/capture_views.py:340-347`). **Fix:** a *Keyboard shortcuts* switch under Settings → Appearance (on by default is allowed if it can be turned off), or a modifier key. Ignore keys while an IME is composing. ## 3. Text fields have no focus indicator in forced-colours mode `field-input` uses `focus:outline-none` (`assets/css/app.css:249`), which compiles to `outline-style: none` and overrides the base `:focus-visible` outline. The remaining cue, a border colour plus a box-shadow ring, is discarded in forced colours, and there is no `forced-colors` rule anywhere. This affects every input, select, textarea and table filter. **Fix:** `focus:outline-hidden` (a transparent outline that forced colours make visible), or `@media (forced-colors: active) { outline: 2px solid Highlight }`. Add an e2e pass with Playwright `forced_colors="active"`. ## 4. Board status menus - Every card's select is labelled just "Change status" (`applications/partials/application_card.html:55-56`). - `data-autosubmit` submits on `change`; in Chromium on Windows, arrowing through a closed select fires `change` at each step, writing a status change and a timeline event for every status passed (WCAG 3.2.2). - `aria-describedby="board-drag-help"` sits on a non-focusable `<article>`. - `draggable="true"` is set in the template, against the rule stated at `app.js:1255`. **Fix:** name each select by its application; submit on Enter or blur, or with a visible *Move* button; move the `describedby` to the select; set `draggable` from the script. ## 5. In Settings and Server settings the `h1` is the sidebar label `settings/base.html:11` and `server/base.html:9` make the small "Settings" label the `h1`, and each page's title is an `h2` at the same level as its sections. About 20 pages announce "Settings" when you jump to the heading. **Fix:** make the sidebar label a `nav` `aria-label` or an `h2`, and the page title the `h1`. Add a check for exactly one `h1` naming the page. ## 6. Column resizing - The handle is named "Widen {column}" although ArrowLeft narrows it. - Arrow presses are silent. - `say()` inserts a `<caption role="status">` that replaces the table's accessible name. - `pointercancel` is not handled. **Fix:** the ARIA splitter pattern (`role="separator"`, `aria-valuenow`/`min`/`max`) with a neutral name, announcements from a region outside the table, and a `pointercancel` handler. ## 7. Destructive actions are inconsistent - Four button styles. - Revoking an API token and discarding a capture act at once, while deletions go through a confirmation page. - Two copied confirmation templates. - *Cancel* goes back via `HTTP_REFERER`. **Fix:** `.btn-danger` and `.btn-danger-ghost` components, one rule (irreversible actions get a confirmation page; reversible ones act at once with an undo), and *Cancel* URLs passed by the view.
tiagoagueda added this to the 0.4.0 milestone 2026-09-15 21:33:25 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Postulo/postulo#227
No description provided.