diff --git a/.changeset/tooltip-keyboard-access.md b/.changeset/tooltip-keyboard-access.md new file mode 100644 index 000000000..aeb450e3d --- /dev/null +++ b/.changeset/tooltip-keyboard-access.md @@ -0,0 +1,5 @@ +--- +'layerchart': minor +--- + +feat(TooltipContext): Add keyboard access to the tooltip `onclick` handler — each data point gets a focusable target that shows its tooltip on focus and activates the handler with Enter or Space diff --git a/docs/src/content/guides/tooltip.md b/docs/src/content/guides/tooltip.md index 2ad35d689..0c63595b6 100644 --- a/docs/src/content/guides/tooltip.md +++ b/docs/src/content/guides/tooltip.md @@ -530,6 +530,8 @@ Add an `onclick` handler to `tooltipContext` to respond to clicks on data points > ``` +Setting `onclick` also gives the chart keyboard access: each data point gets a focusable target that shows its tooltip when focused and calls the same handler when activated with Enter or Space, so keyboard users can step through the data with Tab. + ## TooltipState properties | Property | Type | Description | diff --git a/packages/layerchart/src/lib/components/tooltip/TooltipContext.svelte b/packages/layerchart/src/lib/components/tooltip/TooltipContext.svelte index faafd38ea..09db64e10 100644 --- a/packages/layerchart/src/lib/components/tooltip/TooltipContext.svelte +++ b/packages/layerchart/src/lib/components/tooltip/TooltipContext.svelte @@ -79,8 +79,9 @@ debug?: boolean; /** - * Click handler for the tooltip - * @default () => {} + * Click handler for the tooltip. When set, every data point also gets a + * keyboard-focusable target: focusing one shows its tooltip, and Enter or + * Space calls this handler with that data point. */ onclick?: (e: MouseEvent, { data }: { data: any }) => any; @@ -142,6 +143,9 @@ const geo = getGeoContext(); const settings = getSettings(); + // Hit size of the keyboard focus target drawn around each data point (WCAG 2.5.8 minimum) + const KEYBOARD_TARGET_SIZE = 24; + let { ref: refProp = $bindable(), debug: debugProp, @@ -150,7 +154,7 @@ locked = false, touchEvents = 'pan-y', mode = 'manual', - onclick = () => {}, + onclick, radius = Infinity, raiseTarget = false, state: stateProp = $bindable() as TooltipStateType, @@ -752,6 +756,27 @@ ['bisect-x', 'bisect-y', 'bisect-band', 'quadtree', 'quadtree-x', 'quadtree-y'].includes(mode) ); + /** + * Keyboard access to `onclick`: when a click handler is set, every data point gets an invisible + * focusable target. Focusing one shows its tooltip, and Enter or Space activates the same + * handler a pointer click would. Radial charts and `manual` mode wire their own events on their + * own shapes, so they are left out. + */ + const keyboardTargetsEnabled = $derived(onclick != null && mode !== 'manual' && !ctx.radial); + + /** Accessible name for a keyboard target — the position axis value, like the tooltip header */ + function keyboardTargetLabel(d: any) { + const value = ctx.valueAxis === 'y' ? ctx.x?.(d) : ctx.y?.(d); + return value == null ? 'Data point' : `${value}`; + } + + function onKeyboardTargetKeydown(e: KeyboardEvent) { + if (e.key !== 'Enter' && e.key !== ' ') return; + e.preventDefault(); + // Like a native button: Enter/Space synthesize the click the `onclick` handler receives + e.currentTarget?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + } + /** * Whether a transform pointer gesture (drag or pinch) is in progress. Pointer capture normally * retargets events to `TransformContext` mid-gesture, but events which arrive before capture is @@ -819,7 +844,7 @@ onclick={(e) => { // Ignore clicks without data (triggered from Legend clicks, for example) if (triggerPointerEvents && tooltipState.data != null) { - onclick(e, { data: tooltipState.data }); + onclick?.(e, { data: tooltipState.data }); } }} onkeydown={() => {}} @@ -858,7 +883,7 @@ } }} onclick={(e, { data }) => { - onclick(e, { data }); + onclick?.(e, { data }); }} classes={{ path: cls('lc-tooltip-voronoi-path', debug && 'debug') }} /> @@ -888,7 +913,7 @@ } }} onclick={(e) => { - onclick(e, { data: rect?.data }); + onclick?.(e, { data: rect?.data }); }} /> {/await} @@ -909,7 +934,7 @@ } }} onclick={(e) => { - onclick(e, { data: rect?.data }); + onclick?.(e, { data: rect?.data }); }} /> {/if} @@ -936,6 +961,38 @@ {/if} + + {#if keyboardTargetsEnabled} + + {#snippet children({ facet })} + + {#each panelData(facet) as d (d)} + {@const coords = dataCoords(ctx, d)} + showTooltip({ data: d })} + onblur={() => hideTooltip()} + onkeydown={onKeyboardTargetKeydown} + onclick={(e) => { + // Only ever reached from the keydown-synthesized click (this layer has + // pointer-events disabled) — stop it so the container's own click handler, + // which would call `onclick` a second time, does not fire + e.stopPropagation(); + onclick?.(e, { data: d }); + }} + /> + {/each} + + {/snippet} + + {/if} @@ -979,5 +1036,14 @@ fill: color-mix(in oklab, var(--color-danger) 10%, transparent); } } + + :where(.lc-tooltip-keyboard-target) { + fill: transparent; + + &:focus-visible { + stroke: currentColor; + fill: color-mix(in oklab, currentColor 10%, transparent); + } + } } diff --git a/packages/layerchart/src/lib/components/tooltip/TooltipContext.svelte.test.ts b/packages/layerchart/src/lib/components/tooltip/TooltipContext.svelte.test.ts new file mode 100644 index 000000000..388299218 --- /dev/null +++ b/packages/layerchart/src/lib/components/tooltip/TooltipContext.svelte.test.ts @@ -0,0 +1,163 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render } from 'vitest-browser-svelte'; + +import LineChart from '../charts/LineChart/LineChart.svelte'; +import BarChart from '../charts/BarChart/BarChart.svelte'; + +const data = [ + { date: 0, value: 10 }, + { date: 1, value: 30 }, + { date: 2, value: 20 }, + { date: 3, value: 50 }, + { date: 4, value: 40 }, +]; + +const barData = [ + { name: 'A', value: 10 }, + { name: 'B', value: 20 }, + { name: 'C', value: 15 }, + { name: 'D', value: 25 }, +]; + +/** + * Tooltip roots portal to `document.body` and their fade transition can outlive a test, so the + * previous test's root would still be the last match. Clear any leftovers before each test. + */ +beforeEach(() => { + document.body.querySelectorAll('.lc-tooltip-root').forEach((el) => el.remove()); +}); + +function getTooltipRoot() { + const roots = document.querySelectorAll('.lc-tooltip-root'); + return roots.length ? roots[roots.length - 1] : null; +} + +function getKeyboardTargets(scope: ParentNode = document) { + return [...scope.querySelectorAll('.lc-tooltip-keyboard-target')]; +} + +function pressKey(el: Element, key: string) { + el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })); +} + +describe('TooltipContext keyboard access', () => { + it('renders no keyboard targets without a click handler', async () => { + const { container } = render(LineChart, { + props: { data, x: 'date', y: 'value', height: 300, width: 400 }, + }); + + await expect + .element(container.querySelector('.lc-tooltip-context') as HTMLElement) + .toBeInTheDocument(); + expect(getKeyboardTargets(container)).toHaveLength(0); + }); + + it('renders a labeled, tabbable target per data point when a click handler is set', async () => { + const { container } = render(LineChart, { + props: { + data, + x: 'date', + y: 'value', + height: 300, + width: 400, + onTooltipClick: () => {}, + }, + }); + + await vi.waitFor(() => { + expect(getKeyboardTargets(container)).toHaveLength(data.length); + }); + + const targets = getKeyboardTargets(container); + targets.forEach((target, i) => { + expect(target.getAttribute('tabindex')).toBe('0'); + expect(target.getAttribute('role')).toBe('button'); + expect(target.getAttribute('aria-label')).toBe(`${data[i].date}`); + }); + }); + + it('shows the tooltip of the focused data point and hides it on blur', async () => { + const { container } = render(LineChart, { + props: { + data, + x: 'date', + y: 'value', + height: 300, + width: 400, + onTooltipClick: () => {}, + }, + }); + + await vi.waitFor(() => { + expect(getKeyboardTargets(container)).toHaveLength(data.length); + }); + expect(getTooltipRoot()).toBeNull(); + + const targets = getKeyboardTargets(container); + targets[1].focus(); + await vi.waitFor(() => { + expect(getTooltipRoot()?.textContent).toContain('30'); + }); + + targets[1].blur(); + await vi.waitFor(() => expect(getTooltipRoot()).toBeNull()); + }); + + it('activates the click handler on Enter and Space, once per press', async () => { + const onTooltipClick = vi.fn(); + const { container } = render(LineChart, { + props: { + data, + x: 'date', + y: 'value', + height: 300, + width: 400, + onTooltipClick, + }, + }); + + await vi.waitFor(() => { + expect(getKeyboardTargets(container)).toHaveLength(data.length); + }); + + const targets = getKeyboardTargets(container); + + targets[2].focus(); + pressKey(targets[2], 'Enter'); + expect(onTooltipClick).toHaveBeenCalledTimes(1); + expect(onTooltipClick.mock.calls[0][0]).toBeInstanceOf(MouseEvent); + expect(onTooltipClick.mock.calls[0][1]).toEqual({ data: data[2] }); + + pressKey(targets[2], ' '); + expect(onTooltipClick).toHaveBeenCalledTimes(2); + expect(onTooltipClick.mock.calls[1][1]).toEqual({ data: data[2] }); + + // No other key activates + pressKey(targets[2], 'ArrowRight'); + expect(onTooltipClick).toHaveBeenCalledTimes(2); + }); + + it('covers band mode targets with the same handler', async () => { + const onTooltipClick = vi.fn(); + const { container } = render(BarChart, { + props: { + data: barData, + x: 'name', + y: 'value', + height: 300, + width: 400, + onTooltipClick, + }, + }); + + await vi.waitFor(() => { + expect(getKeyboardTargets(container)).toHaveLength(barData.length); + }); + + const targets = getKeyboardTargets(container); + targets[3].focus(); + pressKey(targets[3], 'Enter'); + expect(onTooltipClick).toHaveBeenCalledTimes(1); + expect(onTooltipClick.mock.calls[0][1]).toEqual({ data: barData[3] }); + }); +});