Repository navigation
[Bug] Focus ownership: Escape does not clear keyboard focus, a pointer pass loses it, and controlled focus survives pointer leave #189
Description
Version: @tanstack/charts 1.1.0 (57774e1).
The renderer tracks who owns focus (focusOwner: pointer, keyboard, controlled), but three transitions are wrong. With keyboard focus on the chart SVG (Tab), Escape does nothing (A), and a pointer passing over the chart drops the keyboard position, so the next arrow restarts at index 0 (B). Focus set through the controller from a pointer resolution is never cleared when the pointer leaves (C). All three are in the same ownership logic in renderer.ts.
A. Escape does not dismiss keyboard focus
handleKeyDownhandles Escape only for a cursor session or a pinned tooltip (renderer.ts:882-890).- For plain keyboard focus the navigation helper returns
undefinedfor Escape (interaction.ts:155-156) andhandleKeyDownreturns (renderer.ts:926). dismissTooltipalready clears focus but is not reached (renderer.ts:584-592).
Expected: Escape clears focus (tooltip, guides, onFocusChange(null)), DOM focus stays on the SVG, and the next arrow resumes navigation. Escape is also missing from the key list in tooltips-and-focus.md:654.
B. A pointer pass loses keyboard focus
- Pointer move sets
focusOwner = 'pointer'(renderer.ts:748-749). Mouseleave then clears focus (renderer.ts:764-768), so the next ArrowRight lands on index 0. - A controller cannot hand focus back as keyboard focus:
sourceis'pointer' | 'programmatic'(dom-types.ts:74) andsetControlledFocusalways setsfocusOwner = 'controlled'(renderer.ts:666-668).
Expected: keyboard focus survives a pointer pass and the next arrow continues from it.
C. Controlled focus from a pointer resolution survives pointer leave (from this comment)
mouseleaveandpointercancelrunclearPointerFocus, which acts only whenfocusOwner === 'pointer'(renderer.ts:764-781, bound at renderer.ts:964-965).ChartControlledFocusOptions.sourceonly labels the focus for marks and tooltips (dom-types.ts:72-77, renderer.ts:688-690); it does not hand ownership to the pointer.- Example: after a drill-down zoom no pointer event fires, so the app re-resolves focus for the still pointer with
interaction.setControlledFocus(interaction.resolvePointer(x, y)). When the pointer then leaves, focus, tooltip and dimming stay. Read from source, not run.
Expected: focus set from a pointer resolution is cleared on pointer leave, like pointer focus.
Repro (A and B)
Save as packages/charts-core/src/repro.test.ts, run pnpm vitest run packages/charts-core/src/repro.test.ts. The tests assert the expected behaviour.
import { expect, it, vi } from 'vitest'
import { scaleLinear } from 'd3-scale'
import { dot } from './dot'
import { mountChartRenderer } from './renderer'
import { defineChart } from './scene'
const data = [0, 1, 2].map((x) => ({ x }))
const definition = defineChart({
marks: [dot(data, { x: 'x', y: 'x', key: (d) => String(d.x) })],
scales: { x: { scale: scaleLinear().domain([0, 2]) }, y: { scale: scaleLinear().domain([0, 2]) } },
guides: false,
})
function setup() {
const el = document.createElement('div')
const surface: any = { element: el, render: () => { el.tabIndex = 0 }, paintFocus() {}, destroy() {},
clientToScene: (s: any) => ({ x: s.points[0].x, y: s.points[0].y }) }
const renderer: any = { id: 't', prerender: () => '', mount: (c: HTMLElement) => (c.append(el), surface) }
const onFocusChange = vi.fn()
const root = document.body.appendChild(document.createElement('div'))
mountChartRenderer(root, { definition, renderer, width: 400, height: 300, ariaLabel: 'g', onFocusChange })
const fire = (e: Event) => el.dispatchEvent(e)
fire(new FocusEvent('focusin', { bubbles: true })) // Tab into the SVG
return {
fire, root,
key: (key: string) => fire(new KeyboardEvent('keydown', { bubbles: true, key })),
last: () => onFocusChange.mock.calls.at(-1)?.[0]?.datum ?? null,
}
}
it('A: Escape clears keyboard focus', () => {
const t = setup(); expect(t.last()).toEqual({ x: 0 })
t.key('Escape'); expect(t.last()).toBeNull()
})
it('B: keyboard focus survives a pointer pass', () => {
const t = setup(); t.key('ArrowRight') // x=1
t.fire(new MouseEvent('pointermove', { bubbles: true, clientX: 5, clientY: 5 }))
t.root.dispatchEvent(new MouseEvent('mouseleave'))
t.key('ArrowRight'); expect(t.last()).toEqual({ x: 2 }) // continues, not restart
})Output on 57774e1 (vitest 4.1.10): both fail.
A: AssertionError: expected { x: +0 } to be null
B: AssertionError: expected { x: +0 } to deeply equal { x: 2 }
Proposed fix
- A: handle Escape for plain keyboard focus through the existing
dismissTooltippath. - B and C: let
setControlledFocustake ownership from itssource: acceptsource: 'keyboard'(already inChartFocusSource, types.ts:1736-1737) and setfocusOwner = 'keyboard'for it, and setfocusOwner = 'pointer'forsource: 'pointer'or a pointer resolution, so the usual leave handling clears it. Alternative for C: document a "re-resolve at the last pointer position" call that keeps pointer ownership.
Impact (bklit migration, Chrome): B is worked around by re-applying the last point with setControlledFocus on pointer leave, passing source: 'keyboard' through a type cast; C needs our own mouseleave listener. There is no workaround for A.
Related: PR #164 "feat(focus): two-dimensional grid focus for heatmaps" (open), #203 (early pointer input hits the same ownership rule).
Activity
toomingos commentedon Oct 11, 2026
Same family as B (focus ownership across pointer and keyboard): a focus set through the interaction controller is not cleared when the pointer leaves. Checked against
57774e14a1a86081eddb3a23523724a40da05640(v1.1.0).mouseleaveandpointercancelrunclearPointerFocus, which acts only whenfocusOwner === 'pointer'(renderer.ts:764-781, bound at renderer.ts:964-965).setControlledFocusalways setsfocusOwner = 'controlled'(renderer.ts:667).ChartControlledFocusOptions.source('pointer' | 'programmatic') only labels the focus for marks and tooltips (dom-types.ts:72-77, renderer.ts:688-690); it does not hand ownership to the pointer.Example: after a definition change (a drill-down zoom), no pointer event fires, so the app re-resolves focus for the still pointer with
interaction.resolvePointer(x, y)andsetControlledFocus(resolution). When the pointer then leaves the chart, the focus, tooltip and dimming stay, because the owner is'controlled'. The app has to add its ownmouseleavelistener that clears focus through the controller.Proposal: when
setControlledFocusreceives a pointer resolution (orsource: 'pointer'), let the pointer own it, so the usual leave handling clears it. Or document a "re-resolve at the last pointer position" call that keeps pointer ownership.[-]Keyboard focus: Escape does not dismiss it, and a pointer pass loses it[/-][+][Bug] Focus ownership: Escape does not clear keyboard focus, a pointer pass loses it, and controlled focus survives pointer leave[/+]