TanStack
Catalog

Editable event range

interaction

644 lines · 4 files · 17.8 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/example.tsx531 lines · entry
cases/92-editable-event-range/example.tsx
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Chart } from '@tanstack/charts/react'
import {
  clampEditableEventEnd,
  editableDateFromAnchor,
  editableDateKey,
  editableEventEndValues,
} from './model'
import { editableEvents, initialEditableEventEnd } from './scenario'
import type { ChartScene } from '@tanstack/charts'
import type { HandleXChange } from '@tanstack/charts/interaction/handle'
import type { FormEvent, KeyboardEvent, PointerEvent } from 'react'
import type { EditableEvent } from './scenario'

export interface EditableState {
  end: Date
  editing: boolean
  editCount: number
  originEnd: Date | null
}
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 { editableDurationDays } from './model'
import { editableDomain, editableEventStart, editableLanes } from './scenario'
const validationMessage = 'Choose a release end date within the range.'

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

export interface EditableChartInput extends ExampleChartInput {
  end: Date
}

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,
    }
  })
}

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())
}

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

export default function EditableEventExample({
  width = 640,
  height = 480,
  revision = 0,
}: ExampleProps = {}) {
  const input = { width, height, revision, preview: false, interactive: true }
  const idPrefix = '92-editable-event-range'
  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])

  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>
  )
}
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))
}