From 3fa30a1ecec1183c2e6380af9590ae7ebafb6224 Mon Sep 17 00:00:00 2001 From: albadra2 Date: Wed, 6 May 2026 15:37:21 +0100 Subject: [PATCH 1/5] fix: return focus to legend item on Tab when tooltip is visible --- src/internal/components/chart-legend/index.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/internal/components/chart-legend/index.tsx b/src/internal/components/chart-legend/index.tsx index f9fb07c4..58a7b010 100644 --- a/src/internal/components/chart-legend/index.tsx +++ b/src/internal/components/chart-legend/index.tsx @@ -98,6 +98,11 @@ export const ChartLegend = ({ hideTooltip(true); elementsByIdRef.current[tooltipItemId]?.focus(); } + if (event.keyCode === KeyCode.tab) { + event.preventDefault(); + hideTooltip(true); + elementsByIdRef.current[tooltipItemId]?.focus(); + } }; document.addEventListener("keydown", onDocumentKeyDown, true); return () => { From 364bcc19f7f577e7747bf02b54cdbe55464a6d1e Mon Sep 17 00:00:00 2001 From: albadra2 Date: Fri, 8 May 2026 17:50:57 +0100 Subject: [PATCH 2/5] Replace Tab interception with dismiss-button approach and fix SingleTabStopNavigationProvider scope --- .../components/chart-legend/index.tsx | 155 ++++++++++++------ 1 file changed, 102 insertions(+), 53 deletions(-) diff --git a/src/internal/components/chart-legend/index.tsx b/src/internal/components/chart-legend/index.tsx index 58a7b010..808f7d6b 100644 --- a/src/internal/components/chart-legend/index.tsx +++ b/src/internal/components/chart-legend/index.tsx @@ -6,6 +6,7 @@ import clsx from "clsx"; import { circleIndex, + getAllFocusables, handleKey, KeyCode, SingleTabStopNavigationAPI, @@ -28,6 +29,32 @@ const TOOLTIP_BLUR_DELAY = 50; const HIGHLIGHT_LOST_DELAY = 50; const SCROLL_DELAY = 100; +// Selector for "real" interactive elements inside the popover. Skips the popover's +// internal TabTrap helpers (rendered as
) so Tab from the trigger +// lands on the dismiss button or the first content control. +const TOOLTIP_INTERACTIVE_SELECTOR = "button, a[href], input, select, textarea"; + +function focusFirstInteractiveInTooltip(tooltipEl: HTMLElement): boolean { + const first = getAllFocusables(tooltipEl).find((el) => el.matches(TOOLTIP_INTERACTIVE_SELECTOR)); + if (first) { + first.focus(); + return true; + } + return false; +} + +function focusStaysInTooltipScope( + next: EventTarget | null, + tooltipEl: HTMLElement | null, + triggerEl: HTMLElement | undefined, +): boolean { + if (!next) { + return false; + } + const node = next as Node; + return !!(tooltipEl?.contains(node) || triggerEl?.contains(node)); +} + export type LegendAlignment = "horizontal" | "vertical"; export type LegendHorizontalAlignment = "start" | "center" | "end"; @@ -74,11 +101,15 @@ export const ChartLegend = ({ const scrollIntoViewControl = useMemo(() => new DebouncedCall(), []); const [selectedIndex, setSelectedIndex] = useState(0); const [tooltipItemId, setTooltipItemId] = useState(null); + const [dismissButtonVisible, setDismissButtonVisible] = useState(false); const { showTooltip, hideTooltip } = useMemo(() => { const control = new DebouncedCall(); return { - showTooltip(itemId: string) { - control.call(() => setTooltipItemId(itemId), TOOLTIP_SHOW_DELAY); + showTooltip(itemId: string, mode: "keyboard" | "mouse") { + control.call(() => { + setTooltipItemId(itemId); + setDismissButtonVisible(mode === "keyboard"); + }, TOOLTIP_SHOW_DELAY); }, hideTooltip(lock = false) { control.call(() => setTooltipItemId(null), TOOLTIP_BLUR_DELAY); @@ -98,17 +129,19 @@ export const ChartLegend = ({ hideTooltip(true); elementsByIdRef.current[tooltipItemId]?.focus(); } - if (event.keyCode === KeyCode.tab) { - event.preventDefault(); - hideTooltip(true); - elementsByIdRef.current[tooltipItemId]?.focus(); - } }; document.addEventListener("keydown", onDocumentKeyDown, true); return () => { document.removeEventListener("keydown", onDocumentKeyDown, true); }; - }, [items, tooltipItemId, hideTooltip]); + }, [tooltipItemId, hideTooltip]); + + useEffect(() => { + if (tooltipItemId && dismissButtonVisible) { + elementsByIdRef.current[tooltipItemId]?.focus({ preventScroll: true }); + } + }, [tooltipItemId, dismissButtonVisible]); + const isMouseInContainer = useRef(false); // Scrolling to the highlighted legend item. @@ -146,7 +179,7 @@ export const ChartLegend = ({ setSelectedIndex(index); navigationAPI.current!.updateFocusTarget(); showHighlight(itemId); - showTooltip(itemId); + showTooltip(itemId, "keyboard"); } function onBlur(event: React.FocusEvent) { @@ -164,6 +197,12 @@ export const ChartLegend = ({ } function onKeyDown(event: React.KeyboardEvent) { + if (event.keyCode === KeyCode.tab && !event.shiftKey && tooltipRef.current) { + if (focusFirstInteractiveInTooltip(tooltipRef.current)) { + event.preventDefault(); + return; + } + } if ( event.keyCode === KeyCode.right || event.keyCode === KeyCode.left || @@ -222,25 +261,25 @@ export const ChartLegend = ({ const tooltipPosition = isVertical ? "left" : "bottom"; return ( - getNextFocusTarget()} - onUnregisterActive={(element: HTMLElement) => onUnregisterActive(element, navigationAPI)} +
(isMouseInContainer.current = true)} + onMouseLeave={() => (isMouseInContainer.current = false)} > -
(isMouseInContainer.current = true)} - onMouseLeave={() => (isMouseInContainer.current = false)} + {legendTitle && ( + + {legendTitle} + + )} + getNextFocusTarget()} + onUnregisterActive={(element: HTMLElement) => onUnregisterActive(element, navigationAPI)} > - {legendTitle && ( - - {legendTitle} - - )}
{ showHighlight(item.id); - showTooltip(item.id); + showTooltip(item.id, "mouse"); }, onMouseLeave: () => { clearHighlight(); @@ -311,33 +350,43 @@ export const ChartLegend = ({ ); })}
- {tooltipContent && ( - {}} - position={tooltipPosition} - title={tooltipContent.header} - onMouseEnter={() => showTooltip(tooltipTarget.id)} - onMouseLeave={() => hideTooltip()} - onBlur={() => hideTooltip()} - footer={ - tooltipContent.footer && ( - <> -
- {tooltipContent.footer} - - ) +
+ {tooltipContent && ( + { + hideTooltip(true); + elementsByIdRef.current[tooltipTarget.id]?.focus(); + }} + position={tooltipPosition} + title={tooltipContent.header} + onMouseEnter={() => showTooltip(tooltipTarget.id, dismissButtonVisible ? "keyboard" : "mouse")} + onMouseLeave={() => hideTooltip()} + onBlur={(event) => { + const trigger = elementsByIdRef.current[tooltipTarget.id]; + if (focusStaysInTooltipScope(event.relatedTarget, tooltipRef.current, trigger)) { + return; } - > - {tooltipContent.body} - - )} -
- + hideTooltip(); + }} + footer={ + tooltipContent.footer && ( + <> +
+ {tooltipContent.footer} + + ) + } + > + {tooltipContent.body} + + )} +
); }; From b0df621cb6277df94a71791daa6edb35ae516507 Mon Sep 17 00:00:00 2001 From: albadra2 Date: Mon, 11 May 2026 17:15:51 +0100 Subject: [PATCH 3/5] Rework: unconditional dismiss button with invisible tab trap per review feedback --- .../components/chart-legend/index.tsx | 64 +++++++++---------- 1 file changed, 29 insertions(+), 35 deletions(-) diff --git a/src/internal/components/chart-legend/index.tsx b/src/internal/components/chart-legend/index.tsx index 808f7d6b..0e1b4fba 100644 --- a/src/internal/components/chart-legend/index.tsx +++ b/src/internal/components/chart-legend/index.tsx @@ -6,7 +6,6 @@ import clsx from "clsx"; import { circleIndex, - getAllFocusables, handleKey, KeyCode, SingleTabStopNavigationAPI, @@ -29,20 +28,6 @@ const TOOLTIP_BLUR_DELAY = 50; const HIGHLIGHT_LOST_DELAY = 50; const SCROLL_DELAY = 100; -// Selector for "real" interactive elements inside the popover. Skips the popover's -// internal TabTrap helpers (rendered as
) so Tab from the trigger -// lands on the dismiss button or the first content control. -const TOOLTIP_INTERACTIVE_SELECTOR = "button, a[href], input, select, textarea"; - -function focusFirstInteractiveInTooltip(tooltipEl: HTMLElement): boolean { - const first = getAllFocusables(tooltipEl).find((el) => el.matches(TOOLTIP_INTERACTIVE_SELECTOR)); - if (first) { - first.focus(); - return true; - } - return false; -} - function focusStaysInTooltipScope( next: EventTarget | null, tooltipEl: HTMLElement | null, @@ -97,19 +82,16 @@ export const ChartLegend = ({ const elementsByIndexRef = useRef>([]); const elementsByIdRef = useRef>({}); const tooltipRef = useRef(null); + const tabTrapRef = useRef(null); const highlightControl = useMemo(() => new DebouncedCall(), []); const scrollIntoViewControl = useMemo(() => new DebouncedCall(), []); const [selectedIndex, setSelectedIndex] = useState(0); const [tooltipItemId, setTooltipItemId] = useState(null); - const [dismissButtonVisible, setDismissButtonVisible] = useState(false); const { showTooltip, hideTooltip } = useMemo(() => { const control = new DebouncedCall(); return { - showTooltip(itemId: string, mode: "keyboard" | "mouse") { - control.call(() => { - setTooltipItemId(itemId); - setDismissButtonVisible(mode === "keyboard"); - }, TOOLTIP_SHOW_DELAY); + showTooltip(itemId: string) { + control.call(() => setTooltipItemId(itemId), TOOLTIP_SHOW_DELAY); }, hideTooltip(lock = false) { control.call(() => setTooltipItemId(null), TOOLTIP_BLUR_DELAY); @@ -136,11 +118,14 @@ export const ChartLegend = ({ }; }, [tooltipItemId, hideTooltip]); + // Workaround: PopoverBody auto-focuses the dismiss button on mount. + // We re-focus the legend trigger here, relying on React's child-before-parent effect ordering. + // Remove this once InternalChartTooltip supports a `disableAutoFocus` prop. useEffect(() => { - if (tooltipItemId && dismissButtonVisible) { + if (tooltipItemId) { elementsByIdRef.current[tooltipItemId]?.focus({ preventScroll: true }); } - }, [tooltipItemId, dismissButtonVisible]); + }, [tooltipItemId]); const isMouseInContainer = useRef(false); @@ -179,14 +164,21 @@ export const ChartLegend = ({ setSelectedIndex(index); navigationAPI.current!.updateFocusTarget(); showHighlight(itemId); - showTooltip(itemId, "keyboard"); + showTooltip(itemId); } function onBlur(event: React.FocusEvent) { navigationAPI.current!.updateFocusTarget(); - // Hide tooltip and clear highlight unless focus moves inside tooltip; - if (tooltipRef.current && event.relatedTarget && !tooltipRef.current.contains(event.relatedTarget)) { + // Hide tooltip and clear highlight unless focus moves inside tooltip or to the tab trap; + const next = event.relatedTarget as Node | null; + if (next && tooltipRef.current?.contains(next)) { + return; + } + if (next && tabTrapRef.current?.contains(next)) { + return; + } + if (next) { clearHighlight(); hideTooltip(); } @@ -197,12 +189,6 @@ export const ChartLegend = ({ } function onKeyDown(event: React.KeyboardEvent) { - if (event.keyCode === KeyCode.tab && !event.shiftKey && tooltipRef.current) { - if (focusFirstInteractiveInTooltip(tooltipRef.current)) { - event.preventDefault(); - return; - } - } if ( event.keyCode === KeyCode.right || event.keyCode === KeyCode.left || @@ -305,7 +291,7 @@ export const ChartLegend = ({ const handlers = { onMouseEnter: () => { showHighlight(item.id); - showTooltip(item.id, "mouse"); + showTooltip(item.id); }, onMouseLeave: () => { clearHighlight(); @@ -351,6 +337,14 @@ export const ChartLegend = ({ })}
+ {tooltipContent && ( +
tooltipRef.current?.querySelector("button")?.focus()} + style={{ position: "absolute", width: 0, height: 0, overflow: "hidden" }} + /> + )} {tooltipContent && ( { hideTooltip(true); elementsByIdRef.current[tooltipTarget.id]?.focus(); }} position={tooltipPosition} title={tooltipContent.header} - onMouseEnter={() => showTooltip(tooltipTarget.id, dismissButtonVisible ? "keyboard" : "mouse")} + onMouseEnter={() => showTooltip(tooltipTarget.id)} onMouseLeave={() => hideTooltip()} onBlur={(event) => { const trigger = elementsByIdRef.current[tooltipTarget.id]; From 00f75bfdfe01abd2d7a0503b61ca346c3be208f6 Mon Sep 17 00:00:00 2001 From: albadra2 Date: Thu, 14 May 2026 17:07:12 +0100 Subject: [PATCH 4/5] Address review feedback: replace Tab capture with tab traps, use disableDismissAutoFocus --- .../components/chart-legend/index.tsx | 109 +++++++----------- 1 file changed, 42 insertions(+), 67 deletions(-) diff --git a/src/internal/components/chart-legend/index.tsx b/src/internal/components/chart-legend/index.tsx index 0e1b4fba..6f9fd758 100644 --- a/src/internal/components/chart-legend/index.tsx +++ b/src/internal/components/chart-legend/index.tsx @@ -6,6 +6,7 @@ import clsx from "clsx"; import { circleIndex, + getAllFocusables, handleKey, KeyCode, SingleTabStopNavigationAPI, @@ -28,18 +29,6 @@ const TOOLTIP_BLUR_DELAY = 50; const HIGHLIGHT_LOST_DELAY = 50; const SCROLL_DELAY = 100; -function focusStaysInTooltipScope( - next: EventTarget | null, - tooltipEl: HTMLElement | null, - triggerEl: HTMLElement | undefined, -): boolean { - if (!next) { - return false; - } - const node = next as Node; - return !!(tooltipEl?.contains(node) || triggerEl?.contains(node)); -} - export type LegendAlignment = "horizontal" | "vertical"; export type LegendHorizontalAlignment = "start" | "center" | "end"; @@ -82,7 +71,7 @@ export const ChartLegend = ({ const elementsByIndexRef = useRef>([]); const elementsByIdRef = useRef>({}); const tooltipRef = useRef(null); - const tabTrapRef = useRef(null); + const tooltipWrapperRef = useRef(null); const highlightControl = useMemo(() => new DebouncedCall(), []); const scrollIntoViewControl = useMemo(() => new DebouncedCall(), []); const [selectedIndex, setSelectedIndex] = useState(0); @@ -118,15 +107,6 @@ export const ChartLegend = ({ }; }, [tooltipItemId, hideTooltip]); - // Workaround: PopoverBody auto-focuses the dismiss button on mount. - // We re-focus the legend trigger here, relying on React's child-before-parent effect ordering. - // Remove this once InternalChartTooltip supports a `disableAutoFocus` prop. - useEffect(() => { - if (tooltipItemId) { - elementsByIdRef.current[tooltipItemId]?.focus({ preventScroll: true }); - } - }, [tooltipItemId]); - const isMouseInContainer = useRef(false); // Scrolling to the highlighted legend item. @@ -170,12 +150,10 @@ export const ChartLegend = ({ function onBlur(event: React.FocusEvent) { navigationAPI.current!.updateFocusTarget(); - // Hide tooltip and clear highlight unless focus moves inside tooltip or to the tab trap; + // Hide tooltip and clear highlight unless focus moves inside the tooltip wrapper + // (which contains the tooltip itself and its entry/exit tab traps). const next = event.relatedTarget as Node | null; - if (next && tooltipRef.current?.contains(next)) { - return; - } - if (next && tabTrapRef.current?.contains(next)) { + if (next && tooltipWrapperRef.current?.contains(next)) { return; } if (next) { @@ -338,47 +316,44 @@ export const ChartLegend = ({
{tooltipContent && ( -
tooltipRef.current?.querySelector("button")?.focus()} - style={{ position: "absolute", width: 0, height: 0, overflow: "hidden" }} - /> - )} - {tooltipContent && ( - { - hideTooltip(true); - elementsByIdRef.current[tooltipTarget.id]?.focus(); - }} - position={tooltipPosition} - title={tooltipContent.header} - onMouseEnter={() => showTooltip(tooltipTarget.id)} - onMouseLeave={() => hideTooltip()} - onBlur={(event) => { - const trigger = elementsByIdRef.current[tooltipTarget.id]; - if (focusStaysInTooltipScope(event.relatedTarget, tooltipRef.current, trigger)) { - return; +
+ {/* [1] skips FocusLock's leading TabTrap to target the dismiss button. */} +
tooltipRef.current && getAllFocusables(tooltipRef.current)[1]?.focus()} /> + { + hideTooltip(true); + elementsByIdRef.current[tooltipTarget.id]?.focus(); + }} + position={tooltipPosition} + title={tooltipContent.header} + onMouseEnter={() => showTooltip(tooltipTarget.id)} + onMouseLeave={() => hideTooltip()} + onBlur={(event) => { + if (tooltipWrapperRef.current?.contains(event.relatedTarget as Node)) { + return; + } + hideTooltip(); + }} + footer={ + tooltipContent.footer && ( + <> +
+ {tooltipContent.footer} + + ) } - hideTooltip(); - }} - footer={ - tooltipContent.footer && ( - <> -
- {tooltipContent.footer} - - ) - } - > - {tooltipContent.body} -
+ > + {tooltipContent.body} + +
tooltipRef.current && getAllFocusables(tooltipRef.current)[1]?.focus()} /> +
)}
); From f11a1b821f8ecc613a09fcb42fd532747d33314d Mon Sep 17 00:00:00 2001 From: albadra2 Date: Fri, 15 May 2026 13:32:53 +0100 Subject: [PATCH 5/5] Address review feedback: Remove onBlur --- src/internal/components/chart-legend/index.tsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/internal/components/chart-legend/index.tsx b/src/internal/components/chart-legend/index.tsx index 6f9fd758..2f47f130 100644 --- a/src/internal/components/chart-legend/index.tsx +++ b/src/internal/components/chart-legend/index.tsx @@ -335,12 +335,6 @@ export const ChartLegend = ({ title={tooltipContent.header} onMouseEnter={() => showTooltip(tooltipTarget.id)} onMouseLeave={() => hideTooltip()} - onBlur={(event) => { - if (tooltipWrapperRef.current?.contains(event.relatedTarget as Node)) { - return; - } - hideTooltip(); - }} footer={ tooltipContent.footer && ( <>