|
1 | 1 | "use client"; |
2 | 2 |
|
3 | | -import { useState, useRef, useEffect, useMemo } from "react"; |
| 3 | +import { useState } from "react"; |
4 | 4 | import { login } from "@inrupt/solid-client-authn-browser"; |
5 | 5 | import Image from "next/image"; |
6 | 6 | import Button from "./shared/Button"; |
7 | | -import { ChevronDownIcon } from "@heroicons/react/24/outline"; |
| 7 | +import UrlCombobox, { ComboboxOption } from "./shared/UrlCombobox"; |
8 | 8 |
|
9 | | -const PRESET_ISSUERS = [ |
10 | | - { label: "Solid Community", value: "https://solidcommunity.net/" }, |
11 | | - { label: "Inrupt", value: "https://login.inrupt.com" }, |
12 | | -] as const; |
| 9 | +const PRESET_ISSUERS: ComboboxOption[] = [ |
| 10 | + { label: "Solid Community", value: "https://solidcommunity.net/", secondaryLabel: "https://solidcommunity.net/" }, |
| 11 | + { label: "Inrupt", value: "https://login.inrupt.com", secondaryLabel: "https://login.inrupt.com" }, |
| 12 | +]; |
13 | 13 |
|
14 | 14 | export default function LoginPage() { |
15 | 15 | const [issuerInput, setIssuerInput] = useState<string>( |
16 | 16 | process.env.NEXT_PUBLIC_OIDC_ISSUER || "" |
17 | 17 | ); |
18 | 18 | const [isLoading, setIsLoading] = useState(false); |
19 | | - const [showDropdown, setShowDropdown] = useState(false); |
20 | 19 | const [error, setError] = useState<string | null>(null); |
21 | | - const [highlightedIndex, setHighlightedIndex] = useState<number>(-1); |
22 | | - const inputRef = useRef<HTMLInputElement>(null); |
23 | | - const dropdownRef = useRef<HTMLDivElement>(null); |
24 | | - |
25 | | - // Close dropdown when clicking outside |
26 | | - useEffect(() => { |
27 | | - const handleClickOutside = (event: MouseEvent) => { |
28 | | - if ( |
29 | | - dropdownRef.current && |
30 | | - !dropdownRef.current.contains(event.target as Node) && |
31 | | - inputRef.current && |
32 | | - !inputRef.current.contains(event.target as Node) |
33 | | - ) { |
34 | | - setShowDropdown(false); |
35 | | - } |
36 | | - }; |
37 | | - |
38 | | - if (showDropdown) { |
39 | | - document.addEventListener("mousedown", handleClickOutside); |
40 | | - return () => document.removeEventListener("mousedown", handleClickOutside); |
41 | | - } |
42 | | - }, [showDropdown]); |
43 | 20 |
|
44 | 21 | const validateIssuerUrl = (url: string): boolean => { |
45 | 22 | if (!url.trim()) { |
@@ -82,74 +59,13 @@ export default function LoginPage() { |
82 | 59 | } |
83 | 60 | }; |
84 | 61 |
|
85 | | - const handleIssuerSelect = (value: string) => { |
| 62 | + const handleIssuerChange = (value: string) => { |
86 | 63 | setIssuerInput(value); |
87 | | - setShowDropdown(false); |
88 | | - setError(null); |
89 | | - setHighlightedIndex(-1); |
90 | | - inputRef.current?.focus(); |
91 | | - }; |
92 | | - |
93 | | - const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { |
94 | | - setIssuerInput(e.target.value); |
95 | | - setHighlightedIndex(-1); |
96 | 64 | if (error) { |
97 | 65 | setError(null); |
98 | 66 | } |
99 | 67 | }; |
100 | 68 |
|
101 | | - const handleInputFocus = () => { |
102 | | - setShowDropdown(true); |
103 | | - setHighlightedIndex(-1); |
104 | | - }; |
105 | | - |
106 | | - // Filter preset issuers based on input |
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 | | - }; |
152 | | - |
153 | 69 | return ( |
154 | 70 | <main className="flex min-h-screen bg-white" role="main" aria-label="Sign in page"> |
155 | 71 | {/* Left side - Logo and branding */} |
@@ -211,98 +127,17 @@ export default function LoginPage() { |
211 | 127 | noValidate |
212 | 128 | > |
213 | 129 | {/* Identity Provider Input */} |
214 | | - <div> |
215 | | - <label |
216 | | - htmlFor="oidc-issuer" |
217 | | - className="mb-2 block text-sm font-medium text-black" |
218 | | - > |
219 | | - Solid Identity Provider |
220 | | - </label> |
221 | | - <div className="relative"> |
222 | | - <input |
223 | | - ref={inputRef} |
224 | | - id="oidc-issuer" |
225 | | - name="oidc-issuer" |
226 | | - type="text" |
227 | | - value={issuerInput} |
228 | | - onChange={handleInputChange} |
229 | | - onFocus={handleInputFocus} |
230 | | - onKeyDown={handleKeyDown} |
231 | | - placeholder="Enter your provider URL or select from the list" |
232 | | - 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 ${ |
233 | | - error |
234 | | - ? "border-red-300 focus:border-red-500 focus:ring-red-500" |
235 | | - : "border-gray-300 focus:border-[#7B42F6] focus:ring-[#7B42F6]" |
236 | | - }`} |
237 | | - disabled={isLoading} |
238 | | - required |
239 | | - aria-required="true" |
240 | | - aria-label="Enter or select Solid Identity Provider" |
241 | | - aria-describedby={error ? "oidc-issuer-error" : "oidc-issuer-description"} |
242 | | - aria-invalid={!!error} |
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" |
249 | | - /> |
250 | | - <button |
251 | | - type="button" |
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} |
259 | | - > |
260 | | - <ChevronDownIcon className={`h-5 w-5 transition-transform ${showDropdown ? "rotate-180" : ""}`} /> |
261 | | - </button> |
262 | | - |
263 | | - {/* Dropdown with preset options */} |
264 | | - {showDropdown && filteredIssuers.length > 0 && ( |
265 | | - <div |
266 | | - ref={dropdownRef} |
267 | | - id="issuer-listbox" |
268 | | - className="absolute z-10 mt-1 w-full rounded-md border border-gray-200 bg-white shadow-lg max-h-60 overflow-auto" |
269 | | - role="listbox" |
270 | | - aria-label="Preset identity providers" |
271 | | - > |
272 | | - {filteredIssuers.map((issuer, index) => ( |
273 | | - <button |
274 | | - key={issuer.value} |
275 | | - id={`issuer-option-${index}`} |
276 | | - type="button" |
277 | | - onClick={() => handleIssuerSelect(issuer.value)} |
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 | | - }`} |
283 | | - role="option" |
284 | | - aria-selected={issuerInput === issuer.value} |
285 | | - > |
286 | | - <div className="text-sm font-medium text-gray-900"> |
287 | | - {issuer.label} |
288 | | - </div> |
289 | | - <div className="text-xs text-gray-500"> |
290 | | - {issuer.value} |
291 | | - </div> |
292 | | - </button> |
293 | | - ))} |
294 | | - </div> |
295 | | - )} |
296 | | - </div> |
297 | | - {error && ( |
298 | | - <p id="oidc-issuer-error" className="mt-1 text-xs text-red-600" role="alert"> |
299 | | - {error} |
300 | | - </p> |
301 | | - )} |
302 | | - <p id="oidc-issuer-description" className="sr-only"> |
303 | | - Enter your Solid Identity Provider URL or select from the preset options |
304 | | - </p> |
305 | | - </div> |
| 130 | + <UrlCombobox |
| 131 | + id="oidc-issuer" |
| 132 | + label="Solid Identity Provider" |
| 133 | + value={issuerInput} |
| 134 | + onChange={handleIssuerChange} |
| 135 | + options={PRESET_ISSUERS} |
| 136 | + placeholder="Enter your provider URL or select from the list" |
| 137 | + error={error || undefined} |
| 138 | + disabled={isLoading} |
| 139 | + aria-label="Enter or select Solid Identity Provider" |
| 140 | + /> |
306 | 141 |
|
307 | 142 | {/* Action button */} |
308 | 143 | <div className="flex items-center justify-end pt-4"> |
|
0 commit comments