TanStack
Catalog

Scrollable resource timeline lanes

interaction

503 lines · 4 files · 14.0 kB

cases/85-scrollable-resource-lanes/colors.ts7 lines · dependency
cases/85-scrollable-resource-lanes/colors.ts
import type { TimelineStatus } from './scenario'

export const timelineStatusColors: Readonly<Record<TimelineStatus, string>> = {
  planned: '#64748b',
  active: '#2563eb',
  review: '#c2410c',
}
cases/85-scrollable-resource-lanes/example.tsx380 lines · entry
cases/85-scrollable-resource-lanes/example.tsx
import { useLayoutEffect, useMemo, useRef, useState } from 'react'
import { Chart } from '@tanstack/charts/react'
import { timelineStatusColors } from './colors'
import {
  timelineBodyHeight,
  timelineChartHeight,
  timelineContentWidth,
  timelineLaneRailWidth,
} from './layout'
import { resourceLanes, resourceTasks, timelineStatuses } from './scenario'
import type { ChartPoint, ChartScene } from '@tanstack/charts'
import type { ResourceLane, ResourceTask } from './scenario'
import { defineChart, rect } from '@tanstack/charts'
import { tooltip } from '@tanstack/charts/tooltip'
import { scaleBand, scaleUtc } from 'd3-scale'
import { timelineMargin } from './layout'
import { resourceTimelineDomain } from './scenario'
const focusScrollPadding = 32

interface TimelineFocusState {
  taskId: string | null
  centerX: number | null
  scrolled: boolean
}

const visuallyHidden = {
  position: 'absolute',
  width: 1,
  height: 1,
  padding: 0,
  margin: -1,
  overflow: 'hidden',
  clip: 'rect(0, 0, 0, 0)',
  whiteSpace: 'nowrap',
  border: 0,
} as const

function ensureTimelineFocusVisible(viewport: HTMLDivElement, centerX: number) {
  const previous = viewport.scrollLeft
  const visibleStart = previous + focusScrollPadding
  const visibleEnd = previous + viewport.clientWidth - focusScrollPadding
  let next = previous
  if (centerX < visibleStart) next = centerX - focusScrollPadding
  else if (centerX > visibleEnd) {
    next = centerX - viewport.clientWidth + focusScrollPadding
  }
  viewport.scrollLeft = Math.max(
    0,
    Math.min(next, viewport.scrollWidth - viewport.clientWidth),
  )
  return Math.abs(viewport.scrollLeft - previous) > 1
}

function taskDetails(task: ResourceTask) {
  return `${task.resource} · ${task.label} · ${task.status} · ${formatDate(task.start)}${formatDate(task.end)}`
}

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

export interface ExampleProps {
  width?: number
  height?: number
  revision?: number
}

const taskInset = 5

export const resourceTimelineDefinition = (input: ExampleChartInput) => {
  const rows = resourceTasks(input.revision)

  return defineChart(
    ({ width }) => {
      return {
        marks: [
          rect(rows, {
            key: 'id',
            x1: 'start',
            x2: 'end',
            y: 'resource',
            color: 'status',
            inset: taskInset,
            radius: 4,
            stroke: '#ffffff',
            strokeWidth: 1,
          }),
        ],
        scales: {
          x: {
            scale: scaleUtc().domain(resourceTimelineDomain),
            grid: true,
            axis: { ticks: { count: Math.max(6, Math.floor(width / 84)) } },
          },
          y: {
            scale: scaleBand<string>()
              .domain(resourceLanes)
              .paddingInner(0.08)
              .paddingOuter(0.04),
            grid: false,
            axis: false,
          },
        },

        color: {
          domain: timelineStatuses,
          range: timelineStatuses.map((status) => timelineStatusColors[status]),
        },
        margin: timelineMargin,
      }
    },
    {
      svgAnimation: false,
      keyboard: true,
      tooltip: {
        use: tooltip,
        format: (point) =>
          `${point.datum.resource} · ${point.datum.label} · ${
            point.datum.status
          } · ${formatTaskDate(point.datum.start)}${formatTaskDate(
            point.datum.end,
          )}`,
      },
    },
  )
}

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

export interface ExampleChartInput {
  width: number
  height: number
  revision: number
  preview?: boolean
  interactive?: boolean
}

export default function ResourceTimelineExample({
  width = 640,
  height = 480,
  revision = 0,
}: ExampleProps = {}) {
  const input = { width, height, revision, preview: false, interactive: true }
  const idPrefix = '85-scrollable-resource-lanes'
  const viewportRef = useRef<HTMLDivElement>(null)

  const chartSurfaceRef = useRef<HTMLDivElement>(null)

  const sceneRef = useRef<ChartScene<ResourceTask>>(null)

  const inputRef = useRef(input)

  const focusRef = useRef<TimelineFocusState>({
    taskId: null,
    centerX: null,
    scrolled: false,
  })

  const [focusedTask, setFocusedTask] = useState<ResourceTask | null>(null)

  const [lanePositions, setLanePositions] = useState<
    Readonly<Record<ResourceLane, number>>
  >({} as Record<ResourceLane, number>)

  inputRef.current = input

  const rows = useMemo(() => resourceTasks(input.revision), [input.revision])

  const definition = useMemo(() => resourceTimelineDefinition(input), [input])

  const railWidth = timelineLaneRailWidth(input.width)

  const bodyHeight = timelineBodyHeight(input.height)

  const viewportWidth = Math.max(1, input.width - railWidth)

  const contentWidth = timelineContentWidth(viewportWidth)

  const chartHeight = timelineChartHeight(bodyHeight)

  useLayoutEffect(() => {
    const viewport = viewportRef.current
    if (!viewport) return
    viewport.scrollLeft = Math.min(
      viewport.scrollLeft,
      Math.max(0, viewport.scrollWidth - viewport.clientWidth),
    )
  }, [contentWidth, input.revision, viewportWidth])

  const updateFocusedTask = (point: ChartPoint<ResourceTask> | null) => {
    const viewport = viewportRef.current
    const chartSurface = chartSurfaceRef.current
    const keyboardFocused =
      chartSurface?.querySelector('svg.ts-chart') ===
      chartSurface?.ownerDocument.activeElement
    focusRef.current = {
      taskId: point?.datum.id ?? null,
      centerX: point?.x ?? null,
      scrolled:
        viewport && point && keyboardFocused
          ? ensureTimelineFocusVisible(viewport, point.x)
          : false,
    }
    setFocusedTask(point?.datum ?? null)
  }

  return (
    <div
      style={{
        display: 'grid',
        gridTemplateRows: '42px minmax(0, 1fr)',
        position: 'relative',
        width: input.width,
        height: input.height,
      }}
    >
      <div
        data-conformance-timeline-legend=""
        aria-label="Task status legend"
        style={{
          display: 'flex',
          alignItems: 'center',
          gap: '8px 12px',
          padding: '5px 10px',
          boxSizing: 'border-box',
          overflow: 'hidden',
          borderBottom:
            '1px solid color-mix(in srgb, CanvasText 14%, transparent)',
          background: 'color-mix(in srgb, Canvas 94%, CanvasText 6%)',
          color: 'CanvasText',
          font: '600 11px/1.2 system-ui, sans-serif',
          whiteSpace: 'nowrap',
        }}
      >
        {timelineStatuses.map((status) => (
          <span
            key={status}
            data-conformance-timeline-status={status}
            style={{ display: 'inline-flex', alignItems: 'center', gap: 5 }}
          >
            <span
              style={{
                width: 9,
                height: 9,
                borderRadius: 3,
                background: timelineStatusColors[status],
              }}
            />
            <span>{status[0]?.toUpperCase() + status.slice(1)}</span>
          </span>
        ))}
        <output
          data-conformance-overflow-cue=""
          data-conformance-timeline-details=""
          aria-live="polite"
          aria-atomic="true"
          title={
            focusedTask
              ? taskDetails(focusedTask)
              : 'Scroll horizontally through the schedule'
          }
          style={{
            marginLeft: 'auto',
            overflow: 'hidden',
            opacity: 0.76,
            textOverflow: 'ellipsis',
          }}
        >
          {focusedTask ? taskDetails(focusedTask) : 'Scroll dates →'}
        </output>
      </div>
      <div
        style={{
          display: 'grid',
          gridTemplateColumns: `${railWidth}px minmax(0, 1fr)`,
          minHeight: 0,
        }}
      >
        <div
          data-conformance-lane-rail=""
          aria-label="Resource lanes"
          style={{
            position: 'relative',
            zIndex: 2,
            width: railWidth,
            height: bodyHeight,
            overflow: 'hidden',
            borderRight:
              '1px solid color-mix(in srgb, CanvasText 18%, transparent)',
            background: 'Canvas',
            color: 'CanvasText',
            font: '600 11px/1.15 system-ui, sans-serif',
          }}
        >
          {resourceLanes.map((lane) => (
            <span
              key={lane}
              data-conformance-lane={lane}
              title={lane}
              style={{
                position: 'absolute',
                top: lanePositions[lane],
                left: 8,
                right: 6,
                overflow: 'hidden',
                textOverflow: 'ellipsis',
                transform: 'translateY(-50%)',
                whiteSpace: 'nowrap',
              }}
            >
              {lane}
            </span>
          ))}
        </div>
        <div
          ref={viewportRef}
          data-conformance-view="main"
          data-conformance-scroll-viewport=""
          role="region"
          aria-label="Scrollable resource schedule. Use horizontal scrolling to move through dates."
          tabIndex={0}
          style={{
            width: viewportWidth,
            height: bodyHeight,
            overflowX: 'auto',
            overflowY: 'hidden',
            overscrollBehaviorX: 'contain',
            position: 'relative',
            scrollbarGutter: 'stable',
          }}
        >
          <div
            ref={chartSurfaceRef}
            style={{ width: contentWidth, height: chartHeight }}
          >
            <Chart
              idPrefix={idPrefix}
              definition={definition}
              width={contentWidth}
              height={chartHeight}
              ariaLabel="Tasks scheduled across five resource lanes"
              ariaDescription="Focus the chart and use the arrow, Home, and End keys to inspect tasks. Offscreen tasks scroll into view."
              onFocusChange={updateFocusedTask}
              onRender={({ scene }) => {
                sceneRef.current = scene
                const next = Object.fromEntries(
                  resourceLanes.map((lane) => [lane, scene.scales.y.map(lane)]),
                ) as Record<ResourceLane, number>
                setLanePositions((current) =>
                  resourceLanes.every((lane) => current[lane] === next[lane])
                    ? current
                    : next,
                )
              }}
            />
          </div>
        </div>
      </div>
      <ul aria-label="Task schedule details" style={visuallyHidden}>
        {rows.map((row) => (
          <li key={row.id}>
            {row.resource}: {row.label}, {row.status}, {formatDate(row.start)}{' '}
            through {formatDate(row.end)}
          </li>
        ))}
      </ul>
    </div>
  )
}
cases/85-scrollable-resource-lanes/layout.ts24 lines · dependency
cases/85-scrollable-resource-lanes/layout.ts
export const timelineMargin = {
  top: 18,
  right: 24,
  bottom: 50,
  left: 12,
} as const

const headerHeight = 42

export function timelineBodyHeight(height: number) {
  return Math.max(220, height - headerHeight)
}

export function timelineContentWidth(viewportWidth: number) {
  return Math.max(960, viewportWidth * 2)
}

export function timelineChartHeight(viewportHeight: number) {
  return Math.max(240, viewportHeight - 16)
}

export function timelineLaneRailWidth(width: number) {
  return Math.round(Math.max(96, Math.min(128, width * 0.28)))
}
cases/85-scrollable-resource-lanes/scenario.ts92 lines · dependency
cases/85-scrollable-resource-lanes/scenario.ts
export const resourceLanes = [
  'Design',
  'Infrastructure',
  'API',
  'Quality',
  'Docs',
] as const

export type ResourceLane = (typeof resourceLanes)[number]

export const timelineStatuses = ['planned', 'active', 'review'] as const

export type TimelineStatus = (typeof timelineStatuses)[number]

export interface ResourceTask {
  id: string
  resource: ResourceLane
  label: string
  start: Date
  end: Date
  status: TimelineStatus
}

const day = 86_400_000

export const resourceTimelineDomain: readonly [Date, Date] = [
  utcDay(1),
  utcDay(74),
]

const initialTasks: readonly ResourceTask[] = [
  task('design-plan', 'Design', 'Experience plan', 2, 11, 'planned'),
  task('design-system', 'Design', 'Interface system', 34, 49, 'active'),
  task(
    'infra-foundation',
    'Infrastructure',
    'Runtime foundation',
    4,
    16,
    'active',
  ),
  task(
    'infra-hardening',
    'Infrastructure',
    'Runtime hardening',
    55,
    68,
    'review',
  ),
  task('api-contract', 'API', 'Contract review', 1, 9, 'review'),
  task('api-build', 'API', 'Endpoint build', 27, 44, 'active'),
  task('quality-fixtures', 'Quality', 'Fixture suite', 7, 18, 'planned'),
  task('quality-release', 'Quality', 'Release checks', 46, 59, 'review'),
  task('docs-outline', 'Docs', 'Guide outline', 3, 14, 'planned'),
  task('docs-publish', 'Docs', 'Publish guides', 60, 72, 'active'),
]

export function resourceTasks(revision = 0): readonly ResourceTask[] {
  if (revision % 2 === 0) return initialTasks

  return initialTasks.map((row) => {
    if (row.id === 'api-build') {
      return { ...row, end: new Date(row.end.getTime() + day * 3) }
    }
    if (row.id === 'quality-release') {
      return { ...row, start: new Date(row.start.getTime() - day * 2) }
    }
    return row
  })
}

function task(
  id: string,
  resource: ResourceLane,
  label: string,
  startDay: number,
  endDay: number,
  status: TimelineStatus,
): ResourceTask {
  return {
    id,
    resource,
    label,
    start: utcDay(startDay),
    end: utcDay(endDay),
    status,
  }
}

function utcDay(dayOfYear: number) {
  return new Date(Date.UTC(2025, 0, dayOfYear))
}