From 0be07a9c33242207389641454ea78237acba5231 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 17 Oct 2025 17:43:54 +0200 Subject: [PATCH 1/4] Add aria-describedby for hover tooltips Tooltips that appear on hover now use aria-describedby to properly associate supplemental content with trigger elements. This provides better semantic information for assistive technology compared to the interactive aria-expanded/aria-controls pattern used for click-based tooltips. Chapter links with summaries now use the render function pattern to receive and apply the aria-describedby attribute. REDMINE-20970 --- .../package/spec/frontend/Tooltip-spec.js | 30 +++++++++++++------ .../scrolled/package/src/frontend/Tooltip.js | 6 ++-- .../widgets/defaultNavigation/ChapterLink.js | 9 +++--- .../defaultNavigation/LegalInfoMenu.js | 4 +-- .../widgets/defaultNavigation/SharingMenu.js | 4 +-- .../defaultNavigation/TranslationsMenu.js | 4 +-- 6 files changed, 36 insertions(+), 21 deletions(-) diff --git a/entry_types/scrolled/package/spec/frontend/Tooltip-spec.js b/entry_types/scrolled/package/spec/frontend/Tooltip-spec.js index 0824bb26b..a75b0e267 100644 --- a/entry_types/scrolled/package/spec/frontend/Tooltip-spec.js +++ b/entry_types/scrolled/package/spec/frontend/Tooltip-spec.js @@ -8,7 +8,7 @@ describe('Tooltip', () => { it('renders trigger', () => { const {getByTestId} = render( - {(buttonProps) => } + {(triggerProps) => } ); expect(getByTestId('trigger')).toBeDefined(); @@ -26,7 +26,7 @@ describe('Tooltip', () => { it('opens tooltip when button is clicked', () => { const {getByTestId} = render( content}> - {(buttonProps) => } + {(triggerProps) => } ); @@ -42,7 +42,7 @@ describe('Tooltip', () => { it('closes tooltip when button is clicked again', () => { const {getByTestId} = render( content}> - {(buttonProps) => } + {(triggerProps) => } ); @@ -58,7 +58,7 @@ describe('Tooltip', () => { it('closes tooltip when ESC key is pressed', () => { const {getByTestId} = render( content}> - {(buttonProps) => } + {(triggerProps) => } ); @@ -74,7 +74,7 @@ describe('Tooltip', () => { it('returns focus to button when ESC key is pressed', async () => { const {getByTestId} = render( content button}> - {(buttonProps) => } + {(triggerProps) => } ); @@ -96,7 +96,7 @@ describe('Tooltip', () => { it('sets aria-expanded and aria-controls attributes', () => { const {getByTestId} = render( content}> - {(buttonProps) => } + {(triggerProps) => } ); @@ -114,7 +114,7 @@ describe('Tooltip', () => { it('does not toggle on click when openOnHover is true', () => { const {getByTestId} = render( content}> - {(buttonProps) => } + {(triggerProps) => } ); @@ -127,10 +127,22 @@ describe('Tooltip', () => { expect(button.getAttribute('aria-expanded')).toBeNull(); }); + it('sets aria-describedby when openOnHover is true', () => { + const {getByTestId} = render( + content}> + {(triggerProps) => } + + ); + + const button = getByTestId('trigger'); + + expect(button.getAttribute('aria-describedby')).toBe('tooltip-test'); + }); + it('does not toggle on click when fixed is true', () => { const {getByTestId} = render( content}> - {(buttonProps) => } + {(triggerProps) => } ); @@ -147,7 +159,7 @@ describe('Tooltip', () => { const {getByTestId} = render( <> content}> - {(buttonProps) => } + {(triggerProps) => } diff --git a/entry_types/scrolled/package/src/frontend/Tooltip.js b/entry_types/scrolled/package/src/frontend/Tooltip.js index 773d5574d..b68a32083 100644 --- a/entry_types/scrolled/package/src/frontend/Tooltip.js +++ b/entry_types/scrolled/package/src/frontend/Tooltip.js @@ -40,11 +40,13 @@ export function Tooltip({ const isControlled = !openOnHover && !fixed; - const buttonProps = isControlled ? { + const triggerProps = isControlled ? { onClick: handleClick, ref: buttonRef, 'aria-expanded': isOpen, 'aria-controls': tooltipId + } : openOnHover ? { + 'aria-describedby': tooltipId } : {}; return ( @@ -55,7 +57,7 @@ export function Tooltip({ })} onKeyDown={isControlled ? handleKeyDown : undefined} onBlur={isControlled ? handleBlur : undefined}> - {typeof children === 'function' ? children(buttonProps) : children} + {typeof children === 'function' ? children(triggerProps) : children} (
- props.handleMenuClick(props.chapterLinkId)}> {presence(props.title) || t('pageflow_scrolled.public.navigation.chapter', {number: props.chapterIndex})} @@ -26,7 +27,7 @@ export function ChapterLink(props) { ); if (isBlank(props.summary)) { - return item; + return renderLink(); } const content = ( @@ -35,7 +36,7 @@ export function ChapterLink(props) { return ( - {item} + {renderLink} ) } diff --git a/entry_types/scrolled/package/src/widgets/defaultNavigation/LegalInfoMenu.js b/entry_types/scrolled/package/src/widgets/defaultNavigation/LegalInfoMenu.js index ee2c6c2b1..5ee0eba36 100644 --- a/entry_types/scrolled/package/src/widgets/defaultNavigation/LegalInfoMenu.js +++ b/entry_types/scrolled/package/src/widgets/defaultNavigation/LegalInfoMenu.js @@ -50,8 +50,8 @@ export function LegalInfoMenu(props) { horizontalOffset={props.tooltipOffset - 30} arrowPos={120 - props.tooltipOffset} content={content}> - {(buttonProps) => ( -
}> {(triggerProps) => } ); + const user = userEvent.setup(); const button = getByTestId('trigger'); expect(button.getAttribute('aria-expanded')).toBe('false'); - fireEvent.click(button); + await user.click(button); expect(button.getAttribute('aria-expanded')).toBe('true'); }); - it('closes tooltip when button is clicked again', () => { + it('closes tooltip when button is clicked again', async () => { const {getByTestId} = render( content}> {(triggerProps) => } ); + const user = userEvent.setup(); const button = getByTestId('trigger'); - fireEvent.click(button); + await user.click(button); expect(button.getAttribute('aria-expanded')).toBe('true'); - fireEvent.click(button); + await user.click(button); expect(button.getAttribute('aria-expanded')).toBe('false'); }); - it('closes tooltip when ESC key is pressed', () => { + it('closes tooltip when ESC key is pressed', async () => { const {getByTestId} = render( content}> {(triggerProps) => } ); + const user = userEvent.setup(); const button = getByTestId('trigger'); - fireEvent.click(button); + await user.click(button); expect(button.getAttribute('aria-expanded')).toBe('true'); - fireEvent.keyDown(button, {key: 'Escape'}); + await user.keyboard('{Escape}'); expect(button.getAttribute('aria-expanded')).toBe('false'); }); @@ -78,51 +82,54 @@ describe('Tooltip', () => { ); + const user = userEvent.setup(); const button = getByTestId('trigger'); const contentButton = getByTestId('content-button'); - fireEvent.click(button); + await user.click(button); contentButton.focus(); expect(document.activeElement).toBe(contentButton); - fireEvent.keyDown(button, {key: 'Escape'}); + await user.keyboard('{Escape}'); await waitFor(() => { expect(document.activeElement).toBe(button); }); }); - it('sets aria-expanded and aria-controls attributes', () => { + it('sets aria-expanded and aria-controls attributes', async () => { const {getByTestId} = render( content}> {(triggerProps) => } ); + const user = userEvent.setup(); const button = getByTestId('trigger'); expect(button.getAttribute('aria-expanded')).toBe('false'); expect(button.getAttribute('aria-controls')).toBe('tooltip-test'); - fireEvent.click(button); + await user.click(button); expect(button.getAttribute('aria-expanded')).toBe('true'); expect(button.getAttribute('aria-controls')).toBe('tooltip-test'); }); - it('does not toggle on click when openOnHover is true', () => { + it('does not toggle on click when openOnHover is true', async () => { const {getByTestId} = render( content}> {(triggerProps) => } ); + const user = userEvent.setup(); const button = getByTestId('trigger'); expect(button.getAttribute('aria-expanded')).toBeNull(); - fireEvent.click(button); + await user.click(button); expect(button.getAttribute('aria-expanded')).toBeNull(); }); @@ -139,39 +146,42 @@ describe('Tooltip', () => { expect(button.getAttribute('aria-describedby')).toBe('tooltip-test'); }); - it('does not toggle on click when fixed is true', () => { + it('does not toggle on click when fixed is true', async () => { const {getByTestId} = render( content}> {(triggerProps) => } ); + const user = userEvent.setup(); const button = getByTestId('trigger'); expect(button.getAttribute('aria-expanded')).toBeNull(); - fireEvent.click(button); + await user.click(button); expect(button.getAttribute('aria-expanded')).toBeNull(); }); - it('closes tooltip when focus leaves the container', () => { + it('closes tooltip when focus leaves the container', async () => { const {getByTestId} = render( <> - content}> + content button}> {(triggerProps) => } ); + const user = userEvent.setup(); const button = getByTestId('trigger'); - const outsideButton = getByTestId('outside'); + const contentButton = getByTestId('content-button'); - fireEvent.click(button); + await user.click(button); expect(button.getAttribute('aria-expanded')).toBe('true'); - fireEvent.blur(button, {relatedTarget: outsideButton}); + contentButton.focus(); + await user.keyboard('{Tab}'); expect(button.getAttribute('aria-expanded')).toBe('false'); }); diff --git a/entry_types/scrolled/package/src/frontend/Tooltip.js b/entry_types/scrolled/package/src/frontend/Tooltip.js index b68a32083..cb67991c1 100644 --- a/entry_types/scrolled/package/src/frontend/Tooltip.js +++ b/entry_types/scrolled/package/src/frontend/Tooltip.js @@ -1,4 +1,4 @@ -import React, {useState, useRef} from 'react' +import React, {useState, useRef, useEffect} from 'react' import classNames from 'classnames'; import styles from './Tooltip.module.css' @@ -33,13 +33,33 @@ export function Tooltip({ }; const handleBlur = (event) => { - if (isOpen && !containerRef.current?.contains(event.relatedTarget)) { + if (isOpen && + event.relatedTarget && + !containerRef.current?.contains(event.relatedTarget)) { setIsOpen(false); } }; const isControlled = !openOnHover && !fixed; + useEffect(() => { + if (!isControlled || !isOpen) { + return; + } + + const handleDocumentClick = (event) => { + if (!containerRef.current?.contains(event.target)) { + setIsOpen(false); + } + }; + + document.addEventListener('click', handleDocumentClick); + + return () => { + document.removeEventListener('click', handleDocumentClick); + }; + }, [isControlled, isOpen]); + const triggerProps = isControlled ? { onClick: handleClick, ref: buttonRef, From c11e6ffc56d6c1c2072865354061fcb00ead414d Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Tue, 11 Nov 2025 19:06:16 +0100 Subject: [PATCH 4/4] Make main content skip link first element of page REDMINE-20970 --- .../package/src/widgets/defaultNavigation/DefaultNavigation.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/entry_types/scrolled/package/src/widgets/defaultNavigation/DefaultNavigation.js b/entry_types/scrolled/package/src/widgets/defaultNavigation/DefaultNavigation.js index e712b26eb..adaae9406 100644 --- a/entry_types/scrolled/package/src/widgets/defaultNavigation/DefaultNavigation.js +++ b/entry_types/scrolled/package/src/widgets/defaultNavigation/DefaultNavigation.js @@ -157,11 +157,12 @@ export function DefaultNavigation({ onFocus={() => setNavExpanded(true)}>
+ + {(hasChapters || hasMenu) && } - {renderNav()}