From 58b7c4c5d392b48cfddbd8184e755cf067d81f65 Mon Sep 17 00:00:00 2001 From: Slizhevsky Vladislav Date: Fri, 9 Oct 2026 12:05:33 +0200 Subject: [PATCH 1/5] [UIK-6028][d3-chart] hide hovered ticks in case a tick is focused --- semcore/d3-chart/src/Hover.jsx | 7 ++----- semcore/d3-chart/src/style/plot.shadow.css | 4 ++++ 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/semcore/d3-chart/src/Hover.jsx b/semcore/d3-chart/src/Hover.jsx index 1d15c9c01a..72c5e039dc 100644 --- a/semcore/d3-chart/src/Hover.jsx +++ b/semcore/d3-chart/src/Hover.jsx @@ -238,9 +238,6 @@ const HOVERED_TICK_PADDING_X = 12; function HoveredTick(props) { const { tickFormatter, value, isFirstTick = false, isLastTick = false, styles, x, yRange, size, position } = props; - const contentRef = React.useRef(null); - const [interactive, setInteractive] = React.useState(false); - const formattedValue = tickFormatter(value); const isMightBeInteractive = formattedValue !== null && typeof formattedValue === 'object'; @@ -281,9 +278,9 @@ function HoveredTick(props) { data-is-first={isFirstTick} data-is-last={isLastTick} position={position} - aria-hidden={interactive ? undefined : true} + data-hovered-tick > - + {formattedValue} , diff --git a/semcore/d3-chart/src/style/plot.shadow.css b/semcore/d3-chart/src/style/plot.shadow.css index 3c5c4b20b4..77b6c21ce0 100644 --- a/semcore/d3-chart/src/style/plot.shadow.css +++ b/semcore/d3-chart/src/style/plot.shadow.css @@ -15,6 +15,10 @@ foreignObject[data-aria-only] { pointer-events: none; } +SPlot:has([data-tick-content] :focus-visible) foreignObject[data-hovered-tick] { + visibility: hidden; +} + SPlot:has(foreignObject[data-aria-only]:focus-within) { @mixin focus-outline-mixin; border-radius: var(--intergalactic-surface-rounded, 8px); From 14f0eef51b397a52dfeffbab810be4b093b0a9ce Mon Sep 17 00:00:00 2001 From: Slizhevsky Vladislav Date: Fri, 9 Oct 2026 12:10:41 +0200 Subject: [PATCH 2/5] [UIK-6028][d3-chart] hide hovered ticks in case a tick is focused --- semcore/d3-chart/src/Hover.jsx | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/semcore/d3-chart/src/Hover.jsx b/semcore/d3-chart/src/Hover.jsx index 72c5e039dc..12bd59d795 100644 --- a/semcore/d3-chart/src/Hover.jsx +++ b/semcore/d3-chart/src/Hover.jsx @@ -1,9 +1,7 @@ import { Component, sstyled, Root } from '@semcore/core'; import canUseDOM from '@semcore/core/lib/utils/canUseDOM'; -import { getFocusableIn } from '@semcore/core/lib/utils/focus-lock/getFocusableIn'; import { hasParent } from '@semcore/core/lib/utils/hasParent'; import trottle from '@semcore/core/lib/utils/rafTrottle'; -import useEnhancedEffect from '@semcore/core/lib/utils/use/useEnhancedEffect'; import React from 'react'; import createElement from './createElement'; @@ -240,17 +238,6 @@ function HoveredTick(props) { const formattedValue = tickFormatter(value); - const isMightBeInteractive = formattedValue !== null && typeof formattedValue === 'object'; - - useEnhancedEffect(() => { - if (!isMightBeInteractive || !contentRef.current) { - setInteractive(false); - return; - } - - setInteractive(getFocusableIn(contentRef.current).length > 0); - }); - if (size?.width === undefined || size?.height === undefined || position === null) return null; const { width, height } = size; From 590523393400eed3e83237a90f68df5a7a1d653b Mon Sep 17 00:00:00 2001 From: Slizhevsky Vladislav Date: Fri, 9 Oct 2026 12:21:24 +0200 Subject: [PATCH 3/5] [UIK-6028][d3-chart] hide hovered ticks in case a tick is focused --- semcore/d3-chart/src/Hover.jsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/semcore/d3-chart/src/Hover.jsx b/semcore/d3-chart/src/Hover.jsx index 12bd59d795..2917deeefd 100644 --- a/semcore/d3-chart/src/Hover.jsx +++ b/semcore/d3-chart/src/Hover.jsx @@ -266,8 +266,9 @@ function HoveredTick(props) { data-is-last={isLastTick} position={position} data-hovered-tick + aria-hidden='true' > - + {formattedValue} , From e40b5d933fe353ec43d6b27671e0ec33dc71c720 Mon Sep 17 00:00:00 2001 From: Slizhevsky Vladislav Date: Fri, 16 Oct 2026 11:30:11 +0200 Subject: [PATCH 4/5] [UIK-6028][d3-chart] hide hovered ticks in case a tick is focused --- semcore/d3-chart/src/Hover.jsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/semcore/d3-chart/src/Hover.jsx b/semcore/d3-chart/src/Hover.jsx index 2917deeefd..ceedd16de0 100644 --- a/semcore/d3-chart/src/Hover.jsx +++ b/semcore/d3-chart/src/Hover.jsx @@ -1,7 +1,9 @@ import { Component, sstyled, Root } from '@semcore/core'; import canUseDOM from '@semcore/core/lib/utils/canUseDOM'; +import { getFocusableIn } from '@semcore/core/lib/utils/focus-lock/getFocusableIn'; import { hasParent } from '@semcore/core/lib/utils/hasParent'; import trottle from '@semcore/core/lib/utils/rafTrottle'; +import useEnhancedEffect from '@semcore/core/lib/utils/use/useEnhancedEffect'; import React from 'react'; import createElement from './createElement'; @@ -238,7 +240,18 @@ function HoveredTick(props) { const formattedValue = tickFormatter(value); - if (size?.width === undefined || size?.height === undefined || position === null) return null; + const contentRef = React.useRef(null); + + const isRendered = size?.width !== undefined && size?.height !== undefined && position !== null; + const isMightBeInteractive = formattedValue !== null && typeof formattedValue === 'object'; + + useEnhancedEffect(() => { + if (!isRendered || !isMightBeInteractive || !contentRef.current) return; + + getFocusableIn(contentRef.current).forEach((node) => node.setAttribute('tabindex', '-1')); + }, [value, isRendered, isMightBeInteractive]); + + if (!isRendered) return null; const { width, height } = size; @@ -268,7 +281,7 @@ function HoveredTick(props) { data-hovered-tick aria-hidden='true' > - + {formattedValue} , From 6308fa14735630c1abf48d19c62c66eaa3dc4ea9 Mon Sep 17 00:00:00 2001 From: Valeryia Zimnitskaya Date: Tue, 20 Oct 2026 12:41:08 +0200 Subject: [PATCH 5/5] [chore] update test --- .../axe-tests/bar-chart.axe-test.tsx | 14 ++++++ .../__tests__/d3-chart-base.browser-test.tsx | 43 +++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/semcore/d3-chart/__tests__/axe-tests/bar-chart.axe-test.tsx b/semcore/d3-chart/__tests__/axe-tests/bar-chart.axe-test.tsx index 9beefbae28..f8f68b2ae4 100644 --- a/semcore/d3-chart/__tests__/axe-tests/bar-chart.axe-test.tsx +++ b/semcore/d3-chart/__tests__/axe-tests/bar-chart.axe-test.tsx @@ -45,6 +45,20 @@ test.describe(`@d3-chart @bar-chart ${TAG.ACCESSIBILITY}`, () => { expect(violations).toEqual([]); }); + test('links with hovered tick', async ({ page }) => { + await loadPage(page, 'stories/components/d3-chart/docs/examples/bar-chart/links.tsx', 'en'); + + const plotBox = await page.locator('svg[data-ui-name="Plot"]').boundingBox(); + const linkBox = await page.locator('[data-ui-name="Axis.Ticks"] a').first().boundingBox(); + if (!plotBox || !linkBox) throw new Error('Bounding box not found'); + + await page.mouse.move(linkBox.x + linkBox.width / 2, plotBox.y + plotBox.height / 2); + await expect(page.locator('foreignObject[data-hovered-tick] a')).toHaveCount(1); + + const violations = await getAccessibilityViolations({ page }); + expect(violations).toEqual([]); + }); + test('negative-values', async ({ page }) => { await loadPage(page, 'stories/components/d3-chart/docs/examples/bar-chart/negative-values.tsx', 'en'); const violations = await getAccessibilityViolations({ page }); diff --git a/semcore/d3-chart/__tests__/d3-chart-base.browser-test.tsx b/semcore/d3-chart/__tests__/d3-chart-base.browser-test.tsx index c53215f7fc..904af85c1f 100644 --- a/semcore/d3-chart/__tests__/d3-chart-base.browser-test.tsx +++ b/semcore/d3-chart/__tests__/d3-chart-base.browser-test.tsx @@ -150,6 +150,18 @@ const hoverPlotCenter = async (page: Page) => { return box; }; +/** + * Hovers the plot column under the given tick, so the hovered tick pill shows the same + * content as that tick. + */ +const hoverTickColumn = async (page: Page, tick: ReturnType) => { + const plotBox = await locators.plot(page).first().boundingBox(); + const tickBox = await tick.boundingBox(); + if (!plotBox || !tickBox) throw new Error('Bounding box not found'); + + await page.mouse.move(tickBox.x + tickBox.width / 2, plotBox.y + plotBox.height / 2); +}; + /** * Dots give the exact position of a data point, so the hovered index needs no * coordinate math. The first 10 dots belong to the first series, one per point. @@ -742,6 +754,37 @@ test.describe(`${TAG.FUNCTIONAL}`, () => { // The link is styled as a link, but it must not grow or shrink the label. expect(linkSize).toBe(tickSize); }); + + test('Verify the hovered tick hides while a tick link has keyboard focus', { + tag: [TAG.PRIORITY_HIGH, TAG.KEYBOARD, TAG.MOUSE, '@d3-chart', + '@bar-chart'], + }, async ({ page }) => { + await loadPage(page, LINKS_EXAMPLE, 'en'); + await locators.plot(page).waitFor({ state: 'visible' }); + + const tickLinks = locators.axisTicks(page).locator('a'); + const pill = locators.hoveredTick(page); + + await hoverTickColumn(page, tickLinks.first()); + await expect(pill).toHaveCSS('visibility', 'visible'); + + await test.step('Focus the link with the keyboard', async () => { + // Focus arrives by Shift+Tab, so the link matches :focus-visible in every browser. + await tickLinks.nth(1).focus(); + await page.keyboard.press('Shift+Tab'); + + await expect(tickLinks.first()).toBeFocused(); + await expect(pill).toHaveCSS('visibility', 'hidden'); + }); + + await test.step('Move focus off the tick links', async () => { + // The first link is the first tab stop, so Shift+Tab would leave the page. + await tickLinks.first().blur(); + + await expect(tickLinks.first()).not.toBeFocused(); + await expect(pill).toHaveCSS('visibility', 'visible'); + }); + }); }); test.describe('Pattern fills, dots and lines', () => {