11"use client" ;
22
3- import { useState , useRef , useEffect } from "react" ;
3+ import { useState , useRef , useEffect , useMemo } from "react" ;
44import { login } from "@inrupt/solid-client-authn-browser" ;
55import Image from "next/image" ;
66import Button from "./shared/Button" ;
@@ -18,6 +18,7 @@ export default function LoginPage() {
1818 const [ isLoading , setIsLoading ] = useState ( false ) ;
1919 const [ showDropdown , setShowDropdown ] = useState ( false ) ;
2020 const [ error , setError ] = useState < string | null > ( null ) ;
21+ const [ highlightedIndex , setHighlightedIndex ] = useState < number > ( - 1 ) ;
2122 const inputRef = useRef < HTMLInputElement > ( null ) ;
2223 const dropdownRef = useRef < HTMLDivElement > ( null ) ;
2324
@@ -85,25 +86,69 @@ export default function LoginPage() {
8586 setIssuerInput ( value ) ;
8687 setShowDropdown ( false ) ;
8788 setError ( null ) ;
89+ setHighlightedIndex ( - 1 ) ;
8890 inputRef . current ?. focus ( ) ;
8991 } ;
9092
9193 const handleInputChange = ( e : React . ChangeEvent < HTMLInputElement > ) => {
9294 setIssuerInput ( e . target . value ) ;
95+ setHighlightedIndex ( - 1 ) ;
9396 if ( error ) {
9497 setError ( null ) ;
9598 }
9699 } ;
97100
101+ const handleInputFocus = ( ) => {
102+ setShowDropdown ( true ) ;
103+ setHighlightedIndex ( - 1 ) ;
104+ } ;
105+
98106 // Filter preset issuers based on input
99- const filteredIssuers = PRESET_ISSUERS . filter ( ( issuer ) => {
100- if ( ! issuerInput . trim ( ) ) return true ;
101- const query = issuerInput . toLowerCase ( ) ;
102- return (
103- issuer . label . toLowerCase ( ) . includes ( query ) ||
104- issuer . value . toLowerCase ( ) . includes ( query )
105- ) ;
106- } ) ;
107+ const filteredIssuers = useMemo ( ( ) => {
108+ return PRESET_ISSUERS . filter ( ( issuer ) => {
109+ if ( ! issuerInput . trim ( ) ) return true ;
110+ const query = issuerInput . toLowerCase ( ) ;
111+ return (
112+ issuer . label . toLowerCase ( ) . includes ( query ) ||
113+ issuer . value . toLowerCase ( ) . includes ( query )
114+ ) ;
115+ } ) ;
116+ } , [ issuerInput ] ) ;
117+
118+ const handleKeyDown = ( e : React . KeyboardEvent < HTMLInputElement > ) => {
119+ if ( ! showDropdown ) {
120+ if ( e . key === "ArrowDown" || e . key === "ArrowUp" ) {
121+ e . preventDefault ( ) ;
122+ setShowDropdown ( true ) ;
123+ setHighlightedIndex ( - 1 ) ;
124+ }
125+ return ;
126+ }
127+
128+ switch ( e . key ) {
129+ case "ArrowDown" :
130+ e . preventDefault ( ) ;
131+ setHighlightedIndex ( ( prev ) =>
132+ prev < filteredIssuers . length - 1 ? prev + 1 : prev
133+ ) ;
134+ break ;
135+ case "ArrowUp" :
136+ e . preventDefault ( ) ;
137+ setHighlightedIndex ( ( prev ) => ( prev > 0 ? prev - 1 : prev ) ) ;
138+ break ;
139+ case "Enter" :
140+ if ( highlightedIndex >= 0 && highlightedIndex < filteredIssuers . length ) {
141+ e . preventDefault ( ) ;
142+ handleIssuerSelect ( filteredIssuers [ highlightedIndex ] . value ) ;
143+ }
144+ break ;
145+ case "Escape" :
146+ e . preventDefault ( ) ;
147+ setShowDropdown ( false ) ;
148+ setHighlightedIndex ( - 1 ) ;
149+ break ;
150+ }
151+ } ;
107152
108153 return (
109154 < main className = "flex min-h-screen bg-white" role = "main" aria-label = "Sign in page" >
@@ -181,7 +226,8 @@ export default function LoginPage() {
181226 type = "text"
182227 value = { issuerInput }
183228 onChange = { handleInputChange }
184- onFocus = { ( ) => setShowDropdown ( true ) }
229+ onFocus = { handleInputFocus }
230+ onKeyDown = { handleKeyDown }
185231 placeholder = "Enter your provider URL or select from the list"
186232 className = { `h-12 w-full rounded-md border bg-white px-4 pr-10 text-black placeholder:text-gray-500 focus:outline-none focus:ring-1 disabled:cursor-not-allowed disabled:opacity-50 ${
187233 error
@@ -194,14 +240,22 @@ export default function LoginPage() {
194240 aria-label = "Enter or select Solid Identity Provider"
195241 aria-describedby = { error ? "oidc-issuer-error" : "oidc-issuer-description" }
196242 aria-invalid = { ! ! error }
197- autoComplete = "url"
243+ aria-expanded = { showDropdown }
244+ aria-activedescendant = { highlightedIndex >= 0 ? `issuer-option-${ highlightedIndex } ` : undefined }
245+ role = "combobox"
246+ aria-autocomplete = "list"
247+ aria-controls = "issuer-listbox"
248+ autoComplete = "off"
198249 />
199250 < button
200251 type = "button"
201- onClick = { ( ) => setShowDropdown ( ! showDropdown ) }
202- className = "absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
203- aria-label = "Show provider options"
204- tabIndex = { - 1 }
252+ onClick = { ( ) => {
253+ setShowDropdown ( ! showDropdown ) ;
254+ inputRef . current ?. focus ( ) ;
255+ } }
256+ className = "absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 focus:outline-none focus:text-gray-600"
257+ aria-label = { showDropdown ? "Hide provider options" : "Show provider options" }
258+ aria-expanded = { showDropdown }
205259 >
206260 < ChevronDownIcon className = { `h-5 w-5 transition-transform ${ showDropdown ? "rotate-180" : "" } ` } />
207261 </ button >
@@ -210,16 +264,22 @@ export default function LoginPage() {
210264 { showDropdown && filteredIssuers . length > 0 && (
211265 < div
212266 ref = { dropdownRef }
267+ id = "issuer-listbox"
213268 className = "absolute z-10 mt-1 w-full rounded-md border border-gray-200 bg-white shadow-lg max-h-60 overflow-auto"
214269 role = "listbox"
215270 aria-label = "Preset identity providers"
216271 >
217- { filteredIssuers . map ( ( issuer ) => (
272+ { filteredIssuers . map ( ( issuer , index ) => (
218273 < button
219274 key = { issuer . value }
275+ id = { `issuer-option-${ index } ` }
220276 type = "button"
221277 onClick = { ( ) => handleIssuerSelect ( issuer . value ) }
222- className = "w-full px-4 py-3 text-left hover:bg-gray-100 focus:bg-gray-100 focus:outline-none"
278+ className = { `w-full px-4 py-3 text-left focus:outline-none ${
279+ highlightedIndex === index
280+ ? "bg-gray-100"
281+ : "hover:bg-gray-100"
282+ } `}
223283 role = "option"
224284 aria-selected = { issuerInput === issuer . value }
225285 >
0 commit comments