TanStack
Catalog

Editable event range

interaction

TanStack

TanStack source

931 chart lines · 5 files · 26.4 kB

cases/92-editable-event-range/tanstack.ts543 lines · entry
cases/92-editable-event-range/tanstack.ts
import { defineChart, mountChart, rect, text } from '@tanstack/charts'
import { focusDisabled } from '@tanstack/charts/focus/disabled'
import { scaleBand, scaleUtc } from 'd3-scale'
import { editableEventColor } from './colors'
import {
  editableDomain,
  editableEvents,
  editableEventStart,
  editableLanes,
  initialEditableEventEnd,
} from './scenario'
import {
  clampEditableEventEnd,
  editableDateFromAnchor,
  editableDateKey,
  editableDurationDays,
} from './model'
import { createEditableHandleOverlay } from './overlay'
import type { ChartHost, ChartScene, ChartHostOptions } from '@tanstack/charts'
import type { EditableEvent } from './scenario'
import type { EditableHandleLayout } from './overlay'
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 millisecondsPerDay = 86_400_000

const definition = (input: EditableChartInput) => {
  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, {
          x1: 'start',
          x2: 'end',
          y: 'lane',
          color: 'id',
          radius: 5,
          stroke: '#ffffff',
          strokeWidth: 1,
        }),
        text(outsideLabels, {
          x: 'labelDate',
          y: 'lane',
          text: 'label',
          anchor: 'start',
          dx: 5,
          fill: 'currentColor',
          fontSize: 10,
          fontWeight: 600,
        }),
        text(releaseLabels, {
          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'),
        ],
      },
      margin,
    }
  })
}

export const mount: ConformanceMount = (container, input) => {
  let currentInput = input
  const state: EditableState = {
    end: initialEditableEventEnd,
    editing: false,
    editCount: 0,
    originEnd: null,
  }
  const view = container.ownerDocument.createElement('div')
  view.dataset.conformanceView = 'main'
  view.style.position = 'relative'
  view.style.touchAction = 'pan-y'
  sizeView(view, input)

  const chartSurface = container.ownerDocument.createElement('div')
  view.append(chartSurface)
  container.append(view)

  const options = (): ChartHostOptions<EditableEvent> => ({
    definition: defineChart(
      definition({
        ...currentInput,
        end: state.end,
      }),
      { animate: false, keyboard: false, focus: focusDisabled },
    ),
    width: currentInput.width,
    height: currentInput.height,
    ariaLabel: editableAriaLabel(currentInput.revision, state.end),
  })
  const host = mountChart(chartSurface, options())
  const interactions = createEditableInteractions(
    chartSurface,
    view,
    () => currentInput,
    state,
    host,
    options,
  )
  interactions.paint()

  return {
    driver: interactions.driver,
    update(nextInput) {
      currentInput = nextInput
      sizeView(view, nextInput)
      host.update(options())
      interactions.paint()
    },
    destroy() {
      interactions.destroy()
      host.destroy()
      view.remove()
    },
  }
}

function createEditableInteractions(
  chartSurface: HTMLDivElement,
  view: HTMLDivElement,
  getInput: () => ConformanceInput,
  state: EditableState,
  host: ChartHost<EditableEvent>,
  options: () => ChartHostOptions<EditableEvent>,
) {
  let paint = () => {}
  const beginEdit = () => {
    if (state.editing) return
    state.originEnd = state.end
    state.editing = true
  }
  const setEnd = (date: Date) => {
    state.end = clampEditableEventEnd(date)
    host.update(options())
    paint()
  }
  const overlay = createEditableHandleOverlay(
    view,
    (value) => {
      beginEdit()
      setEnd(new Date(editableDomain[0].getTime() + value * millisecondsPerDay))
    },
    (value) => {
      const date = editableDateFromAnchor(`date:${value}`)
      if (!date) return false
      beginEdit()
      setEnd(date)
      return true
    },
  )
  const layout = () =>
    editableLayout(chartSurface, view, host.getScene(), state.end)
  paint = () => {
    const nextLayout = layout()
    if (!nextLayout) return
    overlay.paint(nextLayout, {
      value: editableDayIndex(state.end),
      min: editableDayIndex(
        new Date(editableEventStart.getTime() + millisecondsPerDay),
      ),
      max: editableDayIndex(editableDomain[1]),
      date: editableDateKey(state.end),
      minDate: editableDateKey(
        new Date(editableEventStart.getTime() + millisecondsPerDay),
      ),
      maxDate: editableDateKey(editableDomain[1]),
      valueText: `Release: ${editableDateKey(editableEventStart)}${editableDateKey(state.end)} · ${editableDurationDays(editableEventStart, state.end)} days`,
      summaryText: `Release · ${compactDate(editableEventStart)}${compactDate(state.end)} · ${editableDurationDays(editableEventStart, state.end)} days`,
      eventDescriptions: editableEvents(getInput().revision, state.end).map(
        (row) =>
          `${row.label}: ${editableDateKey(row.start)} to ${editableDateKey(row.end)}`,
      ),
    })
  }

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

  const cancelEdit = () => {
    if (!state.editing) return
    const originEnd = state.originEnd
    state.editing = false
    state.originEnd = null
    if (originEnd) setEnd(originEnd)
    else paint()
  }

  overlay.range.addEventListener('pointerdown', beginEdit)
  overlay.range.addEventListener('change', commitEdit)
  overlay.range.addEventListener('pointercancel', cancelEdit)
  overlay.dateInput.addEventListener('change', commitEdit)
  overlay.dateInput.addEventListener('pointercancel', cancelEdit)

  const driver: ConformanceTestDriver = {
    resolveTarget(target) {
      return resolveTarget(
        chartSurface,
        view,
        host.getScene(),
        state.end,
        target,
      )
    },
    readState() {
      return interactionState(state, getInput())
    },
    geometry(query) {
      return editableGeometry(
        chartSurface,
        view,
        host.getScene(),
        getInput(),
        state.end,
        layout(),
        overlay.handleGeometry(),
        overlay.trackGeometry(),
        query,
      )
    },
    viewBounds(viewName) {
      return viewName === undefined || viewName === 'main'
        ? clientBounds(view)
        : null
    },
    settle: paint,
  }

  return {
    driver,
    paint,
    destroy() {
      overlay.range.removeEventListener('pointerdown', beginEdit)
      overlay.range.removeEventListener('change', commitEdit)
      overlay.range.removeEventListener('pointercancel', cancelEdit)
      overlay.dateInput.removeEventListener('change', commitEdit)
      overlay.dateInput.removeEventListener('pointercancel', cancelEdit)
      overlay.destroy()
    },
  }
}

function resolveTarget(
  chartSurface: HTMLDivElement,
  view: HTMLDivElement,
  scene: ChartScene<EditableEvent>,
  end: Date,
  target: ConformanceTarget,
) {
  if (target.view !== undefined && target.view !== 'main') return null
  if (target.anchor === 'control:date') {
    const dateInput = view.querySelector<HTMLInputElement>(
      '.ts-conformance-event-date',
    )
    if (!dateInput) return null
    const bounds = dateInput.getBoundingClientRect()
    return {
      x: bounds.left + bounds.width / 2,
      y: bounds.top + bounds.height / 2,
      focusElement: dateInput,
    }
  }
  const date =
    target.anchor === 'event:release:end'
      ? end
      : editableDateFromAnchor(target.anchor)
  if (!date) return null
  const point = sceneLocalPoint(
    chartSurface,
    view,
    scene,
    scene.scales.x.map(date),
    scene.scales.y.map('Engineering'),
  )
  if (!point) return null
  const bounds = view.getBoundingClientRect()
  return {
    x: bounds.left + point[0],
    y: bounds.top + point[1],
    focusElement:
      view.querySelector<HTMLInputElement>('.ts-conformance-event-range') ??
      undefined,
  }
}

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,
  view: HTMLDivElement,
  scene: ChartScene<EditableEvent>,
  input: ConformanceInput,
  end: Date,
  layout: EditableHandleLayout | null,
  overlayHandle: ConformanceGeometrySample,
  overlayTrack: ConformanceGeometrySample,
  query: ConformanceGeometryQuery,
): readonly ConformanceGeometrySample[] {
  if (!layout || (query.view !== undefined && query.view !== 'main')) {
    return []
  }
  if (query.role === 'dot') return [overlayHandle]
  if (query.role === 'rule') return [overlayTrack]
  if (query.role !== 'rect') return []
  const viewBounds = view.getBoundingClientRect()
  const height = scene.scales.y.bandwidth
  return editableEvents(input.revision, end).flatMap((row) => {
    const start = sceneLocalPoint(
      chartSurface,
      view,
      scene,
      scene.scales.x.map(row.start),
      scene.scales.y.map(row.lane),
    )
    const finish = sceneLocalPoint(
      chartSurface,
      view,
      scene,
      scene.scales.x.map(row.end),
      scene.scales.y.map(row.lane),
    )
    const top = sceneLocalPoint(
      chartSurface,
      view,
      scene,
      scene.scales.x.map(row.start),
      scene.scales.y.map(row.lane) - height / 2,
    )
    const bottom = sceneLocalPoint(
      chartSurface,
      view,
      scene,
      scene.scales.x.map(row.start),
      scene.scales.y.map(row.lane) + height / 2,
    )
    if (!start || !finish || !top || !bottom) return []
    return [
      {
        x: viewBounds.left + Math.min(start[0], finish[0]),
        y: viewBounds.top + Math.min(top[1], bottom[1]),
        width: Math.abs(finish[0] - start[0]),
        height: Math.abs(bottom[1] - top[1]),
        paint: editableEventColor(row.id),
      },
    ]
  })
}

function editableLayout(
  chartSurface: HTMLDivElement,
  view: HTMLDivElement,
  scene: ChartScene<EditableEvent>,
  end: Date,
): EditableHandleLayout | null {
  const point = sceneLocalPoint(
    chartSurface,
    view,
    scene,
    scene.scales.x.map(end),
    scene.scales.y.map('Engineering'),
  )
  const minimum = sceneLocalPoint(
    chartSurface,
    view,
    scene,
    scene.scales.x.map(
      new Date(editableEventStart.getTime() + millisecondsPerDay),
    ),
    scene.scales.y.map('Engineering'),
  )
  const maximum = sceneLocalPoint(
    chartSurface,
    view,
    scene,
    scene.scales.x.map(editableDomain[1]),
    scene.scales.y.map('Engineering'),
  )
  return point && minimum && maximum
    ? { x: point[0], y: point[1], minX: minimum[0], maxX: maximum[0] }
    : null
}

function sceneLocalPoint(
  chartSurface: HTMLDivElement,
  view: HTMLDivElement,
  scene: ChartScene<EditableEvent>,
  sceneX: number,
  sceneY: number,
): readonly [number, number] | null {
  const svg = chartSurface.querySelector<SVGSVGElement>('svg.ts-chart')
  if (!svg) return null
  const svgBounds = svg.getBoundingClientRect()
  const viewBounds = view.getBoundingClientRect()
  return [
    svgBounds.left - viewBounds.left + (sceneX / scene.width) * svgBounds.width,
    svgBounds.top - viewBounds.top + (sceneY / scene.height) * svgBounds.height,
  ]
}

function editableDayIndex(date: Date) {
  return Math.round(
    (date.getTime() - editableDomain[0].getTime()) / millisecondsPerDay,
  )
}

function clientBounds(element: HTMLElement): ConformanceGeometrySample {
  const bounds = element.getBoundingClientRect()
  return {
    x: bounds.left,
    y: bounds.top,
    width: bounds.width,
    height: bounds.height,
  }
}

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

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
}
cases/92-editable-event-range/colors.ts5 lines · support
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.ts36 lines · support
cases/92-editable-event-range/model.ts
import { utcDay } from 'd3-time'
import { editableDomain, editableEventStart } from './scenario'

const day = 86_400_000

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/overlay.ts280 lines · support
cases/92-editable-event-range/overlay.ts
export interface EditableHandleLayout {
  x: number
  y: number
  minX: number
  maxX: number
}

export interface EditableHandleState {
  value: number
  min: number
  max: number
  date: string
  minDate: string
  maxDate: string
  valueText: string
  summaryText: string
  eventDescriptions: readonly string[]
}

export function createEditableHandleOverlay(
  view: HTMLDivElement,
  onRangeInput: (value: number) => void,
  onDateInput: (value: string) => boolean,
) {
  const document = view.ownerDocument
  const style = document.createElement('style')
  style.textContent = `
    .ts-conformance-event-range {
      appearance: none;
      -webkit-appearance: none;
      background: transparent;
      border: 0;
      color-scheme: light dark;
      cursor: ew-resize;
      margin: 0;
      padding: 0;
    }
    .ts-conformance-event-range::-webkit-slider-runnable-track {
      height: 2px;
      background: transparent;
    }
    .ts-conformance-event-range::-webkit-slider-thumb {
      appearance: none;
      -webkit-appearance: none;
      width: 20px;
      height: 20px;
      margin-top: -9px;
      border: 0;
      background: transparent;
    }
    .ts-conformance-event-range::-moz-range-track {
      height: 2px;
      background: transparent;
    }
    .ts-conformance-event-range::-moz-range-thumb {
      width: 20px;
      height: 20px;
      border: 0;
      background: transparent;
    }
    .ts-conformance-event-range:focus-visible {
      outline: none;
    }
    .ts-conformance-event-range:focus-visible
      + .ts-conformance-event-handle {
      outline: 3px solid var(--ts-chart-1, #2563eb);
      outline-offset: 3px;
    }
    .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')
  layer.style.position = 'absolute'
  layer.style.inset = '0'
  layer.style.zIndex = '3'
  layer.style.pointerEvents = 'none'

  const track = document.createElement('div')
  track.className = 'ts-conformance-event-track'
  track.style.position = 'absolute'
  track.style.height = '4px'
  track.style.borderRadius = '999px'
  track.style.background =
    'color-mix(in srgb, var(--ts-chart-2, #f97316) 58%, transparent)'

  const range = document.createElement('input')
  range.className = 'ts-conformance-event-range'
  range.type = 'range'
  range.step = '1'
  range.setAttribute('aria-label', 'Release end handle')
  range.style.position = 'absolute'
  range.style.height = '44px'
  range.style.pointerEvents = 'auto'

  const handle = document.createElement('div')
  handle.className = 'ts-conformance-event-handle'
  handle.style.position = 'absolute'
  handle.style.boxSizing = 'border-box'
  handle.style.width = '20px'
  handle.style.height = '20px'
  handle.style.border = '2px solid Canvas'
  handle.style.borderRadius = '999px'
  handle.style.background = 'var(--ts-chart-2, #f97316)'
  handle.style.boxShadow = '0 1px 4px rgb(15 23 42 / 0.35)'

  const toolbar = document.createElement('div')
  toolbar.className = 'ts-conformance-event-toolbar'
  toolbar.setAttribute('role', 'group')
  toolbar.setAttribute('aria-label', 'Release event editor')
  toolbar.style.position = 'absolute'
  toolbar.style.top = '4px'
  toolbar.style.left = '12px'
  toolbar.style.right = '12px'
  toolbar.style.display = 'flex'
  toolbar.style.flexWrap = 'wrap'
  toolbar.style.alignItems = 'flex-end'
  toolbar.style.justifyContent = 'flex-end'
  toolbar.style.gap = '8px'
  toolbar.style.color = 'inherit'
  toolbar.style.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')
  status.style.boxSizing = 'border-box'
  status.style.flex = '1 1 120px'
  status.style.minWidth = '120px'
  status.style.minHeight = '44px'
  status.style.padding = '8px 10px'
  status.style.borderRadius = '10px'
  status.style.display = 'flex'
  status.style.alignItems = 'center'
  status.style.font = '600 12px/1.25 system-ui, sans-serif'

  const dateLabel = document.createElement('label')
  dateLabel.style.boxSizing = 'border-box'
  dateLabel.style.flex = '0 1 140px'
  dateLabel.style.minWidth = '128px'
  dateLabel.style.display = 'grid'
  dateLabel.style.gap = '2px'
  dateLabel.style.color = 'inherit'
  dateLabel.style.font = '600 11px/1.15 system-ui, sans-serif'
  dateLabel.style.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')
  dateInput.style.boxSizing = 'border-box'
  dateInput.style.width = '100%'
  dateInput.style.height = '44px'
  dateInput.style.padding = '6px 8px'
  dateInput.style.borderRadius = '8px'
  dateInput.style.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
  validation.style.flex = '1 0 100%'
  validation.style.color = '#dc2626'
  validation.style.font = '600 11px/1.2 system-ui, sans-serif'

  const eventList = document.createElement('ul')
  eventList.className = 'ts-conformance-event-identities'
  eventList.style.position = 'absolute'
  eventList.style.width = '1px'
  eventList.style.height = '1px'
  eventList.style.padding = '0'
  eventList.style.margin = '-1px'
  eventList.style.overflow = 'hidden'
  eventList.style.clipPath = 'inset(50%)'
  eventList.style.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 handleRangeInput = () => {
    setDateValidity(true)
    onRangeInput(Number(range.value))
  }
  const handleDateInput = () => {
    setDateValidity(onDateInput(dateInput.value))
  }
  range.addEventListener('input', handleRangeInput)
  dateInput.addEventListener('input', handleDateInput)

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

  return {
    range,
    dateInput,
    handleGeometry() {
      return measureElement(handle)
    },
    trackGeometry() {
      return measureElement(track)
    },
    paint(layout: EditableHandleLayout, state: EditableHandleState) {
      track.style.left = `${layout.minX}px`
      track.style.top = `${layout.y - 2}px`
      track.style.width = `${Math.max(0, layout.maxX - layout.minX)}px`

      range.min = String(state.min)
      range.max = String(state.max)
      range.value = String(state.value)
      range.setAttribute('aria-valuetext', state.valueText)
      range.style.left = `${layout.minX - 10}px`
      range.style.top = `${layout.y - 22}px`
      range.style.width = `${Math.max(0, layout.maxX - layout.minX + 20)}px`

      handle.style.left = `${layout.x - 10}px`
      handle.style.top = `${layout.y - 10}px`

      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() {
      range.removeEventListener('input', handleRangeInput)
      dateInput.removeEventListener('input', handleDateInput)
      style.remove()
      layer.remove()
    },
  }
}

function measureElement(element: HTMLElement) {
  const bounds = element.getBoundingClientRect()
  return {
    x: bounds.left,
    y: bounds.top,
    width: bounds.width,
    height: bounds.height,
    paint: getComputedStyle(element).backgroundColor,
  }
}
cases/92-editable-event-range/scenario.ts67 lines · support
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))
}