Skip to content

Commit 4217f77

Browse files
authored
Merge pull request #522 from clidey/claude/issue-357-20250615_172123
fix: improve accessibility across frontend components
2 parents aa87927 + 68a02cc commit 4217f77

File tree

5 files changed

+362
-55
lines changed

5 files changed

+362
-55
lines changed

frontend/src/components/button.tsx

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,16 @@ export type IButtonProps = {
3333
}
3434

3535
export const Button: FC<IButtonProps> = (props) => {
36-
return <motion.button className={twMerge(classNames(ClassNames.Button, ClassNames.Hover, props.className, {
37-
"cursor-not-allowed opacity-75": props.disabled,
38-
"h-[35px] rounded-xl gap-2": props.type === "lg",
39-
}, ClassNames.Text))} onClick={props.onClick} disabled={props.disabled} whileTap={{ scale: 0.8 }} data-testid={props.testId}>
36+
return <motion.button
37+
className={twMerge(classNames(ClassNames.Button, ClassNames.Hover, props.className, {
38+
"cursor-not-allowed opacity-75": props.disabled,
39+
"h-[35px] rounded-xl gap-2": props.type === "lg",
40+
}, ClassNames.Text))}
41+
onClick={props.onClick}
42+
disabled={props.disabled}
43+
whileTap={{ scale: 0.8 }}
44+
data-testid={props.testId}
45+
aria-label={props.label}>
4046
<div className={classNames("text-xs", props.labelClassName)}>
4147
{props.label}
4248
</div>
@@ -59,15 +65,21 @@ export type IActionButtonProps = {
5965
disabled?: boolean;
6066
children?: ReactNode;
6167
testId?: string;
68+
ariaLabel?: string;
6269
}
6370

64-
export const ActionButton: FC<IActionButtonProps> = ({ onClick, icon, className, containerClassName, disabled, children, testId }) => {
71+
export const ActionButton: FC<IActionButtonProps> = ({ onClick, icon, className, containerClassName, disabled, children, testId, ariaLabel }) => {
6572
return (
6673
<div className="group relative" data-testid={testId}>
67-
<motion.button className={twMerge(classNames("rounded-full bg-white border-gray-200 dark:bg-white/10 dark:border-white/5 dark:backdrop-blur-xs h-12 w-12 transition-all border shadow-xs flex items-center justify-center", containerClassName, {
68-
"cursor-not-allowed": disabled,
69-
"hover:shadow-lg hover:cursor-pointer hover:scale-110": !disabled,
70-
}))} onClick={disabled ? undefined : onClick} whileTap={{ scale: 0.6, transition: { duration: 0.05 }, }}>
74+
<motion.button
75+
className={twMerge(classNames("rounded-full bg-white border-gray-200 dark:bg-white/10 dark:border-white/5 dark:backdrop-blur-xs h-12 w-12 transition-all border shadow-xs flex items-center justify-center", containerClassName, {
76+
"cursor-not-allowed": disabled,
77+
"hover:shadow-lg hover:cursor-pointer hover:scale-110": !disabled,
78+
}))}
79+
onClick={disabled ? undefined : onClick}
80+
whileTap={{ scale: 0.6, transition: { duration: 0.05 }, }}
81+
aria-label={ariaLabel || "Action button"}
82+
disabled={disabled}>
7183
{cloneElement(icon, {
7284
className: twMerge(classNames("w-8 h-8 stroke-neutral-500 cursor-pointer dark:stroke-neutral-300", className))
7385
})}

frontend/src/components/dropdown.tsx

Lines changed: 166 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
*/
1616

1717
import classNames from "classnames";
18-
import { FC, ReactElement, cloneElement, useCallback, useState } from "react";
18+
import { FC, ReactElement, cloneElement, useCallback, useState, useRef, useEffect, KeyboardEvent } from "react";
1919
import { Icons } from "./icons";
2020
import { Label } from "./input";
2121
import { Loading } from "./loading";
@@ -59,6 +59,11 @@ const ITEM_CLASS = "group/item flex items-center gap-1 transition-all cursor-poi
5959

6060
export const Dropdown: FC<IDropdownProps> = (props) => {
6161
const [open, setOpen] = useState(false);
62+
const [focusedIndex, setFocusedIndex] = useState(-1);
63+
const dropdownRef = useRef<HTMLDivElement>(null);
64+
const triggerRef = useRef<HTMLButtonElement>(null);
65+
const itemsRef = useRef<HTMLDivElement[]>([]);
66+
const blurTimeoutRef = useRef<NodeJS.Timeout | null>(null);
6267

6368
const handleClick = useCallback((item: IDropdownItem) => {
6469
setOpen(false);
@@ -71,15 +76,128 @@ export const Dropdown: FC<IDropdownProps> = (props) => {
7176

7277
const handleClose = useCallback(() => {
7378
setOpen(false);
79+
setFocusedIndex(-1);
80+
// Clear any pending blur timeout
81+
if (blurTimeoutRef.current) {
82+
clearTimeout(blurTimeoutRef.current);
83+
blurTimeoutRef.current = null;
84+
}
85+
// Ensure focus returns to trigger button
86+
setTimeout(() => {
87+
triggerRef.current?.focus();
88+
}, 0);
7489
}, []);
7590

91+
const handleDropdownBlur = useCallback((event: React.FocusEvent<HTMLDivElement>) => {
92+
// Clear any existing timeout
93+
if (blurTimeoutRef.current) {
94+
clearTimeout(blurTimeoutRef.current);
95+
}
96+
97+
// Set a timeout to check if focus moved outside the dropdown
98+
blurTimeoutRef.current = setTimeout(() => {
99+
if (dropdownRef.current && !dropdownRef.current.contains(document.activeElement)) {
100+
setOpen(false);
101+
setFocusedIndex(-1);
102+
}
103+
}, 100);
104+
}, []);
105+
106+
const handleDropdownFocus = useCallback(() => {
107+
// Clear the blur timeout if focus returns to dropdown
108+
if (blurTimeoutRef.current) {
109+
clearTimeout(blurTimeoutRef.current);
110+
blurTimeoutRef.current = null;
111+
}
112+
}, []);
113+
114+
const handleKeyDown = useCallback((event: KeyboardEvent<HTMLButtonElement>) => {
115+
switch (event.key) {
116+
case 'Enter':
117+
case ' ':
118+
case 'ArrowDown':
119+
event.preventDefault();
120+
setOpen(true);
121+
setFocusedIndex(0);
122+
break;
123+
case 'ArrowUp':
124+
event.preventDefault();
125+
setOpen(true);
126+
setFocusedIndex(props.items.length - 1);
127+
break;
128+
case 'Escape':
129+
handleClose();
130+
break;
131+
}
132+
}, [props.items.length, handleClose]);
133+
134+
const handleItemKeyDown = useCallback((event: KeyboardEvent<HTMLDivElement>, item: IDropdownItem, index: number) => {
135+
switch (event.key) {
136+
case 'Enter':
137+
case ' ':
138+
event.preventDefault();
139+
handleClick(item);
140+
break;
141+
case 'ArrowDown':
142+
event.preventDefault();
143+
const nextIndex = Math.min(index + 1, props.items.length - 1);
144+
setFocusedIndex(nextIndex);
145+
break;
146+
case 'ArrowUp':
147+
event.preventDefault();
148+
const prevIndex = Math.max(index - 1, 0);
149+
setFocusedIndex(prevIndex);
150+
break;
151+
case 'Escape':
152+
event.preventDefault();
153+
handleClose();
154+
break;
155+
case 'Tab':
156+
handleClose();
157+
break;
158+
}
159+
}, [handleClick, props.items.length, handleClose]);
160+
161+
useEffect(() => {
162+
if (open && focusedIndex >= 0 && itemsRef.current[focusedIndex]) {
163+
itemsRef.current[focusedIndex].focus();
164+
}
165+
}, [open, focusedIndex]);
166+
167+
useEffect(() => {
168+
const handleClickOutside = (event: MouseEvent) => {
169+
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
170+
handleClose();
171+
}
172+
};
173+
174+
if (open) {
175+
document.addEventListener('mousedown', handleClickOutside);
176+
return () => document.removeEventListener('mousedown', handleClickOutside);
177+
}
178+
}, [open, handleClose]);
179+
76180
return (
77-
<div className={classNames("relative", props.className)}>
181+
<div
182+
ref={dropdownRef}
183+
className={classNames("relative", props.className)}
184+
onBlur={handleDropdownBlur}
185+
onFocus={handleDropdownFocus}
186+
>
78187
{open && <div className="fixed inset-0" onClick={handleClose} />}
79188
{props.loading ? <div className="flex h-full w-full items-center justify-center">
80189
<Loading hideText={true} size="sm" />
81190
</div> :
82-
<> <button tabIndex={0} className="group/dropdown flex gap-1 justify-between items-center border border-neutral-600/20 rounded-lg w-full p-1 h-[34px] px-2 dark:bg-[#2C2F33] dark:border-white/5" onClick={handleToggleOpen} data-testid={props.testId}>
191+
<> <button
192+
ref={triggerRef}
193+
tabIndex={0}
194+
className="group/dropdown flex gap-1 justify-between items-center border border-neutral-600/20 rounded-lg w-full p-1 h-[34px] px-2 dark:bg-[#2C2F33] dark:border-white/5"
195+
onClick={handleToggleOpen}
196+
onKeyDown={handleKeyDown}
197+
aria-haspopup="listbox"
198+
aria-expanded={open}
199+
aria-labelledby={props.testId ? `${props.testId}-label` : undefined}
200+
data-testid={props.testId}>
83201
<div className={classNames(ClassNames.Text, "flex gap-1 text-sm truncate items-center")}>
84202
{props.value?.icon != null && <div className="flex items-center w-6">
85203
{props.value.icon}
@@ -95,13 +213,27 @@ export const Dropdown: FC<IDropdownProps> = (props) => {
95213
"block animate-fade": open,
96214
"w-fit min-w-[200px]": !props.fullWidth,
97215
"w-full": props.fullWidth,
98-
}, props.dropdownContainerHeight)}>
216+
}, props.dropdownContainerHeight)}
217+
role="listbox"
218+
aria-labelledby={props.testId ? `${props.testId}-label` : undefined}>
99219
<ul className={classNames(ClassNames.Text, "py-1 text-sm nowheel flex flex-col")}>
100220
{
101221
props.items.map((item, i) => (
102-
<div role="button" tabIndex={0} key={`dropdown-item-${i}`} className={classNames(ITEM_CLASS, {
103-
"hover:gap-2": item.icon != null,
104-
})} onClick={() => handleClick(item)} data-value={item.id}>
222+
<div
223+
role="option"
224+
tabIndex={focusedIndex === i ? 0 : -1}
225+
key={`dropdown-item-${i}`}
226+
ref={el => {
227+
if (el) itemsRef.current[i] = el;
228+
}}
229+
className={classNames(ITEM_CLASS, {
230+
"hover:gap-2": item.icon != null,
231+
"bg-blue-100 dark:bg-blue-900/30": focusedIndex === i,
232+
})}
233+
onClick={() => handleClick(item)}
234+
onKeyDown={(e) => handleItemKeyDown(e, item, i)}
235+
aria-selected={props.value?.id === item.id}
236+
data-value={item.id}>
105237
<div>{props.value?.id === item.id ? Icons.CheckCircle : item.icon}</div>
106238
<div className="whitespace-nowrap flex-1">{item.label}</div>
107239
{item.info && (
@@ -127,16 +259,36 @@ export const Dropdown: FC<IDropdownProps> = (props) => {
127259
}
128260
{
129261
props.defaultItem != null &&
130-
<div role="button" tabIndex={0} className={classNames(ITEM_CLASS, {
131-
"hover:scale-105": props.defaultItem.icon == null,
132-
}, props.defaultItemClassName)} onClick={props.onDefaultItemClick}>
262+
<div
263+
role="option"
264+
tabIndex={0}
265+
className={classNames(ITEM_CLASS, {
266+
"hover:scale-105": props.defaultItem.icon == null,
267+
}, props.defaultItemClassName)}
268+
onClick={props.onDefaultItemClick}
269+
onKeyDown={(e) => {
270+
if (e.key === 'Enter' || e.key === ' ') {
271+
e.preventDefault();
272+
props.onDefaultItemClick?.();
273+
}
274+
}}>
133275
<div>{props.defaultItem.icon}</div>
134276
<div>{props.defaultItem.label}</div>
135277
</div>
136278
}
137279
{
138280
props.items.length === 0 && props.defaultItem == null &&
139-
<div role="button" tabIndex={0} className="flex items-center gap-1 px-2 dark:text-neutral-300" onClick={props.onDefaultItemClick}>
281+
<div
282+
role="option"
283+
tabIndex={0}
284+
className="flex items-center gap-1 px-2 dark:text-neutral-300"
285+
onClick={props.onDefaultItemClick}
286+
onKeyDown={(e) => {
287+
if (e.key === 'Enter' || e.key === ' ') {
288+
e.preventDefault();
289+
props.onDefaultItemClick?.();
290+
}
291+
}}>
140292
<div>{Icons.SadSmile}</div>
141293
<div>{props.noItemsLabel}</div>
142294
</div>
@@ -149,8 +301,9 @@ export const Dropdown: FC<IDropdownProps> = (props) => {
149301
}
150302

151303
export const DropdownWithLabel: FC<IDropdownProps & { label: string, testId?: string }> = ({ label, testId, ...props }) => {
304+
const dropdownId = testId ? `${testId}-dropdown` : `dropdown-${label.toLowerCase().replace(/\s+/g, '-')}`;
152305
return <div className="flex flex-col gap-1" data-testid={testId}>
153-
<Label label={label} />
154-
<Dropdown {...props} />
306+
<Label label={label} htmlFor={dropdownId} />
307+
<Dropdown {...props} testId={dropdownId} />
155308
</div>
156309
}

frontend/src/components/input.tsx

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ export const Text: FC<{ label: string }> = ({ label }) => {
2525
return <span className={classNames(ClassNames.Text, "text-xs")}>{label}</span>
2626
}
2727

28-
export const Label: FC<{ label: string }> = ({ label }) => {
29-
return <strong><label className={classNames(ClassNames.Text, "text-xs mt-2")}>{label}</label></strong>
28+
export const Label: FC<{ label: string; htmlFor?: string }> = ({ label, htmlFor }) => {
29+
return <strong><label htmlFor={htmlFor} className={classNames(ClassNames.Text, "text-xs mt-2")}>{label}</label></strong>
3030
}
3131

3232
type InputProps = {
@@ -64,6 +64,7 @@ type InputWithLabelProps = {
6464

6565
export const InputWithlabel: FC<InputWithLabelProps> = ({ value, setValue, label, type = "text", placeholder = `Enter ${label.toLowerCase()}`, inputProps, testId }) => {
6666
const [hide, setHide] = useState(true);
67+
const inputId = testId ? `${testId}-input` : `input-${label.toLowerCase().replace(/\s+/g, '-')}`;
6768

6869
const handleShow = useCallback(() => {
6970
setHide(status => !status);
@@ -72,12 +73,21 @@ export const InputWithlabel: FC<InputWithLabelProps> = ({ value, setValue, label
7273
const inputType = type === "password" ? hide ? "password" : "text" : type;
7374

7475
return <div className="flex flex-col gap-1" data-testid={testId}>
75-
<Label label={label} />
76+
<Label label={label} htmlFor={inputId} />
7677
<div className="relative">
77-
<Input type={inputType} value={value} setValue={setValue} inputProps={inputProps} placeholder={placeholder} />
78+
<Input type={inputType} value={value} setValue={setValue} inputProps={{...inputProps, id: inputId}} placeholder={placeholder} />
7879
{type === "password" && cloneElement(hide ? Icons.Show : Icons.Hide, {
7980
className: "w-4 h-4 absolute right-2 top-1/2 -translate-y-1/2 cursor-pointer transition-all hover:scale-110 dark:stroke-neutral-300",
8081
onClick: handleShow,
82+
"aria-label": hide ? "Show password" : "Hide password",
83+
role: "button",
84+
tabIndex: 0,
85+
onKeyDown: (e: any) => {
86+
if (e.key === 'Enter' || e.key === ' ') {
87+
e.preventDefault();
88+
handleShow();
89+
}
90+
}
8191
})}
8292
</div>
8393
</div>
@@ -95,8 +105,18 @@ export const ToggleInput: FC<IToggleInputProps> = ({ value, setValue }) => {
95105

96106
return (
97107
<label className="inline-flex items-center cursor-pointer scale-75">
98-
<input type="checkbox" checked={value} className="sr-only peer" onChange={handleChange} />
99-
<div className="relative w-11 h-6 bg-gray-200 peer-focus:outline-hidden peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:rtl:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-[#ca6f1e]"></div>
108+
<input
109+
type="checkbox"
110+
checked={value}
111+
className="sr-only peer"
112+
onChange={handleChange}
113+
aria-describedby="toggle-description"
114+
/>
115+
<div
116+
className="relative w-11 h-6 bg-gray-200 peer-focus:outline-hidden peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:rtl:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-[#ca6f1e]"
117+
role="switch"
118+
aria-checked={value}
119+
></div>
100120
</label>
101121
);
102122
}
@@ -113,6 +133,12 @@ export const CheckBoxInput: FC<ICheckBoxInputProps> = ({ value, setValue }) => {
113133
}, [setValue]);
114134

115135
return (
116-
<input className="hover:cursor-pointer accent-[#ca6f1e] dark:accent-[#ca6f1e]" type="checkbox" checked={value} onChange={handleChange} />
136+
<input
137+
className="hover:cursor-pointer accent-[#ca6f1e] dark:accent-[#ca6f1e]"
138+
type="checkbox"
139+
checked={value}
140+
onChange={handleChange}
141+
aria-checked={value}
142+
/>
117143
);
118144
}

0 commit comments

Comments
 (0)