-
Notifications
You must be signed in to change notification settings - Fork 7.5k
fix(accessibility) make tooltip component keyboard accessible #16333
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,4 +1,4 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import React, { ReactElement, useCallback, useEffect, useRef, useState } from 'react'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import React, { ReactElement, cloneElement, useCallback, useEffect, useRef, useState } from 'react'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { useDispatch, useSelector } from 'react-redux'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { keyframes } from 'tss-react'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { makeStyles } from 'tss-react/mui'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -58,12 +58,14 @@ const Tooltip = ({ containerClassName, content, children, position = 'top' }: IP | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const dispatch = useDispatch(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const [ visible, setVisible ] = useState(false); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const [ isUnmounting, setIsUnmounting ] = useState(false); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const [ wasOpenedWithKeyboard, setWasOpenedWithKeyboard ] = useState(false); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const overflowDrawer = useSelector((state: IReduxState) => state['features/toolbox'].overflowDrawer); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const { classes, cx } = useStyles(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const timeoutID = useRef({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| open: 0, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| close: 0 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const tooltipId = useRef(`tooltip-${Math.random().toString(36).substring(2, 11)}`); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| content: storeContent, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| previousContent, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -73,7 +75,10 @@ const Tooltip = ({ containerClassName, content, children, position = 'top' }: IP | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const contentComponent = ( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| <div | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| className = { cx(classes.container, previousContent === '' && 'mounting-animation', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| isUnmounting && 'unmounting') }> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| isUnmounting && 'unmounting') } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| id = { tooltipId.current } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| role = 'tooltip' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| tabIndex = { wasOpenedWithKeyboard ? 0 : -1 }> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| {content} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -89,31 +94,37 @@ const Tooltip = ({ containerClassName, content, children, position = 'top' }: IP | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| setIsUnmounting(false); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const onPopoverOpen = useCallback(() => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const onPopoverOpen = useCallback((keyboardTriggered = false) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (isUnmounting) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| setWasOpenedWithKeyboard(keyboardTriggered); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| clearTimeout(timeoutID.current.close); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| timeoutID.current.close = 0; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!visible) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (isVisible) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| openPopover(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const delay = keyboardTriggered ? 0 : TOOLTIP_DELAY; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| timeoutID.current.open = window.setTimeout(() => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| openPopover(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, TOOLTIP_DELAY); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, delay); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, [ visible, isVisible, isUnmounting ]); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const onPopoverClose = useCallback(() => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const onPopoverClose = useCallback((immediate = false) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| clearTimeout(timeoutID.current.open); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (visible) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const delay = immediate ? 0 : TOOLTIP_DELAY; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| timeoutID.current.close = window.setTimeout(() => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| setIsUnmounting(true); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, TOOLTIP_DELAY); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, delay); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| setWasOpenedWithKeyboard(false); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, [ visible ]); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| useEffect(() => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -132,13 +143,50 @@ const Tooltip = ({ containerClassName, content, children, position = 'top' }: IP | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| clearTimeout(timeoutID.current.close); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| timeoutID.current.close = 0; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, [ storeContent ]); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, [ storeContent, content ]); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const handleFocus = useCallback(() => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| onPopoverOpen(true); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, [ onPopoverOpen ]); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const handleBlur = useCallback(() => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| onPopoverClose(true); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, [ onPopoverClose ]); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const handleKeyDown = useCallback((event: React.KeyboardEvent) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (event.key === 'Escape' && visible) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| event.preventDefault(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| onPopoverClose(true); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, [ visible, onPopoverClose ]); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (isMobileBrowser() || overflowDrawer) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return children; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const enhancedChildren = cloneElement(children, { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 'aria-describedby': visible ? tooltipId.current : undefined, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| tabIndex: children.props.tabIndex !== undefined ? children.props.tabIndex : 0, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+167
to
+169
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const enhancedChildren = cloneElement(children, { | |
| 'aria-describedby': visible ? tooltipId.current : undefined, | |
| tabIndex: children.props.tabIndex !== undefined ? children.props.tabIndex : 0, | |
| // Helper to check if the child is natively focusable | |
| function isNaturallyFocusable(element: ReactElement): boolean { | |
| const focusableTags = [ | |
| 'a', | |
| 'button', | |
| 'input', | |
| 'select', | |
| 'textarea' | |
| ]; | |
| // If it's a string type (native element), check tag name | |
| if (typeof element.type === 'string') { | |
| if (focusableTags.includes(element.type)) { | |
| return true; | |
| } | |
| // <a> must have href to be focusable | |
| if (element.type === 'a' && element.props.href) { | |
| return true; | |
| } | |
| return false; | |
| } | |
| // For custom components, only set tabIndex if already present | |
| return element.props.tabIndex !== undefined; | |
| } | |
| const enhancedChildren = cloneElement(children, { | |
| 'aria-describedby': visible ? tooltipId.current : undefined, | |
| ...(isNaturallyFocusable(children) || children.props.tabIndex !== undefined | |
| ? { tabIndex: children.props.tabIndex !== undefined ? children.props.tabIndex : 0 } | |
| : {}), |
Copilot
AI
Sep 29, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The role='tooltip' attribute is being set on the Popover component, but tooltips already have role='tooltip' set on the content div (line 80). This creates duplicate role attributes which may confuse screen readers.
| role = 'tooltip' |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Using Math.random() for generating IDs can potentially cause collisions in applications with many tooltip instances. Consider using a more robust ID generation method like crypto.randomUUID() or a counter-based approach to ensure uniqueness.