Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tooltip-keyboard-access.md
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions docs/src/content/guides/tooltip.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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,
Expand All @@ -150,7 +154,7 @@
locked = false,
touchEvents = 'pan-y',
mode = 'manual',
onclick = () => {},
onclick,
radius = Infinity,
raiseTarget = false,
state: stateProp = $bindable() as TooltipStateType<TData>,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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={() => {}}
Expand Down Expand Up @@ -858,7 +883,7 @@
}
}}
onclick={(e, { data }) => {
onclick(e, { data });
onclick?.(e, { data });
}}
classes={{ path: cls('lc-tooltip-voronoi-path', debug && 'debug') }}
/>
Expand Down Expand Up @@ -888,7 +913,7 @@
}
}}
onclick={(e) => {
onclick(e, { data: rect?.data });
onclick?.(e, { data: rect?.data });
}}
/>
{/await}
Expand All @@ -909,7 +934,7 @@
}
}}
onclick={(e) => {
onclick(e, { data: rect?.data });
onclick?.(e, { data: rect?.data });
}}
/>
{/if}
Expand All @@ -936,6 +961,38 @@
</ChartClipPath>
</Svg>
{/if}

{#if keyboardTargetsEnabled}
<Svg pointerEvents={false}>
{#snippet children({ facet })}
<g class="lc-tooltip-keyboard-g">
{#each panelData(facet) as d (d)}
{@const coords = dataCoords(ctx, d)}
<rect
x={coords.x - KEYBOARD_TARGET_SIZE / 2 - ctx.padding.left - facet.x}
y={coords.y - KEYBOARD_TARGET_SIZE / 2 - ctx.padding.top - facet.y}
width={KEYBOARD_TARGET_SIZE}
height={KEYBOARD_TARGET_SIZE}
class="lc-tooltip-keyboard-target"
tabindex="0"
role="button"
aria-label={keyboardTargetLabel(d)}
onfocus={() => 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}
</g>
{/snippet}
</Svg>
{/if}
</div>
</div>

Expand Down Expand Up @@ -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);
}
}
}
</style>
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>('.lc-tooltip-root');
return roots.length ? roots[roots.length - 1] : null;
}

function getKeyboardTargets(scope: ParentNode = document) {
return [...scope.querySelectorAll<SVGRectElement>('.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] });
});
});
Loading