Skip to content

Commit f0054c2

Browse files
fix: resolve accessibility focus management issues in dropdown, sidebar, and table components
- Fix dropdown focus loss on blur by adding proper blur/focus handlers with timeout management - Fix sidebar submenu focus management by improving timeout handling and escape key behavior - Add focus management to table pagination after page changes - focus returns to current/available page button Addresses jazzberry AI accessibility report issues: - Dropdown focus loss on blur (Medium severity) - Sidebar submenu focus management issue (Medium severity) - Table Pagination focus management after page change (Low severity) Co-authored-by: Anguel <modelorona@users.noreply.github.com>
1 parent ee1a4c5 commit f0054c2

File tree

3 files changed

+108
-13
lines changed

3 files changed

+108
-13
lines changed

frontend/src/components/dropdown.tsx

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export const Dropdown: FC<IDropdownProps> = (props) => {
6363
const dropdownRef = useRef<HTMLDivElement>(null);
6464
const triggerRef = useRef<HTMLButtonElement>(null);
6565
const itemsRef = useRef<HTMLDivElement[]>([]);
66+
const blurTimeoutRef = useRef<NodeJS.Timeout | null>(null);
6667

6768
const handleClick = useCallback((item: IDropdownItem) => {
6869
setOpen(false);
@@ -76,7 +77,38 @@ export const Dropdown: FC<IDropdownProps> = (props) => {
7677
const handleClose = useCallback(() => {
7778
setOpen(false);
7879
setFocusedIndex(-1);
79-
triggerRef.current?.focus();
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);
89+
}, []);
90+
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+
}
80112
}, []);
81113

82114
const handleKeyDown = useCallback((event: KeyboardEvent<HTMLButtonElement>) => {
@@ -146,7 +178,12 @@ export const Dropdown: FC<IDropdownProps> = (props) => {
146178
}, [open, handleClose]);
147179

148180
return (
149-
<div ref={dropdownRef} className={classNames("relative", props.className)}>
181+
<div
182+
ref={dropdownRef}
183+
className={classNames("relative", props.className)}
184+
onBlur={handleDropdownBlur}
185+
onFocus={handleDropdownFocus}
186+
>
150187
{open && <div className="fixed inset-0" onClick={handleClose} />}
151188
{props.loading ? <div className="flex h-full w-full items-center justify-center">
152189
<Loading hideText={true} size="sm" />

frontend/src/components/sidebar/sidebar.tsx

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ export const SideMenu: FC<IRouteProps> = (props) => {
5757
const navigate = useNavigate();
5858
const [hover, setHover] = useState(false);
5959
const [focused, setFocused] = useState(false);
60+
const blurTimeoutIdRef = useRef<NodeJS.Timeout | null>(null);
61+
const menuRef = useRef<HTMLDivElement>(null);
6062
const status = (hover || focused) ? "show" : "hide";
6163
const pathname = useLocation().pathname;
6264

@@ -84,7 +86,16 @@ export const SideMenu: FC<IRouteProps> = (props) => {
8486
setFocused(true);
8587
}
8688
if (e.key === 'Escape') {
89+
// Clear any pending timeout and properly close submenu
90+
if (blurTimeoutIdRef.current) {
91+
clearTimeout(blurTimeoutIdRef.current);
92+
blurTimeoutIdRef.current = null;
93+
}
8794
setFocused(false);
95+
// Ensure focus remains on the trigger element
96+
setTimeout(() => {
97+
(e.target as HTMLElement)?.focus();
98+
}, 0);
8899
}
89100
}, [handleClick, props.routes]);
90101

@@ -94,14 +105,39 @@ export const SideMenu: FC<IRouteProps> = (props) => {
94105
}
95106
}, [props.routes]);
96107

97-
const handleBlur = useCallback(() => {
98-
// Delay hiding to allow focus to move to submenu items
99-
setTimeout(() => setFocused(false), 100);
108+
const handleBlur = useCallback((event: React.FocusEvent<HTMLDivElement>) => {
109+
// Clear any existing timeout
110+
if (blurTimeoutIdRef.current) {
111+
clearTimeout(blurTimeoutIdRef.current);
112+
}
113+
114+
// Set a timeout to check if focus moved outside the menu
115+
blurTimeoutIdRef.current = setTimeout(() => {
116+
if (menuRef.current && !menuRef.current.contains(document.activeElement)) {
117+
setFocused(false);
118+
}
119+
}, 100);
120+
}, []);
121+
122+
const handleMenuFocus = useCallback(() => {
123+
// Clear the blur timeout if focus returns to menu
124+
if (blurTimeoutIdRef.current) {
125+
clearTimeout(blurTimeoutIdRef.current);
126+
blurTimeoutIdRef.current = null;
127+
}
100128
}, []);
101129

102-
return <div className={classNames("flex items-center", {
103-
"justify-center": props.collapse,
104-
})} onMouseEnter={handleMouseEnter} onMouseOver={handleMouseEnter} onMouseLeave={handleMouseLeave} data-testid={props.testId}>
130+
return <div
131+
ref={menuRef}
132+
className={classNames("flex items-center", {
133+
"justify-center": props.collapse,
134+
})}
135+
onMouseEnter={handleMouseEnter}
136+
onMouseOver={handleMouseEnter}
137+
onMouseLeave={handleMouseLeave}
138+
onFocus={handleMenuFocus}
139+
onBlur={handleBlur}
140+
data-testid={props.testId}>
105141
<AnimatePresence mode="sync">
106142
<div className={twMerge(classNames("cursor-default text-md inline-flex gap-2 transition-all hover:gap-2 relative w-full py-4 rounded-md dark:border-white/5 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800", {
107143
"cursor-pointer": props.path != null,

frontend/src/components/table.tsx

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,28 @@ type IPaginationProps = {
3939
}
4040

4141
const Pagination: FC<IPaginationProps> = ({ pageCount, currentPage, onPageChange }) => {
42+
const paginationRef = useRef<HTMLDivElement>(null);
43+
const [focusedPage, setFocusedPage] = useState<number | null>(null);
44+
45+
const handlePageChange = useCallback((page: number) => {
46+
setFocusedPage(page);
47+
onPageChange?.(page);
48+
49+
// Set focus to the new current page button after page change
50+
setTimeout(() => {
51+
if (paginationRef.current) {
52+
const pageButton = paginationRef.current.querySelector(`button[aria-current="page"]`) as HTMLButtonElement;
53+
if (pageButton) {
54+
pageButton.focus();
55+
} else {
56+
// Fallback: focus first available page button
57+
const firstButton = paginationRef.current.querySelector('button') as HTMLButtonElement;
58+
firstButton?.focus();
59+
}
60+
}
61+
}, 100);
62+
}, [onPageChange]);
63+
4264
const renderPageNumbers = () => {
4365
const pageNumbers = [];
4466
const maxVisiblePages = 5;
@@ -49,11 +71,11 @@ const Pagination: FC<IPaginationProps> = ({ pageCount, currentPage, onPageChange
4971
<button
5072
key={i}
5173
className={`cursor-pointer p-2 text-sm hover:scale-110 hover:bg-gray-200 rounded-md text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 ${currentPage === i ? 'bg-gray-300' : ''}`}
52-
onClick={() => onPageChange?.(i)}
74+
onClick={() => handlePageChange(i)}
5375
onKeyDown={(e) => {
5476
if (e.key === 'Enter' || e.key === ' ') {
5577
e.preventDefault();
56-
onPageChange?.(i);
78+
handlePageChange(i);
5779
}
5880
}}
5981
aria-label={`Go to page ${i}`}
@@ -70,11 +92,11 @@ const Pagination: FC<IPaginationProps> = ({ pageCount, currentPage, onPageChange
7092
className={classNames("cursor-pointer p-2 text-sm hover:scale-110 hover:bg-gray-200 dark:hover:bg-white/15 rounded-md text-gray-600 dark:text-neutral-300 focus:outline-none focus:ring-2 focus:ring-blue-500", {
7193
"bg-gray-300 dark:bg-white/10": currentPage === i,
7294
})}
73-
onClick={() => onPageChange?.(i)}
95+
onClick={() => handlePageChange(i)}
7496
onKeyDown={(e) => {
7597
if (e.key === 'Enter' || e.key === ' ') {
7698
e.preventDefault();
77-
onPageChange?.(i);
99+
handlePageChange(i);
78100
}
79101
}}
80102
aria-label={`Go to page ${i}`}
@@ -113,7 +135,7 @@ const Pagination: FC<IPaginationProps> = ({ pageCount, currentPage, onPageChange
113135

114136
return (
115137
<nav aria-label="Table pagination" role="navigation">
116-
<div className="flex space-x-2">
138+
<div ref={paginationRef} className="flex space-x-2">
117139
{renderPageNumbers()}
118140
</div>
119141
</nav>

0 commit comments

Comments
 (0)