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', () => { diff --git a/semcore/d3-chart/src/Hover.jsx b/semcore/d3-chart/src/Hover.jsx index 1d15c9c01a..ceedd16de0 100644 --- a/semcore/d3-chart/src/Hover.jsx +++ b/semcore/d3-chart/src/Hover.jsx @@ -238,23 +238,20 @@ 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 contentRef = React.useRef(null); + + const isRendered = size?.width !== undefined && size?.height !== undefined && position !== null; const isMightBeInteractive = formattedValue !== null && typeof formattedValue === 'object'; useEnhancedEffect(() => { - if (!isMightBeInteractive || !contentRef.current) { - setInteractive(false); - return; - } + if (!isRendered || !isMightBeInteractive || !contentRef.current) return; - setInteractive(getFocusableIn(contentRef.current).length > 0); - }); + getFocusableIn(contentRef.current).forEach((node) => node.setAttribute('tabindex', '-1')); + }, [value, isRendered, isMightBeInteractive]); - if (size?.width === undefined || size?.height === undefined || position === null) return null; + if (!isRendered) return null; const { width, height } = size; @@ -281,7 +278,8 @@ function HoveredTick(props) { data-is-first={isFirstTick} data-is-last={isLastTick} position={position} - aria-hidden={interactive ? undefined : true} + data-hovered-tick + aria-hidden='true' > {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);