1,677 lines · 10 files · 44.9 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/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.ts390 lines · entry
cases/92-editable-event-range/tanstack.ts
import { defineChart, 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 {
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 { ChartScene } from '@tanstack/charts'
import type { HandleXChange } from '@tanstack/charts/interaction/handle'
import type { EditableEvent } from './scenario'
import type {
ConformanceGeometryQuery,
ConformanceGeometrySample,
ConformanceInput,
ConformanceTarget,
ConformanceTestDriver,
} from '../../types'
export interface EditableChartInput extends ConformanceInput {
end: Date
}
export 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 { mount } from './view'
export 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 editableHandleValueText(end: Date) {
return `Release: ${editableDateKey(editableEventStart)} → ${editableDateKey(end)} · ${editableDurationDays(editableEventStart, end)} days`
}
export 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',
})
}
export 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
}
export function cloneDate(date: Date) {
return new Date(date.getTime())
}cases/92-editable-event-range/view.tsx348 lines · dependency
cases/92-editable-event-range/view.tsx
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react'
import { Chart } from '@tanstack/charts/react'
import { reactMount } from '../../shared/react-mount'
import {
clampEditableEventEnd,
editableDateFromAnchor,
editableDateKey,
editableEventEndValues,
} from './model'
import { editableEvents, initialEditableEventEnd } from './scenario'
import {
cloneDate,
createDriver,
editableAriaLabel,
editableEventDefinition,
editableSummaryText,
} from './tanstack'
import type { ChartScene } from '@tanstack/charts'
import type { HandleXChange } from '@tanstack/charts/interaction/handle'
import type { FormEvent, KeyboardEvent, PointerEvent } from 'react'
import type { ReactConformanceProps } from '../../shared/react-mount'
import type { ConformanceTestDriver } from '../../types'
import type { EditableEvent } from './scenario'
import type { EditableState } from './tanstack'
const validationMessage = 'Choose a release end date within the range.'
const EditableEventExample = forwardRef<
ConformanceTestDriver,
ReactConformanceProps
>(function EditableEventExample({ input, idPrefix }, ref) {
const viewRef = useRef<HTMLDivElement>(null)
const chartRef = useRef<HTMLDivElement>(null)
const dateRef = useRef<HTMLInputElement>(null)
const sceneRef = useRef<ChartScene<
EditableEvent,
Date | number,
string
> | null>(null)
const inputRef = useRef(input)
inputRef.current = input
const [acceptedEnd, setAcceptedEnd] = useState(() =>
cloneDate(initialEditableEventEnd),
)
const [state, setState] = useState<EditableState>(() => ({
end: cloneDate(initialEditableEventEnd),
editing: false,
editCount: 0,
originEnd: null,
}))
const [dateValue, setDateValue] = useState(() =>
editableDateKey(initialEditableEventEnd),
)
const [invalid, setInvalid] = useState(false)
const stateRef = useRef(state)
stateRef.current = state
const commitState = useCallback((next: EditableState) => {
stateRef.current = next
setState(next)
}, [])
const beginEdit = useCallback(
(origin = stateRef.current.end) => {
if (stateRef.current.editing) return
commitState({
...stateRef.current,
originEnd: cloneDate(origin),
editing: true,
})
},
[commitState],
)
const applyEnd = useCallback(
(next: Date) => {
const end = clampEditableEventEnd(next)
setAcceptedEnd(end)
setDateValue(editableDateKey(end))
setInvalid(false)
commitState({ ...stateRef.current, end: cloneDate(end) })
},
[commitState],
)
const commitEdit = useCallback(() => {
if (!stateRef.current.editing) return
commitState({
...stateRef.current,
editing: false,
originEnd: null,
editCount: stateRef.current.editCount + 1,
})
}, [commitState])
const cancelEdit = useCallback(
(fallback?: Date) => {
if (!stateRef.current.editing && !fallback) return
const origin = fallback ?? stateRef.current.originEnd
const end = origin ? clampEditableEventEnd(origin) : stateRef.current.end
setAcceptedEnd(end)
setDateValue(editableDateKey(end))
setInvalid(false)
commitState({
...stateRef.current,
end: cloneDate(end),
editing: false,
originEnd: null,
})
},
[commitState],
)
const handleEndChange = useCallback(
(next: Date, reason: HandleXChange<Date>) => {
if (reason.type === 'preview') {
beginEdit(reason.origin)
applyEnd(next)
return
}
if (reason.type === 'cancel') {
cancelEdit(reason.origin)
return
}
beginEdit(reason.origin)
applyEnd(next)
commitEdit()
},
[applyEnd, beginEdit, cancelEdit, commitEdit],
)
const definition = useMemo(
() =>
editableEventDefinition({ ...input, end: acceptedEnd }, handleEndChange),
[acceptedEnd, handleEndChange, input],
)
useEffect(() => {
dateRef.current?.setCustomValidity(invalid ? validationMessage : '')
}, [invalid])
useImperativeHandle(ref, () => {
const view = viewRef.current
const chart = chartRef.current
const date = dateRef.current
if (!view || !chart || !date) throw new Error('Missing editable event view')
return createDriver(
view,
chart,
date,
() => {
if (!sceneRef.current) throw new Error('Missing editable event scene')
return sceneRef.current
},
() => stateRef.current,
() => inputRef.current,
)
}, [])
const handleDateInput = (event: FormEvent<HTMLInputElement>) => {
const value = event.currentTarget.value
setDateValue(value)
const next = editableDateFromAnchor(`date:${value}`)
if (!next || clampEditableEventEnd(next).getTime() !== next.getTime()) {
setInvalid(true)
return
}
beginEdit()
applyEnd(next)
}
const handleDateKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter' && !invalid) commitEdit()
if (event.key === 'Escape') cancelEdit()
}
const handlePointerCancel = (_event: PointerEvent<HTMLInputElement>) => {
cancelEdit()
}
const minDate = editableDateKey(editableEventEndValues[0]!)
const maxDate = editableDateKey(editableEventEndValues.at(-1)!)
const eventDescriptions = editableEvents(input.revision, state.end).map(
(row) =>
`${row.label}: ${editableDateKey(row.start)} to ${editableDateKey(row.end)}`,
)
return (
<div
ref={viewRef}
data-conformance-view="main"
style={{
position: 'relative',
width: input.width,
height: input.height,
touchAction: 'pan-y',
}}
>
<style>{`
.ts-conformance-event-date:focus-visible {
outline: 3px solid var(--ts-chart-1, #2563eb);
outline-offset: 2px;
}
`}</style>
<div ref={chartRef}>
<Chart
idPrefix={idPrefix}
definition={definition}
width={input.width}
height={input.height}
ariaLabel={editableAriaLabel(input.revision, state.end)}
onRender={({ scene }) => {
sceneRef.current = scene
}}
/>
</div>
<div
style={{
position: 'absolute',
inset: 0,
zIndex: 3,
pointerEvents: 'none',
}}
>
<div
className="ts-conformance-event-toolbar"
role="group"
aria-label="Release event editor"
style={{
position: 'absolute',
top: 4,
left: 12,
right: 12,
display: 'flex',
flexWrap: 'wrap',
alignItems: 'flex-end',
justifyContent: 'flex-end',
gap: 8,
color: 'inherit',
pointerEvents: 'none',
}}
>
<output
className="ts-conformance-event-summary"
role="status"
aria-live="polite"
aria-atomic="true"
style={{
boxSizing: 'border-box',
flex: '1 1 120px',
minWidth: 120,
minHeight: 44,
padding: '8px 10px',
border:
'1px solid color-mix(in srgb, currentColor 32%, transparent)',
borderRadius: 10,
display: 'flex',
alignItems: 'center',
background:
'color-mix(in srgb, var(--ts-chart-2, #f97316) 12%, Canvas)',
color: 'inherit',
font: '600 12px/1.25 system-ui, sans-serif',
}}
>
{editableSummaryText(state.end)}
</output>
<label
style={{
boxSizing: 'border-box',
flex: '0 1 140px',
minWidth: 128,
display: 'grid',
gap: 2,
color: 'inherit',
font: '600 11px/1.15 system-ui, sans-serif',
pointerEvents: 'auto',
}}
>
Release end
<input
ref={dateRef}
className="ts-conformance-event-date"
type="date"
required
min={minDate}
max={maxDate}
value={dateValue}
aria-label="Release end date input"
aria-invalid={invalid}
onInput={handleDateInput}
onBlur={() => {
if (!invalid) commitEdit()
}}
onKeyDown={handleDateKeyDown}
onPointerCancel={handlePointerCancel}
style={{
boxSizing: 'border-box',
width: '100%',
height: 44,
padding: '6px 8px',
border: `1px solid ${
invalid
? '#dc2626'
: 'color-mix(in srgb, currentColor 32%, transparent)'
}`,
borderRadius: 8,
background:
'color-mix(in srgb, var(--ts-chart-2, #f97316) 12%, Canvas)',
color: 'inherit',
colorScheme: 'light dark',
font: '600 12px/1 system-ui, sans-serif',
}}
/>
</label>
<span
className="ts-conformance-event-validation"
aria-live="polite"
hidden={!invalid}
style={{
flex: '1 0 100%',
color: '#dc2626',
font: '600 11px/1.2 system-ui, sans-serif',
}}
>
{invalid ? validationMessage : ''}
</span>
</div>
<ul
className="ts-conformance-event-identities"
style={{
position: 'absolute',
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: 'hidden',
clipPath: 'inset(50%)',
whiteSpace: 'nowrap',
}}
>
{eventDescriptions.map((description) => (
<li key={description}>{description}</li>
))}
</ul>
</div>
</div>
)
})
export const mount = reactMount(EditableEventExample)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))
}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.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 }
}