You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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 --><divid="disconnected"role="alert">
...
<buttonaria-label="close"phx-click={JS.push("lv:clear-flash")|>JS.hide(to: "#disconnected")}><.iconname="hero-x-mark-solid"/></button></div><!-- "I Understand" button in a modal --><.modalid="alert-modal"show={true}>
...
<buttonphx-click={JS.push("dismiss_alert")|>hide_modal("alert-modal")}>
I Understand
</button></.modal><!-- Menu item that also closes the menu on activation --><divid="filter-menu-product"role="menu"><buttonphx-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 onlyhide (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:
<divrole=\"dialog\" aria-modal=\"true\" tabindex=\"0\" aria-labelledby=\"{@id}-title\"><.focus_wrapid=\"{@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.
Hi — filing this after running
mix excessibilityagainst 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_statefires on close / dismiss buttonsThe rule fires on any
JS.show/JS.hide/JS.togglecall. Butaria-expandedis 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-expandedwould be wrong:For close / dismiss / menu-item-that-closes buttons, the correct ARIA is
aria-labelon the button +role=\"alert\"/aria-modal=\"true\"/role=\"menu\"on the container — notaria-expandedon the close button. Addingaria-expandedto 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)
hide(nottoggleorshow) and targets an element that is currently rendered / visible — that's a dismiss, not a toggler.role=\"dialog\",role=\"alert\",role=\"status\", orrole=\"menu\"and the button targets that ancestor (or its background) — that's a dismiss pattern.aria-expanded, but only if there's no other element that is the "owning" toggler for the target. If another button already declaresaria-controlspointing 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_interactiveshould special-caserole=\"dialog\"The standard ARIA dialog pattern looks roughly like this:
The
<.focus_wrap>is the dialog's interactive surface, andphx-click-awayon it is the "click outside to dismiss" mechanism — a required piece of the dialog pattern. But bothphx_click_on_non_interactiveandclick_away_without_escapecurrently fire on it, asking the developer to turn the div into a<button>/ addtabindex=\"0\"/ add a keyboard handler for click-away. None of those are correct for a dialog — the dialog already has Escape handling viaphx-window-keydown(whenclose_on_escapeis true).Suggested fix
phx_click_on_non_interactivewhen the element is inside (or is) an ancestor withrole=\"dialog\"and the handler isphx-click-awayrather thanphx-click. Click-away is dismiss, not activate.click_away_without_escapeshould recognizephx-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-keyvalue is case-sensitive againstKeyboardEvent.keyI had
phx-key=\"escape\"(lowercase) on a modal component. Phoenix LiveView matchesphx-keyagainstevent.keyliterally, and the DOM spec value is\"Escape\"with a capitalE, so this silently didn't fire. The analyzer correctly flagged the modal asclick_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: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:
phx_click_on_non_interactiveclick_away_without_escapetoggle_missing_aria_stateThe 57 remaining
toggle_missing_aria_state+ 1 remainingphx_click_on_non_interactiveare 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.