860 lines · 5 files · 22.9 kB
cases/82-chart-table-selection/model.ts62 lines · dependency
cases/82-chart-table-selection/model.ts
import type { PenguinsRow } from '@charts-poc/demo-data/penguins'
export type CompletePenguin = PenguinsRow & {
readonly culmen_length_mm: number
readonly culmen_depth_mm: number
readonly flipper_length_mm: number
readonly body_mass_g: number
readonly sex: string
}
export const selectionIds = [
'adelie-torgersen-male',
'adelie-biscoe-female',
'adelie-dream-female',
'chinstrap-dream-male',
'gentoo-biscoe-male',
] as const
export type SelectionId = (typeof selectionIds)[number]
export function isSelectionId(value: unknown): value is SelectionId {
return selectionIds.some((id) => id === value)
}
export function penguinSelectionId(row: PenguinsRow): SelectionId | null {
const key = `${row.species}-${row.island}-${row.sex}`.toLowerCase()
return isSelectionId(key) ? key : null
}
export function penguinSelectionLabel(row: CompletePenguin) {
return `${row.species} ${row.sex.toLowerCase()} on ${row.island}`
}
export function selectionRowId(row: CompletePenguin): SelectionId {
const id = penguinSelectionId(row)
if (!id) throw new TypeError('Expected a chart/table selection row')
return id
}
export function selectionRows(
rows: readonly PenguinsRow[],
revision = 0,
): readonly CompletePenguin[] {
const representatives = selectionIds.flatMap((id) => {
const row = rows.find(
(row): row is CompletePenguin =>
isCompletePenguin(row) && penguinSelectionId(row) === id,
)
return row ? [row] : []
})
return revision % 2 === 1 ? representatives.reverse() : representatives
}
function isCompletePenguin(row: PenguinsRow): row is CompletePenguin {
return (
row.culmen_length_mm !== null &&
row.culmen_depth_mm !== null &&
row.flipper_length_mm !== null &&
row.body_mass_g !== null &&
row.sex !== null
)
}cases/82-chart-table-selection/tanstack.ts1 line · entry
cases/82-chart-table-selection/tanstack.ts
export { chartTableSelectionDefinition, mount } from './view'cases/82-chart-table-selection/view.tsx366 lines · dependency
cases/82-chart-table-selection/view.tsx
import {
forwardRef,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react'
import { defineChart, dot } from '@tanstack/charts'
import { controlledSignal } from '@tanstack/charts/interaction/signal'
import { keyedSelection, whenSelected } from '@tanstack/charts/selection'
import { Chart } from '@tanstack/react-charts'
import { penguins } from '@charts-poc/demo-data/penguins'
import { scaleLinear } from 'd3-scale'
import { reactMount } from '../../shared/react-mount'
import {
isSelectionId,
penguinSelectionId,
penguinSelectionLabel,
selectionRowId,
selectionRows,
} from './model'
import type { ChartScene } from '@tanstack/charts'
import type { KeyedSelectionChange } from '@tanstack/charts/selection'
import type { ConformanceTarget, ConformanceTestDriver } from '../../types'
import type { ReactConformanceProps } from '../../shared/react-mount'
import type { CompletePenguin, SelectionId } from './model'
export function chartTableSelectionDefinition(
revision: number,
selectedId: SelectionId | null,
onSelectedIdChange: (selectedId: SelectionId | null) => void,
) {
const rows = selectionRows(penguins, revision)
const selection = keyedSelection<
CompletePenguin,
SelectionId,
number,
number
>({
selected: controlledSignal<
SelectionId | null,
KeyedSelectionChange<CompletePenguin, SelectionId, number, number>
>(selectedId, (next) => onSelectedIdChange(next)),
key: (datum) => penguinSelectionId(datum),
})
return defineChart({
marks: [
dot(rows, {
id: 'observations',
x: 'flipper_length_mm',
y: 'body_mass_g',
key: selectionRowId,
r: 4.5,
fill: '#2563eb',
}),
whenSelected(
dot(rows, {
id: 'selected-observation',
x: 'flipper_length_mm',
y: 'body_mass_g',
key: selectionRowId,
r: 7,
fill: '#f97316',
stroke: '#ffffff',
strokeWidth: 2,
}),
selection,
),
],
x: { scale: scaleLinear, axis: { label: 'Flipper length (mm)' } },
y: {
scale: scaleLinear,
grid: true,
axis: { ticks: { count: 5 }, label: 'Body mass (g)' },
},
margin: { top: 16, right: 24, bottom: 42, left: 62 },
svgAnimation: false,
keyboard: true,
maxFocusDistance: 40,
selection,
})
}
const ChartTableExample = forwardRef<
ConformanceTestDriver,
ReactConformanceProps
>(function ChartTableExample({ input, idPrefix }, ref) {
const viewRef = useRef<HTMLDivElement>(null)
const chartSurfaceRef = useRef<HTMLDivElement>(null)
const renderedChartRef = useRef<{
scene: ChartScene<CompletePenguin, number, number>
svg: SVGSVGElement
} | null>(null)
const [selectedId, setSelectedId] = useState<SelectionId | null>(null)
const rows = useMemo(
() => selectionRows(penguins, input.revision),
[input.revision],
)
const selectedDatum = rows.find(
(row) => penguinSelectionId(row) === selectedId,
)
const chartHeight = Math.max(96, input.height - 204)
const tableHeight = Math.max(44, input.height - chartHeight - 52)
const definition = useMemo(
() =>
chartTableSelectionDefinition(input.revision, selectedId, setSelectedId),
[input.revision, selectedId],
)
const announcement = selectedDatum
? `Selected ${penguinSelectionLabel(selectedDatum)}: ${selectedDatum.body_mass_g.toLocaleString()} g`
: 'No observation selected'
useImperativeHandle(
ref,
() => ({
resolveTarget(target) {
if (
(target.view === undefined || target.view === 'main') &&
target.anchor === 'control:clear-selection'
) {
const button = viewRef.current?.querySelector<HTMLElement>(
'[data-clear-selection]',
)
return button ? center(button) : null
}
const selection = selectionFromTarget(target)
if (!selection) return null
if (selection.kind === 'row') {
const button = viewRef.current?.querySelector<HTMLElement>(
`[data-row-select="${selection.id}"]`,
)
return button ? center(button) : null
}
return pointCoordinate(renderedChartRef.current, selection.id)
},
readState() {
const activeElement = viewRef.current?.ownerDocument.activeElement
return {
selectedId,
selectedRow: selectedDatum ? selectedId : null,
selectedValue: selectedDatum?.body_mass_g ?? null,
announcement,
focusedRow:
activeElement instanceof HTMLElement
? (activeElement.dataset.rowSelect ?? null)
: null,
focusedControl:
activeElement instanceof HTMLElement &&
activeElement.hasAttribute('data-clear-selection')
? 'clear-selection'
: null,
selectedOverlayCount:
chartSurfaceRef.current?.querySelectorAll(
'.ts-chart__dot[data-ts-key="selected-observation"] circle',
).length ?? 0,
}
},
}),
[announcement, selectedDatum, selectedId],
)
if (input.preview) {
return (
<Chart
idPrefix={idPrefix}
definition={definition}
initialWidth={input.width}
aspectRatio={input.width / input.height}
ariaLabel="Selectable observations chart"
ariaDescription="Use arrow keys to move between observations and Enter or Space to select one. The table below offers the same selections."
/>
)
}
return (
<div
ref={viewRef}
data-conformance-view="main"
role="region"
aria-label="Selectable observations with data table"
style={{
width: input.width,
height: input.height,
display: 'grid',
gridTemplateRows: `${chartHeight}px 52px ${tableHeight}px`,
}}
>
<div ref={chartSurfaceRef}>
<Chart
idPrefix={idPrefix}
definition={definition}
width={input.width}
height={chartHeight}
ariaLabel="Selectable observations chart"
ariaDescription="Use arrow keys to move between observations and Enter or Space to select one. The table below offers the same selections."
onRender={({ scene, svg }) => {
renderedChartRef.current = { scene, svg }
}}
/>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
minHeight: 52,
padding: '4px 8px',
boxSizing: 'border-box',
gap: 8,
font: '600 12px/1.3 system-ui, sans-serif',
}}
>
<span role="status" aria-live="polite" data-selection-status>
{announcement}
</span>
<button
type="button"
data-clear-selection
aria-disabled={selectedId === null}
onClick={() => setSelectedId(null)}
style={{
minWidth: 112,
minHeight: 44,
padding: '8px 10px',
border: '1px solid color-mix(in srgb, CanvasText 25%, transparent)',
borderRadius: 5,
background: 'Canvas',
color: 'CanvasText',
cursor: selectedId === null ? 'default' : 'pointer',
font: 'inherit',
opacity: selectedId === null ? 0.55 : 1,
}}
>
Clear selection
</button>
</div>
<div style={{ overflow: 'auto' }}>
<table
aria-label="Observation values"
style={{
width: '100%',
borderCollapse: 'collapse',
fontSize: 12,
}}
>
<thead>
<tr>
{['Island', 'Penguin', 'Body mass (g)'].map((label) => (
<th
key={label}
scope="col"
style={{
padding: '4px 8px',
textAlign: label === 'Body mass (g)' ? 'right' : 'left',
}}
>
{label}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((datum) => {
const id = penguinSelectionId(datum)
if (!id) return null
const selected = id === selectedId
return (
<tr
key={id}
data-row-id={id}
aria-selected={selected}
style={{
borderTop:
'1px solid color-mix(in srgb, CanvasText 12%, transparent)',
background: selected
? 'color-mix(in srgb, #f97316 16%, Canvas)'
: 'Canvas',
color: 'CanvasText',
}}
>
<td style={{ padding: '4px 8px' }}>{datum.island}</td>
<th scope="row" style={{ padding: 0 }}>
<button
type="button"
data-row-select={id}
aria-pressed={selected}
onClick={() => setSelectedId(id)}
style={{
width: '100%',
minHeight: 44,
boxSizing: 'border-box',
padding: '4px 8px',
border: 0,
background: 'transparent',
color: 'inherit',
cursor: 'pointer',
textAlign: 'left',
outlineOffset: -3,
fontWeight: selected ? 750 : 600,
}}
>
{penguinSelectionLabel(datum)}
</button>
</th>
<td
style={{
padding: '4px 8px',
textAlign: 'right',
fontVariantNumeric: 'tabular-nums',
}}
>
{datum.body_mass_g.toLocaleString()}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)
})
export const catalogComponent = ChartTableExample
export const mount = reactMount(ChartTableExample)
function selectionFromTarget(target: ConformanceTarget) {
if (target.view !== undefined && target.view !== 'main') return null
const [kind, id] = target.anchor.split(':')
return (kind === 'point' || kind === 'row') && isSelectionId(id)
? { kind, id }
: null
}
function pointCoordinate(
renderedChart: {
scene: ChartScene<CompletePenguin, number, number>
svg: SVGSVGElement
} | null,
pointId: SelectionId,
) {
if (!renderedChart) return null
const { scene, svg } = renderedChart
const point = scene.points.find(
(candidate) =>
candidate.markId === 'observations' &&
penguinSelectionId(candidate.datum) === pointId,
)
if (!point) return null
const bounds = svg.getBoundingClientRect()
return {
x: bounds.left + (point.x / scene.width) * bounds.width,
y: bounds.top + (point.y / scene.height) * bounds.height,
focusElement: svg,
}
}
function center(element: HTMLElement | SVGElement) {
const bounds = element.getBoundingClientRect()
return {
x: bounds.left + bounds.width / 2,
y: bounds.top + bounds.height / 2,
focusElement: element,
}
}shared/react-mount.ts57 lines · dependency
shared/react-mount.ts
import { createElement } from 'react'
import { flushSync } from 'react-dom'
import { createRoot } from 'react-dom/client'
import type { ForwardRefExoticComponent, RefAttributes } from 'react'
import type {
ConformanceInput,
ConformanceMount,
ConformanceTestDriver,
} from '../types'
export interface ReactConformanceProps {
input: ConformanceInput
idPrefix?: string
}
export type ReactConformanceComponent = ForwardRefExoticComponent<
ReactConformanceProps & RefAttributes<ConformanceTestDriver>
>
export function reactMount(
Component: ReactConformanceComponent,
): ConformanceMount {
return (container, input) => {
const root = createRoot(container)
let activeDriver: ConformanceTestDriver | null = null
const driver = new Proxy({} as ConformanceTestDriver, {
get(_target, property) {
const value = activeDriver?.[property as keyof ConformanceTestDriver]
return typeof value === 'function' ? value.bind(activeDriver) : value
},
})
const render = (nextInput: ConformanceInput) => {
flushSync(() => {
root.render(
createElement(Component, {
input: nextInput,
ref: (nextDriver: ConformanceTestDriver | null) => {
activeDriver = nextDriver
},
}),
)
})
}
render(input)
return {
update: render,
driver,
destroy() {
flushSync(() => {
root.unmount()
})
},
}
}
}types.ts374 lines · dependency
types.ts
export type ConformanceReferenceRenderer =
'observable-plot' | 'recharts' | 'echarts'
export type ConformanceRenderer = ConformanceReferenceRenderer | 'tanstack'
export type ConformanceSupport = 'native' | 'composed' | 'gap' | 'deferred'
export type ConformanceGeometryRole =
| 'arc'
| 'area'
| 'arrow'
| 'bar'
| 'cell'
| 'contour'
| 'delaunay'
| 'density'
| 'dot'
| 'frame'
| 'geo'
| 'hexagon'
| 'line'
| 'link'
| 'rect'
| 'radar'
| 'regression'
| 'rule'
| 'text'
| 'tick'
| 'vector'
| 'voronoi'
| 'waffle'
export interface ConformanceInput {
width: number
height: number
revision: number
interactive?: boolean
/** Use lower-detail geometry suited to compact catalog cards. */
preview?: boolean
/** True only for semantic browser scenarios, not catalog or visual mounts. */
behavior?: boolean
}
export interface ConformanceHandle {
update: (input: ConformanceInput) => void
driver?: ConformanceTestDriver
destroy: () => void
}
export type ConformanceMount = (
container: HTMLElement,
input: ConformanceInput,
) => ConformanceHandle
export interface ConformanceGeometryExpectation {
id?: string
view?: string
role: ConformanceGeometryRole
count: number
maxCount?: number
rendererRoles?: Partial<Record<ConformanceRenderer, ConformanceGeometryRole>>
}
export type ConformanceAxis = 'x' | 'y' | 'fx' | 'fy'
export interface ConformanceGuideExpectation {
id: string
axis:
| ConformanceAxis
| (Record<'tanstack', ConformanceAxis> &
Partial<Record<ConformanceReferenceRenderer, ConformanceAxis>>)
sequence?: readonly string[]
maxRepeat?: number
}
export type ConformanceJsonValue =
| null
| boolean
| number
| string
| readonly ConformanceJsonValue[]
| ConformanceJsonObject
export interface ConformanceJsonObject {
readonly [key: string]: ConformanceJsonValue
}
export interface ConformanceTarget {
view?: string
anchor: string
}
export type ConformanceRenderedTarget =
| {
selector: string
index?: number
role?: never
name?: never
exact?: never
root?: never
page?: never
}
| {
role: string
name?: string
exact?: boolean
index?: number
selector?: never
root?: never
page?: never
}
| {
root: true
selector?: never
role?: never
name?: never
exact?: never
index?: never
page?: never
}
| {
page: true
selector?: never
role?: never
name?: never
exact?: never
index?: never
root?: never
}
export interface ConformanceResolvedTarget {
/** Viewport-relative client coordinate used by Playwright mouse input. */
x: number
/** Viewport-relative client coordinate used by Playwright mouse input. */
y: number
/** Optional element to focus before a real Playwright keyboard action. */
focusElement?: HTMLElement | SVGElement
}
export interface ConformanceGeometryQuery {
view?: string
role: ConformanceGeometryRole
}
export interface ConformanceGeometrySample {
/** Viewport-relative client box, matching getBoundingClientRect coordinates. */
x: number
y: number
width: number
height: number
paint?: string
}
export interface ConformanceTestDriver {
/**
* Benchmark-only semantic bridge. Case metadata names anchors; each renderer
* resolves those anchors without exposing renderer-specific selectors.
*/
resolveTarget: (target: ConformanceTarget) => ConformanceResolvedTarget | null
readState: () => ConformanceJsonObject
geometry?: (
query: ConformanceGeometryQuery,
) => readonly ConformanceGeometrySample[]
/**
* Viewport-relative logical view bounds. Multi-grid renderers may expose
* independent views without separate DOM roots.
*/
viewBounds?: (view?: string) => ConformanceGeometrySample | null
settle?: () => void | Promise<void>
}
export type ConformanceStateAssertion =
| {
path: string
equals: ConformanceJsonValue
}
| {
path: string
includes: ConformanceJsonValue
}
| {
path: string
approx: number
tolerance: number
}
type ConformanceRenderedStringMatcher =
| {
equals: string | null
includes?: never
}
| {
includes: string
equals?: never
}
type ConformanceRenderedNumberMatcher =
| {
equals: number
approx?: never
tolerance?: never
atLeast?: never
atMost?: never
}
| {
approx: number
tolerance: number
equals?: never
atLeast?: never
atMost?: never
}
| {
atLeast: number
equals?: never
approx?: never
tolerance?: never
atMost?: never
}
| {
atMost: number
equals?: never
approx?: never
tolerance?: never
atLeast?: never
}
export type ConformanceRenderedAssertion =
| ({
target: ConformanceRenderedTarget
property: 'count'
} & ConformanceRenderedNumberMatcher)
| ({
target: ConformanceRenderedTarget
property: 'text'
} & ConformanceRenderedStringMatcher)
| ({
target: ConformanceRenderedTarget
property: 'attribute'
attribute: string
} & ConformanceRenderedStringMatcher)
| {
target: ConformanceRenderedTarget
property: 'visible' | 'focused'
equals: boolean
}
| ({
target: ConformanceRenderedTarget
property:
| 'scrollLeft'
| 'scrollTop'
| 'scrollWidth'
| 'scrollHeight'
| 'clientWidth'
| 'clientHeight'
| 'width'
| 'height'
} & ConformanceRenderedNumberMatcher)
| {
target: ConformanceRenderedTarget
property: 'contained'
within?: ConformanceRenderedTarget
tolerance?: number
equals: true
}
export type ConformanceInteractionStep =
| {
type: 'pointerMove'
target: ConformanceTarget
steps?: number
}
| {
type: 'pointerDown'
target: ConformanceTarget
}
| {
type: 'pointerUp'
target: ConformanceTarget
}
| {
type: 'pointerCancel'
}
| {
type: 'pointerLeave'
view?: string
}
| {
type: 'update'
revision: number
}
| {
type: 'click'
target: ConformanceTarget
}
| {
type: 'key'
key: string
target?: ConformanceTarget
}
| {
type: 'drag'
from: ConformanceTarget
to: ConformanceTarget
steps?: number
}
| {
type: 'wheel'
target: ConformanceTarget
deltaX?: number
deltaY?: number
steps?: number
deltaMode?: 'pixel' | 'line' | 'page'
}
| {
type: 'touchTap'
target: ConformanceTarget
}
| {
type: 'touchDrag'
from: ConformanceTarget
to: ConformanceTarget
steps?: number
cancel?: boolean
}
| {
type: 'wait'
durationMs: number
}
| {
type: 'assert'
assertions: readonly ConformanceStateAssertion[]
}
| {
type: 'assertRendered'
assertions: readonly ConformanceRenderedAssertion[]
}
| {
type: 'screenshot'
name: string
view?: string
}
export interface ConformanceInteractionScenario {
id: string
steps: readonly ConformanceInteractionStep[]
}
export interface ConformanceCaseMeta {
schemaVersion: 1
referenceRenderer?: ConformanceReferenceRenderer
order: number
id: string
title: string
family: string
intent: string
support: ConformanceSupport
features: readonly string[]
geometry: readonly ConformanceGeometryExpectation[]
minimumGeometrySimilarity?: number
guideAssertions?: readonly ConformanceGuideExpectation[]
interactionScenarios?: readonly ConformanceInteractionScenario[]
source: {
title: string
url: string
}
ai: {
create: string
maintain: string
}
}
export interface ConformanceImplementationModule {
mount: ConformanceMount
}