From 3fa30a1ecec1183c2e6380af9590ae7ebafb6224 Mon Sep 17 00:00:00 2001 From: albadra2 Date: Wed, 6 May 2026 15:37:21 +0100 Subject: [PATCH 1/3] 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/3] 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/3] 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];