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 60285231c40..00000000000 --- a/packages/react/src/TooltipV2/Tooltip.dev.stories.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import {Button} from '../Button' -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 = () => ( -
- - - -
-) 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.repro.stories.tsx b/packages/react/src/TooltipV2/Tooltip.repro.stories.tsx new file mode 100644 index 00000000000..f639b734360 --- /dev/null +++ b/packages/react/src/TooltipV2/Tooltip.repro.stories.tsx @@ -0,0 +1,174 @@ +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.repro.stories.module.css' + +export default { + title: 'Components/TooltipV2/Repro', + 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}`} + /> + +
  • + ))} +
+
+ ) +}