From 2411abb687622534d372a8c0d2c5e035529cf673 Mon Sep 17 00:00:00 2001 From: prabhuignoto Date: Fri, 20 Jun 2025 14:03:15 +0530 Subject: [PATCH 1/2] feat: Enhance accessibility and focus management across timeline components - Updated ARIA attributes for timeline elements to improve screen reader support. - Implemented focus trapping in modals and popovers using a new `useFocusTrap` hook. - Enhanced search input focus management during typing to prevent focus loss. - Added accessibility utilities for generating IDs, announcing messages, and managing focus. - Improved keyboard navigation support for interactive elements. - Created comprehensive accessibility tests for timeline components. - Refactored timeline components to ensure proper semantic structure and ARIA roles. - Updated snapshots to reflect changes in component structure and styling. --- src/components/elements/list/list-item.tsx | 18 +- src/components/elements/popover/index.tsx | 29 ++- .../memoized/expand-button-memo.tsx | 8 +- .../memoized/show-hide-button.tsx | 8 +- .../content-header.test.tsx.snap | 6 +- .../timeline-card-content/content-footer.tsx | 8 +- .../timeline-card-content.tsx | 21 +- .../timeline-card-media.test.tsx.snap | 22 +- .../timeline-card-media/video.tsx | 3 +- .../timeline-horizontal-card.test.tsx.snap | 10 +- .../timeline-control.test.tsx.snap | 19 +- .../timeline-control/timeline-control.tsx | 3 +- .../timeline-card-title.test.tsx.snap | 2 +- .../__tests__/timeline-horizontal.test.tsx | 4 +- .../timeline-horizontal.tsx | 3 +- .../timeline-point.test.tsx.snap | 7 +- .../timeline-vertical-item.test.tsx.snap | 30 +-- .../timeline-vertical/timeline-point.tsx | 14 +- .../timeline-vertical-item.tsx | 5 +- .../timeline-vertical/timeline-vertical.tsx | 7 +- src/components/timeline/timeline-toolbar.tsx | 31 ++- src/components/timeline/timeline.tsx | 3 + src/components/toolbar/index.tsx | 10 +- src/hooks/__tests__/useTimelineSearch.test.ts | 58 +++++ src/hooks/useFocusTrap.ts | 99 +++++++++ src/hooks/useTimelineSearch.ts | 153 +++++++++++++- src/utils/accessibility-test-utils.ts | 157 ++++++++++++++ src/utils/accessibility.ts | 200 ++++++++++++++++++ src/utils/index.ts | 3 + 29 files changed, 863 insertions(+), 78 deletions(-) create mode 100644 src/hooks/useFocusTrap.ts create mode 100644 src/utils/accessibility-test-utils.ts create mode 100644 src/utils/accessibility.ts diff --git a/src/components/elements/list/list-item.tsx b/src/components/elements/list/list-item.tsx index 9b707f9a9..54227dfcc 100644 --- a/src/components/elements/list/list-item.tsx +++ b/src/components/elements/list/list-item.tsx @@ -47,10 +47,11 @@ const ListItem: FunctionComponent = memo( * @param {string} id - Item identifier */ const handleKeyPress = useCallback((ev: KeyboardEvent, id: string) => { - if (ev.key === 'Enter') { + if (ev.key === 'Enter' || ev.key === ' ') { + ev.preventDefault(); handleOnClick(id); } - }, []); + }, [handleOnClick]); return ( = memo( $active={active} tabIndex={0} $selectable={selectable} - onKeyUp={(ev) => handleKeyPress(ev, id)} + onKeyDown={(ev) => handleKeyPress(ev, id)} + role="listitem" + aria-selected={active} + aria-describedby={description ? `${id}-description` : undefined} > {selectable ? ( @@ -77,9 +81,11 @@ const ListItem: FunctionComponent = memo( ) : null} {title} - - {description} - + {description && ( + + {description} + + )} ); diff --git a/src/components/elements/popover/index.tsx b/src/components/elements/popover/index.tsx index af08d45fc..86741f621 100644 --- a/src/components/elements/popover/index.tsx +++ b/src/components/elements/popover/index.tsx @@ -7,6 +7,7 @@ import React, { memo, } from 'react'; import useCloseClickOutside from 'src/components/effects/useCloseClickOutside'; +import { useFocusTrap } from 'src/hooks/useFocusTrap'; import { ChevronDown, CloseIcon } from 'src/components/icons'; import { PopOverModel } from './popover.model'; import { @@ -61,6 +62,7 @@ const PopOver: FunctionComponent = ({ $isMobile = false, }) => { const ref = useRef(null); + const [state, dispatch] = useReducer(popoverReducer, { open: false, isVisible: false, @@ -73,12 +75,18 @@ const PopOver: FunctionComponent = ({ const closePopover = useCallback(() => { dispatch({ type: 'CLOSE' }); }, []); + + const focusTrapRef = useFocusTrap(state.open, closePopover); const handleKeyPress = useCallback((ev: React.KeyboardEvent) => { - if (ev.key === 'Enter') { + if (ev.key === 'Enter' || ev.key === ' ') { + ev.preventDefault(); dispatch({ type: 'TOGGLE' }); + } else if (ev.key === 'Escape' && state.open) { + ev.preventDefault(); + dispatch({ type: 'CLOSE' }); } - }, []); + }, [state.open]); useCloseClickOutside(ref, closePopover); @@ -103,9 +111,13 @@ const PopOver: FunctionComponent = ({ $open={state.open} $isDarkMode={isDarkMode} tabIndex={0} - onKeyUp={handleKeyPress} + onKeyDown={handleKeyPress} $isMobile={$isMobile} title={placeholder} + aria-expanded={state.open} + aria-haspopup="menu" + aria-controls={state.open ? 'popover-content' : undefined} + id="popover-trigger" > {icon || } @@ -122,9 +134,18 @@ const PopOver: FunctionComponent = ({ $theme={theme} $isMobile={$isMobile} $visible={state.isVisible} + id="popover-content" + role="menu" + aria-labelledby={state.open ? 'popover-trigger' : undefined} + ref={focusTrapRef} >
- +
diff --git a/src/components/timeline-elements/memoized/expand-button-memo.tsx b/src/components/timeline-elements/memoized/expand-button-memo.tsx index c03f2d7a4..77b50bcf7 100644 --- a/src/components/timeline-elements/memoized/expand-button-memo.tsx +++ b/src/components/timeline-elements/memoized/expand-button-memo.tsx @@ -17,12 +17,18 @@ const ExpandButtonMemo = memo( return textOverlay ? ( ev.key === 'Enter' && onExpand?.(ev)} + onKeyDown={(ev) => { + if (ev.key === 'Enter' || ev.key === ' ') { + ev.preventDefault(); + onExpand?.(ev); + } + }} theme={theme} aria-expanded={expanded} tabIndex={0} aria-label={label} title={label} + role="button" > {expanded ? : } diff --git a/src/components/timeline-elements/memoized/show-hide-button.tsx b/src/components/timeline-elements/memoized/show-hide-button.tsx index 6ff397eaa..4a6436371 100644 --- a/src/components/timeline-elements/memoized/show-hide-button.tsx +++ b/src/components/timeline-elements/memoized/show-hide-button.tsx @@ -19,9 +19,15 @@ const ShowOrHideTextButtonMemo = memo( onPointerDown={onToggle} theme={theme} tabIndex={0} - onKeyDown={(ev) => ev.key === 'Enter' && onToggle?.(ev)} + onKeyDown={(ev) => { + if (ev.key === 'Enter' || ev.key === ' ') { + ev.preventDefault(); + onToggle?.(ev); + } + }} aria-label={label} title={label} + aria-pressed={show} > {show ? : } diff --git a/src/components/timeline-elements/timeline-card-content/__tests__/__snapshots__/content-header.test.tsx.snap b/src/components/timeline-elements/timeline-card-content/__tests__/__snapshots__/content-header.test.tsx.snap index bd726c39c..7a95d4716 100644 --- a/src/components/timeline-elements/timeline-card-content/__tests__/__snapshots__/content-header.test.tsx.snap +++ b/src/components/timeline-elements/timeline-card-content/__tests__/__snapshots__/content-header.test.tsx.snap @@ -3,16 +3,16 @@ exports[`Content Header > should match the snapshot 1`] = `
title content diff --git a/src/components/timeline-elements/timeline-card-content/content-footer.tsx b/src/components/timeline-elements/timeline-card-content/content-footer.tsx index fe3696bd4..a37e3f520 100644 --- a/src/components/timeline-elements/timeline-card-content/content-footer.tsx +++ b/src/components/timeline-elements/timeline-card-content/content-footer.tsx @@ -66,14 +66,18 @@ const ContentFooter: FunctionComponent = ({ { - if (event.key === 'Enter') { + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); onExpand(); } }} show={canShow ? 'true' : 'false'} theme={theme} tabIndex={0} + role="button" + aria-expanded={showMore} + aria-label={showMore ? 'Show less content' : 'Show more content'} > {{showMore ? 'read less' : 'read more'}} diff --git a/src/components/timeline-elements/timeline-card-content/timeline-card-content.tsx b/src/components/timeline-elements/timeline-card-content/timeline-card-content.tsx index 5f81e2ca2..c7a773f81 100644 --- a/src/components/timeline-elements/timeline-card-content/timeline-card-content.tsx +++ b/src/components/timeline-elements/timeline-card-content/timeline-card-content.tsx @@ -209,7 +209,23 @@ const TimelineCardContent: React.FunctionComponent = // Set focus when needed and ensure entire card row is completely visible useEffect(() => { if (hasFocus && active && containerRef.current) { - containerRef.current.focus(); + // Check if there's an active search input on the page to avoid stealing focus + const activeElement = document.activeElement; + const isSearchInputFocused = activeElement && + (activeElement instanceof HTMLInputElement && activeElement.type === 'search') || + activeElement?.getAttribute('type') === 'search' || + activeElement?.getAttribute('placeholder')?.toLowerCase().includes('search'); + + // Check if there's any search query in the page to be extra cautious + const searchInputs = document.querySelectorAll('input[type="search"], input[placeholder*="search" i]'); + const hasActiveSearch = Array.from(searchInputs).some(input => + input instanceof HTMLInputElement && input.value.trim() !== '' + ); + + // Only focus the card if search input is not active and no active search + if (!isSearchInputFocused && !hasActiveSearch) { + containerRef.current.focus(); + } // Ensure the entire vertical item row is completely visible when it receives focus setTimeout(() => { @@ -428,6 +444,9 @@ const TimelineCardContent: React.FunctionComponent = $textDensity={textDensity} $customContent={!!customContent} $theme={theme} + aria-current={active ? 'step' : undefined} + aria-expanded={showMore ? 'true' : 'false'} + role="region" > {/* Only show the content header if we're not using text overlay mode with media */} {(!textOverlay || !media) && ( diff --git a/src/components/timeline-elements/timeline-card-media/__tests__/__snapshots__/timeline-card-media.test.tsx.snap b/src/components/timeline-elements/timeline-card-media/__tests__/__snapshots__/timeline-card-media.test.tsx.snap index 382f89bb0..49f340c4f 100644 --- a/src/components/timeline-elements/timeline-card-media/__tests__/__snapshots__/timeline-card-media.test.tsx.snap +++ b/src/components/timeline-elements/timeline-card-media/__tests__/__snapshots__/timeline-card-media.test.tsx.snap @@ -3,16 +3,16 @@ exports[`Timeline Card media > should match the snapshot ( IMAGE ) 1`] = `
Image should match the snapshot ( IMAGE ) 1`] = `
This is another test @@ -41,13 +41,13 @@ exports[`Timeline Card media > should match the snapshot ( IMAGE ) 1`] = ` exports[`Timeline Card media > should match the snapshot ( VIDEO ) 1`] = `