Skip to content

Commit a5ee9b5

Browse files
committed
more accessibility
1 parent cb153b6 commit a5ee9b5

2 files changed

Lines changed: 113 additions & 13 deletions

File tree

src/widgets/peopleSearch.ts

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -133,13 +133,21 @@ export const createPeopleSearch = function (
133133
warmupHint.style.color = '#666'
134134
warmupHint.textContent = 'Warming up contacts…'
135135

136+
const liveStatus = searchForm.appendChild(dom.createElement('div'))
137+
liveStatus.className = 'people-search-sr-only'
138+
liveStatus.setAttribute('role', 'status')
139+
liveStatus.setAttribute('aria-live', 'polite')
140+
136141
const discoveredPeople = new Map<string, PersonEntry>()
137142
const personRows = new Map<string, HTMLDivElement>()
138143
const status = searchDiv.appendChild(dom.createElement('p'))
139144
status.style.margin = '5px 0'
140145
status.style.color = '#666'
141-
status.setAttribute('role', 'status')
142-
status.setAttribute('aria-live', 'polite')
146+
147+
const setStatusText = function (text: string) {
148+
status.textContent = text
149+
liveStatus.textContent = text
150+
}
143151

144152
let activeRow: HTMLDivElement | null = null
145153

@@ -163,6 +171,9 @@ export const createPeopleSearch = function (
163171
if (activeRow) {
164172
activeRow.style.backgroundColor = '#f0f0f0'
165173
activeRow.setAttribute('aria-selected', 'true')
174+
if (typeof activeRow.scrollIntoView === 'function') {
175+
activeRow.scrollIntoView({ block: 'nearest' })
176+
}
166177
if (activeRow.id) {
167178
searchInput.setAttribute('aria-activedescendant', activeRow.id)
168179
}
@@ -527,7 +538,8 @@ export const createPeopleSearch = function (
527538
}
528539

529540
discoveryStarted = true
530-
status.textContent = 'Searching...'
541+
searchDiv.setAttribute('aria-busy', 'true')
542+
setStatusText('Searching...')
531543
warmupHint.style.display = 'block'
532544

533545
discoveryPromise = (async function () {
@@ -563,15 +575,16 @@ export const createPeopleSearch = function (
563575
}
564576
})()
565577
.catch(() => {
566-
status.textContent = 'Unable to load contacts.'
578+
setStatusText('Unable to load contacts.')
567579
})
568580
.finally(() => {
569581
discoveryStarted = false
582+
searchDiv.setAttribute('aria-busy', 'false')
570583
warmupHint.style.display = 'none'
571584
if (discoveredPeople.size === 0) {
572-
status.textContent = me ? 'No contacts found.' : 'Sign in to search contacts.'
585+
setStatusText(me ? 'No contacts found.' : 'Sign in to search contacts.')
573586
} else {
574-
status.textContent = ''
587+
setStatusText('')
575588
}
576589
})
577590

@@ -584,7 +597,7 @@ export const createPeopleSearch = function (
584597

585598
const visibleCount = updateVisibleRows(query.trim())
586599
if (!me) {
587-
status.textContent = 'Sign in to search contacts.'
600+
setStatusText('Sign in to search contacts.')
588601
return
589602
}
590603

@@ -595,13 +608,13 @@ export const createPeopleSearch = function (
595608
if (searchId !== activeSearchId) return
596609

597610
if (visibleCount > 0) {
598-
status.textContent = discoveryStarted ? 'Searching...' : ''
611+
setStatusText(discoveryStarted ? 'Searching...' : '')
599612
return
600613
}
601614

602-
status.textContent = discoveryStarted
615+
setStatusText(discoveryStarted
603616
? 'Searching...'
604-
: 'No contacts match that name.'
617+
: 'No contacts match that name.')
605618
}
606619

607620
const onInputHandler = function () {
@@ -622,12 +635,31 @@ export const createPeopleSearch = function (
622635
const onKeyDownHandler = function (event: KeyboardEvent) {
623636
const visibleRows = getVisibleRows()
624637

638+
if (event.key === 'Tab') {
639+
setActiveRow(null)
640+
setDropdownOpen(false)
641+
return
642+
}
643+
625644
if (event.key === 'Escape') {
626645
setActiveRow(null)
627646
setDropdownOpen(false)
628647
return
629648
}
630649

650+
if (event.key === 'Home' || event.key === 'End') {
651+
if (visibleRows.length === 0) {
652+
return
653+
}
654+
event.preventDefault()
655+
if (searchDiv.style.display === 'none') {
656+
setDropdownOpen(true)
657+
}
658+
const targetIndex = event.key === 'Home' ? 0 : visibleRows.length - 1
659+
setActiveRow(visibleRows[targetIndex])
660+
return
661+
}
662+
631663
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
632664
event.preventDefault()
633665
if (searchDiv.style.display === 'none') {
@@ -639,7 +671,7 @@ export const createPeopleSearch = function (
639671
const currentIndex = activeRow ? visibleRows.indexOf(activeRow) : -1
640672
const nextIndex = event.key === 'ArrowDown'
641673
? Math.min(currentIndex + 1, visibleRows.length - 1)
642-
: Math.max(currentIndex - 1, 0)
674+
: (currentIndex <= 0 ? visibleRows.length - 1 : currentIndex - 1)
643675
setActiveRow(visibleRows[nextIndex])
644676
return
645677
}

test/unit/widgets/peopleSearch.test.ts

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,7 @@ describe('createPeopleSearch', () => {
244244
const input = form.querySelector('input') as HTMLInputElement
245245
const label = form.querySelector('label') as HTMLLabelElement
246246
const dropdown = form.querySelector('.people-search-dropdown') as HTMLDivElement
247+
const liveRegion = form.querySelector('div[role="status"]') as HTMLDivElement
247248

248249
expect(label).not.toBeNull()
249250
expect(label.textContent).toBe('Search for people')
@@ -253,11 +254,14 @@ describe('createPeopleSearch', () => {
253254
expect(input.getAttribute('aria-labelledby')).toBe(label.id)
254255
expect(input.getAttribute('aria-controls')).toBe(dropdown.id)
255256
expect(input.getAttribute('aria-expanded')).toBe('false')
257+
expect(liveRegion).not.toBeNull()
258+
expect(typeof liveRegion.textContent).toBe('string')
256259

257260
await openDropdown(form)
258261

259262
const personRow = rowFor(form, 'https://alice.example/profile/card#me')
260263
expect(dropdown.getAttribute('role')).toBe('listbox')
264+
expect(dropdown.getAttribute('aria-busy')).toBe('false')
261265
expect(input.getAttribute('aria-expanded')).toBe('true')
262266
expect(personRow?.getAttribute('role')).toBe('option')
263267
expect(personRow?.id).toContain('-option-')
@@ -294,16 +298,21 @@ describe('createPeopleSearch', () => {
294298
const input = form.querySelector('input') as HTMLInputElement
295299
const dropdown = form.querySelector('.people-search-dropdown') as HTMLDivElement
296300
const aliceRow = rowFor(form, 'https://alice.example/profile/card#me') as HTMLDivElement
301+
const bobRow = rowFor(form, 'https://bob.example/profile/card#me') as HTMLDivElement
297302

298303
keyDown(input, 'ArrowDown')
299304
expect(input.getAttribute('aria-activedescendant')).toBe(aliceRow.id)
300305
expect(aliceRow.getAttribute('aria-selected')).toBe('true')
301306

307+
keyDown(input, 'ArrowUp')
308+
expect(input.getAttribute('aria-activedescendant')).toBe(bobRow.id)
309+
expect(bobRow.getAttribute('aria-selected')).toBe('true')
310+
302311
keyDown(input, 'Enter')
303312
expect(onClickHandler).toHaveBeenCalledTimes(1)
304313
expect(onClickHandler).toHaveBeenCalledWith({
305-
name: 'Alice Example',
306-
webId: 'https://alice.example/profile/card#me',
314+
name: 'Bob Stone',
315+
webId: 'https://bob.example/profile/card#me',
307316
relationshipLabel: 'Contact'
308317
})
309318
expect(dropdown.style.display).toBe('none')
@@ -315,6 +324,50 @@ describe('createPeopleSearch', () => {
315324
expect(input.getAttribute('aria-expanded')).toBe('false')
316325
})
317326

327+
it('supports Home/End navigation and closes on Tab', async () => {
328+
mockReadAddressBook.mockResolvedValue({
329+
contacts: [
330+
{
331+
uri: 'https://pod.example/contacts/1#this',
332+
name: 'Alice Example'
333+
},
334+
{
335+
uri: 'https://pod.example/contacts/2#this',
336+
name: 'Bob Stone'
337+
}
338+
]
339+
})
340+
341+
const kb = makeKb({
342+
contactWebIdsByCardUri: {
343+
'https://pod.example/contacts/1#this': 'https://alice.example/profile/card#me',
344+
'https://pod.example/contacts/2#this': 'https://bob.example/profile/card#me'
345+
}
346+
})
347+
const me = new NamedNode('https://user-11.example/profile/card#me')
348+
349+
const form = createPeopleSearch(document, kb as any, me)
350+
document.body.appendChild(form)
351+
352+
await openDropdown(form)
353+
354+
const input = form.querySelector('input') as HTMLInputElement
355+
const dropdown = form.querySelector('.people-search-dropdown') as HTMLDivElement
356+
const aliceRow = rowFor(form, 'https://alice.example/profile/card#me') as HTMLDivElement
357+
const bobRow = rowFor(form, 'https://bob.example/profile/card#me') as HTMLDivElement
358+
359+
keyDown(input, 'End')
360+
expect(input.getAttribute('aria-activedescendant')).toBe(bobRow.id)
361+
362+
keyDown(input, 'Home')
363+
expect(input.getAttribute('aria-activedescendant')).toBe(aliceRow.id)
364+
365+
keyDown(input, 'Tab')
366+
expect(dropdown.style.display).toBe('none')
367+
expect(input.getAttribute('aria-expanded')).toBe('false')
368+
expect(input.getAttribute('aria-activedescendant')).toBeNull()
369+
})
370+
318371
it('matches names by tokenized, case-insensitive words', async () => {
319372
mockReadAddressBook.mockResolvedValue({
320373
contacts: [
@@ -427,4 +480,19 @@ describe('createPeopleSearch', () => {
427480

428481
expect(dropdown.textContent).toContain('No contacts match that name.')
429482
})
483+
484+
it('updates hidden live status text for no-match state', async () => {
485+
const kb = makeKb()
486+
const me = new NamedNode('https://user-10.example/profile/card#me')
487+
488+
const form = createPeopleSearch(document, kb as any, me)
489+
document.body.appendChild(form)
490+
491+
const liveRegion = form.querySelector('div[role="status"]') as HTMLDivElement
492+
493+
await openDropdown(form)
494+
await setSearchQuery(form, 'no-person-will-match-this')
495+
496+
expect(liveRegion.textContent).toContain('No contacts match that name.')
497+
})
430498
})

0 commit comments

Comments
 (0)