TanStack
Catalog

Editable event range

interaction

1,603 lines · 9 files · 42.2 kB

cases/92-editable-event-range/colors.ts5 lines · dependency
cases/92-editable-event-range/colors.ts
import type { EditableEventId } from './scenario'

export function editableEventColor(id: EditableEventId) {
  return id === 'release' ? '#f97316' : '#2563eb'
}
cases/92-editable-event-range/controls.ts190 lines · dependency
cases/92-editable-event-range/controls.ts
export interface EditableControlsState {
  date: string
  minDate: string
  maxDate: string
  summaryText: string
  eventDescriptions: readonly string[]
}

export interface EditableControlsOptions {
  onDateInput: (value: string) => boolean
  onDateCommit: () => void
  onDateCancel: () => void
}

export function createEditableControls(
  view: HTMLDivElement,
  options: EditableControlsOptions,
) {
  const document = view.ownerDocument
  const style = document.createElement('style')
  style.textContent = `
    .ts-conformance-event-date:focus-visible {
      outline: 3px solid var(--ts-chart-1, #2563eb);
      outline-offset: 2px;
    }
    .ts-conformance-event-summary,
    .ts-conformance-event-date {
      border: 1px solid color-mix(in srgb, currentColor 32%, transparent);
      background: color-mix(in srgb, var(--ts-chart-2, #f97316) 12%, Canvas);
      color: inherit;
    }
    .ts-conformance-event-date {
      color-scheme: light dark;
    }
    .ts-conformance-event-date[aria-invalid="true"] {
      border-color: #dc2626;
    }
  `

  const layer = document.createElement('div')
  Object.assign(layer.style, {
    position: 'absolute',
    inset: '0',
    zIndex: '3',
    pointerEvents: 'none',
  })

  const toolbar = document.createElement('div')
  toolbar.className = 'ts-conformance-event-toolbar'
  toolbar.setAttribute('role', 'group')
  toolbar.setAttribute('aria-label', 'Release event editor')
  Object.assign(toolbar.style, {
    position: 'absolute',
    top: '4px',
    left: '12px',
    right: '12px',
    display: 'flex',
    flexWrap: 'wrap',
    alignItems: 'flex-end',
    justifyContent: 'flex-end',
    gap: '8px',
    color: 'inherit',
    pointerEvents: 'none',
  })

  const status = document.createElement('output')
  status.className = 'ts-conformance-event-summary'
  status.setAttribute('role', 'status')
  status.setAttribute('aria-live', 'polite')
  status.setAttribute('aria-atomic', 'true')
  Object.assign(status.style, {
    boxSizing: 'border-box',
    flex: '1 1 120px',
    minWidth: '120px',
    minHeight: '44px',
    padding: '8px 10px',
    borderRadius: '10px',
    display: 'flex',
    alignItems: 'center',
    font: '600 12px/1.25 system-ui, sans-serif',
  })

  const dateLabel = document.createElement('label')
  Object.assign(dateLabel.style, {
    boxSizing: 'border-box',
    flex: '0 1 140px',
    minWidth: '128px',
    display: 'grid',
    gap: '2px',
    color: 'inherit',
    font: '600 11px/1.15 system-ui, sans-serif',
    pointerEvents: 'auto',
  })
  dateLabel.append('Release end')

  const dateInput = document.createElement('input')
  dateInput.className = 'ts-conformance-event-date'
  dateInput.type = 'date'
  dateInput.required = true
  dateInput.setAttribute('aria-label', 'Release end date input')
  dateInput.setAttribute('aria-invalid', 'false')
  Object.assign(dateInput.style, {
    boxSizing: 'border-box',
    width: '100%',
    height: '44px',
    padding: '6px 8px',
    borderRadius: '8px',
    font: '600 12px/1 system-ui, sans-serif',
  })
  dateLabel.append(dateInput)

  const validation = document.createElement('span')
  validation.className = 'ts-conformance-event-validation'
  validation.setAttribute('aria-live', 'polite')
  validation.hidden = true
  Object.assign(validation.style, {
    flex: '1 0 100%',
    color: '#dc2626',
    font: '600 11px/1.2 system-ui, sans-serif',
  })

  const eventList = document.createElement('ul')
  eventList.className = 'ts-conformance-event-identities'
  Object.assign(eventList.style, {
    position: 'absolute',
    width: '1px',
    height: '1px',
    padding: '0',
    margin: '-1px',
    overflow: 'hidden',
    clipPath: 'inset(50%)',
    whiteSpace: 'nowrap',
  })

  const setDateValidity = (valid: boolean) => {
    const message = valid ? '' : 'Choose a release end date within the range.'
    dateInput.setAttribute('aria-invalid', String(!valid))
    dateInput.setCustomValidity(message)
    validation.hidden = valid
    validation.textContent = message
  }
  const handleDateInput = () => {
    setDateValidity(options.onDateInput(dateInput.value))
  }
  const handleDateCommit = () => {
    if (dateInput.getAttribute('aria-invalid') !== 'true') {
      options.onDateCommit()
    }
  }
  const handleDateCancel = () => options.onDateCancel()
  dateInput.addEventListener('input', handleDateInput)
  dateInput.addEventListener('change', handleDateCommit)
  dateInput.addEventListener('pointercancel', handleDateCancel)

  toolbar.append(status, dateLabel, validation)
  layer.append(toolbar, eventList)
  view.append(style, layer)

  return {
    dateInput,
    paint(state: EditableControlsState) {
      dateInput.min = state.minDate
      dateInput.max = state.maxDate
      if (
        document.activeElement !== dateInput ||
        dateInput.getAttribute('aria-invalid') !== 'true'
      ) {
        dateInput.value = state.date
        setDateValidity(true)
      }

      status.value = state.summaryText
      status.textContent = state.summaryText
      eventList.replaceChildren(
        ...state.eventDescriptions.map((description) => {
          const item = document.createElement('li')
          item.textContent = description
          return item
        }),
      )
    },
    destroy() {
      dateInput.removeEventListener('input', handleDateInput)
      dateInput.removeEventListener('change', handleDateCommit)
      dateInput.removeEventListener('pointercancel', handleDateCancel)
      style.remove()
      layer.remove()
    },
  }
}
cases/92-editable-event-range/model.ts41 lines · dependency
cases/92-editable-event-range/model.ts
import { utcDay } from 'd3-time'
import { editableDomain, editableEventStart } from './scenario'

const day = 86_400_000

export const editableEventEndValues = utcDay.range(
  utcDay.offset(editableEventStart, 1),
  utcDay.offset(editableDomain[1], 1),
)

export function editableDateKey(date: Date) {
  return date.toISOString().slice(0, 10)
}

export function editableDateFromAnchor(anchor: string) {
  const key = anchor.startsWith('date:') ? anchor.slice(5) : ''
  if (!/^\d{4}-\d{2}-\d{2}$/.test(key)) return null
  const date = new Date(`${key}T00:00:00.000Z`)
  if (
    !Number.isFinite(date.getTime()) ||
    editableDateKey(date) !== key ||
    date < editableDomain[0] ||
    date > editableDomain[1]
  ) {
    return null
  }
  return date
}

export function clampEditableEventEnd(date: Date) {
  const minimum = editableEventStart.getTime() + day
  const timestamp = Math.min(
    editableDomain[1].getTime(),
    Math.max(minimum, utcDay.round(date).getTime()),
  )
  return new Date(timestamp)
}

export function editableDurationDays(start: Date, end: Date) {
  return (end.getTime() - start.getTime()) / day
}
cases/92-editable-event-range/scenario.ts67 lines · dependency
cases/92-editable-event-range/scenario.ts
export type EditableEventId = 'discovery' | 'design' | 'campaign' | 'release'

export type EditableLane = 'Product' | 'Design' | 'Marketing' | 'Engineering'

export interface EditableEvent {
  id: EditableEventId
  label: string
  lane: EditableLane
  start: Date
  end: Date
}

export const editableLanes: readonly EditableLane[] = [
  'Product',
  'Design',
  'Marketing',
  'Engineering',
]

export const editableDomain: readonly [Date, Date] = [
  utcDate(2025, 0, 1),
  utcDate(2025, 2, 1),
]

export const editableEventStart = utcDate(2025, 1, 3)
export const initialEditableEventEnd = utcDate(2025, 1, 12)

export function editableEvents(
  revision = 0,
  releaseEnd = initialEditableEventEnd,
): readonly EditableEvent[] {
  const updated = revision % 2 === 1
  return [
    {
      id: 'discovery',
      label: 'Discovery',
      lane: 'Product',
      start: utcDate(2025, 0, 4),
      end: utcDate(2025, 0, 13),
    },
    {
      id: 'design',
      label: 'Design system',
      lane: 'Design',
      start: utcDate(2025, 0, 10),
      end: utcDate(2025, 0, updated ? 26 : 24),
    },
    {
      id: 'campaign',
      label: 'Campaign',
      lane: 'Marketing',
      start: utcDate(2025, 0, updated ? 19 : 20),
      end: utcDate(2025, 1, 7),
    },
    {
      id: 'release',
      label: 'Release window',
      lane: 'Engineering',
      start: editableEventStart,
      end: releaseEnd,
    },
  ]
}

function utcDate(year: number, month: number, date: number) {
  return new Date(Date.UTC(year, month, date))
}
cases/92-editable-event-range/tanstack.ts531 lines · entry
cases/92-editable-event-range/tanstack.ts
import { defineChart, mountChart, rect, text } from '@tanstack/charts'
import { handleX } from '@tanstack/charts/interaction/handle'
import { controlledSignal } from '@tanstack/charts/interaction/signal'
import { scaleBand, scaleUtc } from 'd3-scale'
import { editableEventColor } from './colors'
import { createEditableControls } from './controls'
import {
  clampEditableEventEnd,
  editableDateFromAnchor,
  editableDateKey,
  editableDurationDays,
  editableEventEndValues,
} from './model'
import {
  editableDomain,
  editableEvents,
  editableEventStart,
  editableLanes,
  initialEditableEventEnd,
} from './scenario'
import { scenePointToClient } from '../../shared/driver-geometry'
import { tanstackCase } from '../../shared/mount'
import type { ChartHost, ChartHostOptions, ChartScene } from '@tanstack/charts'
import type { HandleXChange } from '@tanstack/charts/interaction/handle'
import type { EditableEvent } from './scenario'
import type {
  ConformanceGeometryQuery,
  ConformanceGeometrySample,
  ConformanceInput,
  ConformanceMount,
  ConformanceTarget,
  ConformanceTestDriver,
} from '../../types'

interface EditableChartInput extends ConformanceInput {
  end: Date
}

interface EditableState {
  end: Date
  editing: boolean
  editCount: number
  originEnd: Date | null
}

const margin = { top: 96, right: 26, bottom: 48, left: 82 }
const handleId = 'release-end'

export function editableEventDefinition(
  input: EditableChartInput,
  onEndChange: (value: Date, reason: HandleXChange<Date>) => void,
) {
  const rows = editableEvents(input.revision, input.end)
  const outsideLabels = rows
    .filter((row) => row.id !== 'release')
    .map((row) => ({ ...row, labelDate: row.end }))

  return defineChart(({ width }) => {
    const releaseLabels = rows
      .filter(
        (row) =>
          row.id === 'release' && eventBarCanFitLabel(row, width, 'Release'),
      )
      .map((row) => ({
        ...row,
        labelDate: row.start,
        shortLabel: 'Release',
      }))
    return {
      marks: [
        rect(rows, {
          id: 'event-ranges',
          x1: 'start',
          x2: 'end',
          y: 'lane',
          color: 'id',
          radius: 5,
          stroke: '#ffffff',
          strokeWidth: 1,
        }),
        ...(input.preview === true
          ? []
          : [
              text(outsideLabels, {
                id: 'event-labels',
                x: 'labelDate',
                y: 'lane',
                text: 'label',
                anchor: 'start',
                dx: 5,
                fill: 'currentColor',
                fontSize: 10,
                fontWeight: 600,
              }),
              text(releaseLabels, {
                id: 'release-label',
                x: 'labelDate',
                y: 'lane',
                text: 'shortLabel',
                anchor: 'start',
                dx: 5,
                fill: '#431407',
                fontSize: 10,
                fontWeight: 700,
              }),
            ]),
      ],
      x: {
        scale: scaleUtc().domain(editableDomain),
        grid: true,
        axis: {
          ticks: {
            format: (value: Date) =>
              value.toLocaleDateString(undefined, {
                month: 'short',
                day: 'numeric',
                timeZone: 'UTC',
              }),
          },
        },
      },
      y: {
        scale: scaleBand<string>()
          .domain(editableLanes)
          .paddingInner(0.38)
          .paddingOuter(0.19),
        grid: false,
      },
      color: {
        domain: ['discovery', 'design', 'campaign', 'release'],
        range: [
          editableEventColor('discovery'),
          editableEventColor('design'),
          editableEventColor('campaign'),
          editableEventColor('release'),
        ],
      },
      controls: [
        handleX<Date, string>({
          id: handleId,
          value: controlledSignal<Date, HandleXChange<Date>>(
            input.end,
            (next, { reason }) => onEndChange(next, reason),
          ),
          values: editableEventEndValues,
          cross: { value: 'Engineering' },
          trackStyle: {
            fill: 'color-mix(in srgb, var(--ts-chart-2, #f97316) 58%, transparent)',
          },
          ruleStyle: false,
          handleStyle: {
            fill: 'var(--ts-chart-2, #f97316)',
            stroke: 'Canvas',
            strokeWidth: 2,
          },
          hitSize: 44,
          ariaLabel: 'Release end handle',
          format: (value) => editableHandleValueText(value),
        }),
      ],
      svgAnimation: false,
      keyboard: false,
      focusRing: false,
      margin,
    }
  })
}

export const catalogCase = tanstackCase(
  (input: ConformanceInput) =>
    editableEventDefinition(
      { ...input, end: initialEditableEventEnd },
      () => {},
    ),
  editableAriaLabel(0, initialEditableEventEnd),
)

export const mount: ConformanceMount = (container, input) => {
  let currentInput = input
  let acceptedEnd = cloneDate(initialEditableEventEnd)
  let host: ChartHost<EditableEvent, Date | number, string> | undefined
  const state: EditableState = {
    end: cloneDate(acceptedEnd),
    editing: false,
    editCount: 0,
    originEnd: null,
  }

  const document = container.ownerDocument
  const view = document.createElement('div')
  const chartSurface = document.createElement('div')
  view.dataset.conformanceView = 'main'
  view.style.position = 'relative'
  view.style.touchAction = 'pan-y'
  view.append(chartSurface)
  container.append(view)
  sizeView(view, input)

  const beginEdit = (origin = state.end) => {
    if (state.editing) return
    state.originEnd = cloneDate(origin)
    state.editing = true
  }

  const options = (): ChartHostOptions<
    EditableEvent,
    Date | number,
    string
  > => ({
    definition: editableEventDefinition(
      { ...currentInput, end: acceptedEnd },
      handleEndChange,
    ),
    width: currentInput.width,
    height: currentInput.height,
    ariaLabel: editableAriaLabel(currentInput.revision, state.end),
  })

  const applyEnd = (next: Date) => {
    acceptedEnd = clampEditableEventEnd(next)
    state.end = cloneDate(acceptedEnd)
    host?.update(options())
  }

  const commitEdit = () => {
    if (!state.editing) return
    state.editing = false
    state.originEnd = null
    state.editCount += 1
    paintControls()
  }

  const cancelEdit = (fallback?: Date) => {
    if (!state.editing && !fallback) return
    const origin = fallback ?? state.originEnd
    state.editing = false
    state.originEnd = null
    if (origin) applyEnd(origin)
    paintControls()
  }

  function handleEndChange(next: Date, reason: HandleXChange<Date>) {
    if (reason.type === 'preview') {
      beginEdit(reason.origin)
      applyEnd(next)
      paintControls()
      return
    }
    if (reason.type === 'cancel') {
      cancelEdit(reason.origin)
      return
    }
    beginEdit(reason.origin)
    applyEnd(next)
    commitEdit()
  }

  const controls = createEditableControls(view, {
    onDateInput(value) {
      const next = editableDateFromAnchor(`date:${value}`)
      if (!next || clampEditableEventEnd(next).getTime() !== next.getTime()) {
        return false
      }
      beginEdit()
      applyEnd(next)
      paintControls()
      return true
    },
    onDateCommit: commitEdit,
    onDateCancel: () => cancelEdit(),
  })

  function paintControls() {
    controls.paint({
      date: editableDateKey(state.end),
      minDate: editableDateKey(editableEventEndValues[0]!),
      maxDate: editableDateKey(editableEventEndValues.at(-1)!),
      summaryText: editableSummaryText(state.end),
      eventDescriptions: editableEvents(currentInput.revision, state.end).map(
        (row) =>
          `${row.label}: ${editableDateKey(row.start)} to ${editableDateKey(row.end)}`,
      ),
    })
  }

  host = mountChart(chartSurface, options())
  paintControls()

  const driver = createDriver(
    view,
    chartSurface,
    controls.dateInput,
    () => host!.getScene(),
    () => state,
    () => currentInput,
  )

  return {
    driver,
    update(nextInput) {
      currentInput = nextInput
      sizeView(view, nextInput)
      host!.update(options())
      paintControls()
    },
    destroy() {
      controls.destroy()
      host!.destroy()
      view.remove()
    },
  }
}

function createDriver(
  view: HTMLDivElement,
  chartSurface: HTMLDivElement,
  dateInput: HTMLInputElement,
  getScene: () => ChartScene<EditableEvent, Date | number, string>,
  getState: () => EditableState,
  getInput: () => ConformanceInput,
): ConformanceTestDriver {
  return {
    resolveTarget(target) {
      return resolveTarget(
        chartSurface,
        dateInput,
        getScene(),
        getState().end,
        target,
      )
    },
    readState() {
      return interactionState(getState(), getInput())
    },
    geometry(query) {
      return editableGeometry(
        chartSurface,
        getScene(),
        getInput(),
        getState().end,
        query,
      )
    },
    viewBounds(viewName) {
      if (viewName !== undefined && viewName !== 'main') return null
      return elementGeometry(view)
    },
  }
}

function resolveTarget(
  chartSurface: HTMLDivElement,
  dateInput: HTMLInputElement,
  scene: ChartScene<EditableEvent, Date | number, string>,
  end: Date,
  target: ConformanceTarget,
) {
  if (target.view !== undefined && target.view !== 'main') return null
  if (target.anchor === 'control:date') return elementCenter(dateInput)
  const date =
    target.anchor === 'event:release:end'
      ? end
      : editableDateFromAnchor(target.anchor)
  if (!date) return null
  const point = scenePointToClient(
    chartSurface,
    scene,
    scene.scales.x.map(date),
    scene.scales.y.map('Engineering'),
  )
  if (!point) return null
  return {
    ...point,
    focusElement:
      chartSurface.querySelector<SVGElement>(
        `[data-chart-handle-surface="${handleId}"]`,
      ) ?? point.focusElement,
  }
}

function interactionState(state: EditableState, input: ConformanceInput) {
  const rows = editableEvents(input.revision, state.end)
  const design = rows.find((row) => row.id === 'design')
  const campaign = rows.find((row) => row.id === 'campaign')
  return {
    editor: {
      id: 'release',
      start: editableDateKey(editableEventStart),
      end: editableDateKey(state.end),
      durationDays: editableDurationDays(editableEventStart, state.end),
      editing: state.editing,
      editCount: state.editCount,
    },
    events: {
      count: rows.length,
      ids: rows.map((row) => row.id),
      designEnd: design ? editableDateKey(design.end) : null,
      campaignStart: campaign ? editableDateKey(campaign.start) : null,
    },
  }
}

function editableGeometry(
  chartSurface: HTMLDivElement,
  scene: ChartScene<EditableEvent, Date | number, string>,
  input: ConformanceInput,
  end: Date,
  query: ConformanceGeometryQuery,
): readonly ConformanceGeometrySample[] {
  if (query.view !== undefined && query.view !== 'main') return []
  if (query.role === 'dot') {
    const handle = chartSurface.querySelector<SVGElement>(
      `[data-chart-handle="${handleId}"]`,
    )
    return handle ? [elementGeometry(handle)] : []
  }
  if (query.role === 'rule') {
    const track = chartSurface.querySelector<SVGElement>(
      `[data-chart-handle-track="${handleId}"]`,
    )
    return track ? [elementGeometry(track)] : []
  }
  if (query.role !== 'rect') return []

  const height = scene.scales.y.bandwidth
  return editableEvents(input.revision, end).flatMap((row) => {
    const start = scenePointToClient(
      chartSurface,
      scene,
      scene.scales.x.map(row.start),
      scene.scales.y.map(row.lane),
    )
    const finish = scenePointToClient(
      chartSurface,
      scene,
      scene.scales.x.map(row.end),
      scene.scales.y.map(row.lane),
    )
    const top = scenePointToClient(
      chartSurface,
      scene,
      scene.scales.x.map(row.start),
      scene.scales.y.map(row.lane) - height / 2,
    )
    const bottom = scenePointToClient(
      chartSurface,
      scene,
      scene.scales.x.map(row.start),
      scene.scales.y.map(row.lane) + height / 2,
    )
    if (!start || !finish || !top || !bottom) return []
    return [
      {
        x: Math.min(start.x, finish.x),
        y: Math.min(top.y, bottom.y),
        width: Math.abs(finish.x - start.x),
        height: Math.abs(bottom.y - top.y),
        paint: editableEventColor(row.id),
      },
    ]
  })
}

function elementGeometry(
  element: HTMLElement | SVGElement,
): ConformanceGeometrySample {
  const bounds = element.getBoundingClientRect()
  const style = getComputedStyle(element)
  return {
    x: bounds.left,
    y: bounds.top,
    width: bounds.width,
    height: bounds.height,
    paint: style.fill || style.backgroundColor || style.stroke,
  }
}

function elementCenter(element: HTMLElement | SVGElement) {
  const bounds = element.getBoundingClientRect()
  return {
    x: bounds.left + bounds.width / 2,
    y: bounds.top + bounds.height / 2,
    focusElement: element,
  }
}

function sizeView(view: HTMLDivElement, input: ConformanceInput) {
  view.style.width = `${input.width}px`
  view.style.height = `${input.height}px`
}

function editableHandleValueText(end: Date) {
  return `Release: ${editableDateKey(editableEventStart)}${editableDateKey(end)} · ${editableDurationDays(editableEventStart, end)} days`
}

function editableSummaryText(end: Date) {
  return `Release · ${compactDate(editableEventStart)}${compactDate(end)} · ${editableDurationDays(editableEventStart, end)} days`
}

function compactDate(date: Date) {
  return date.toLocaleDateString(undefined, {
    month: 'short',
    day: 'numeric',
    timeZone: 'UTC',
  })
}

function editableAriaLabel(revision: number, end: Date) {
  return `Editable schedule. ${editableEvents(revision, end)
    .map(
      (row) =>
        `${row.label}, ${editableDateKey(row.start)} to ${editableDateKey(row.end)}`,
    )
    .join('. ')}.`
}

function eventBarCanFitLabel(
  event: EditableEvent,
  width: number,
  label: string,
) {
  const plotWidth = Math.max(0, width - margin.left - margin.right)
  const domainWidth = editableDomain[1].getTime() - editableDomain[0].getTime()
  const eventWidth = event.end.getTime() - event.start.getTime()
  const barWidth = domainWidth > 0 ? (eventWidth / domainWidth) * plotWidth : 0
  return barWidth >= label.length * 6 + 10
}

function cloneDate(date: Date) {
  return new Date(date.getTime())
}
shared/driver-geometry.ts70 lines · dependency
shared/driver-geometry.ts
import type {
  ConformanceGeometrySample,
  ConformanceResolvedTarget,
} from '../types'

export interface ClientPointBoundsOptions {
  paint: string
  scaleX?: number
  scaleY?: number
}

/**
 * Bounds local chart points in viewport-relative client coordinates.
 * Degenerate point clouds retain a one-pixel geometry sample for comparison.
 */
export function clientPointBounds(
  points: readonly (readonly [number, number])[],
  origin: Pick<DOMRectReadOnly, 'left' | 'top'>,
  options: ClientPointBoundsOptions,
): ConformanceGeometrySample | null {
  if (!points.length) return null

  let left = Number.POSITIVE_INFINITY
  let right = Number.NEGATIVE_INFINITY
  let top = Number.POSITIVE_INFINITY
  let bottom = Number.NEGATIVE_INFINITY
  for (const [x, y] of points) {
    left = Math.min(left, x)
    right = Math.max(right, x)
    top = Math.min(top, y)
    bottom = Math.max(bottom, y)
  }

  const scaleX = options.scaleX ?? 1
  const scaleY = options.scaleY ?? 1
  return {
    x: origin.left + left * scaleX,
    y: origin.top + top * scaleY,
    width: Math.max(1, (right - left) * scaleX),
    height: Math.max(1, (bottom - top) * scaleY),
    paint: options.paint,
  }
}

/** Maps one outer-scene coordinate through the mounted SVG viewport. */
export function scenePointToClient(
  surface: ParentNode,
  scene: { readonly width: number; readonly height: number },
  x: number,
  y: number,
): ConformanceResolvedTarget | null {
  const svg = surface.querySelector<SVGSVGElement>('svg.ts-chart')
  if (
    !svg ||
    !Number.isFinite(scene.width) ||
    !Number.isFinite(scene.height) ||
    scene.width <= 0 ||
    scene.height <= 0 ||
    !Number.isFinite(x) ||
    !Number.isFinite(y)
  ) {
    return null
  }
  const bounds = svg.getBoundingClientRect()
  return {
    x: bounds.left + (x / scene.width) * bounds.width,
    y: bounds.top + (y / scene.height) * bounds.height,
    focusElement: svg,
  }
}
shared/mount.ts179 lines · dependency
shared/mount.ts
import {
  defineChart,
  isResponsiveChartDefinition,
  mountChart,
} from '@tanstack/charts'
import { tooltip } from '@tanstack/charts/tooltip'
import type {
  DomChartDefinition,
  ChartDefinitionOptions,
  ChartValue,
  ChartTooltipOptions,
} from '@tanstack/charts'
import type {
  ConformanceHandle,
  ConformanceInput,
  ConformanceMount,
} from '../types'
import { catalogPreviewDefinition, type CatalogPreviewOptions } from './preview'

export function mountObservablePlot(
  container: HTMLElement,
  input: ConformanceInput,
  render: (input: ConformanceInput) => HTMLElement | SVGSVGElement,
): ConformanceHandle {
  let element = render(input)
  container.append(element)

  return {
    update(nextInput) {
      const nextElement = render(nextInput)
      element.replaceWith(nextElement)
      element = nextElement
    },
    destroy() {
      element.remove()
    },
  }
}

export function tanstackMount<
  TDatum,
  TXValue extends ChartValue = ChartValue,
  TYValue extends ChartValue = ChartValue,
>(
  createDefinition: (
    input: ConformanceInput,
  ) => DomChartDefinition<TDatum, TXValue, TYValue>,
  ariaLabel: string,
  interactiveTooltip: true | ChartTooltipOptions<TDatum> = true,
  previewOptions: CatalogPreviewOptions<TDatum, TXValue, TYValue> = {},
): TanStackConformanceCase<TDatum, TXValue, TYValue> {
  const mount: ConformanceMount = (container, input) => {
    const options = {
      definition: withConformanceBehavior(
        createDefinition(input),
        input,
        interactiveTooltip,
        previewOptions,
      ),
      width: input.width,
      height: input.height,
      ariaLabel,
    } as const
    const host = mountChart(container, options)
    applyCatalogPreviewFocus(host, input, previewOptions)

    return {
      update(nextInput) {
        host.update({
          ...options,
          definition: withConformanceBehavior(
            createDefinition(nextInput),
            nextInput,
            interactiveTooltip,
            previewOptions,
          ),
          width: nextInput.width,
          height: nextInput.height,
        })
        applyCatalogPreviewFocus(host, nextInput, previewOptions)
      },
      destroy() {
        host.destroy()
      },
    }
  }

  const catalogCase = Object.assign(mount, {
    createDefinition,
    ariaLabel,
    interactiveTooltip,
  })

  return Object.assign(catalogCase, { mount: catalogCase })
}

export interface TanStackConformanceCase<
  TDatum,
  TXValue extends ChartValue = ChartValue,
  TYValue extends ChartValue = ChartValue,
> {
  (container: HTMLElement, input: ConformanceInput): ConformanceHandle
  createDefinition: (
    input: ConformanceInput,
  ) => DomChartDefinition<TDatum, TXValue, TYValue>
  ariaLabel: string
  interactiveTooltip: true | ChartTooltipOptions<TDatum>
  mount: ConformanceMount
}

export function tanstackCase<
  TDatum,
  TXValue extends ChartValue = ChartValue,
  TYValue extends ChartValue = ChartValue,
>(
  createDefinition: (
    input: ConformanceInput,
  ) => DomChartDefinition<TDatum, TXValue, TYValue>,
  ariaLabel: string,
  interactiveTooltip: true | ChartTooltipOptions<TDatum> = true,
  previewOptions: CatalogPreviewOptions<TDatum, TXValue, TYValue> = {},
): TanStackConformanceCase<TDatum, TXValue, TYValue> {
  return tanstackMount(
    createDefinition,
    ariaLabel,
    interactiveTooltip,
    previewOptions,
  )
}

export function withConformanceBehavior<
  TDatum,
  TXValue extends ChartValue,
  TYValue extends ChartValue,
>(
  definition: DomChartDefinition<TDatum, TXValue, TYValue>,
  input: ConformanceInput,
  interactiveTooltip: true | ChartTooltipOptions<TDatum>,
  previewOptions: CatalogPreviewOptions<TDatum, TXValue, TYValue> = {},
): DomChartDefinition<TDatum, TXValue, TYValue> {
  const presentation =
    input.preview === true
      ? catalogPreviewDefinition(definition, previewOptions)
      : definition
  const behavior: ChartDefinitionOptions<TDatum, TXValue, TYValue, 'dom'> = {
    svgAnimation: false,
    ...(input.interactive === true ||
    (input.preview === true && previewOptions.focus)
      ? {}
      : { focus: false }),
    keyboard: input.interactive === true,
    tooltip:
      input.interactive !== true
        ? false
        : interactiveTooltip === true
          ? tooltip
          : { use: tooltip, ...interactiveTooltip },
  }

  if (isResponsiveChartDefinition(presentation)) {
    return defineChart(presentation, behavior)
  }
  return defineChart(presentation, behavior)
}

function applyCatalogPreviewFocus<
  TDatum,
  TXValue extends ChartValue,
  TYValue extends ChartValue,
>(
  host: ReturnType<typeof mountChart<TDatum, TXValue, TYValue>>,
  input: ConformanceInput,
  options: CatalogPreviewOptions<TDatum, TXValue, TYValue>,
) {
  if (input.preview !== true || !options.focus) return
  host.interaction.setControlledFocus(options.focus(host.getScene(), input), {
    source: 'programmatic',
  })
}
shared/preview.ts144 lines · dependency
shared/preview.ts
import { isResponsiveChartDefinition } from '@tanstack/charts'
import type {
  ChartPoint,
  ChartScene,
  ChartValue,
  DomChartDefinition,
} from '@tanstack/charts'
import type { ConformanceInput } from '../types'

export interface CatalogPreviewOptions<
  TDatum = unknown,
  TXValue extends ChartValue = ChartValue,
  TYValue extends ChartValue = ChartValue,
> {
  /** Keep the source definition's Cartesian axes and grid. */
  guides?: boolean
  /** Keep the source definition's color legend. */
  legend?: boolean
  /** Keep the source definition's authored or automatic margins. */
  margin?: boolean
  /** Paint one deterministic source point through the chart's focus strategy. */
  focus?: (
    scene: ChartScene<TDatum, TXValue, TYValue>,
    input: ConformanceInput,
  ) => ChartPoint<TDatum, TXValue, TYValue> | null
}

export function catalogPreviewDefinition<
  TDatum,
  TXValue extends ChartValue,
  TYValue extends ChartValue,
>(
  definition: DomChartDefinition<TDatum, TXValue, TYValue>,
  options: CatalogPreviewOptions<TDatum, TXValue, TYValue> = {},
): DomChartDefinition<TDatum, TXValue, TYValue> {
  if (isResponsiveChartDefinition(definition)) {
    return {
      ...definition,
      chart(context) {
        const spec = definition.chart(context)
        const color = previewColor(spec.color, options.legend === true)
        return {
          ...spec,
          ...(options.guides === true ? {} : { guides: false }),
          ...(options.margin === true ? {} : { margin: 0 }),
          ...(color ? { color } : {}),
        }
      },
    }
  }

  const color = previewColor(definition.color, options.legend === true)
  return {
    ...definition,
    ...(options.guides === true ? {} : { guides: false }),
    ...(options.margin === true ? {} : { margin: 0 }),
    ...(color ? { color } : {}),
  }
}

function previewColor<TColor extends { legend?: unknown }>(
  color: TColor | undefined,
  keepLegend: boolean,
): Omit<TColor, 'legend'> | TColor | undefined {
  if (!color || keepLegend) return color
  const { legend: _legend, ...withoutLegend } = color
  return withoutLegend
}

export function samplePreviewData<TDatum>(
  data: readonly TDatum[],
  input: ConformanceInput,
  limit: number,
  accessors: readonly ((datum: TDatum) => number | null | undefined)[] = [],
): readonly TDatum[] {
  if (input.preview !== true || data.length <= limit) return data

  const selected = new Set<number>()
  const slots = Math.max(2, limit - accessors.length * 2)
  for (let slot = 0; slot < slots; slot += 1) {
    selected.add(Math.round((slot / (slots - 1)) * (data.length - 1)))
  }

  for (const accessor of accessors) {
    let minimumIndex = -1
    let minimum = Number.POSITIVE_INFINITY
    let maximumIndex = -1
    let maximum = Number.NEGATIVE_INFINITY

    data.forEach((datum, index) => {
      const value = accessor(datum)
      if (value === null || value === undefined || !Number.isFinite(value)) {
        return
      }
      if (value < minimum) {
        minimum = value
        minimumIndex = index
      }
      if (value > maximum) {
        maximum = value
        maximumIndex = index
      }
    })

    if (minimumIndex >= 0) selected.add(minimumIndex)
    if (maximumIndex >= 0) selected.add(maximumIndex)
  }

  return data.filter((_datum, index) => selected.has(index))
}

export function samplePreviewSeries<TDatum, TSeries>(
  data: readonly TDatum[],
  input: ConformanceInput,
  limitPerSeries: number,
  series: (datum: TDatum) => TSeries,
): readonly TDatum[] {
  if (input.preview !== true) return data

  const indicesBySeries = new Map<TSeries, number[]>()
  data.forEach((datum, index) => {
    const key = series(datum)
    const indices = indicesBySeries.get(key) ?? []
    indices.push(index)
    indicesBySeries.set(key, indices)
  })

  const selected = new Set<number>()
  for (const indices of indicesBySeries.values()) {
    if (indices.length <= limitPerSeries) {
      indices.forEach((index) => selected.add(index))
      continue
    }
    for (let slot = 0; slot < limitPerSeries; slot += 1) {
      const index =
        indices[
          Math.round((slot / (limitPerSeries - 1)) * (indices.length - 1))
        ]
      if (index !== undefined) selected.add(index)
    }
  }

  return data.filter((_datum, index) => selected.has(index))
}
types.ts376 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
  /** Definition-only mount used by compact generated catalog previews. */
  catalogCase?: { mount: ConformanceMount }
}