Skip to content

feat: 4 new LiveView rules (#102, #103, #105, #106)#109

Merged
lessthanseventy merged 1 commit into
mainfrom
feat/liveview-rules-102-103-105-106
Apr 11, 2026
Merged

feat: 4 new LiveView rules (#102, #103, #105, #106)#109
lessthanseventy merged 1 commit into
mainfrom
feat/liveview-rules-102-103-105-106

Conversation

@lessthanseventy
Copy link
Copy Markdown
Owner

Summary

Builds on the LiveViewRules framework shipped in #108 by adding
four new rules that catch Phoenix-specific a11y anti-patterns
axe-core can't see. Each rule is an isolated module under
lib/excessibility/live_view_rules/rules/ and is auto-discovered
at compile time.

Version stays at 0.13.0 — the previous PR's version is not yet
published to Hex, so these additions fold into the same release.

Rules added

:toggle_missing_aria_state (closes #102)

Parses the serialized JSON in phx-click to detect
JS.toggle/show/hide operations, and flags triggers that have no
aria-expanded. The target id from the JS command surfaces in the
finding message.

<!-- Bad -->
<div phx-click={JS.toggle(to: \"#menu\")}>Open</div>

<!-- Good -->
<button phx-click={JS.toggle(to: \"#menu\")} aria-expanded=\"false\" aria-controls=\"menu\">
  Open
</button>

:click_away_without_escape (closes #103)

Flags phx-click-away without a matching phx-window-keydown /
phx-keydown + phx-key=\"Escape\" on the same element.
role=\"dialog\" and role=\"alertdialog\" are accepted as
evidence of keyboard dismissal.

:debounce_without_live_region (closes #105)

Conservative heuristic: flags <input phx-debounce> only when the
whole snapshot has zero aria-live / role=\"status\" /
role=\"alert\" / role=\"log\" regions. This avoids false
positives when the app announces updates in a live region that
isn't directly adjacent to the input.

:hidden_form_control_without_aria (closes #106)

Flags <input type=\"checkbox|radio\" class=\"hidden|sr-only\">
when the wrapping <label> (resolved via for= or structural
containment) doesn't expose checked state via aria-checked,
aria-pressed, role=\"checkbox\", or role=\"radio\".

Framework fix

Drive-by: Excessibility.LiveViewRules now registers every
discovered rule file as @external_resource. Without this, editing
an existing rule wouldn't trigger recompilation of the scanner
module and the edit would silently not take effect (I hit this
while writing the new rules). Adding a brand-new rule file still
requires mix clean or touching the scanner module — that's
documented in CHANGELOG and is a known Elixir limitation of
compile-time file globs.

Deferred

#104 cross-snapshot diffing is intentionally not in this PR.
It needs:

  • A new rule callback shape: check(prev_tree, curr_tree, opts)
    instead of check(tree, opts)
  • Snapshot pairing logic in mix excessibility (before/after pairs
    from the same test interaction)
  • Possibly new snapshot metadata so rules know "this pair
    represents an interaction"

It's worth its own focused PR where we can design the cross-
snapshot protocol cleanly rather than wedging it into the single-
snapshot framework.

Test plan

  • mix test test/live_view_rules/ — 48 rule tests passing
  • mix test — 637 tests passing
  • Stability sweep: seeds 1000, 42, 777, 31337, 5150 — all 637/0
  • mix format --check-formatted
  • MIX_ENV=test mix credo --strict — zero issues
  • mix compile --warnings-as-errors
  • Exercise on a real LiveView snapshot with known offenders
    (e.g. topps_pro's `pill_dropdown`) to confirm rule hit rate
    matches expectations

🤖 Generated with Claude Code

Builds on the LiveViewRules framework shipped in 0.13.0 by adding
four new rules that catch Phoenix-specific a11y anti-patterns
axe-core can't see. Each rule is a single module in
lib/excessibility/live_view_rules/rules/, auto-discovered at compile
time.

- :toggle_missing_aria_state (#102)
  Parses the serialized JSON in phx-click to detect
  JS.toggle/show/hide operations; flags triggers that have no
  aria-expanded. Surfaces the target id from the JS command in the
  finding message.

- :click_away_without_escape (#103)
  Flags phx-click-away without a matching phx-window-keydown or
  phx-keydown paired with phx-key="Escape". role="dialog" /
  role="alertdialog" are accepted as evidence of keyboard dismissal.

- :debounce_without_live_region (#105)
  Conservative heuristic: flags <input phx-debounce> only when the
  whole snapshot has zero aria-live / role=status|alert|log regions.
  Avoids false positives when an app announces updates somewhere
  else in the page.

- :hidden_form_control_without_aria (#106)
  Flags <input type="checkbox|radio" class="hidden|sr-only"> when
  the wrapping <label> doesn't expose state via aria-checked,
  aria-pressed, role="checkbox" or role="radio".

Also registers discovered rule files as @external_resource so edits
to existing rules trigger recompilation of the scanner module.
(Adding a brand-new rule file still requires touching the scanner or
running mix clean — documented in CHANGELOG.)

The #104 cross-snapshot diffing rule is deliberately left for a
follow-up PR because it requires a different rule protocol (two
snapshots instead of one HTML) and new snapshot-pairing logic in
mix excessibility.

Version stays at 0.13.0 (not yet published to Hex).

Closes #102
Closes #103
Closes #105
Closes #106

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@lessthanseventy lessthanseventy merged commit 0cd5e0f into main Apr 11, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant