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
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Richer `axe-runner.js` output: `final_url` (after redirects), `duration_ms`, `engine.axe_version`, `engine.chromium_version`, `passes_count`, `inapplicable_count`.
- `mix excessibility.check` gains `--wait-until`, `--tags`, `--timeout`, `--viewport`, `--user-agent` flags.
- **`Excessibility.LiveViewRules` — LiveView-aware accessibility rules** that complement axe-core on Phoenix-specific patterns. Rules auto-discovered from `lib/excessibility/live_view_rules/rules/`; custom rules registered via `config :excessibility, custom_live_view_rules: [...]`. `mix excessibility` now runs both axe-core AND these rules on each snapshot and fails if either finds issues. Rules are no-ops on HTML without `phx-*` attributes, so the feature is safe on non-Phoenix snapshots.
- **Rule: `:phx_click_on_non_interactive`** ([#101](https://github.com/lessthanseventy/excessibility/issues/101)) — flags `phx-click` / `phx-click-away` on elements that are not natively keyboard-accessible (anything other than `<a>`/`<button>`/`<input>`/`<select>`/`<textarea>`/`<summary>`/`<details>`, or elements with `tabindex` or an interactive `role`).
- Config knobs: `:lv_rules_enabled?` (default `true`) and `:lv_rules_disabled` (list of rule ids to skip).
- **Built-in LiveView rules:**
- **`:phx_click_on_non_interactive`** ([#101](https://github.com/lessthanseventy/excessibility/issues/101)) — flags `phx-click` / `phx-click-away` on elements that are not natively keyboard-accessible (anything other than `<a>`/`<button>`/`<input>`/`<select>`/`<textarea>`/`<summary>`/`<details>`, or elements with `tabindex` or an interactive `role`).
- **`:toggle_missing_aria_state`** ([#102](https://github.com/lessthanseventy/excessibility/issues/102)) — flags elements whose `phx-click` serializes `JS.toggle/show/hide` but have no `aria-expanded`. Parses the serialized JSON to detect toggle operations and target ids.
- **`:click_away_without_escape`** ([#103](https://github.com/lessthanseventy/excessibility/issues/103)) — flags `phx-click-away` without a matching `phx-window-keydown`/`phx-keydown` + `phx-key="Escape"` (or `role="dialog"`), so keyboard users can actually dismiss the overlay.
- **`:debounce_without_live_region`** ([#105](https://github.com/lessthanseventy/excessibility/issues/105)) — flags `<input phx-debounce>` when the snapshot has no `aria-live`, `role="status"`, `role="alert"`, or `role="log"` region anywhere. Conservative: only fires when the whole snapshot lacks any live region.
- **`:hidden_form_control_without_aria`** ([#106](https://github.com/lessthanseventy/excessibility/issues/106)) — flags `<input type="checkbox|radio">` hidden via `hidden` or `sr-only` when the wrapping `<label>` does not expose state via `aria-checked`, `aria-pressed`, `role="checkbox"`, or `role="radio"`.
- `Excessibility.LiveViewRules` registers discovered rule files as `@external_resource` so edits to existing rules trigger recompilation of the scanner module.

### Changed
- **`Excessibility.AxeRunner` removed and folded into `Excessibility.Scanner`.** The old module was an internal helper; all callers (`mix excessibility`, `mix excessibility.check`, the `a11y_check` MCP tool, snapshot screenshotting) now go through `Scanner.scan/2`. No end-user behavior change for existing Mix task users.
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ Built-in rules:
| Rule | What it flags |
| --- | --- |
| `:phx_click_on_non_interactive` | `phx-click` / `phx-click-away` on `<div>`, `<li>`, `<span>`, `<tr>`, etc. without `tabindex` or an interactive `role` — visually clickable but unreachable by keyboard |
| `:toggle_missing_aria_state` | Elements whose `phx-click` uses `JS.toggle/show/hide` but are missing `aria-expanded` — screen readers can't tell whether the target is open or closed |
| `:click_away_without_escape` | `phx-click-away` with no matching `phx-window-keydown` + `phx-key="Escape"` (or `role="dialog"`) — keyboard users can't dismiss the overlay |
| `:debounce_without_live_region` | `<input phx-debounce>` when the page has no `aria-live` / `role="status"` region anywhere — screen readers never hear that results updated |
| `:hidden_form_control_without_aria` | Visually hidden `<input type="checkbox\|radio">` whose wrapping `<label>` doesn't expose state via `aria-checked` or `role="checkbox"`/`"radio"` |

On non-Phoenix HTML (no `phx-*` attributes) these rules are no-ops, so
enabling them never adds noise for projects that don't use LiveView.
Expand Down
11 changes: 7 additions & 4 deletions lib/excessibility/live_view_rules.ex
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,15 @@ defmodule Excessibility.LiveViewRules do
# ── Compile-time rule discovery ────────────────────────────────────

rules_dir = Path.join([__DIR__, "live_view_rules", "rules"])
rule_files = rules_dir |> Path.join("*.ex") |> Path.wildcard()

# Force recompilation of this module whenever an existing rule file
# changes. Note: adding a NEW rule file still requires `mix clean` on
# this module (or touching it) so the wildcard re-runs.
for path <- rule_files, do: @external_resource(path)

rule_modules =
rules_dir
|> Path.join("*.ex")
|> Path.wildcard()
|> Enum.map(fn path ->
Enum.map(rule_files, fn path ->
module_name = path |> Path.basename(".ex") |> Macro.camelize()
Module.concat([Excessibility.LiveViewRules.Rules, module_name])
end)
Expand Down
105 changes: 105 additions & 0 deletions lib/excessibility/live_view_rules/rules/click_away_without_escape.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
defmodule Excessibility.LiveViewRules.Rules.ClickAwayWithoutEscape do
@moduledoc """
Flags elements that use `phx-click-away` for dismissal but provide no
keyboard equivalent, leaving keyboard and screen-reader users unable
to close the overlay.

## What's flagged

An element with `phx-click-away` that is not:

* carrying a `phx-window-keydown` or `phx-keydown` handler paired
with `phx-key="Escape"` on the same element
* a dialog (`role="dialog"` or `role="alertdialog"`), which is
assumed to manage its own focus and Escape handling

## Fix

<!-- Bad -->
<div id="menu" phx-click-away={JS.hide(to: "#menu")}>
...
</div>

<!-- Good -->
<div
id="menu"
phx-click-away={JS.hide(to: "#menu")}
phx-window-keydown={JS.hide(to: "#menu")}
phx-key="Escape"
>
...
</div>
"""

@behaviour Excessibility.LiveViewRules.Rule

@dialog_roles ~w(dialog alertdialog)

@impl true
def id, do: :click_away_without_escape

@impl true
def default_enabled?, do: true

@impl true
def check(tree, _opts) do
tree
|> Floki.find("[phx-click-away]")
|> Enum.reject(&has_escape_handling?/1)
|> Enum.map(&build_finding/1)
end

# ── Implementation ────────────────────────────────────────────────

defp has_escape_handling?({_tag, attrs, _children}) do
dialog?(attrs) or escape_keydown?(attrs)
end

defp dialog?(attrs) do
case find_attr(attrs, "role") do
nil -> false
role -> role in @dialog_roles
end
end

defp escape_keydown?(attrs) do
has_keydown? = has_attr?(attrs, "phx-keydown") or has_attr?(attrs, "phx-window-keydown")
escape_key? = find_attr(attrs, "phx-key") == "Escape"
has_keydown? and escape_key?
end

defp build_finding({tag, attrs, _children} = element) do
%{
rule: id(),
severity: :serious,
message:
"<#{tag}> uses phx-click-away for dismissal but has no keyboard " <>
"equivalent. Keyboard and screen-reader users cannot close this overlay.",
element: element |> Floki.raw_html() |> String.slice(0, 300),
selector: build_selector(tag, attrs),
help:
"Add `phx-window-keydown` (or `phx-keydown`) with `phx-key=\"Escape\"` " <>
"on the same element, pointing at the same JS command. For modal " <>
"dialogs, use `role=\"dialog\"` with proper focus management.",
help_url: "https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/"
}
end

defp find_attr(attrs, name) do
Enum.find_value(attrs, fn
{^name, v} -> v
_ -> nil
end)
end

defp has_attr?(attrs, name) do
Enum.any?(attrs, fn {n, _} -> n == name end)
end

defp build_selector(tag, attrs) do
case find_attr(attrs, "id") do
nil -> tag
id -> "#{tag}##{id}"
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
defmodule Excessibility.LiveViewRules.Rules.DebounceWithoutLiveRegion do
@moduledoc """
Flags `<input phx-debounce>` when the snapshot has no ARIA live region
anywhere.

LiveView search-style inputs with `phx-debounce` push updates silently;
screen-reader users typing a search term have no idea that results
appeared or changed. At minimum, the page should contain an
`aria-live` region (or `role="status" | "alert" | "log"`) somewhere
the updated results can land.

## Detection

This rule is conservative: it only flags a debounced input when the
**entire snapshot** has zero ARIA live regions. If any live region
exists, we assume the app is announcing changes correctly. This
minimizes false positives at the cost of missing cases where a live
region exists but isn't where the results actually render.

## Fix

<!-- Bad -->
<input type="search" name="q" phx-debounce="200" />
<table><tbody>...results...</tbody></table>

<!-- Good -->
<input type="search" name="q" phx-debounce="200" />
<div aria-live="polite">
<table><tbody>...results...</tbody></table>
</div>
"""

@behaviour Excessibility.LiveViewRules.Rule

@live_region_selectors [
"[aria-live]",
"[role=\"status\"]",
"[role=\"alert\"]",
"[role=\"log\"]"
]

@impl true
def id, do: :debounce_without_live_region

@impl true
def default_enabled?, do: true

@impl true
def check(tree, _opts) do
if has_live_region?(tree) do
[]
else
tree
|> Floki.find("input[phx-debounce]")
|> Enum.map(&build_finding/1)
end
end

# ── Implementation ────────────────────────────────────────────────

defp has_live_region?(tree) do
Enum.any?(@live_region_selectors, fn selector ->
tree |> Floki.find(selector) |> Enum.any?()
end)
end

defp build_finding({tag, attrs, _children} = element) do
%{
rule: id(),
severity: :moderate,
message:
"<#{tag}> has phx-debounce but the page has no aria-live region. " <>
"Screen readers will not announce result updates when the user types.",
element: element |> Floki.raw_html() |> String.slice(0, 300),
selector: build_selector(tag, attrs),
help:
"Wrap the results container in an element with `aria-live=\"polite\"` " <>
"or add `role=\"status\"` so assistive tech announces updates.",
help_url: "https://www.w3.org/WAI/ARIA/apg/practices/live-regions/"
}
end

defp find_attr(attrs, name) do
Enum.find_value(attrs, fn
{^name, v} -> v
_ -> nil
end)
end

defp build_selector(tag, attrs) do
case find_attr(attrs, "id") do
nil -> tag
id -> "#{tag}##{id}"
end
end
end
Loading
Loading