Row-Click Keyboard Affordance Gap Across Workspace Regions¶
📜 Historical snapshot — not current docs
Captured 2026-04-20 during Dazzle's autonomous-improvement cycles. It records the framework as it was then and the gap being worked at the time; it may not describe current behaviour. Start from the documentation home, or see Project Evolution for how these fit together.
Date: 2026-04-20 (cycle 281) Class: Framework-level accessibility gap Status: Open for implementation
Problem statement¶
Four workspace regions render clickable <div> or <tr> row bodies that fire HTMX drill-downs into the detail drawer. None of them expose the affordance to keyboard-only users:
| Region | Click target | Keyboard focusable? | Screen reader hint? |
|---|---|---|---|
grid-region (UX-066) |
<div class="dz-grid-cell"> |
No | None |
timeline-region (UX-067) |
<div class="dz-timeline-content"> |
No | None |
queue-region (UX-068) |
<div class="flex-1 min-w-0 cursor-pointer"> |
No | None |
list-region (UX-069) |
<tr class="dz-list-row"> |
No | None |
Mouse users can click the row and see the detail drawer open via HTMX. Keyboard users cannot reach the row's drill-down at all — the Tab sequence walks through filter <select>s, sortable column anchors, and ref-cell anchors inside the row, but never lands on the row container itself.
This excludes:
- Users with motor impairments who navigate by keyboard
- Power users who prefer keyboard-only navigation
- Screen reader users (who also can't hear "clickable" hint since there's no role="button")
For a dashboard that relies on row-click to preview records, this is a meaningful accessibility failure.
Evidence¶
- Cycle 275 (UX-066 grid-region contract) — v2 Q1: "Cells are
<div>not<button>or<a>— keyboard users can't reach the drill-down via Tab." - Cycle 276 (UX-067 timeline-region contract) — v2 Q3: "Content pad is
<div>not<button>— keyboard users can't reach HTMX drill-down." - Cycle 277 (UX-068 queue-region contract) — v2 Q2: button-group
<div>has stopPropagation but row body itself has no keyboard affordance. (Indirect.) - Cycle 278 (UX-069 list-region contract) — v2 Q2: "Row click is on
<tr>with norole='button'/tabindex. Keyboard users can't reach row-level drill-down."
Four independent contract cycles surfaced the same gap. Cross-cycle reinforcement >3 → framework-level theme.
Root cause hypothesis¶
Each region's row template was written before accessibility was a priority, and the "cursor-pointer + hx-get on row <div>" pattern got copied forward as new regions were added. The pattern is valid HTMX but invalid a11y — HTMX doesn't require <button> or <a> semantics, so templates didn't adopt them.
Not a helper-audit class defect (Heuristic 2) — there's no shared helper that does the right thing but isn't called. It's a pattern-copy class defect — each region implements the same wrong pattern independently.
Nearest existing reference: data-table (UX-002) has similar row-click behaviour and also the same gap. Suggests the pattern predates the workspace-region family.
Fix sketch¶
Three viable approaches, in increasing order of invasiveness:
Option A (minimal): add role, tabindex, keyboard handlers¶
For each row container <div> / <tr> with hx-get:
<div class="..."
role="button"
tabindex="0"
@keydown.enter.prevent="$el.click()"
@keydown.space.prevent="$el.click()"
hx-get="..." ...>
role="button"tells screen readers "this is a button"tabindex="0"includes in the natural Tab sequence- The Alpine
@keydownhandlers translate Enter/Space keypresses into clicks
Pros: minimal change, preserves all existing HTMX wiring, no visual difference.
Cons: introduces Alpine dependency into previously-pure-HTMX regions (grid, timeline, list). Violates the "no Alpine for static-rendering regions" stance in each contract. Also: $el.click() is slightly hacky — some browsers fire the HTMX click handler correctly, others might race.
Option B (semantic): convert row body to <a href> with hx-boost¶
Instead of <div hx-get="...">, use <a href="/app/item/{id}" hx-boost="true">:
- <a> is natively focusable and keyboard-activatable (Enter fires click).
- hx-boost="true" makes HTMX intercept the click, turning the naive navigation into an HTMX swap into the detail drawer.
- Falls back to native navigation when JS disabled.
Pros: semantically correct, no Alpine, works with JS disabled.
Cons: requires changing the DOM structure (wrapping row content in <a>). For list-region, <a> can't be a direct child of <tr>; would need <tr><td><a>...</a></td></tr> reshape. Destroys current single-row-click semantics.
Option C (shared primitive): dz-clickable-row Alpine component¶
Extract a reusable dz-clickable-row primitive:
Alpine.data('dzClickableRow', () => ({
init() {
this.$el.setAttribute('role', 'button');
this.$el.setAttribute('tabindex', '0');
this.$el.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this.$el.click();
}
});
}
}));
Each region uses: <div x-data="dzClickableRow" hx-get="...">. The Alpine component handles the a11y wiring; HTMX handles the actual swap.
Pros: shared logic, easy to add cross-cutting a11y improvements later (focus-visible styling, aria-live announcements, etc).
Cons: introduces Alpine dependency into static-rendering regions (same as Option A). Alpine init is async (fires on Alpine:init), so the role/tabindex attrs aren't present in the server-rendered HTML — screen readers on first paint see a plain <div> until Alpine hydrates. For SSR-first apps this is a regression.
Recommendation¶
Option B (semantic) with exceptions for <tr> contexts:
- Grid, timeline, queue content-pad → wrap in <a href hx-boost>.
- List → keep the <tr hx-get> structure but add role="button" tabindex="0" at the template level (no Alpine needed) + an explicit keyboard event handler... actually the server-rendered role/tabindex + the native HTMX click handler should work. Enter on a focused element with role="button" fires click in modern browsers per ARIA spec. Verify via Heuristic 1.
This mixes approaches per region (A for list, B for everything else) based on what the DOM structure allows. It's pragmatic and preserves pure-HTMX stance for static regions.
Reference implementation: check components/detail-view.html (UX-029) — the detail-view page has back/action buttons that are legitimate <a href> + hx-boost. That pattern is the precedent.
Blast radius¶
- Affected regions: grid (UX-066), timeline (UX-067), queue (UX-068), list (UX-069). ALSO:
data-table(UX-002) same gap, worth including. - Affected apps: all 5 example apps. Default-mode rendering uses list-region, so blast radius is full-fleet.
- Regression tests needed: keyboard-activation tests via Playwright (cycle 217-227 pattern). HTTP-layer tests can verify
role="button" tabindex="0"present; behavioural tests need browser automation. - Visual regression risk:
<a>wrapping may change the hover/focus styling — need to verify currenthover:bg-muted/0.4doesn't break.<tr>role+tabindex should have zero visual impact.
Open questions¶
-
Heuristic 1 (real-thing check): does
<tr role="button" tabindex="0">withhx-getactually respond to Enter keypress? ARIA spec says yes, but browser implementations vary. Before writing the fix, manually verify in a live browser. A "manual testing" cycle might precede the code cycle. -
Focus-visible styling: currently rows use
hover:bg-muted/0.4. Keyboard-focused rows needfocus-visible:bg-muted/0.6or similar to signal focus. Framework should add this to the shared pattern. -
Tab order reachability: adding
tabindex="0"to every row in a large list (100 rows) floods the Tab sequence. Is that a feature or bug? Keyboard users may prefer a structural skip (Tab → filter bar → table → row region → out); with 100 tabindexed rows, Tab becomes unusable. Considertabindex="-1"on rows + a wrapper withtabindex="0"that uses roving-tabindex pattern (arrow keys between rows). -
Screen-reader announcement:
role="button"gets announced as "button". But the row is semantically a table row or a card, not a button. Alternative:role="link"for drill-down rows, since they do navigate somewhere. Orrole="row"on<tr>(native) +aria-rowindexfor position tracking. Detail worth settling before shipping. -
Data-table (UX-002) inclusion: fixing 4 workspace regions but leaving the similar pattern in data-table produces another inconsistency. Fix should probably span 5 components, not 4.
-
Detail drawer focus management: when HTMX drops the drill-down response into
#dz-detail-drawer-content, focus doesn't automatically move to the drawer's close button. Keyboard users don't know "something opened". A future cycle could addhx-trigger="load" @hx-swap:afterSettle="$refs.drawerClose.focus()"or similar focus-move Alpine handler. Out of scope for the initial row-click fix.
Implementation sketch¶
Order of operations (if we go with Option B+A hybrid):
- Write a short manual-testing doc confirming
<tr role="button" tabindex="0">works with Enter keypress across Firefox/Chrome/Safari. (Heuristic 1, first step.) - For grid, timeline, queue: wrap the HTMX-triggering
<div>in<a href="{action_url | replace('{id}', ...)}" hx-boost="true">. Preserve existing class chain. Drophx-get(boost handles it). - For list:
<tr class="dz-list-row" role="button" tabindex="0" ...>— minimal template edit. - Also apply to data-table's list rows (UX-002).
- Add
focus-visible:variants to row hover styles so keyboard focus is distinct from mouse hover. - Regression tests: HTTP-layer assertions for
role="button"+tabindex="0"presence per region. Browser-layer tests deferred (they'd need Playwright). - Per Heuristic 3: verify on all 5 example apps. Tab through a dashboard using each region + confirm drill-down fires on Enter.
- Update the 5 affected contracts (grid/timeline/queue/list/data-table) to document the new keyboard affordance + mark the previous v2 open questions RESOLVED.
Estimated scope: one /ux-cycle session (90-120 min) plus manual a11y verification. Bigger than the attention-tier extraction because it touches 5 region templates + requires browser-level verification.
Not in scope: detail-drawer focus management (open question #6). Data-table structural rework if <a> wrapping doesn't fit its column layout.