@@ -26,6 +26,7 @@ import { ns } from '..'
2626const PEOPLE_SEARCH_CONCURRENCY = 6
2727const CONTACT_CARD_CONCURRENCY = 8
2828const MAX_FOAF_DISTANCE = 3
29+ let peopleSearchInstanceCounter = 0
2930const addressBookListCache = new Map < string , Promise < string [ ] > > ( )
3031const addressBookCache = new Map < string , Promise < AddressBook > > ( )
3132const 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 - z A - Z 0 - 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 ( )
0 commit comments