Skip to content

Commit dd4fefd

Browse files
fix: improve accessibility across frontend components
- Add full keyboard navigation to dropdown component (Enter, Space, Arrow keys, Escape) - Implement proper ARIA attributes (aria-expanded, aria-haspopup, aria-labelledby) - Add focus management and visual focus indicators - Fix button components with proper aria-labels for screen readers - Associate labels with inputs using htmlFor attributes - Add keyboard support for password show/hide toggles - Make sidebar hover-only interactions keyboard accessible - Add semantic nav elements with proper ARIA roles - Convert table pagination divs to accessible buttons - Add keyboard support and ARIA labels for pagination Fixes #357 Co-authored-by: Anguel <modelorona@users.noreply.github.com>
1 parent cde214b commit dd4fefd

File tree

5 files changed

+260
-49
lines changed

5 files changed

+260
-49
lines changed

frontend/src/components/button.tsx

Lines changed: 19 additions & 8 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>
@@ -64,10 +70,15 @@ export type IActionButtonProps = {
6470
export const ActionButton: FC<IActionButtonProps> = ({ onClick, icon, className, containerClassName, disabled, children, testId }) => {
6571
return (
6672
<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 }, }}>
73+
<motion.button
74+
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, {
75+
"cursor-not-allowed": disabled,
76+
"hover:shadow-lg hover:cursor-pointer hover:scale-110": !disabled,
77+
}))}
78+
onClick={disabled ? undefined : onClick}
79+
whileTap={{ scale: 0.6, transition: { duration: 0.05 }, }}
80+
aria-label="Action button"
81+
disabled={disabled}>
7182
{cloneElement(icon, {
7283
className: twMerge(classNames("w-8 h-8 stroke-neutral-500 cursor-pointer dark:stroke-neutral-300", className))
7384
})}

frontend/src/components/dropdown.tsx

Lines changed: 129 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,10 @@ 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[]>([]);
6266

6367
const handleClick = useCallback((item: IDropdownItem) => {
6468
setOpen(false);
@@ -71,15 +75,92 @@ export const Dropdown: FC<IDropdownProps> = (props) => {
7175

7276
const handleClose = useCallback(() => {
7377
setOpen(false);
78+
setFocusedIndex(-1);
79+
triggerRef.current?.focus();
7480
}, []);
7581

82+
const handleKeyDown = useCallback((event: KeyboardEvent<HTMLButtonElement>) => {
83+
switch (event.key) {
84+
case 'Enter':
85+
case ' ':
86+
case 'ArrowDown':
87+
event.preventDefault();
88+
setOpen(true);
89+
setFocusedIndex(0);
90+
break;
91+
case 'ArrowUp':
92+
event.preventDefault();
93+
setOpen(true);
94+
setFocusedIndex(props.items.length - 1);
95+
break;
96+
case 'Escape':
97+
handleClose();
98+
break;
99+
}
100+
}, [props.items.length, handleClose]);
101+
102+
const handleItemKeyDown = useCallback((event: KeyboardEvent<HTMLDivElement>, item: IDropdownItem, index: number) => {
103+
switch (event.key) {
104+
case 'Enter':
105+
case ' ':
106+
event.preventDefault();
107+
handleClick(item);
108+
break;
109+
case 'ArrowDown':
110+
event.preventDefault();
111+
const nextIndex = Math.min(index + 1, props.items.length - 1);
112+
setFocusedIndex(nextIndex);
113+
break;
114+
case 'ArrowUp':
115+
event.preventDefault();
116+
const prevIndex = Math.max(index - 1, 0);
117+
setFocusedIndex(prevIndex);
118+
break;
119+
case 'Escape':
120+
event.preventDefault();
121+
handleClose();
122+
break;
123+
case 'Tab':
124+
handleClose();
125+
break;
126+
}
127+
}, [handleClick, props.items.length, handleClose]);
128+
129+
useEffect(() => {
130+
if (open && focusedIndex >= 0 && itemsRef.current[focusedIndex]) {
131+
itemsRef.current[focusedIndex].focus();
132+
}
133+
}, [open, focusedIndex]);
134+
135+
useEffect(() => {
136+
const handleClickOutside = (event: MouseEvent) => {
137+
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
138+
handleClose();
139+
}
140+
};
141+
142+
if (open) {
143+
document.addEventListener('mousedown', handleClickOutside);
144+
return () => document.removeEventListener('mousedown', handleClickOutside);
145+
}
146+
}, [open, handleClose]);
147+
76148
return (
77-
<div className={classNames("relative", props.className)}>
149+
<div ref={dropdownRef} className={classNames("relative", props.className)}>
78150
{open && <div className="fixed inset-0" onClick={handleClose} />}
79151
{props.loading ? <div className="flex h-full w-full items-center justify-center">
80152
<Loading hideText={true} size="sm" />
81153
</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}>
154+
<> <button
155+
ref={triggerRef}
156+
tabIndex={0}
157+
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"
158+
onClick={handleToggleOpen}
159+
onKeyDown={handleKeyDown}
160+
aria-haspopup="listbox"
161+
aria-expanded={open}
162+
aria-labelledby={props.testId ? `${props.testId}-label` : undefined}
163+
data-testid={props.testId}>
83164
<div className={classNames(ClassNames.Text, "flex gap-1 text-sm truncate items-center")}>
84165
{props.value?.icon != null && <div className="flex items-center w-6">
85166
{props.value.icon}
@@ -95,13 +176,27 @@ export const Dropdown: FC<IDropdownProps> = (props) => {
95176
"block animate-fade": open,
96177
"w-fit min-w-[200px]": !props.fullWidth,
97178
"w-full": props.fullWidth,
98-
}, props.dropdownContainerHeight)}>
179+
}, props.dropdownContainerHeight)}
180+
role="listbox"
181+
aria-labelledby={props.testId ? `${props.testId}-label` : undefined}>
99182
<ul className={classNames(ClassNames.Text, "py-1 text-sm nowheel flex flex-col")}>
100183
{
101184
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}>
185+
<div
186+
role="option"
187+
tabIndex={-1}
188+
key={`dropdown-item-${i}`}
189+
ref={el => {
190+
if (el) itemsRef.current[i] = el;
191+
}}
192+
className={classNames(ITEM_CLASS, {
193+
"hover:gap-2": item.icon != null,
194+
"bg-blue-100 dark:bg-blue-900/30": focusedIndex === i,
195+
})}
196+
onClick={() => handleClick(item)}
197+
onKeyDown={(e) => handleItemKeyDown(e, item, i)}
198+
aria-selected={props.value?.id === item.id}
199+
data-value={item.id}>
105200
<div>{props.value?.id === item.id ? Icons.CheckCircle : item.icon}</div>
106201
<div className="whitespace-nowrap flex-1">{item.label}</div>
107202
{item.info && (
@@ -127,16 +222,36 @@ export const Dropdown: FC<IDropdownProps> = (props) => {
127222
}
128223
{
129224
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}>
225+
<div
226+
role="option"
227+
tabIndex={-1}
228+
className={classNames(ITEM_CLASS, {
229+
"hover:scale-105": props.defaultItem.icon == null,
230+
}, props.defaultItemClassName)}
231+
onClick={props.onDefaultItemClick}
232+
onKeyDown={(e) => {
233+
if (e.key === 'Enter' || e.key === ' ') {
234+
e.preventDefault();
235+
props.onDefaultItemClick?.();
236+
}
237+
}}>
133238
<div>{props.defaultItem.icon}</div>
134239
<div>{props.defaultItem.label}</div>
135240
</div>
136241
}
137242
{
138243
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}>
244+
<div
245+
role="option"
246+
tabIndex={-1}
247+
className="flex items-center gap-1 px-2 dark:text-neutral-300"
248+
onClick={props.onDefaultItemClick}
249+
onKeyDown={(e) => {
250+
if (e.key === 'Enter' || e.key === ' ') {
251+
e.preventDefault();
252+
props.onDefaultItemClick?.();
253+
}
254+
}}>
140255
<div>{Icons.SadSmile}</div>
141256
<div>{props.noItemsLabel}</div>
142257
</div>
@@ -149,8 +264,9 @@ export const Dropdown: FC<IDropdownProps> = (props) => {
149264
}
150265

151266
export const DropdownWithLabel: FC<IDropdownProps & { label: string, testId?: string }> = ({ label, testId, ...props }) => {
267+
const dropdownId = testId ? `${testId}-dropdown` : `dropdown-${label.toLowerCase().replace(/\s+/g, '-')}`;
152268
return <div className="flex flex-col gap-1" data-testid={testId}>
153-
<Label label={label} />
154-
<Dropdown {...props} />
269+
<Label label={label} htmlFor={dropdownId} />
270+
<Dropdown {...props} testId={dropdownId} />
155271
</div>
156272
}

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)