Skip to content

Commit cb153b6

Browse files
committed
accessibility
1 parent adab92e commit cb153b6

2 files changed

Lines changed: 220 additions & 13 deletions

File tree

src/widgets/peopleSearch.ts

Lines changed: 135 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { ns } from '..'
2626
const PEOPLE_SEARCH_CONCURRENCY = 6
2727
const CONTACT_CARD_CONCURRENCY = 8
2828
const MAX_FOAF_DISTANCE = 3
29+
let peopleSearchInstanceCounter = 0
2930
const addressBookListCache = new Map<string, Promise<string[]>>()
3031
const addressBookCache = new Map<string, Promise<AddressBook>>()
3132
const contactWebIdCache = new Map<string, Promise<string | null>>()
@@ -42,6 +43,12 @@ export const createPeopleSearch = function (
4243
me: NamedNode | null,
4344
onClickHandler?: (person: PersonEntry) => void
4445
): HTMLFormElement {
46+
peopleSearchInstanceCounter += 1
47+
const instanceId = `people-search-${peopleSearchInstanceCounter}`
48+
const inputId = `${instanceId}-input`
49+
const labelId = `${instanceId}-label`
50+
const listboxId = `${instanceId}-listbox`
51+
4552
const contactsModule = new ContactsModuleRdfLib({
4653
store: kb,
4754
fetcher: kb.fetcher,
@@ -65,6 +72,17 @@ export const createPeopleSearch = function (
6572
width: max(28%, 280px);
6673
max-width: 80%;
6774
}
75+
.people-search-sr-only {
76+
position: absolute;
77+
width: 1px;
78+
height: 1px;
79+
padding: 0;
80+
margin: -1px;
81+
overflow: hidden;
82+
clip: rect(0, 0, 0, 0);
83+
white-space: nowrap;
84+
border: 0;
85+
}
6886
@media (max-width: 600px) {
6987
.people-search-input,
7088
.people-search-dropdown {
@@ -77,13 +95,29 @@ export const createPeopleSearch = function (
7795
}
7896

7997
const searchForm = dom.createElement('form')
98+
const searchLabel = searchForm.appendChild(dom.createElement('label'))
99+
searchLabel.id = labelId
100+
searchLabel.htmlFor = inputId
101+
searchLabel.className = 'people-search-sr-only'
102+
searchLabel.textContent = 'Search for people'
103+
80104
const searchInput = searchForm.appendChild(dom.createElement('input'))
105+
searchInput.id = inputId
81106
searchInput.type = 'text'
82107
searchInput.placeholder = 'Search for people...'
83108
searchInput.className = 'people-search-input'
109+
searchInput.setAttribute('role', 'combobox')
110+
searchInput.setAttribute('aria-autocomplete', 'list')
111+
searchInput.setAttribute('aria-haspopup', 'listbox')
112+
searchInput.setAttribute('aria-expanded', 'false')
113+
searchInput.setAttribute('aria-labelledby', labelId)
114+
searchInput.setAttribute('aria-controls', listboxId)
84115

85116
const searchDiv = searchForm.appendChild(dom.createElement('div'))
117+
searchDiv.id = listboxId
86118
searchDiv.className = 'people-search-dropdown'
119+
searchDiv.setAttribute('role', 'listbox')
120+
searchDiv.setAttribute('aria-label', 'People search results')
87121
searchDiv.style.display = 'none'
88122
searchDiv.style.border = '1px solid #ccc'
89123
searchDiv.style.marginTop = '5px'
@@ -104,6 +138,58 @@ export const createPeopleSearch = function (
104138
const status = searchDiv.appendChild(dom.createElement('p'))
105139
status.style.margin = '5px 0'
106140
status.style.color = '#666'
141+
status.setAttribute('role', 'status')
142+
status.setAttribute('aria-live', 'polite')
143+
144+
let activeRow: HTMLDivElement | null = null
145+
146+
const setDropdownOpen = function (isOpen: boolean) {
147+
searchDiv.style.display = isOpen ? 'block' : 'none'
148+
searchInput.setAttribute('aria-expanded', isOpen ? 'true' : 'false')
149+
}
150+
151+
const getVisibleRows = function (): HTMLDivElement[] {
152+
return Array.from(personRows.values()).filter(row => row.style.display !== 'none')
153+
}
154+
155+
const setActiveRow = function (row: HTMLDivElement | null) {
156+
if (activeRow) {
157+
activeRow.style.backgroundColor = 'white'
158+
activeRow.setAttribute('aria-selected', 'false')
159+
}
160+
161+
activeRow = row
162+
163+
if (activeRow) {
164+
activeRow.style.backgroundColor = '#f0f0f0'
165+
activeRow.setAttribute('aria-selected', 'true')
166+
if (activeRow.id) {
167+
searchInput.setAttribute('aria-activedescendant', activeRow.id)
168+
}
169+
} else {
170+
searchInput.removeAttribute('aria-activedescendant')
171+
}
172+
}
173+
174+
const ensureActiveRowIsVisible = function () {
175+
if (!activeRow) return
176+
if (activeRow.style.display === 'none') {
177+
setActiveRow(null)
178+
}
179+
}
180+
181+
const selectPerson = function (person: PersonEntry) {
182+
if (onClickHandler) {
183+
onClickHandler(person)
184+
} else {
185+
const newWindow = window.open(person.webId, '_blank', 'noopener,noreferrer')
186+
if (newWindow) {
187+
newWindow.opener = null
188+
}
189+
}
190+
setActiveRow(null)
191+
setDropdownOpen(false)
192+
}
107193

108194
const addPersonRow = function (person: PersonEntry) {
109195
const existingRow = personRows.get(person.webId)
@@ -121,35 +207,33 @@ export const createPeopleSearch = function (
121207
}
122208

123209
const personElement = dom.createElement('div')
210+
const optionIdSafeWebId = person.webId.replace(/[^a-zA-Z0-9_-]/g, '_')
124211
const nameElement = personElement.appendChild(dom.createElement('div'))
125212
const labelElement = personElement.appendChild(dom.createElement('div'))
126213

127214
nameElement.textContent = person.name
128215
labelElement.textContent = person.relationshipLabel
129216

130217
personElement.title = person.webId
218+
personElement.id = `${instanceId}-option-${optionIdSafeWebId}`
219+
personElement.setAttribute('role', 'option')
220+
personElement.setAttribute('aria-selected', 'false')
131221
personElement.style.cursor = 'pointer'
132222
personElement.style.margin = '5px 0'
133223
personElement.style.padding = '2px 4px'
134224
labelElement.style.fontSize = '0.75em'
135225
labelElement.style.color = '#666'
136226

137227
personElement.addEventListener('click', function () {
138-
if (onClickHandler) {
139-
onClickHandler(person)
140-
} else {
141-
const newWindow = window.open(person.webId, '_blank', 'noopener,noreferrer')
142-
if (newWindow) {
143-
newWindow.opener = null
144-
}
145-
}
146-
searchDiv.style.display = 'none'
228+
selectPerson(person)
147229
})
148230
personElement.addEventListener('mouseover', function () {
149-
personElement.style.backgroundColor = '#f0f0f0'
231+
setActiveRow(personElement)
150232
})
151233
personElement.addEventListener('mouseout', function () {
152-
personElement.style.backgroundColor = 'white'
234+
if (activeRow !== personElement) {
235+
personElement.style.backgroundColor = 'white'
236+
}
153237
})
154238
searchDiv.appendChild(personElement)
155239
personRows.set(person.webId, personElement)
@@ -201,6 +285,7 @@ export const createPeopleSearch = function (
201285
}
202286
}
203287
sortVisibleRows()
288+
ensureActiveRowIsVisible()
204289
return visibleCount
205290
}
206291

@@ -209,6 +294,7 @@ export const createPeopleSearch = function (
209294
const isVisible = matchesNameWords(person.name, query)
210295
row.style.display = isVisible ? 'block' : 'none'
211296
scheduleSortVisibleRows()
297+
ensureActiveRowIsVisible()
212298
return isVisible
213299
}
214300

@@ -494,7 +580,7 @@ export const createPeopleSearch = function (
494580

495581
const runSearch = async function (query: string) {
496582
const searchId = ++activeSearchId
497-
searchDiv.style.display = 'block'
583+
setDropdownOpen(true)
498584

499585
const visibleCount = updateVisibleRows(query.trim())
500586
if (!me) {
@@ -528,13 +614,49 @@ export const createPeopleSearch = function (
528614

529615
const onBlurHandler = function () {
530616
setTimeout(() => {
531-
searchDiv.style.display = 'none'
617+
setActiveRow(null)
618+
setDropdownOpen(false)
532619
}, 200)
533620
}
534621

622+
const onKeyDownHandler = function (event: KeyboardEvent) {
623+
const visibleRows = getVisibleRows()
624+
625+
if (event.key === 'Escape') {
626+
setActiveRow(null)
627+
setDropdownOpen(false)
628+
return
629+
}
630+
631+
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
632+
event.preventDefault()
633+
if (searchDiv.style.display === 'none') {
634+
setDropdownOpen(true)
635+
}
636+
if (visibleRows.length === 0) {
637+
return
638+
}
639+
const currentIndex = activeRow ? visibleRows.indexOf(activeRow) : -1
640+
const nextIndex = event.key === 'ArrowDown'
641+
? Math.min(currentIndex + 1, visibleRows.length - 1)
642+
: Math.max(currentIndex - 1, 0)
643+
setActiveRow(visibleRows[nextIndex])
644+
return
645+
}
646+
647+
if (event.key === 'Enter' && activeRow) {
648+
event.preventDefault()
649+
const selectedPerson = discoveredPeople.get(activeRow.title)
650+
if (selectedPerson) {
651+
selectPerson(selectedPerson)
652+
}
653+
}
654+
}
655+
535656
searchInput.addEventListener('input', onInputHandler)
536657
searchInput.addEventListener('focus', onFocusHandler)
537658
searchInput.addEventListener('blur', onBlurHandler)
659+
searchInput.addEventListener('keydown', onKeyDownHandler)
538660

539661
searchForm.addEventListener('submit', function (event) {
540662
event.preventDefault()

test/unit/widgets/peopleSearch.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,10 @@ const setSearchQuery = async function (form: HTMLFormElement, query: string) {
111111
await flushDiscovery()
112112
}
113113

114+
const keyDown = function (element: HTMLElement, key: string) {
115+
element.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }))
116+
}
117+
114118
const rowFor = function (form: HTMLFormElement, webId: string) {
115119
return form.querySelector(`div[title="${webId}"]`) as HTMLDivElement | null
116120
}
@@ -230,6 +234,87 @@ describe('createPeopleSearch', () => {
230234
expect(dropdown.textContent).toContain('Sign in to search contacts.')
231235
})
232236

237+
it('applies combobox/listbox accessibility attributes', async () => {
238+
const kb = makeKb()
239+
const me = new NamedNode('https://user-8.example/profile/card#me')
240+
241+
const form = createPeopleSearch(document, kb as any, me)
242+
document.body.appendChild(form)
243+
244+
const input = form.querySelector('input') as HTMLInputElement
245+
const label = form.querySelector('label') as HTMLLabelElement
246+
const dropdown = form.querySelector('.people-search-dropdown') as HTMLDivElement
247+
248+
expect(label).not.toBeNull()
249+
expect(label.textContent).toBe('Search for people')
250+
expect(input.getAttribute('role')).toBe('combobox')
251+
expect(input.getAttribute('aria-autocomplete')).toBe('list')
252+
expect(input.getAttribute('aria-haspopup')).toBe('listbox')
253+
expect(input.getAttribute('aria-labelledby')).toBe(label.id)
254+
expect(input.getAttribute('aria-controls')).toBe(dropdown.id)
255+
expect(input.getAttribute('aria-expanded')).toBe('false')
256+
257+
await openDropdown(form)
258+
259+
const personRow = rowFor(form, 'https://alice.example/profile/card#me')
260+
expect(dropdown.getAttribute('role')).toBe('listbox')
261+
expect(input.getAttribute('aria-expanded')).toBe('true')
262+
expect(personRow?.getAttribute('role')).toBe('option')
263+
expect(personRow?.id).toContain('-option-')
264+
})
265+
266+
it('supports keyboard navigation and selection from the input', async () => {
267+
mockReadAddressBook.mockResolvedValue({
268+
contacts: [
269+
{
270+
uri: 'https://pod.example/contacts/1#this',
271+
name: 'Alice Example'
272+
},
273+
{
274+
uri: 'https://pod.example/contacts/2#this',
275+
name: 'Bob Stone'
276+
}
277+
]
278+
})
279+
280+
const kb = makeKb({
281+
contactWebIdsByCardUri: {
282+
'https://pod.example/contacts/1#this': 'https://alice.example/profile/card#me',
283+
'https://pod.example/contacts/2#this': 'https://bob.example/profile/card#me'
284+
}
285+
})
286+
const me = new NamedNode('https://user-9.example/profile/card#me')
287+
const onClickHandler = jest.fn()
288+
289+
const form = createPeopleSearch(document, kb as any, me, onClickHandler)
290+
document.body.appendChild(form)
291+
292+
await openDropdown(form)
293+
294+
const input = form.querySelector('input') as HTMLInputElement
295+
const dropdown = form.querySelector('.people-search-dropdown') as HTMLDivElement
296+
const aliceRow = rowFor(form, 'https://alice.example/profile/card#me') as HTMLDivElement
297+
298+
keyDown(input, 'ArrowDown')
299+
expect(input.getAttribute('aria-activedescendant')).toBe(aliceRow.id)
300+
expect(aliceRow.getAttribute('aria-selected')).toBe('true')
301+
302+
keyDown(input, 'Enter')
303+
expect(onClickHandler).toHaveBeenCalledTimes(1)
304+
expect(onClickHandler).toHaveBeenCalledWith({
305+
name: 'Alice Example',
306+
webId: 'https://alice.example/profile/card#me',
307+
relationshipLabel: 'Contact'
308+
})
309+
expect(dropdown.style.display).toBe('none')
310+
expect(input.getAttribute('aria-expanded')).toBe('false')
311+
312+
await openDropdown(form)
313+
keyDown(input, 'Escape')
314+
expect(dropdown.style.display).toBe('none')
315+
expect(input.getAttribute('aria-expanded')).toBe('false')
316+
})
317+
233318
it('matches names by tokenized, case-insensitive words', async () => {
234319
mockReadAddressBook.mockResolvedValue({
235320
contacts: [

0 commit comments

Comments
 (0)