TanStack
Catalog

Linked chart and data table selection

interaction

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
}