11"use client" ;
22
3- import { useState , useRef , useEffect , useMemo } from "react" ;
3+ import { useState , useEffect } from "react" ;
44import { useSolidAuth } from "@ldo/solid-react" ;
55import { useRouter } from "next/navigation" ;
66import Image from "next/image" ;
77import Button from "./shared/Button" ;
8- import { ChevronDownIcon } from "@heroicons/react/24/outline " ;
8+ import UrlCombobox , { ComboboxOption } from "./shared/UrlCombobox " ;
99
10- const PRESET_ISSUERS = [
11- { label : "Solid Community" , value : "https://solidcommunity.net/" } ,
12- { label : "Inrupt" , value : "https://login.inrupt.com" } ,
13- ] as const ;
10+ const PRESET_ISSUERS : ComboboxOption [ ] = [
11+ { label : "Solid Community" , value : "https://solidcommunity.net/" , secondaryLabel : "https://solidcommunity.net/" } ,
12+ { label : "Inrupt" , value : "https://login.inrupt.com" , secondaryLabel : "https://login.inrupt.com" } ,
13+ ] ;
1414
1515export default function LoginPage ( ) {
1616 const { session, login } = useSolidAuth ( ) ;
@@ -19,11 +19,7 @@ export default function LoginPage() {
1919 process . env . NEXT_PUBLIC_OIDC_ISSUER || ""
2020 ) ;
2121 const [ isLoading , setIsLoading ] = useState ( false ) ;
22- const [ showDropdown , setShowDropdown ] = useState ( false ) ;
2322 const [ error , setError ] = useState < string | null > ( null ) ;
24- const [ highlightedIndex , setHighlightedIndex ] = useState < number > ( - 1 ) ;
25- const inputRef = useRef < HTMLInputElement > ( null ) ;
26- const dropdownRef = useRef < HTMLDivElement > ( null ) ;
2723
2824 // Redirect to home if already authenticated
2925 useEffect ( ( ) => {
@@ -37,25 +33,6 @@ export default function LoginPage() {
3733 return null ;
3834 }
3935
40- // Close dropdown when clicking outside
41- useEffect ( ( ) => {
42- const handleClickOutside = ( event : MouseEvent ) => {
43- if (
44- dropdownRef . current &&
45- ! dropdownRef . current . contains ( event . target as Node ) &&
46- inputRef . current &&
47- ! inputRef . current . contains ( event . target as Node )
48- ) {
49- setShowDropdown ( false ) ;
50- }
51- } ;
52-
53- if ( showDropdown ) {
54- document . addEventListener ( "mousedown" , handleClickOutside ) ;
55- return ( ) => document . removeEventListener ( "mousedown" , handleClickOutside ) ;
56- }
57- } , [ showDropdown ] ) ;
58-
5936 const validateIssuerUrl = ( url : string ) : boolean => {
6037 if ( ! url . trim ( ) ) {
6138 setError ( "Please enter a Solid Identity Provider URL" ) ;
@@ -92,74 +69,13 @@ export default function LoginPage() {
9269 }
9370 } ;
9471
95- const handleIssuerSelect = ( value : string ) => {
72+ const handleIssuerChange = ( value : string ) => {
9673 setIssuerInput ( value ) ;
97- setShowDropdown ( false ) ;
98- setError ( null ) ;
99- setHighlightedIndex ( - 1 ) ;
100- inputRef . current ?. focus ( ) ;
101- } ;
102-
103- const handleInputChange = ( e : React . ChangeEvent < HTMLInputElement > ) => {
104- setIssuerInput ( e . target . value ) ;
105- setHighlightedIndex ( - 1 ) ;
10674 if ( error ) {
10775 setError ( null ) ;
10876 }
10977 } ;
11078
111- const handleInputFocus = ( ) => {
112- setShowDropdown ( true ) ;
113- setHighlightedIndex ( - 1 ) ;
114- } ;
115-
116- // Filter preset issuers based on input
117- const filteredIssuers = useMemo ( ( ) => {
118- return PRESET_ISSUERS . filter ( ( issuer ) => {
119- if ( ! issuerInput . trim ( ) ) return true ;
120- const query = issuerInput . toLowerCase ( ) ;
121- return (
122- issuer . label . toLowerCase ( ) . includes ( query ) ||
123- issuer . value . toLowerCase ( ) . includes ( query )
124- ) ;
125- } ) ;
126- } , [ issuerInput ] ) ;
127-
128- const handleKeyDown = ( e : React . KeyboardEvent < HTMLInputElement > ) => {
129- if ( ! showDropdown ) {
130- if ( e . key === "ArrowDown" || e . key === "ArrowUp" ) {
131- e . preventDefault ( ) ;
132- setShowDropdown ( true ) ;
133- setHighlightedIndex ( - 1 ) ;
134- }
135- return ;
136- }
137-
138- switch ( e . key ) {
139- case "ArrowDown" :
140- e . preventDefault ( ) ;
141- setHighlightedIndex ( ( prev ) =>
142- prev < filteredIssuers . length - 1 ? prev + 1 : prev
143- ) ;
144- break ;
145- case "ArrowUp" :
146- e . preventDefault ( ) ;
147- setHighlightedIndex ( ( prev ) => ( prev > 0 ? prev - 1 : prev ) ) ;
148- break ;
149- case "Enter" :
150- if ( highlightedIndex >= 0 && highlightedIndex < filteredIssuers . length ) {
151- e . preventDefault ( ) ;
152- handleIssuerSelect ( filteredIssuers [ highlightedIndex ] . value ) ;
153- }
154- break ;
155- case "Escape" :
156- e . preventDefault ( ) ;
157- setShowDropdown ( false ) ;
158- setHighlightedIndex ( - 1 ) ;
159- break ;
160- }
161- } ;
162-
16379 return (
16480 < main className = "flex min-h-screen bg-white" role = "main" aria-label = "Sign in page" >
16581 { /* Left side - Logo and branding */ }
@@ -221,98 +137,17 @@ export default function LoginPage() {
221137 noValidate
222138 >
223139 { /* Identity Provider Input */ }
224- < div >
225- < label
226- htmlFor = "oidc-issuer"
227- className = "mb-2 block text-sm font-medium text-black"
228- >
229- Solid Identity Provider
230- </ label >
231- < div className = "relative" >
232- < input
233- ref = { inputRef }
234- id = "oidc-issuer"
235- name = "oidc-issuer"
236- type = "text"
237- value = { issuerInput }
238- onChange = { handleInputChange }
239- onFocus = { handleInputFocus }
240- onKeyDown = { handleKeyDown }
241- placeholder = "Enter your provider URL or select from the list"
242- 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 ${
243- error
244- ? "border-red-300 focus:border-red-500 focus:ring-red-500"
245- : "border-gray-300 focus:border-[#7B42F6] focus:ring-[#7B42F6]"
246- } `}
247- disabled = { isLoading }
248- required
249- aria-required = "true"
250- aria-label = "Enter or select Solid Identity Provider"
251- aria-describedby = { error ? "oidc-issuer-error" : "oidc-issuer-description" }
252- aria-invalid = { ! ! error }
253- aria-expanded = { showDropdown }
254- aria-activedescendant = { highlightedIndex >= 0 ? `issuer-option-${ highlightedIndex } ` : undefined }
255- role = "combobox"
256- aria-autocomplete = "list"
257- aria-controls = "issuer-listbox"
258- autoComplete = "off"
259- />
260- < button
261- type = "button"
262- onClick = { ( ) => {
263- setShowDropdown ( ! showDropdown ) ;
264- inputRef . current ?. focus ( ) ;
265- } }
266- 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"
267- aria-label = { showDropdown ? "Hide provider options" : "Show provider options" }
268- aria-expanded = { showDropdown }
269- >
270- < ChevronDownIcon className = { `h-5 w-5 transition-transform ${ showDropdown ? "rotate-180" : "" } ` } />
271- </ button >
272-
273- { /* Dropdown with preset options */ }
274- { showDropdown && filteredIssuers . length > 0 && (
275- < div
276- ref = { dropdownRef }
277- id = "issuer-listbox"
278- className = "absolute z-10 mt-1 w-full rounded-md border border-gray-200 bg-white shadow-lg max-h-60 overflow-auto"
279- role = "listbox"
280- aria-label = "Preset identity providers"
281- >
282- { filteredIssuers . map ( ( issuer , index ) => (
283- < button
284- key = { issuer . value }
285- id = { `issuer-option-${ index } ` }
286- type = "button"
287- onClick = { ( ) => handleIssuerSelect ( issuer . value ) }
288- className = { `w-full px-4 py-3 text-left focus:outline-none ${
289- highlightedIndex === index
290- ? "bg-gray-100"
291- : "hover:bg-gray-100"
292- } `}
293- role = "option"
294- aria-selected = { issuerInput === issuer . value }
295- >
296- < div className = "text-sm font-medium text-gray-900" >
297- { issuer . label }
298- </ div >
299- < div className = "text-xs text-gray-500" >
300- { issuer . value }
301- </ div >
302- </ button >
303- ) ) }
304- </ div >
305- ) }
306- </ div >
307- { error && (
308- < p id = "oidc-issuer-error" className = "mt-1 text-xs text-red-600" role = "alert" >
309- { error }
310- </ p >
311- ) }
312- < p id = "oidc-issuer-description" className = "sr-only" >
313- Enter your Solid Identity Provider URL or select from the preset options
314- </ p >
315- </ div >
140+ < UrlCombobox
141+ id = "oidc-issuer"
142+ label = "Solid Identity Provider"
143+ value = { issuerInput }
144+ onChange = { handleIssuerChange }
145+ options = { PRESET_ISSUERS }
146+ placeholder = "Enter your provider URL or select from the list"
147+ error = { error || undefined }
148+ disabled = { isLoading }
149+ aria-label = "Enter or select Solid Identity Provider"
150+ />
316151
317152 { /* Action button */ }
318153 < div className = "flex items-center justify-end pt-4" >
0 commit comments