Skip to content

Commit 22fbb5a

Browse files
Copilotjeswr
andcommitted
Add keyboard navigation and accessibility improvements to IDP selector
Co-authored-by: jeswr <63333554+jeswr@users.noreply.github.com>
1 parent 46d9bba commit 22fbb5a

1 file changed

Lines changed: 77 additions & 17 deletions

File tree

app/components/LoginPage.tsx

Lines changed: 77 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { useState, useRef, useEffect } from "react";
3+
import { useState, useRef, useEffect, useMemo } from "react";
44
import { login } from "@inrupt/solid-client-authn-browser";
55
import Image from "next/image";
66
import 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

Comments
 (0)