Skip to content

Commit 182cf25

Browse files
committed
Add ARIA labels to combobox
1 parent 30facc1 commit 182cf25

File tree

3 files changed

+129
-0
lines changed

3 files changed

+129
-0
lines changed

assets/js/hooks/combobox.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export default {
4444
this.setupEventListeners()
4545
this.initializeCreateOption()
4646
this.syncSelectedAttributes()
47+
this.setupAriaAttributes()
4748

4849
if (this.mode === 'async') {
4950
this.refs.searchInput.dispatchEvent(new Event("input", {bubbles: true}))
@@ -94,6 +95,31 @@ export default {
9495
})
9596
},
9697

98+
setupAriaAttributes() {
99+
// Set aria-controls to link the input to the options container
100+
if (this.refs.optionsContainer && this.refs.searchInput) {
101+
const optionsId = this.refs.optionsContainer.getAttribute('id')
102+
if (optionsId) {
103+
this.refs.searchInput.setAttribute('aria-controls', optionsId)
104+
}
105+
}
106+
107+
// Generate unique IDs for each option if they don't have one
108+
this.ensureOptionIds()
109+
},
110+
111+
ensureOptionIds() {
112+
if (!this.refs.optionsContainer) return
113+
114+
const options = this.refs.optionsContainer.querySelectorAll(SELECTORS.OPTION)
115+
options.forEach((option, index) => {
116+
if (!option.id) {
117+
const comboboxId = this.el.id || 'combobox'
118+
option.id = `${comboboxId}-option-${index}`
119+
}
120+
})
121+
},
122+
97123
cleanup() {
98124
this.cleanupAutoUpdate()
99125

@@ -108,6 +134,7 @@ export default {
108134
},
109135

110136
updated() {
137+
this.ensureOptionIds()
111138
this.positionOptions()
112139
const focusedDomNode = this.refs.optionsContainer?.querySelector(`${SELECTORS.OPTION}[data-value="${this.focusedOptionBeforeUpdate}"]`)
113140
if (this.focusedOptionBeforeUpdate && focusedDomNode) {
@@ -188,6 +215,11 @@ export default {
188215
setFocus(el) {
189216
this.refs.optionsContainer?.querySelector(SELECTORS.FOCUSED_OPTION)?.removeAttribute('data-focus')
190217
el.setAttribute('data-focus', 'true')
218+
219+
// Update aria-activedescendant to point to the focused option
220+
if (el.id) {
221+
this.refs.searchInput.setAttribute('aria-activedescendant', el.id)
222+
}
191223
},
192224

193225
focusFirstOption() {
@@ -359,6 +391,7 @@ export default {
359391
handleAsyncMode() {
360392
if (this.refs.searchInput.value.length > 0) {
361393
this.liveSocket.execJS(this.refs.optionsContainer, this.refs.optionsContainer.getAttribute('js-show'));
394+
this.refs.searchInput.setAttribute('aria-expanded', 'true')
362395
}
363396
this.focusedOptionBeforeUpdate = this.getCurrentFocusedOption()?.dataset.value
364397
},
@@ -448,6 +481,8 @@ export default {
448481
showOptions() {
449482
this.liveSocket.execJS(this.refs.optionsContainer, this.refs.optionsContainer.getAttribute('js-show'));
450483

484+
this.refs.searchInput.setAttribute('aria-expanded', 'true')
485+
451486
this.focusFirstOption()
452487

453488
requestAnimationFrame(() => {
@@ -465,6 +500,8 @@ export default {
465500
if (!this.refs.optionsContainer) return
466501

467502
this.liveSocket.execJS(this.refs.optionsContainer, this.refs.optionsContainer.getAttribute('js-hide'));
503+
this.refs.searchInput.setAttribute('aria-expanded', 'false')
504+
this.refs.searchInput.removeAttribute('aria-activedescendant')
468505
this.cleanupAutoUpdate()
469506

470507
this.refs.optionsContainer.addEventListener('phx:hide-end', () => {

lib/prima/combobox.ex

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,10 @@ defmodule Prima.Combobox do
225225
<input
226226
data-prima-ref="search_input"
227227
type="text"
228+
role="combobox"
229+
aria-expanded="false"
230+
aria-autocomplete="list"
231+
aria-haspopup="listbox"
228232
autocomplete="off"
229233
class={@class}
230234
name={@name <> "_search"}
@@ -410,6 +414,7 @@ defmodule Prima.Combobox do
410414
~H"""
411415
<div
412416
id={@id}
417+
role="listbox"
413418
class={@class}
414419
style="display: none;"
415420
js-show={JS.show(transition: @transition_enter)}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
defmodule PrimaWeb.ComboboxAriaTest do
2+
use Prima.WallabyCase, async: true
3+
4+
@combobox_input Query.css("#demo-combobox input[role=combobox]")
5+
@options_container Query.css("#demo-combobox [data-prima-ref=options]")
6+
7+
feature "combobox input has role=combobox attribute", %{session: session} do
8+
session
9+
|> visit_fixture("/fixtures/simple-combobox", "#demo-combobox")
10+
|> assert_has(@combobox_input)
11+
end
12+
13+
feature "aria-expanded toggles between true and false based on dropdown state", %{
14+
session: session
15+
} do
16+
session
17+
|> visit_fixture("/fixtures/simple-combobox", "#demo-combobox")
18+
# Initially closed - aria-expanded should be false
19+
|> assert_has(Query.css("#demo-combobox input[aria-expanded=false]"))
20+
|> assert_has(@options_container |> Query.visible(false))
21+
# Open options - aria-expanded should be true
22+
|> click(@combobox_input)
23+
|> assert_has(@options_container |> Query.visible(true))
24+
|> assert_has(Query.css("#demo-combobox input[aria-expanded=true]"))
25+
# Close by clicking outside - aria-expanded should be false again
26+
|> click(Query.css("body"))
27+
|> assert_has(@options_container |> Query.visible(false))
28+
|> assert_has(Query.css("#demo-combobox input[aria-expanded=false]"))
29+
end
30+
31+
feature "aria-controls references the options container ID", %{session: session} do
32+
session
33+
|> visit_fixture("/fixtures/simple-combobox", "#demo-combobox")
34+
|> assert_has(
35+
Query.css("#demo-combobox input[role=combobox][aria-controls='demo-combobox-options']")
36+
)
37+
end
38+
39+
feature "options container has role=listbox attribute", %{session: session} do
40+
session
41+
|> visit_fixture("/fixtures/simple-combobox", "#demo-combobox")
42+
|> assert_has(Query.css("#demo-combobox-options[role=listbox]") |> Query.visible(false))
43+
end
44+
45+
feature "aria-activedescendant tracks the focused option", %{session: session} do
46+
session
47+
|> visit_fixture("/fixtures/simple-combobox", "#demo-combobox")
48+
# Initially no aria-activedescendant when closed
49+
|> assert_missing(Query.css("#demo-combobox input[aria-activedescendant]"))
50+
# Open options
51+
|> click(@combobox_input)
52+
|> assert_has(@options_container |> Query.visible(true))
53+
# First option should be focused, input should have aria-activedescendant pointing to it
54+
|> execute_script(
55+
"const input = document.querySelector('#demo-combobox input[role=combobox]'); const firstOption = document.querySelector('#demo-combobox [role=option][data-focus=true]'); return {inputAria: input.getAttribute('aria-activedescendant'), optionId: firstOption ? firstOption.id : null}",
56+
fn result ->
57+
assert result["optionId"] != nil, "Expected first option to have an ID"
58+
59+
assert result["inputAria"] == result["optionId"],
60+
"Expected aria-activedescendant to match focused option ID"
61+
end
62+
)
63+
# Navigate down to second option
64+
|> send_keys([:down_arrow])
65+
|> execute_script(
66+
"const input = document.querySelector('#demo-combobox input[role=combobox]'); const focusedOption = document.querySelector('#demo-combobox [role=option][data-focus=true]'); return {inputAria: input.getAttribute('aria-activedescendant'), optionId: focusedOption ? focusedOption.id : null}",
67+
fn result ->
68+
assert result["optionId"] != nil, "Expected second option to have an ID"
69+
70+
assert result["inputAria"] == result["optionId"],
71+
"Expected aria-activedescendant to match focused option ID after navigation"
72+
end
73+
)
74+
end
75+
76+
feature "combobox input has aria-autocomplete=list attribute", %{session: session} do
77+
session
78+
|> visit_fixture("/fixtures/simple-combobox", "#demo-combobox")
79+
|> assert_has(Query.css("#demo-combobox input[role=combobox][aria-autocomplete=list]"))
80+
end
81+
82+
feature "combobox input has aria-haspopup=listbox attribute", %{session: session} do
83+
session
84+
|> visit_fixture("/fixtures/simple-combobox", "#demo-combobox")
85+
|> assert_has(Query.css("#demo-combobox input[role=combobox][aria-haspopup=listbox]"))
86+
end
87+
end

0 commit comments

Comments
 (0)