Skip to content

LiveView analyzer: scope toggle/click-away/click rules so they don't fire on dismiss buttons or dialogs #110

@lessthanseventy

Description

@lessthanseventy

Hi — filing this after running mix excessibility against a medium-sized Phoenix LiveView app and working through the reported violations. Three rule-scoping issues surfaced repeatedly where the fix the violation suggests would actually make the ARIA worse. Noting them together because they have the same root cause: the rules don't distinguish between togglers and dismissers, or between content divs and dialog wrappers.

1. toggle_missing_aria_state fires on close / dismiss buttons

The rule fires on any JS.show / JS.hide / JS.toggle call. But aria-expanded is only meaningful on the toggler whose state the target reflects — not on dismiss buttons inside an already-visible overlay.

Examples where the rule currently fires but aria-expanded would be wrong:

<!-- Close button on a visible flash -->
<div id="disconnected" role="alert">
  ...
  <button aria-label="close" phx-click={JS.push("lv:clear-flash") |> JS.hide(to: "#disconnected")}>
    <.icon name="hero-x-mark-solid" />
  </button>
</div>

<!-- "I Understand" button in a modal -->
<.modal id="alert-modal" show={true}>
  ...
  <button phx-click={JS.push("dismiss_alert") |> hide_modal("alert-modal")}>
    I Understand
  </button>
</.modal>

<!-- Menu item that also closes the menu on activation -->
<div id="filter-menu-product" role="menu">
  <button phx-click={JS.push("select_product", ...) |> JS.toggle(to: "#filter-menu-product")}>
    ...
  </button>
</div>

For close / dismiss / menu-item-that-closes buttons, the correct ARIA is aria-label on the button + role=\"alert\" / aria-modal=\"true\" / role=\"menu\" on the container — not aria-expanded on the close button. Adding aria-expanded to satisfy the rule would confuse screen readers because the button's state doesn't reflect the target's open/closed state; the button causes the close and then disappears.

Suggested rule tightening (one of, or a combination)

  • Skip the rule when the JS action contains only hide (not toggle or show) and targets an element that is currently rendered / visible — that's a dismiss, not a toggler.
  • Skip when the button's nearest ancestor container has role=\"dialog\", role=\"alert\", role=\"status\", or role=\"menu\" and the button targets that ancestor (or its background) — that's a dismiss pattern.
  • Alternatively: the rule should match pairs — a toggler needs aria-expanded, but only if there's no other element that is the "owning" toggler for the target. If another button already declares aria-controls pointing at the target, this button probably isn't the toggler.

Real-world impact

In my repo, 57 of 58 remaining violations after three legitimate fixes are this rule firing on close/dismiss buttons across the flash, two modals, and nested menu items. The signal-to-noise ratio drops sharply once the shared toggler components are actually fixed.

2. click_away_without_escape + phx_click_on_non_interactive should special-case role=\"dialog\"

The standard ARIA dialog pattern looks roughly like this:

<div role=\"dialog\" aria-modal=\"true\" tabindex=\"0\" aria-labelledby=\"{@id}-title\">
  <.focus_wrap
    id=\"{@id}-container\"
    phx-window-keydown={@close_on_escape && JS.exec(\"data-cancel\", to: \"##{@id}\")}
    phx-key=\"Escape\"
    phx-click-away={@close_on_click_away && JS.exec(\"data-cancel\", to: \"##{@id}\")}
  >
    <!-- dialog content -->
  </.focus_wrap>
</div>

The <.focus_wrap> is the dialog's interactive surface, and phx-click-away on it is the "click outside to dismiss" mechanism — a required piece of the dialog pattern. But both phx_click_on_non_interactive and click_away_without_escape currently fire on it, asking the developer to turn the div into a <button> / add tabindex=\"0\" / add a keyboard handler for click-away. None of those are correct for a dialog — the dialog already has Escape handling via phx-window-keydown (when close_on_escape is true).

Suggested fix

  • Skip phx_click_on_non_interactive when the element is inside (or is) an ancestor with role=\"dialog\" and the handler is phx-click-away rather than phx-click. Click-away is dismiss, not activate.
  • click_away_without_escape should recognize phx-window-keydown={...} + phx-key=\"Escape\" on the same element as satisfying the keyboard-equivalent requirement (see Include note about chromedriver #3 below for why mine wasn't recognized).

3. phx-key value is case-sensitive against KeyboardEvent.key

I had phx-key=\"escape\" (lowercase) on a modal component. Phoenix LiveView matches phx-key against event.key literally, and the DOM spec value is \"Escape\" with a capital E, so this silently didn't fire. The analyzer correctly flagged the modal as click_away_without_escape, which is how I found the bug — great catch.

Suggestion for the violation message: include a hint that the required value must match KeyboardEvent.key (e.g., \"Escape\", \"Enter\", \"Tab\" — capitalized), since that's a common silent-failure pitfall. Something like:

click_away_without_escape @ div#menu<div> uses phx-click-away for dismissal but has no keyboard equivalent. Add phx-window-keydown={...} and phx-key=\"Escape\" (note: must match KeyboardEvent.key, which is capitalized).

Spike evidence

I wrote up a spike on the consumer side that patches the three most-reused components (user-menu dropdown, filter pill, flash close) using the patterns these rules suggest. Results across 32 snapshots:

Before spike After patching 3 shared components After patching 6 shared components
Total violations 400+ 66 58
CRITICAL (axe) 4 2 0
phx_click_on_non_interactive hundreds 6 1
click_away_without_escape many 1 0
toggle_missing_aria_state hundreds 57 57

The 57 remaining toggle_missing_aria_state + 1 remaining phx_click_on_non_interactive are all the scoping issues above. The rest of the rules behave beautifully — this is otherwise a great linter.

Happy to send a PR if you want one; wanted to file the issue first in case you have opinions on the scoping approach.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions