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/list/list.styles.ts b/src/components/elements/list/list.styles.ts index 800daec9f..018211aa3 100644 --- a/src/components/elements/list/list.styles.ts +++ b/src/components/elements/list/list.styles.ts @@ -77,24 +77,40 @@ export const ListItemStyle = styled.li<{ p.$active ? themeStyles.border(p.$theme) : themeStyles.transparent}; flex-direction: ${(p) => (p.$selectable ? 'row' : 'column')}; background: ${(p) => p.$theme.toolbarBtnBgColor}; - padding: 0.25rem; - width: calc(100% - 0.5rem); + padding: 0.5rem; + width: calc(100% - 1rem); user-select: none; + border-radius: 4px; + transition: all 0.15s ease-out; + margin-bottom: 0.625rem; + + &:last-child { + margin-bottom: 0; + } &:hover { border: ${(p) => themeStyles.border(p.$theme)}; cursor: pointer; + background: ${(p) => p.$theme.buttonHoverBgColor || p.$theme.toolbarBtnBgColor}; + } + + &:focus-visible { + outline: 2px solid ${(p) => p.$theme.primary || '#0077ff'}; + outline-offset: 2px; } `; // Title styles export const TitleStyle = styled.h1<{ theme: Theme }>` color: ${(p) => p.theme.iconColor || p.theme.primary}; - font-size: 1rem; - font-weight: normal; - margin: 0.2rem 0; + font-size: 0.95rem; + font-weight: 500; + margin: 0 0 0.25rem 0; text-align: left; + overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; + max-width: 100%; align-self: flex-start; `; @@ -103,39 +119,46 @@ export const TitleDescriptionStyle = styled.p<{ theme: Theme }>` font-size: 0.8rem; font-weight: normal; margin: 0; - padding: 0.1rem; + padding: 0; text-align: left; width: 100%; color: ${(p) => p.theme.cardSubtitleColor}; + line-height: 1.3; `; // Checkbox components with improved structure export const CheckboxWrapper = styled.span` ${flexContainer} - width: 2rem; + width: 1.75rem; + min-width: 1.75rem; justify-content: center; + align-items: center; `; export const CheckboxStyle = styled.span<{ selected?: boolean; theme: Theme }>` ${flexContainer} justify-content: center; - width: 1.25rem; - height: 1.25rem; - margin: 0 0.25rem 0 0.1rem; - border-radius: 50%; - background: ${(p) => (p.selected ? p.theme.primary : p.theme.toolbarBgColor)}; - ${(p) => !p.selected && `box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1)`}; - color: #ffffff; - - svg { - width: 80%; - height: 80%; + align-items: center; + border: 1px solid ${(p) => p.theme.buttonBorderColor || p.theme.primary}; + border-radius: 3px; + height: 18px; + width: 18px; + background: ${(p) => (p.selected ? p.theme.primary : 'transparent')}; + color: white; + transition: all 0.15s ease-out; + + & svg { + height: 12px; + width: 12px; + display: ${(p) => (p.selected ? 'block' : 'none')}; } `; -// Content wrapper with conditional width +// Wrapper for title and description in selectable mode export const StyleAndDescription = styled.div<{ $selectable?: boolean }>` - ${flexContainer} + display: flex; flex-direction: column; - width: ${(p) => (p.$selectable ? 'calc(100% - 2rem)' : '100%')}; + width: 100%; + overflow: hidden; + padding-left: ${(p) => (p.$selectable ? '0.5rem' : '0')}; `; diff --git a/src/components/elements/popover/index.tsx b/src/components/elements/popover/index.tsx index af08d45fc..3c6d0f72e 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 { @@ -52,7 +53,7 @@ const popoverReducer = (state: State, action: Action): State => { */ const PopOver: FunctionComponent = ({ children, - position, + position = 'bottom', // Default to bottom positioning placeholder, theme, width = 350, @@ -61,6 +62,8 @@ const PopOver: FunctionComponent = ({ $isMobile = false, }) => { const ref = useRef(null); + const triggerRef = useRef(null); + const [state, dispatch] = useReducer(popoverReducer, { open: false, isVisible: false, @@ -73,12 +76,21 @@ 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' }); + } else if (ev.key === 'ArrowDown' && !state.open) { + ev.preventDefault(); dispatch({ type: 'TOGGLE' }); } - }, []); + }, [state.open]); useCloseClickOutside(ref, closePopover); @@ -93,28 +105,38 @@ const PopOver: FunctionComponent = ({ } }, [state.open]); + // Return focus to trigger when popover closes + useEffect(() => { + if (!state.open && triggerRef.current) { + triggerRef.current.focus(); + } + }, [state.open]); + return ( - <> - - - - {icon || } - - {placeholder && !$isMobile ? ( - {placeholder} - ) : null} - - + + + + {icon || } + + {placeholder && !$isMobile ? ( + {placeholder} + ) : null} + {state.open ? ( = ({ $theme={theme} $isMobile={$isMobile} $visible={state.isVisible} + id="popover-content" + role="menu" + aria-labelledby="popover-trigger" + ref={focusTrapRef} >
- +
{children}
) : null} - +
); }; diff --git a/src/components/elements/popover/popover.styles.ts b/src/components/elements/popover/popover.styles.ts index 383c269d9..7827e142f 100644 --- a/src/components/elements/popover/popover.styles.ts +++ b/src/components/elements/popover/popover.styles.ts @@ -26,7 +26,10 @@ const getInteractiveShadow = (theme: Theme, isOpen?: boolean) => const BORDER_RADIUS = '6px'; // Base wrapper for the popover component -export const PopoverWrapper = styled.div``; +export const PopoverWrapper = styled.div` + position: relative; + display: inline-block; +`; // Main popover container with positioning and visibility controls export const PopoverHolder = styled.div<{ @@ -44,18 +47,21 @@ export const PopoverHolder = styled.div<{ box-shadow: ${({ $theme }) => getElevatedShadow($theme)}; max-height: 500px; overflow-y: auto; - padding: 0.5rem; + padding: 0.75rem; position: absolute; - ${(p) => (p.$position === 'bottom' ? `bottom: 3.5rem` : `top: 4rem`)}; - ${(p) => (p.$isMobile ? 'left: 4px;' : '')}; + top: calc(100% + 8px); + bottom: auto; + left: 0; + z-index: ${zIndex.popover}; width: ${({ $isMobile, $width = 300 }) => - $isMobile ? '90%' : `${$width}px`}; + $isMobile ? 'calc(100vw - 2rem)' : `${$width}px`}; opacity: ${({ $visible }) => ($visible ? 1 : 0)}; + visibility: ${({ $visible }) => ($visible ? 'visible' : 'hidden')}; transition: opacity 0.2s ease-in-out, - transform 0.2s ease-in-out; - transform: ${(p) => (p.$visible ? 'translateY(0)' : 'translateY(-10px)')}; - z-index: ${zIndex.popover}; /* Use standardized z-index for popovers */ + transform 0.2s ease-in-out, + visibility 0.2s ease-in-out; + transform: ${(p) => (p.$visible ? 'translateY(0)' : 'translateY(-8px)')}; `; // Clickable selector button that triggers the popover @@ -73,13 +79,14 @@ export const Selecter = styled.div<{ box-shadow: ${({ $open, $theme }) => getInteractiveShadow($theme, $open)}; cursor: pointer; justify-content: space-between; - padding: ${(p) => (p.$isMobile ? '0.4rem' : `0.4rem 0.5rem`)}; + padding: ${(p) => (p.$isMobile ? '0.5rem 0.6rem' : `0.5rem 0.75rem`)}; user-select: none; - margin-right: 0.5rem; + margin-right: 0.75rem; + min-height: 36px; transition: - background-color 0.2s ease-out, - border-color 0.2s ease-out, - box-shadow 0.2s ease-out; + background-color 0.15s ease-out, + border-color 0.15s ease-out, + box-shadow 0.15s ease-out; &:hover { background: ${({ $theme }) => @@ -87,6 +94,11 @@ export const Selecter = styled.div<{ border-color: ${({ $theme }) => $theme.buttonHoverBorderColor || $theme.primary}; } + + &:focus-visible { + outline: 2px solid ${({ $theme }) => $theme.primary || '#0077ff'}; + outline-offset: 2px; + } `; // Icon component within the selector with rotation animation @@ -96,7 +108,8 @@ export const SelecterIcon = styled.span<{ $open: boolean; $theme: Theme }>` height: 1.25rem; width: 1.25rem; transition: transform 0.2s ease-in-out; - margin-right: 0.1rem; + margin-right: 0.5rem; + transform: ${({ $open }) => ($open ? 'rotate(180deg)' : 'rotate(0)')}; & svg { height: 100%; @@ -114,15 +127,32 @@ export const SelecterLabel = styled.span` // Top section of the popover containing controls export const Header = styled.div` - height: 30px; + display: flex; + justify-content: flex-end; + align-items: center; width: 100%; + padding-bottom: 0.5rem; `; // Scrollable content area of the popover export const Content = styled.div` - height: calc(100% - 30px); - overflow-y: auto; width: 100%; + overflow-y: auto; + max-height: 400px; + scrollbar-width: thin; + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.2); + border-radius: 3px; + } `; // Close button with icon for dismissing the popover @@ -132,6 +162,19 @@ export const CloseButton = styled.button<{ theme: Theme }>` border: none; color: ${({ theme }) => theme.iconColor || theme.primary}; cursor: pointer; - margin-bottom: 0.5rem; - margin-left: auto; + padding: 0.25rem; + margin: 0; + height: 28px; + width: 28px; + border-radius: 4px; + transition: background-color 0.15s ease; + + &:hover { + background-color: rgba(0, 0, 0, 0.05); + } + + &:focus-visible { + outline: 2px solid ${({ theme }) => theme.primary || '#0077ff'}; + outline-offset: 2px; + } `; 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`] = `