Skip to content

[Bug] Focus ownership: Escape does not clear keyboard focus, a pointer pass loses it, and controlled focus survives pointer leave #189

Open

Description

@toomingos

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

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

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)

  • mouseleave and pointercancel run clearPointerFocus, which acts only when focusOwner === 'pointer' (renderer.ts:764-781, bound at renderer.ts:964-965).
  • ChartControlledFocusOptions.source 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 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 dismissTooltip path.
  • B and C: let setControlledFocus take ownership from its source: accept source: 'keyboard' (already in ChartFocusSource, types.ts:1736-1737) and set focusOwner = 'keyboard' for it, and set focusOwner = 'pointer' for source: '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

  1. toomingos commented on Oct 11, 2026

    @toomingos
    Author

    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).

    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) and setControlledFocus(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 own mouseleave listener that clears focus through the controller.

    Proposal: when setControlledFocus receives a pointer resolution (or source: '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.

  2. changed the title [-]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[/+] on Oct 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions