From 37cd86630d2ae020aaef63aeaab0dc2eb6983fa3 Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Wed, 1 Jul 2026 12:03:39 +0200 Subject: [PATCH 1/4] docs(TooltipV2): add dev stories reproducing conditional-tooltip focus loss Adds ConditionalTooltipWrap and CheckToActivate dev stories that reproduce the accessibility focus-loss bug caused by conditionally wrapping a Button in a Tooltip, plus stable-tree and focus-restore fixes. --- .../src/TooltipV2/Tooltip.dev.stories.tsx | 157 ++++++++++++++++++ 1 file changed, 157 insertions(+) diff --git a/packages/react/src/TooltipV2/Tooltip.dev.stories.tsx b/packages/react/src/TooltipV2/Tooltip.dev.stories.tsx index 60285231c40..cfd629e3362 100644 --- a/packages/react/src/TooltipV2/Tooltip.dev.stories.tsx +++ b/packages/react/src/TooltipV2/Tooltip.dev.stories.tsx @@ -1,4 +1,8 @@ +import React from 'react' +import {flushSync} from 'react-dom' import {Button} from '../Button' +import Checkbox from '../Checkbox' +import {Stack} from '../Stack' import {Tooltip} from './Tooltip' import classes from './Tooltip.dev.stories.module.css' @@ -15,3 +19,156 @@ export const Default = () => ( ) + +function describeActiveElement() { + const el = document.activeElement + if (!el || el === document.body) return 'body' + + const tag = el.tagName.toLowerCase() + const text = el.textContent ? el.textContent.trim() : '' + return text ? `${tag} "${text}"` : tag +} + +export const ConditionalTooltipWrap = () => { + const [inactive, setInactive] = React.useState(false) + const [currentFocus, setCurrentFocus] = React.useState(describeActiveElement) + const buttonRef = React.useRef(null) + + React.useEffect(function printCurrentActiveElement() { + const interval = setInterval(() => { + setCurrentFocus(describeActiveElement()) + }, 100) + return () => clearInterval(interval) + }, []) + + // Buggy flow: same toggle, but no focus restoration. + const saveWithoutRestoringFocus = () => { + setInactive(true) + window.setTimeout(() => setInactive(false), 2000) + } + + // Imperative save flow for the fixed buttons: start, then after 2s flip back, + // restoring focus after each swap. flushSync forces React to commit the + // remount synchronously so buttonRef points at the new node before focus(). + const saveAndRestoreFocus = () => { + flushSync(() => { + setInactive(true) + }) + if (document.activeElement === document.body) buttonRef.current?.focus() + window.setTimeout(() => { + flushSync(() => { + setInactive(false) + }) + if (document.activeElement === document.body) buttonRef.current?.focus() + }, 2000) + } + + return ( + <> +

+ Current focus: {currentFocus} +

+

Buggy

+

+ The element type at this slot swaps between Button and Tooltip, so React unmounts and + recreates the button. Focusing the button and activating it drops focus to body. +

+ + {inactive ? ( + + + + ) : ( + + )} + + + +
+

Fix, Option A: stable tree

+

+ The Tooltip is always mounted and only its text changes, so the button is never + remounted and focus is preserved. Tradeoff: the active button also gets a tooltip. +

+ + + + + + + +
+

Fix, Option B: restore focus via ref

+

+ Accept the remount, then restore focus with a ref (see saveAndRestoreFocus). The ref goes on the{' '} + Tooltip in the inactive branch, because Tooltip overrides its child's ref via{' '} + cloneElement. +

+ + {inactive ? ( + + + + ) : ( + + )} + + + + + ) +} + +const ALERTS = ['Item 1', 'Item 2', 'Item 3'] + +export const CheckToActivate = () => { + const [checked, setChecked] = React.useState(() => ALERTS.map(() => false)) + const [focus, setFocus] = React.useState(describeActiveElement) + + React.useEffect(() => { + const interval = setInterval(() => { + setFocus(describeActiveElement()) + }, 100) + return () => clearInterval(interval) + }, []) + + const selectedCount = checked.filter(Boolean).length + const active = selectedCount >= 2 + + const toggle = (index: number) => { + setChecked(prev => prev.map((value, i) => (i === index ? !value : value))) + } + + return ( +
+

+ Current focus: {focus} +

+

{selectedCount} selected (check at least 2 to activate the button)

+ {active ? ( + + ) : ( + + + + )} +
    + {ALERTS.map((label, index) => ( +
  • + toggle(index)} + aria-label={label} + id={`alert-${index}`} + /> + +
  • + ))} +
+
+ ) +} From 39353717d4433e52fe1f1900a89fe3c75d6c1c21 Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Thu, 2 Jul 2026 11:10:54 +0200 Subject: [PATCH 2/4] Rename TooltipV2 dev stories to examples for production deployment --- .../src/TooltipV2/Tooltip.dev.stories.tsx | 174 -------- ...ss => Tooltip.examples.stories.module.css} | 0 .../TooltipV2/Tooltip.examples.stories.tsx | 374 +++++++----------- 3 files changed, 150 insertions(+), 398 deletions(-) delete mode 100644 packages/react/src/TooltipV2/Tooltip.dev.stories.tsx rename packages/react/src/TooltipV2/{Tooltip.dev.stories.module.css => Tooltip.examples.stories.module.css} (100%) diff --git a/packages/react/src/TooltipV2/Tooltip.dev.stories.tsx b/packages/react/src/TooltipV2/Tooltip.dev.stories.tsx deleted file mode 100644 index cfd629e3362..00000000000 --- a/packages/react/src/TooltipV2/Tooltip.dev.stories.tsx +++ /dev/null @@ -1,174 +0,0 @@ -import React from 'react' -import {flushSync} from 'react-dom' -import {Button} from '../Button' -import Checkbox from '../Checkbox' -import {Stack} from '../Stack' -import {Tooltip} from './Tooltip' -import classes from './Tooltip.dev.stories.module.css' - -export default { - title: 'Components/TooltipV2/Dev', - component: Tooltip, -} - -// Description type, north direction by default -export const Default = () => ( -
- - - -
-) - -function describeActiveElement() { - const el = document.activeElement - if (!el || el === document.body) return 'body' - - const tag = el.tagName.toLowerCase() - const text = el.textContent ? el.textContent.trim() : '' - return text ? `${tag} "${text}"` : tag -} - -export const ConditionalTooltipWrap = () => { - const [inactive, setInactive] = React.useState(false) - const [currentFocus, setCurrentFocus] = React.useState(describeActiveElement) - const buttonRef = React.useRef(null) - - React.useEffect(function printCurrentActiveElement() { - const interval = setInterval(() => { - setCurrentFocus(describeActiveElement()) - }, 100) - return () => clearInterval(interval) - }, []) - - // Buggy flow: same toggle, but no focus restoration. - const saveWithoutRestoringFocus = () => { - setInactive(true) - window.setTimeout(() => setInactive(false), 2000) - } - - // Imperative save flow for the fixed buttons: start, then after 2s flip back, - // restoring focus after each swap. flushSync forces React to commit the - // remount synchronously so buttonRef points at the new node before focus(). - const saveAndRestoreFocus = () => { - flushSync(() => { - setInactive(true) - }) - if (document.activeElement === document.body) buttonRef.current?.focus() - window.setTimeout(() => { - flushSync(() => { - setInactive(false) - }) - if (document.activeElement === document.body) buttonRef.current?.focus() - }, 2000) - } - - return ( - <> -

- Current focus: {currentFocus} -

-

Buggy

-

- The element type at this slot swaps between Button and Tooltip, so React unmounts and - recreates the button. Focusing the button and activating it drops focus to body. -

- - {inactive ? ( - - - - ) : ( - - )} - - - -
-

Fix, Option A: stable tree

-

- The Tooltip is always mounted and only its text changes, so the button is never - remounted and focus is preserved. Tradeoff: the active button also gets a tooltip. -

- - - - - - - -
-

Fix, Option B: restore focus via ref

-

- Accept the remount, then restore focus with a ref (see saveAndRestoreFocus). The ref goes on the{' '} - Tooltip in the inactive branch, because Tooltip overrides its child's ref via{' '} - cloneElement. -

- - {inactive ? ( - - - - ) : ( - - )} - - - - - ) -} - -const ALERTS = ['Item 1', 'Item 2', 'Item 3'] - -export const CheckToActivate = () => { - const [checked, setChecked] = React.useState(() => ALERTS.map(() => false)) - const [focus, setFocus] = React.useState(describeActiveElement) - - React.useEffect(() => { - const interval = setInterval(() => { - setFocus(describeActiveElement()) - }, 100) - return () => clearInterval(interval) - }, []) - - const selectedCount = checked.filter(Boolean).length - const active = selectedCount >= 2 - - const toggle = (index: number) => { - setChecked(prev => prev.map((value, i) => (i === index ? !value : value))) - } - - return ( -
-

- Current focus: {focus} -

-

{selectedCount} selected (check at least 2 to activate the button)

- {active ? ( - - ) : ( - - - - )} -
    - {ALERTS.map((label, index) => ( -
  • - toggle(index)} - aria-label={label} - id={`alert-${index}`} - /> - -
  • - ))} -
-
- ) -} diff --git a/packages/react/src/TooltipV2/Tooltip.dev.stories.module.css b/packages/react/src/TooltipV2/Tooltip.examples.stories.module.css similarity index 100% rename from packages/react/src/TooltipV2/Tooltip.dev.stories.module.css rename to packages/react/src/TooltipV2/Tooltip.examples.stories.module.css diff --git a/packages/react/src/TooltipV2/Tooltip.examples.stories.tsx b/packages/react/src/TooltipV2/Tooltip.examples.stories.tsx index d86ab511334..19768615d9f 100644 --- a/packages/react/src/TooltipV2/Tooltip.examples.stories.tsx +++ b/packages/react/src/TooltipV2/Tooltip.examples.stories.tsx @@ -1,248 +1,174 @@ -import {useState, useCallback, useRef} from 'react' -import {Button, IconButton} from '../Button' -import Breadcrumbs from '../Breadcrumbs' -import {ActionMenu} from '../ActionMenu' -import {ActionList} from '../ActionList' -import {PageHeader} from '../PageHeader' +import React from 'react' +import {flushSync} from 'react-dom' +import {Button} from '../Button' +import Checkbox from '../Checkbox' +import {Stack} from '../Stack' import {Tooltip} from './Tooltip' -import {Dialog} from '../experimental' -import {GitBranchIcon, KebabHorizontalIcon, TriangleDownIcon, CheckIcon, XIcon} from '@primer/octicons-react' -import {default as VisuallyHidden} from '../_VisuallyHidden' +import classes from './Tooltip.examples.stories.module.css' export default { title: 'Components/TooltipV2/Examples', component: Tooltip, } -export const CustomId = () => ( - - {}} /> - +// Description type, north direction by default +export const Default = () => ( +
+ + + +
) -export const FilesPage = () => ( - - - Files - - - - - - - - - - alert('Main')}> - - - - main default - - alert('Branch 1')}>branch-1 - alert('Branch 2')}>branch-2 - - - - - - - - - - - Raw file content - alert('Download')}>Download - - - alert('Jump to line')}> - Jump to line - L - - - alert('Copy path')}> - Copy path - ⌘⇧. - - alert('Copy permalink')}> - Copy permalink - ⌘⇧, - - - - View Options - alert('Show code folding buttons')}> - Show code folding buttons - - alert('Wrap lines')}>Wrap lines - alert('Center content')}>Center content - - - alert('Delete file clicked')}> - Delete file - ⌘D - - - - - - - - - react - src - - PageHeader - - - PageHeader.tsx - - - PageHeader.tsx - - - -) +function describeActiveElement() { + const el = document.activeElement + if (!el || el === document.body) return 'body' -FilesPage.parameters = { - viewport: { - defaultViewport: 'small', - }, + const tag = el.tagName.toLowerCase() + const text = el.textContent ? el.textContent.trim() : '' + return text ? `${tag} "${text}"` : tag } -export const DialogTrigger = () => { - const [isOpen, setIsOpen] = useState(false) - const [secondOpen, setSecondOpen] = useState(false) - const buttonRef = useRef(null) - const onDialogClose = useCallback(() => setIsOpen(false), []) - const onSecondDialogClose = useCallback(() => setSecondOpen(false), []) - const openSecondDialog = useCallback(() => setSecondOpen(true), []) +export const ConditionalTooltipWrap = () => { + const [inactive, setInactive] = React.useState(false) + const [currentFocus, setCurrentFocus] = React.useState(describeActiveElement) + const buttonRef = React.useRef(null) + + React.useEffect(function printCurrentActiveElement() { + const interval = setInterval(() => { + setCurrentFocus(describeActiveElement()) + }, 100) + return () => clearInterval(interval) + }, []) + + // Buggy flow: same toggle, but no focus restoration. + const saveWithoutRestoringFocus = () => { + setInactive(true) + window.setTimeout(() => setInactive(false), 2000) + } + + // Imperative save flow for the fixed buttons: start, then after 2s flip back, + // restoring focus after each swap. flushSync forces React to commit the + // remount synchronously so buttonRef points at the new node before focus(). + const saveAndRestoreFocus = () => { + flushSync(() => { + setInactive(true) + }) + if (document.activeElement === document.body) buttonRef.current?.focus() + window.setTimeout(() => { + flushSync(() => { + setInactive(false) + }) + if (document.activeElement === document.body) buttonRef.current?.focus() + }, 2000) + } + return ( <> - - setIsOpen(!isOpen)} icon={CheckIcon} aria-label="Merge" /> - - {isOpen && ( - - The icon button that triggers the dialog, takes the focus back when the dialog is closed however the tooltip - is not shown again if the dialog is closed with a mouse. Because the tooltip is shown only on focus-visible. - {secondOpen && ( - - Hello world - - )} - - )} +

+ Current focus: {currentFocus} +

+

Buggy

+

+ The element type at this slot swaps between Button and Tooltip, so React unmounts and + recreates the button. Focusing the button and activating it drops focus to body. +

+ + {inactive ? ( + + + + ) : ( + + )} + + + +
+

Fix, Option A: stable tree

+

+ The Tooltip is always mounted and only its text changes, so the button is never + remounted and focus is preserved. Tradeoff: the active button also gets a tooltip. +

+ + + + + + + +
+

Fix, Option B: restore focus via ref

+

+ Accept the remount, then restore focus with a ref (see saveAndRestoreFocus). The ref goes on the{' '} + Tooltip in the inactive branch, because Tooltip overrides its child's ref via{' '} + cloneElement. +

+ + {inactive ? ( + + + + ) : ( + + )} + + + ) } -export const EmojiPicker = () => { - // This example demonstrates a grid of emojis/icons with tooltips that appear after a long delay. - // This pattern is used in places like emoji reactions on comments and the icon picker in the issues dashboard's saved views on GitHub. - // The delay improves UX by preventing distraction when users move their cursor across multiple emojis/icons, - // especially since these icons are generally familiar and don't require immediate explanation. - - const emojis = [ - {emoji: '😀', name: 'Grinning Face'}, - {emoji: '😍', name: 'Heart Eyes'}, - {emoji: '🎉', name: 'Party Popper'}, - {emoji: '👍', name: 'Thumbs Up'}, - {emoji: '❤️', name: 'Red Heart'}, - {emoji: '🔥', name: 'Fire'}, - {emoji: '💯', name: 'Hundred Points'}, - {emoji: '🚀', name: 'Rocket'}, - {emoji: '⭐', name: 'Star'}, - {emoji: '🎯', name: 'Direct Hit'}, - {emoji: '💡', name: 'Light Bulb'}, - {emoji: '🌟', name: 'Glowing Star'}, - {emoji: '🎊', name: 'Confetti Ball'}, - {emoji: '✨', name: 'Sparkles'}, - {emoji: '🌈', name: 'Rainbow'}, - ] +const ALERTS = ['Item 1', 'Item 2', 'Item 3'] + +export const CheckToActivate = () => { + const [checked, setChecked] = React.useState(() => ALERTS.map(() => false)) + const [focus, setFocus] = React.useState(describeActiveElement) + + React.useEffect(() => { + const interval = setInterval(() => { + setFocus(describeActiveElement()) + }, 100) + return () => clearInterval(interval) + }, []) + + const selectedCount = checked.filter(Boolean).length + const active = selectedCount >= 2 + + const toggle = (index: number) => { + setChecked(prev => prev.map((value, i) => (i === index ? !value : value))) + } return ( -
- {emojis.map((emojiItem, index) => ( - - +
+

+ Current focus: {focus} +

+

{selectedCount} selected (check at least 2 to activate the button)

+ {active ? ( + + ) : ( + + - ))} + )} +
    + {ALERTS.map((label, index) => ( +
  • + toggle(index)} + aria-label={label} + id={`alert-${index}`} + /> + +
  • + ))} +
) } From db25063628ace7945caaea3282f4147613c86fee Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Thu, 2 Jul 2026 11:13:31 +0200 Subject: [PATCH 3/4] Rename TooltipV2 dev stories to repro, restore examples --- ....module.css => Tooltip.examples.stories.module.css} | 0 .../src/TooltipV2/Tooltip.repro.stories.module.css | 10 ++++++++++ ...oltip.dev.stories.tsx => Tooltip.repro.stories.tsx} | 4 ++-- 3 files changed, 12 insertions(+), 2 deletions(-) rename packages/react/src/TooltipV2/{Tooltip.dev.stories.module.css => Tooltip.examples.stories.module.css} (100%) create mode 100644 packages/react/src/TooltipV2/Tooltip.repro.stories.module.css rename packages/react/src/TooltipV2/{Tooltip.dev.stories.tsx => Tooltip.repro.stories.tsx} (98%) diff --git a/packages/react/src/TooltipV2/Tooltip.dev.stories.module.css b/packages/react/src/TooltipV2/Tooltip.examples.stories.module.css similarity index 100% rename from packages/react/src/TooltipV2/Tooltip.dev.stories.module.css rename to packages/react/src/TooltipV2/Tooltip.examples.stories.module.css diff --git a/packages/react/src/TooltipV2/Tooltip.repro.stories.module.css b/packages/react/src/TooltipV2/Tooltip.repro.stories.module.css new file mode 100644 index 00000000000..de38b5fcf50 --- /dev/null +++ b/packages/react/src/TooltipV2/Tooltip.repro.stories.module.css @@ -0,0 +1,10 @@ +/* TooltipV2.dev.stories.module.css */ +.PaddedContainer { + padding: var(--base-size-40); +} + +.Popover[popover] { + /* stylelint-disable-next-line color-named */ + background: red; + font-size: var(--text-title-size-small); +} diff --git a/packages/react/src/TooltipV2/Tooltip.dev.stories.tsx b/packages/react/src/TooltipV2/Tooltip.repro.stories.tsx similarity index 98% rename from packages/react/src/TooltipV2/Tooltip.dev.stories.tsx rename to packages/react/src/TooltipV2/Tooltip.repro.stories.tsx index cfd629e3362..f639b734360 100644 --- a/packages/react/src/TooltipV2/Tooltip.dev.stories.tsx +++ b/packages/react/src/TooltipV2/Tooltip.repro.stories.tsx @@ -4,10 +4,10 @@ import {Button} from '../Button' import Checkbox from '../Checkbox' import {Stack} from '../Stack' import {Tooltip} from './Tooltip' -import classes from './Tooltip.dev.stories.module.css' +import classes from './Tooltip.repro.stories.module.css' export default { - title: 'Components/TooltipV2/Dev', + title: 'Components/TooltipV2/Repro', component: Tooltip, } From 6f39d8eb9980cba8b941d66bc4cf79267a991761 Mon Sep 17 00:00:00 2001 From: Siddharth Kshetrapal Date: Thu, 2 Jul 2026 11:14:30 +0200 Subject: [PATCH 4/4] Restore original Tooltip.examples.stories.tsx content --- .../TooltipV2/Tooltip.examples.stories.tsx | 374 +++++++++++------- 1 file changed, 224 insertions(+), 150 deletions(-) diff --git a/packages/react/src/TooltipV2/Tooltip.examples.stories.tsx b/packages/react/src/TooltipV2/Tooltip.examples.stories.tsx index 19768615d9f..d86ab511334 100644 --- a/packages/react/src/TooltipV2/Tooltip.examples.stories.tsx +++ b/packages/react/src/TooltipV2/Tooltip.examples.stories.tsx @@ -1,174 +1,248 @@ -import React from 'react' -import {flushSync} from 'react-dom' -import {Button} from '../Button' -import Checkbox from '../Checkbox' -import {Stack} from '../Stack' +import {useState, useCallback, useRef} from 'react' +import {Button, IconButton} from '../Button' +import Breadcrumbs from '../Breadcrumbs' +import {ActionMenu} from '../ActionMenu' +import {ActionList} from '../ActionList' +import {PageHeader} from '../PageHeader' import {Tooltip} from './Tooltip' -import classes from './Tooltip.examples.stories.module.css' +import {Dialog} from '../experimental' +import {GitBranchIcon, KebabHorizontalIcon, TriangleDownIcon, CheckIcon, XIcon} from '@primer/octicons-react' +import {default as VisuallyHidden} from '../_VisuallyHidden' export default { title: 'Components/TooltipV2/Examples', component: Tooltip, } -// Description type, north direction by default -export const Default = () => ( -
- - - -
+export const CustomId = () => ( + + {}} /> + ) -function describeActiveElement() { - const el = document.activeElement - if (!el || el === document.body) return 'body' +export const FilesPage = () => ( + + + Files + + + + + + + + + + alert('Main')}> + + + + main default + + alert('Branch 1')}>branch-1 + alert('Branch 2')}>branch-2 + + + + + + + + + + + Raw file content + alert('Download')}>Download + + + alert('Jump to line')}> + Jump to line + L + + + alert('Copy path')}> + Copy path + ⌘⇧. + + alert('Copy permalink')}> + Copy permalink + ⌘⇧, + + + + View Options + alert('Show code folding buttons')}> + Show code folding buttons + + alert('Wrap lines')}>Wrap lines + alert('Center content')}>Center content + + + alert('Delete file clicked')}> + Delete file + ⌘D + + + + + + + + + react + src + + PageHeader + + + PageHeader.tsx + + + PageHeader.tsx + + + +) - const tag = el.tagName.toLowerCase() - const text = el.textContent ? el.textContent.trim() : '' - return text ? `${tag} "${text}"` : tag +FilesPage.parameters = { + viewport: { + defaultViewport: 'small', + }, } -export const ConditionalTooltipWrap = () => { - const [inactive, setInactive] = React.useState(false) - const [currentFocus, setCurrentFocus] = React.useState(describeActiveElement) - const buttonRef = React.useRef(null) - - React.useEffect(function printCurrentActiveElement() { - const interval = setInterval(() => { - setCurrentFocus(describeActiveElement()) - }, 100) - return () => clearInterval(interval) - }, []) - - // Buggy flow: same toggle, but no focus restoration. - const saveWithoutRestoringFocus = () => { - setInactive(true) - window.setTimeout(() => setInactive(false), 2000) - } - - // Imperative save flow for the fixed buttons: start, then after 2s flip back, - // restoring focus after each swap. flushSync forces React to commit the - // remount synchronously so buttonRef points at the new node before focus(). - const saveAndRestoreFocus = () => { - flushSync(() => { - setInactive(true) - }) - if (document.activeElement === document.body) buttonRef.current?.focus() - window.setTimeout(() => { - flushSync(() => { - setInactive(false) - }) - if (document.activeElement === document.body) buttonRef.current?.focus() - }, 2000) - } - +export const DialogTrigger = () => { + const [isOpen, setIsOpen] = useState(false) + const [secondOpen, setSecondOpen] = useState(false) + const buttonRef = useRef(null) + const onDialogClose = useCallback(() => setIsOpen(false), []) + const onSecondDialogClose = useCallback(() => setSecondOpen(false), []) + const openSecondDialog = useCallback(() => setSecondOpen(true), []) return ( <> -

- Current focus: {currentFocus} -

-

Buggy

-

- The element type at this slot swaps between Button and Tooltip, so React unmounts and - recreates the button. Focusing the button and activating it drops focus to body. -

- - {inactive ? ( - - - - ) : ( - - )} - - - -
-

Fix, Option A: stable tree

-

- The Tooltip is always mounted and only its text changes, so the button is never - remounted and focus is preserved. Tradeoff: the active button also gets a tooltip. -

- - - - - - - -
-

Fix, Option B: restore focus via ref

-

- Accept the remount, then restore focus with a ref (see saveAndRestoreFocus). The ref goes on the{' '} - Tooltip in the inactive branch, because Tooltip overrides its child's ref via{' '} - cloneElement. -

- - {inactive ? ( - - - - ) : ( - - )} - - - + + setIsOpen(!isOpen)} icon={CheckIcon} aria-label="Merge" /> + + {isOpen && ( + + The icon button that triggers the dialog, takes the focus back when the dialog is closed however the tooltip + is not shown again if the dialog is closed with a mouse. Because the tooltip is shown only on focus-visible. + {secondOpen && ( + + Hello world + + )} + + )} ) } -const ALERTS = ['Item 1', 'Item 2', 'Item 3'] - -export const CheckToActivate = () => { - const [checked, setChecked] = React.useState(() => ALERTS.map(() => false)) - const [focus, setFocus] = React.useState(describeActiveElement) - - React.useEffect(() => { - const interval = setInterval(() => { - setFocus(describeActiveElement()) - }, 100) - return () => clearInterval(interval) - }, []) - - const selectedCount = checked.filter(Boolean).length - const active = selectedCount >= 2 - - const toggle = (index: number) => { - setChecked(prev => prev.map((value, i) => (i === index ? !value : value))) - } +export const EmojiPicker = () => { + // This example demonstrates a grid of emojis/icons with tooltips that appear after a long delay. + // This pattern is used in places like emoji reactions on comments and the icon picker in the issues dashboard's saved views on GitHub. + // The delay improves UX by preventing distraction when users move their cursor across multiple emojis/icons, + // especially since these icons are generally familiar and don't require immediate explanation. + + const emojis = [ + {emoji: '😀', name: 'Grinning Face'}, + {emoji: '😍', name: 'Heart Eyes'}, + {emoji: '🎉', name: 'Party Popper'}, + {emoji: '👍', name: 'Thumbs Up'}, + {emoji: '❤️', name: 'Red Heart'}, + {emoji: '🔥', name: 'Fire'}, + {emoji: '💯', name: 'Hundred Points'}, + {emoji: '🚀', name: 'Rocket'}, + {emoji: '⭐', name: 'Star'}, + {emoji: '🎯', name: 'Direct Hit'}, + {emoji: '💡', name: 'Light Bulb'}, + {emoji: '🌟', name: 'Glowing Star'}, + {emoji: '🎊', name: 'Confetti Ball'}, + {emoji: '✨', name: 'Sparkles'}, + {emoji: '🌈', name: 'Rainbow'}, + ] return ( -
-

- Current focus: {focus} -

-

{selectedCount} selected (check at least 2 to activate the button)

- {active ? ( - - ) : ( - - +
+ {emojis.map((emojiItem, index) => ( + + - )} -
    - {ALERTS.map((label, index) => ( -
  • - toggle(index)} - aria-label={label} - id={`alert-${index}`} - /> - -
  • - ))} -
+ ))}
) }