Orbex Sonar-DB Design system , home

As a list, on phones

Edit layout on a phone

Edit layout

Drag to reorder. Switch modules on or off and pick their size.

On your overview6

  • Orders
  • Daily shipments
  • Balance
  • Deliveries
  • Recurring orders
  • Inventory

Hidden

  • Notifications
  • Invoices
  • Latest documents
  • Credit notes
  • Scheduled orders

Discard changes?

The changes you made to the layout will be lost.

Code
tsx
import { Button } from '@orbex/sonar-components/button'
import { Dialog } from '@orbex/sonar-components/dialog'
import { Switch } from '@orbex/sonar-components/switch'
import { ToastProvider, useToast } from '@orbex/sonar-components/toast'
import { useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent, type PointerEvent } from 'react'
import type { ComponentProps } from 'react'
import dragSymbol from '@material-symbols/svg-400/rounded/drag_indicator.svg?raw'

/* Material Symbols Rounded, weight 400, as icon components (Foundations › Icons) */
const symbol = (svg: string) => {
  const path = svg.match(/ d="([^"]+)"/)?.[1]
  return (props: ComponentProps<'svg'>) => (
    <svg viewBox="0 -960 960 960" fill="currentColor" aria-hidden="true" focusable="false" {...props}>
      <path d={path} />
    </svg>
  )
}

const DragIcon = symbol(dragSymbol)

/*
 * Edit layout on a phone: the overview becomes a list. Each row is one line: a grip to drag it, the card’s name, its
 * sizes and a switch to show or hide it. Hidden cards wait in a list of their own. Reset to default, Cancel and Save
 * layout stay at the foot.
 */

type Size = 'S' | 'M' | 'L' | 'XL'
export interface Entry {
  id: string
  title: string
  size: Size
  sizes: Size[]
  hidden?: boolean
}

const reducedMotion = () => matchMedia('(prefers-reduced-motion: reduce)').matches

const HEIGHTS: Record<Size, number> = { S: 245, M: 375, L: 405, XL: 535 }

/* The default overview in reading order, then the cards that can be added */
const DEFAULT: Entry[] = [
  { id: 'orders', title: 'Orders', size: 'M', sizes: ['S', 'M'] },
  { id: 'shipments', title: 'Daily shipments', size: 'XL', sizes: ['L', 'XL'] },
  { id: 'balance', title: 'Balance', size: 'M', sizes: ['S', 'M', 'L'] },
  { id: 'deliveries', title: 'Deliveries', size: 'L', sizes: ['M', 'L', 'XL'] },
  { id: 'recurring', title: 'Recurring orders', size: 'L', sizes: ['M', 'L', 'XL'] },
  { id: 'inventory', title: 'Inventory', size: 'S', sizes: ['S', 'M'] },
  { id: 'notifications', title: 'Notifications', size: 'L', sizes: ['L'], hidden: true },
  { id: 'invoices', title: 'Invoices', size: 'L', sizes: ['L'], hidden: true },
  { id: 'documents', title: 'Latest documents', size: 'L', sizes: ['L'], hidden: true },
  { id: 'credits', title: 'Credit notes', size: 'L', sizes: ['L'], hidden: true },
  { id: 'scheduled', title: 'Scheduled orders', size: 'L', sizes: ['L'], hidden: true },
]

export interface EditListProps {
  /** The list to edit (the app shell's phone passes its overview); the default overview otherwise */
  initial?: Entry[]
  /** Inside the app shell's phone: Save layout and Cancel hand the list back (null: canceled) instead of staying here */
  onDone?: (list: Entry[] | null) => void
  /** The scale the phone frame is drawn at; a finger's moves are divided by it */
  zoom?: number
}

function Editor({ initial = DEFAULT, onDone, zoom = 1 }: EditListProps) {
  const toast = useToast()
  const [saved, setSaved] = useState(initial)
  const [list, setList] = useState(initial)
  /* A drag: the row follows the finger by y; the rows between its place and to slide by its height to make room */
  const [drag, setDrag] = useState<{ id: string; from: number; to: number; y: number; height: number } | null>(null)
  const [settle, setSettle] = useState<{ id: string; y: number } | null>(null)
  const [picked, setPicked] = useState<{ id: string; before: Entry[] } | null>(null)
  const [ask, setAsk] = useState<'reset' | 'discard' | null>(null)
  const [announcement, setAnnouncement] = useState('')
  const gesture = useRef<{ id: string; pointer: number; startY: number; from: number; centers: number[]; height: number; to: number; y: number; stop: () => void } | null>(null)
  const listRef = useRef<HTMLUListElement>(null)
  const shown = list.filter((entry) => !entry.hidden)
  const hidden = list.filter((entry) => entry.hidden)
  const dirty = JSON.stringify(list) !== JSON.stringify(saved)

  const setSize = (id: string, size: Size) => setList((current) => current.map((entry) => (entry.id === id ? { ...entry, size } : entry)))

  /* Showing a card puts it at the end of the shown ones; hiding it leaves it where it was */
  const toggle = (id: string, on: boolean) =>
    setList((current) => {
      const next = current.map((entry) => ({ ...entry }))
      const index = next.findIndex((entry) => entry.id === id)
      const [entry] = next.splice(index, 1)
      entry!.hidden = !on
      if (on) next.splice(next.reduce((last, other, at) => (other.hidden ? last : at), -1) + 1, 0, entry!)
      else next.splice(index, 0, entry!)
      return next
    })

  const moveTo = (id: string, place: number) =>
    setList((current) => {
      const visible = current.filter((entry) => !entry.hidden && entry.id !== id)
      visible.splice(Math.max(0, Math.min(place, visible.length)), 0, current.find((entry) => entry.id === id)!)
      return [...visible, ...current.filter((entry) => entry.hidden)]
    })

  /*
   * Pointer, with a finger or a mouse: the row lifts and follows; the others slide out of its way. The order only
   * changes when the row is let go, so nothing in the list moves under the finger while it holds the row. The gesture
   * ends however the browser ends it (up, cancel, lost capture, touchend) and always clears.
   */
  const end = (commit: boolean) => {
    const current = gesture.current
    if (!current) return
    gesture.current = null
    current.stop()
    const entry = list.find((other) => other.id === current.id)!
    if (commit && current.to !== current.from) {
      /* The row lands in its new place from where the finger left it */
      const offset = (current.to - current.from) * current.height
      moveTo(current.id, current.to)
      setSettle({ id: current.id, y: current.y - offset })
      setAnnouncement(`${entry.title} dropped at position ${current.to + 1} of ${shown.length}.`)
    } else {
      setSettle(current.y ? { id: current.id, y: current.y } : null)
      if (commit) setAnnouncement(`${entry.title} dropped at position ${current.from + 1} of ${shown.length}.`)
    }
    setDrag(null)
  }
  const onDown = (id: string) => (event: PointerEvent<HTMLButtonElement>) => {
    if (event.button !== 0 || gesture.current) return
    event.preventDefault()
    const handle = event.currentTarget
    handle.setPointerCapture?.(event.pointerId)
    const rows = [...(listRef.current?.querySelectorAll<HTMLElement>(':scope > .edit-list-row') ?? [])]
    const from = rows.findIndex((row) => row.dataset.rowId === id)
    const boxes = rows.map((row) => row.getBoundingClientRect())
    const height = boxes[from]?.height ?? 56
    const pointer = event.pointerId
    const move = (next: globalThis.PointerEvent) => {
      const current = gesture.current
      if (!current || next.pointerId !== current.pointer) return
      next.preventDefault()
      const y = next.clientY - current.startY
      const center = current.centers[current.from]! + y
      /* Its place: after every other row whose middle it has passed */
      const to = current.centers.reduce((place, other, index) => (index === current.from ? place : center > other ? place + 1 : place), 0)
      current.y = y
      current.to = to
      setDrag({ id, from: current.from, to, y, height: current.height })
    }
    const up = (next: globalThis.PointerEvent) => next.pointerId === pointer && end(next.type === 'pointerup')
    const lost = () => end(true)
    const touchEnd = (next: TouchEvent) => next.touches.length === 0 && end(true)
    window.addEventListener('pointermove', move, { passive: false })
    window.addEventListener('pointerup', up)
    window.addEventListener('pointercancel', up)
    window.addEventListener('touchend', touchEnd)
    window.addEventListener('touchcancel', touchEnd)
    handle.addEventListener('lostpointercapture', lost)
    gesture.current = {
      id,
      pointer,
      startY: event.clientY,
      from,
      centers: boxes.map((box) => box.top + box.height / 2),
      height,
      to: from,
      y: 0,
      stop: () => {
        window.removeEventListener('pointermove', move)
        window.removeEventListener('pointerup', up)
        window.removeEventListener('pointercancel', up)
        window.removeEventListener('touchend', touchEnd)
        window.removeEventListener('touchcancel', touchEnd)
        handle.removeEventListener('lostpointercapture', lost)
        if (handle.hasPointerCapture?.(pointer)) handle.releasePointerCapture(pointer)
      },
    }
    setSettle(null)
    setDrag({ id, from, to: from, y: 0, height })
  }
  /* A gesture still open when the list goes away is closed with it */
  useEffect(() => () => gesture.current?.stop(), [])
  /* A document-wide touchmove listener, in place before a finger lands, refuses the page's scroll while a row is held:
     iOS only lets a move be cancelled when such a listener exists as the touch starts (1.0.0-alpha.21, as EditCards) */
  useEffect(() => {
    const hold = (event: TouchEvent) => {
      if (gesture.current && event.cancelable) event.preventDefault()
    }
    document.addEventListener('touchmove', hold, { passive: false })
    return () => document.removeEventListener('touchmove', hold)
  }, [])
  /* Let go: the row glides from under the finger into its slot, then drops its lift */
  useLayoutEffect(() => {
    if (!settle) return
    const row = listRef.current?.querySelector<HTMLElement>(`[data-row-id="${settle.id}"]`)
    if (!row || !settle.y || reducedMotion()) {
      setSettle(null)
      return
    }
    const glide = row.animate([{ translate: `0 ${settle.y / zoom}px` }, { translate: '0 0' }], { duration: 220, easing: 'cubic-bezier(0.2, 0, 0, 1)' })
    glide.onfinish = glide.oncancel = () => setSettle((current) => (current?.id === settle.id ? null : current))
    return () => glide.cancel()
  }, [settle])

  /* Keyboard: Space picks the row up and drops it, the arrow keys move it, Escape puts it back */
  const onKey = (entry: Entry, index: number) => (event: KeyboardEvent<HTMLButtonElement>) => {
    if (event.key === ' ' || event.key === 'Enter') {
      event.preventDefault()
      if (picked?.id === entry.id) {
        setPicked(null)
        setAnnouncement(`${entry.title} dropped at position ${index + 1} of ${shown.length}.`)
      } else {
        setPicked({ id: entry.id, before: list })
        setAnnouncement(`Picked up ${entry.title}. Use the arrow keys to move it, Space to drop it, Escape to cancel.`)
      }
      return
    }
    if (picked?.id !== entry.id) return
    const by = event.key === 'ArrowUp' ? -1 : event.key === 'ArrowDown' ? 1 : 0
    if (by) {
      event.preventDefault()
      const place = Math.max(0, Math.min(index + by, shown.length - 1))
      moveTo(entry.id, place)
      setAnnouncement(`${entry.title} moved to position ${place + 1} of ${shown.length}.`)
      requestAnimationFrame(() => document.querySelector<HTMLElement>(`[data-row-id="${entry.id}"] .edit-list-grip`)?.focus())
    } else if (event.key === 'Escape') {
      event.preventDefault()
      setList(picked.before)
      setPicked(null)
      setAnnouncement(`Moving ${entry.title} canceled.`)
    }
  }

  const save = () => {
    if (onDone) return onDone(list)
    const previous = saved
    setSaved(list)
    toast({ message: 'Layout saved', action: { label: 'Undo', onClick: () => (setSaved(previous), setList(previous)) } })
  }

  /* While a row is dragged: the row under the finger, and the rows between its place and its target slide by its height */
  const shift = (entry: Entry, index: number) => {
    if (!drag || entry.hidden) return undefined
    if (entry.id === drag.id) return { translate: `0 ${drag.y / zoom}px`, transition: 'none' }
    if (index > drag.from && index <= drag.to) return { translate: `0 ${-drag.height / zoom}px` }
    if (index < drag.from && index >= drag.to) return { translate: `0 ${drag.height / zoom}px` }
    return undefined
  }
  const row = (entry: Entry, index: number) => {
    const on = !entry.hidden
    const lifted = drag?.id === entry.id || settle?.id === entry.id
    return (
      <li
        key={entry.id}
        className="edit-list-row"
        data-row-id={entry.id}
        data-off={on ? undefined : ''}
        data-lifted={lifted || picked?.id === entry.id || undefined}
        style={shift(entry, index)}
      >
        {on ? (
          <button
            type="button"
            className="edit-list-grip"
            aria-label={`Move ${entry.title}`}
            aria-pressed={picked?.id === entry.id}
            onPointerDown={onDown(entry.id)}
            onKeyDown={onKey(entry, index)}
          >
            <DragIcon />
          </button>
        ) : (
          <span className="edit-list-grip" aria-hidden="true" />
        )}
        <span className="edit-list-name">{entry.title}</span>
        {on && entry.sizes.length > 1 && (
          <span className="edit-list-sizes" role="group" aria-label={`Size of ${entry.title}`}>
            {entry.sizes.map((size) => (
              <button key={size} type="button" aria-pressed={entry.size === size} title={`${HEIGHTS[size]} px`} onClick={() => setSize(entry.id, size)}>
                {size}
              </button>
            ))}
          </span>
        )}
        <Switch label={<span className="visually-hidden">{`Show ${entry.title}`}</span>} checked={on} onChange={(event) => toggle(entry.id, event.currentTarget.checked)} />
      </li>
    )
  }

  return (
    <div className="edit-list">
      <div className="edit-list-head">
        <h3>Edit layout</h3>
        <p>Drag to reorder. Switch modules on or off and pick their size.</p>
      </div>
      <h4 className="edit-list-label">
        On your overview<span aria-hidden="true">|</span>
        <span className="edit-list-count">{shown.length}</span>
      </h4>
      <ul ref={listRef} className="edit-list-rows" role="list" data-dragging={drag ? '' : undefined} data-settling={settle && !drag ? '' : undefined}>
        {shown.map(row)}
      </ul>
      {hidden.length > 0 && (
        <>
          <h4 className="edit-list-label">Hidden</h4>
          <ul className="edit-list-rows" role="list">
            {hidden.map(row)}
          </ul>
        </>
      )}
      <p className="visually-hidden" aria-live="polite">
        {announcement}
      </p>
      <div className="edit-list-bar">
        <button type="button" className="edit-reset" onClick={() => setAsk('reset')}>
          Reset to default
        </button>
        <Button size="small" variant="outlined" className="edit-cancel" onClick={() => (dirty ? setAsk('discard') : onDone ? onDone(null) : setList(saved))}>
          Cancel
        </Button>
        <Button size="small" disabled={!dirty} onClick={save}>
          Save layout
        </Button>
      </div>
      <Dialog
        open={ask !== null}
        onOpenChange={(open) => !open && setAsk(null)}
        title={ask === 'reset' ? 'Reset to default layout?' : 'Discard changes?'}
        description={ask === 'reset' ? 'All cards go back to their original position and size. Changes you have not saved will be lost.' : 'The changes you made to the layout will be lost.'}
        actions={
          <>
            <Button variant="text" onClick={() => setAsk(null)}>
              {ask === 'reset' ? 'Cancel' : 'Keep editing'}
            </Button>
            <Button
              onClick={() => {
                if (ask !== 'reset' && onDone) onDone(null)
                else setList(ask === 'reset' ? DEFAULT : saved)
                setAsk(null)
              }}
            >
              {ask === 'reset' ? 'Reset' : 'Discard'}
            </Button>
          </>
        }
      />
    </div>
  )
}

/** Edit layout inside the app shell's phone frame */
export function EditListPanel(props: EditListProps) {
  return (
    <ToastProvider>
      <Editor {...props} />
    </ToastProvider>
  )
}

/** The phone's default overview, in reading order, then the cards that can be added */
export const LIST_DEFAULT = DEFAULT

export default function EditList() {
  return (
    <ToastProvider>
      <Editor />
    </ToastProvider>
  )
}

On desktops and tablets

Edit layout at real size, on a desktop or a tablet grid
Screen

Overview

Orders

Open total6,720 plt

May 2026

  • 1,420 pltshipped
  • 80 plton hold
  • 1,100 pltpending

June 2026

  • 1,500 pltshipped
  • 160 plton hold
  • 1,100 pltpending

New order

Create a new purchase order.

Deliveries

May 2026

Calendar view
May 12TuesdayTotal 415 plt
  • 04-118B2 items40 pltDelivered
  • 04-121A7 items150 pltDelayed
  • 04-127C1 item15 pltDelayed
  • 04-130A8 items210 pltDelivered

Daily shipments

Month to date4,485 plt

May 2026


Today
350 plt
Yesterday
370 plt
This month
4,485 plt
Returns
40 plt

Inventory

Total2,800 plt

840 plt1,960 plt
On holdAvailable

Balance

Total balance3,900,000.00 USD

Overdue

2,230,800.00 USD

57.2%


Due today

46,800.00 USD1.2%

Recurring orders, quick action

Selected products1

  • Packaging CB-4030BoxStock: 380 plt
  • Take a look!new recycled-fiber boxes.

  • Packaging SF-2300RollStock: 560 plt
  • Try instead
    SF-2000410 plt

  • Packaging KP-1200RollStock: 210 plt
  • Packaging EP-5050UnitStock: 940 plt
  • Packaging CB-2010BoxStock: 640 plt
  • Packaging SF-1700RollStock: 320 plt
  • Take a look!thinner film, same hold.

  • Packaging TS-1210UnitStock: 150 plt
  • Packaging SB-1200RollStock: 480 plt

Discard changes?

The changes you made to the layout will be lost.

Code: a self-contained version
tsx
import { Button } from '@orbex/sonar-components/button'
import { Card, CardLink } from '@orbex/sonar-components/card'
import { Dialog } from '@orbex/sonar-components/dialog'
import { IconButton } from '@orbex/sonar-components/icon-button'
import { SegmentedControl } from '@orbex/sonar-components/segmented-control'
import { ToastProvider, useToast } from '@orbex/sonar-components/toast'
import { useEffect, useLayoutEffect, useRef, useState, type ComponentType, type CSSProperties, type KeyboardEvent, type PointerEvent, type ReactNode, type RefObject } from 'react'
import {
  AddCircleIcon,
  AddIcon,
  AutorenewIcon,
  BankIcon,
  CheckIcon,
  CloseIcon,
  DescriptionIcon,
  DragIcon,
  EditIcon,
  EventRepeatIcon,
  InStockIcon,
  ListAltIcon,
  MonitoringIcon,
  NotificationsIcon,
  ReceiptIcon,
  RequestQuoteIcon,
  ShippingIcon,
  UnfoldIcon,
  WarehouseIcon,
} from '@orbex/sonar-patterns/icons.tsx'

/*
 * Editing the overview at its real size: the six cards of the default overview, each with its content, in three
 * columns of 370 px as on a 1440 px desktop, or two of 346 px after the 60 px rail as on an 820 px tablet. The frame scrolls sideways when
 * the page is narrower. Edit layout turns the grid into a draft. Drag a card by its top handle (or focus it, press Space and move it
 * with the arrow keys), pull its bottom handle to change its size, remove it with ×, bring it back from New element,
 * then Save layout, Cancel or Reset to default from the bar at the foot.
 */

export type Size = 'S' | 'M' | 'L' | 'XL'
export interface Entry {
  id: string
  size: Size
  hidden?: boolean
}
export type Layout = Record<string, Entry[]>

/* The overview's heights and gap, in pixels, drawn at 1:1 */
const HEIGHTS: Record<Size, number> = { S: 245, M: 375, L: 405, XL: 535 }
const GAP = 20

/* Two grids: a 1440 px desktop has three columns of 370 px; an 820 px tablet, after its 60 px rail and 24 px margins,
   two of 346 px */
type Mode = 'desktop' | 'tablet'
const GRIDS: Record<Mode, { columns: string[]; width: number }> = {
  desktop: { columns: ['c1', 'c2', 'c3'], width: 370 },
  tablet: { columns: ['t1', 't2'], width: 346 },
}

/* Motion, from the product */
const NEIGHBOURS = 320
const REFLOW = 620
const SETTLE = 500

const CARDS: Record<string, { title: string; sizes: Size[] }> = {
  orders: { title: 'Orders', sizes: ['S', 'M'] },
  deliveries: { title: 'Deliveries', sizes: ['M', 'L', 'XL'] },
  shipments: { title: 'Daily shipments', sizes: ['L', 'XL'] },
  inventory: { title: 'Inventory', sizes: ['S', 'M'] },
  balance: { title: 'Balance', sizes: ['S', 'M', 'L'] },
  recurring: { title: 'Recurring orders', sizes: ['M', 'L', 'XL'] },
  /* Modules that can be added from New element; they come in one size */
  notifications: { title: 'Notifications', sizes: ['L'] },
  invoices: { title: 'Invoices', sizes: ['L'] },
  documents: { title: 'Latest documents', sizes: ['L'] },
  credits: { title: 'Credit notes', sizes: ['L'] },
  scheduled: { title: 'Scheduled orders', sizes: ['L'] },
}
const OPTIONAL = ['invoices', 'documents', 'notifications', 'credits', 'scheduled']

/* What each card shows: here its title, at its size; in the product, the card itself with its content */
const BODIES: Record<string, ComponentType<{ size?: any }>> = Object.fromEntries(
  Object.entries(CARDS).map(([id, { title }]) => [id, ({ size }: { size?: any }) => <Card titleAs="h3" title={title} size={size} />]),
)

/* New element's list: an icon and a line for every module */
const INFO: Record<string, [ComponentType, string]> = {
  invoices: [ReceiptIcon, 'Latest invoices with direct download'],
  documents: [DescriptionIcon, 'Documents grouped by order'],
  notifications: [NotificationsIcon, 'Delivery alerts and updates'],
  credits: [RequestQuoteIcon, 'Credits issued to your account'],
  scheduled: [EventRepeatIcon, 'Upcoming orders by date'],
  orders: [ListAltIcon, 'Open orders by month'],
  deliveries: [ShippingIcon, 'Deliveries by day'],
  shipments: [MonitoringIcon, 'Shipments this month'],
  inventory: [WarehouseIcon, 'Stock on hold and available'],
  balance: [BankIcon, 'Overdue, due today and payment calendar'],
  recurring: [AutorenewIcon, 'Quick reorder of favourite products'],
}

const DEFAULT: Layout = {
  c1: [{ id: 'orders', size: 'M' }, { id: 'deliveries', size: 'L' }],
  c2: [{ id: 'shipments', size: 'XL' }, { id: 'inventory', size: 'S' }],
  c3: [{ id: 'balance', size: 'M' }, { id: 'recurring', size: 'L' }],
}

/* The tablet's layout comes from the desktop's: cards are read top to bottom (left to right at the same height) and
   each goes into the shorter column. Orders, Balance and Recurring orders on the left; Daily shipments, Deliveries and
   Inventory on the right. */
function deriveTablet(desk: Layout): Layout {
  const items: { entry: Entry; y: number; column: number }[] = []
  Object.values(desk).forEach((column, index) => {
    let y = 0
    for (const entry of column) {
      items.push({ entry, y, column: index })
      if (!entry.hidden) y += HEIGHTS[entry.size] + GAP
    }
  })
  const out: Layout = { t1: [], t2: [] }
  for (const { entry } of items.sort((a, b) => a.y - b.y || a.column - b.column)) out[shortest(out)]!.push({ ...entry })
  return out
}
const DEFAULTS_FOR: Record<Mode, () => Layout> = { desktop: () => clone(DEFAULT), tablet: () => deriveTablet(DEFAULT) }

const clone = (layout: Layout): Layout => JSON.parse(JSON.stringify(layout))
const visible = (column: Entry[]) => column.filter((entry) => !entry.hidden)
const bottomOf = (column: Entry[]) => visible(column).reduce((sum, entry, index) => sum + HEIGHTS[entry.size] + (index ? GAP : 0), 0)
const shortest = (layout: Layout) => Object.keys(layout).reduce((a, b) => (bottomOf(layout[b]!) < bottomOf(layout[a]!) ? b : a))
const columnOf = (layout: Layout, id: string) => Object.keys(layout).find((column) => layout[column]!.some((entry) => entry.id === id))!
const DEFAULTS = Object.values(DEFAULT).flatMap((column) => column.map(({ id }) => id))

/** The pills and the remove button of a card in edit mode, as the product draws them */
export function Handles({
  title,
  size,
  preview,
  guide,
  moveProps,
  resizeProps,
  onRemove,
}: {
  title: string
  size: Size
  /** Draw a state without interaction: a handle hovered or focused, or held */
  preview?: { move?: 'hover' | 'focus' | 'on'; resize?: 'on'; remove?: 'hover' }
  guide?: ReactNode
  moveProps?: Record<string, unknown>
  resizeProps?: Record<string, unknown>
  onRemove?: () => void
}) {
  const state = (value?: string) => (value === 'on' ? { 'data-on': '' } : {})
  return (
    <>
      <span style={{ display: 'contents' }} data-preview={preview?.move !== 'on' ? preview?.move : undefined}>
        <button type="button" className="edit-handle" data-handle="move" aria-label={`Move ${title}`} {...state(preview?.move)} {...moveProps}>
          <DragIcon />
        </button>
      </span>
      <span style={{ display: 'contents' }} data-preview={preview?.remove}>
        <button type="button" className="edit-remove" aria-label={`Remove ${title} from the overview`} onClick={onRemove}>
          <CloseIcon />
        </button>
      </span>
      {guide}
      <span className="edit-size" aria-hidden="true">
        {size}
      </span>
      <button type="button" className="edit-handle" data-handle="resize" aria-label={`Resize ${title}. Current size ${size}. Use arrow up or down.`} {...state(preview?.resize)} {...resizeProps}>
        <UnfoldIcon />
      </button>
    </>
  )
}

/** The label over the bottom pill while a card is resized */
export const Guide = ({ size, aligned }: { size: Size; aligned: boolean }) => (
  <span className="edit-guide" aria-live="polite">
    {aligned ? (
      <>
        <CheckIcon aria-hidden="true" />
        Aligned · {size}
      </>
    ) : (
      `${size} · ${HEIGHTS[size]} px`
    )}
  </span>
)

/* Cards travel from where they were to where they are: 320 ms while editing, 620 ms when the number of columns changes.
   The card under the pointer is left alone; it follows the pointer. Each grid watches only its own cards, so several
   grids on one page (the app shell's three frames) never animate each other's. */
function useTravel(root: RefObject<HTMLElement | null>, key: string, mode: string, skip: string | undefined, zoom = 1) {
  const rects = useRef(new Map<string, DOMRect>())
  const lastMode = useRef(mode)
  useLayoutEffect(() => {
    const cards = [...(root.current?.querySelectorAll<HTMLElement>('[data-edit-id]') ?? [])]
    const reflow = lastMode.current !== mode
    lastMode.current = mode
    const still = matchMedia('(prefers-reduced-motion: reduce)').matches
    const spring = getComputedStyle(document.documentElement).getPropertyValue('--sonar-motion-spatial-default-easing').trim() || 'ease-out'
    const next = new Map<string, DOMRect>()
    for (const card of cards) {
      const id = card.dataset.editId!
      const now = card.getBoundingClientRect()
      next.set(id, now)
      const was = rects.current.get(id)
      if (still || !was || id === skip) continue
      /* Rectangles are on screen; the translation is inside the zoomed canvas */
      const [dx, dy] = [(was.left - now.left) / zoom, (was.top - now.top) / zoom]
      if (!dx && !dy) continue
      card.getAnimations().forEach((animation) => animation.cancel())
      card.animate([{ translate: `${dx}px ${dy}px` }, { translate: '0 0' }], {
        duration: reflow ? REFLOW : NEIGHBOURS,
        easing: reflow ? 'cubic-bezier(.2,0,0,1)' : spring,
      })
    }
    rects.current = next
  }, [key, mode, skip, zoom])
}

/* The nearest box that scrolls up and down: the app frame's own overview, or else the page */
function scrollerOf(element: HTMLElement): HTMLElement | null {
  for (let node = element.parentElement; node && node !== document.body; node = node.parentElement) {
    const { overflowY } = getComputedStyle(node)
    if (/(auto|scroll)/.test(overflowY) && node.scrollHeight > node.clientHeight + 1) return node
  }
  return null
}

export interface EditCardsProps {
  /** The layout drawn: the draft while editing, the saved one otherwise */
  layout: Layout
  /** Changes the draft (only called while editing) */
  setLayout: (update: (current: Layout) => Layout) => void
  editing: boolean
  /** The scale the grid is drawn at (a fitted canvas or a scaled device frame); pointer moves are divided by it */
  zoom?: number
  /** Changing it (a new number of columns) makes the cards travel on the slower reflow */
  mode?: string
  onAnnounce: (text: string) => void
  className?: string
  style?: CSSProperties
  /** Where New element's All modules link goes (the module sheet; 1.1.5, was Customize overview) */
  customizeHref?: string
  /** The cards' own buttons work while the layout is not edited (the app shell); the editing demo draws them still */
  interactive?: boolean
}

/**
 * The overview's grid, and Edit layout on it. Shared by the editing demo and the app shell's frames.
 *
 * Moving a card: the pointer is held by the grid itself (pointer capture), which is never redrawn. A card that changes
 * column is drawn anew, and on iPhone and iPad a finger stays tied to the element it first touched: when that element
 * left the page, the moves stopped reaching the page and the card froze, lifted, until the next touch. Where the card
 * lands is worked out from the layout (each card's height and the gap), not from what is under the finger, so the
 * card never jumps to the end of its own column and the neighbours never shake while they travel. The card is placed
 * under the finger again after every redraw, from its new slot, so it never drifts. Near the top or bottom edge the
 * overview (or the page) scrolls.
 */
export function EditCards({ layout, setLayout, editing, zoom = 1, mode = '', onAnnounce, className = 'edit-grid', style, customizeHref = '/patterns/editing/#list', interactive = false }: EditCardsProps) {
  const grid = useRef<HTMLDivElement>(null)
  const [drag, setDrag] = useState<string | null>(null)
  const [settling, setSettling] = useState<string | null>(null)
  const settleTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
  const [picked, setPicked] = useState<{ id: string; before: Layout } | null>(null)
  const [resizing, setResizing] = useState<string | null>(null)
  const [adding, setAdding] = useState(false)
  const [chosen, setChosen] = useState<string[]>([])
  const resizeStart = useRef<{ y: number; height: number } | null>(null)
  const gesture = useRef<{ id: string; pointerId: number; x: number; y: number; grabX: number; grabY: number; scroller: HTMLElement | null; frame: number; stop: () => void } | null>(null)
  /* What the window listeners read: always this render's values */
  const now = useRef({ layout, zoom })
  now.current = { layout, zoom }
  useTravel(grid, JSON.stringify(layout), mode, drag ?? undefined, zoom)
  useEffect(() => {
    if (editing) return
    setPicked(null)
    setAdding(false)
    setChosen([])
  }, [editing])
  useEffect(() => () => gesture.current?.stop(), [])

  const shown = Object.values(layout).flatMap((column) => visible(column).map(({ id }) => id))
  const removed = DEFAULTS.filter((id) => !shown.includes(id))
  const change = (fn: (next: Layout) => void) =>
    setLayout((current) => {
      const next = clone(current)
      fn(next)
      return next
    })
  const entryOf = (next: Layout, id: string) => next[columnOf(next, id)]!.find((entry) => entry.id === id)!
  const cardOf = (id: string) => grid.current?.querySelector<HTMLElement>(`[data-edit-id="${id}"]`) ?? null

  /* Moves a card to a column, before another card or at its end */
  const moveTo = (id: string, column: string, before?: string) =>
    change((next) => {
      const from = next[columnOf(next, id)]!
      const [entry] = from.splice(from.findIndex((other) => other.id === id), 1)
      const target = next[column]!
      const index = before ? target.findIndex((other) => other.id === before) : target.length
      target.splice(index < 0 ? target.length : index, 0, entry!)
    })

  const position = (id: string, from = layout) => {
    const column = columnOf(from, id)
    return `column ${Object.keys(from).indexOf(column) + 1}, position ${visible(from[column]!).findIndex((entry) => entry.id === id) + 1}`
  }

  /* ---------- Moving: the top handle ---------- */
  /* The lifted card is drawn where the finger holds it, measured from its slot as it is now */
  const place = () => {
    const g = gesture.current
    const card = g && cardOf(g.id)
    if (!g || !card) return
    const slot = card.getBoundingClientRect()
    card.style.setProperty('--drag-x', `${(g.x - g.grabX - slot.left) / now.current.zoom}px`)
    card.style.setProperty('--drag-y', `${(g.y - g.grabY - slot.top) / now.current.zoom}px`)
  }
  useLayoutEffect(place)

  /* Where the card would land: the column nearest its middle, and in it, before the first card whose middle is below
     the lifted card's middle, counted as if the lifted card had already left (so the answer never flips back and forth) */
  const aim = () => {
    const g = gesture.current
    const root = grid.current
    const card = g && cardOf(g.id)
    if (!g || !root || !card) return
    const { layout: current, zoom: k } = now.current
    const me = entryOf(current, g.id)
    const cx = g.x - g.grabX + (card.offsetWidth * k) / 2
    const cy = g.y - g.grabY + (HEIGHTS[me.size] * k) / 2
    let best: HTMLElement | null = null
    let distance = Infinity
    for (const column of root.querySelectorAll<HTMLElement>('[data-edit-column]')) {
      const box = column.getBoundingClientRect()
      const d = cx < box.left ? box.left - cx : cx > box.right ? cx - box.right : 0
      if (d < distance) [best, distance] = [column, d]
    }
    if (!best) return
    const column = best.dataset.editColumn!
    let y = best.getBoundingClientRect().top
    let before: string | undefined
    for (const other of visible(current[column]!)) {
      if (other.id === g.id) continue
      const height = HEIGHTS[other.size] * k
      if (cy < y + height / 2) {
        before = other.id
        break
      }
      y += height + GAP * k
    }
    const from = columnOf(current, g.id)
    const list = visible(current[from]!)
    if (from === column && list[list.findIndex((entry) => entry.id === g.id) + 1]?.id === before) return
    moveTo(g.id, column, before)
  }

  /* Near an edge the overview scrolls, faster the closer the finger is */
  const scroll = () => {
    const g = gesture.current
    if (!g) return
    const edge = 56
    const box = g.scroller?.getBoundingClientRect()
    const top = Math.max(box?.top ?? 0, 0)
    const bottom = Math.min(box?.bottom ?? innerHeight, innerHeight)
    const dy = g.y < top + edge ? -Math.min(18, Math.ceil((top + edge - g.y) / 3)) : g.y > bottom - edge ? Math.min(18, Math.ceil((g.y - bottom + edge) / 3)) : 0
    if (dy) {
      const before = g.scroller ? g.scroller.scrollTop : scrollY
      if (g.scroller) g.scroller.scrollTop += dy
      else scrollBy(0, dy)
      if ((g.scroller ? g.scroller.scrollTop : scrollY) !== before) {
        place()
        aim()
      }
    }
    g.frame = requestAnimationFrame(scroll)
  }

  /* While the layout is edited, a document-wide touchmove listener that can refuse the move is in place before any
     finger lands (1.0.0-alpha.21). iOS decides when a touch starts whether its moves may be cancelled; a listener added
     on pointerdown came too late, and in the App shell's frames, drawn scaled, the handle's touch-action region did not
     follow the scale on the iPad: the frame scrolled under the finger and the drag was cancelled */
  useEffect(() => {
    if (!editing) return
    const hold = (event: TouchEvent) => {
      if ((gesture.current || resizeStart.current) && event.cancelable) event.preventDefault()
    }
    /* A finger landing on a handle of this grid never starts a scroll: refused at touchstart, the handle keeps its
       pointer events (the handles have no click of their own) */
    const grab = (event: TouchEvent) => {
      const target = event.target instanceof Element ? event.target : null
      if (event.cancelable && target?.closest('[data-handle]') && grid.current?.contains(target)) event.preventDefault()
    }
    document.addEventListener('touchmove', hold, { passive: false })
    document.addEventListener('touchstart', grab, { passive: false })
    return () => {
      document.removeEventListener('touchmove', hold)
      document.removeEventListener('touchstart', grab)
    }
  }, [editing])

  const onMoveDown = (id: string) => (event: PointerEvent<HTMLButtonElement>) => {
    if (event.button !== 0 || gesture.current) return
    const card = cardOf(id)
    if (!card) return
    event.preventDefault()
    card.getAnimations().forEach((animation) => animation.cancel())
    const slot = card.getBoundingClientRect()
    const root = grid.current!
    const pointerId = event.pointerId
    try {
      root.setPointerCapture(pointerId)
    } catch {
      /* A pointer the browser no longer knows: the window still hears it */
    }
    const move = (e: globalThis.PointerEvent) => {
      const g = gesture.current
      if (!g || e.pointerId !== g.pointerId) return
      g.x = e.clientX
      g.y = e.clientY
      place()
      aim()
    }
    const up = (e: globalThis.PointerEvent) => {
      if (e.pointerId === pointerId) drop()
    }
    /* Older iPads scroll the page under a finger unless the move is refused while a card is held */
    const hold = (e: TouchEvent) => e.cancelable && e.preventDefault()
    const stop = () => {
      window.removeEventListener('pointermove', move)
      window.removeEventListener('pointerup', up)
      window.removeEventListener('pointercancel', up)
      window.removeEventListener('touchmove', hold)
      cancelAnimationFrame(gesture.current?.frame ?? 0)
      try {
        if (root.hasPointerCapture(pointerId)) root.releasePointerCapture(pointerId)
      } catch {
        /* already released */
      }
    }
    window.addEventListener('pointermove', move)
    window.addEventListener('pointerup', up)
    window.addEventListener('pointercancel', up)
    window.addEventListener('touchmove', hold, { passive: false })
    gesture.current = { id, pointerId, x: event.clientX, y: event.clientY, grabX: event.clientX - slot.left, grabY: event.clientY - slot.top, scroller: scrollerOf(card), frame: 0, stop }
    gesture.current.frame = requestAnimationFrame(scroll)
    setPicked(null)
    setDrag(id)
  }
  /* Dropped, the card glides from the pointer into its slot on the expressive spring and comes back down */
  const drop = () => {
    const g = gesture.current
    if (!g) return
    g.stop()
    gesture.current = null
    setDrag(null)
    setSettling(g.id)
    clearTimeout(settleTimer.current)
    settleTimer.current = setTimeout(() => {
      setSettling(null)
      const card = cardOf(g.id)
      card?.style.removeProperty('--drag-x')
      card?.style.removeProperty('--drag-y')
    }, SETTLE)
    onAnnounce(`${CARDS[g.id]!.title} dropped at ${position(g.id, now.current.layout)}.`)
  }

  /* Keyboard: Space picks the card up and drops it, the arrow keys move it, Escape puts it back */
  const onMoveKey = (id: string) => (event: KeyboardEvent<HTMLButtonElement>) => {
    const title = CARDS[id]!.title
    if (event.key === ' ' || event.key === 'Enter') {
      event.preventDefault()
      if (picked?.id === id) {
        setPicked(null)
        onAnnounce(`${title} dropped at ${position(id)}.`)
      } else {
        setPicked({ id, before: clone(layout) })
        onAnnounce(`${title} picked up. Use the arrow keys to move it, Space to drop it, Escape to cancel.`)
      }
      return
    }
    if (picked?.id !== id) return
    if (event.key === 'Escape') {
      event.preventDefault()
      const before = picked.before
      setLayout(() => before)
      setPicked(null)
      onAnnounce(`Moving ${title} canceled.`)
      return
    }
    const columns = Object.keys(layout)
    const column = columnOf(layout, id)
    const list = visible(layout[column]!)
    const index = list.findIndex((entry) => entry.id === id)
    if (event.key === 'ArrowUp' && index > 0) moveTo(id, column, list[index - 1]!.id)
    else if (event.key === 'ArrowDown' && index < list.length - 1) moveTo(id, column, list[index + 2]?.id)
    else if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
      const target = columns[columns.indexOf(column) + (event.key === 'ArrowLeft' ? -1 : 1)]
      if (!target) return
      moveTo(id, target, visible(layout[target]!)[index]?.id)
    } else return
    event.preventDefault()
    requestAnimationFrame(() => {
      cardOf(id)?.querySelector<HTMLElement>('[data-handle="move"]')?.focus()
      onAnnounce(`${title} moved to ${position(id, now.current.layout)}.`)
    })
  }

  /* ---------- Resizing: the bottom handle steps through the sizes the card offers ---------- */
  const setSize = (id: string, size: Size) => change((next) => void (entryOf(next, id).size = size))
  const onResizeDown = (entry: Entry) => (event: PointerEvent<HTMLButtonElement>) => {
    event.preventDefault()
    event.currentTarget.setPointerCapture(event.pointerId)
    resizeStart.current = { y: event.clientY, height: HEIGHTS[entry.size] }
    setResizing(entry.id)
  }
  const onResizeMove = (entry: Entry) => (event: PointerEvent<HTMLButtonElement>) => {
    if (!resizeStart.current) return
    const target = resizeStart.current.height + (event.clientY - resizeStart.current.y) / zoom
    const sizes = CARDS[entry.id]!.sizes
    const best = sizes.reduce((a, b) => (Math.abs(HEIGHTS[b] - target) < Math.abs(HEIGHTS[a] - target) ? b : a), sizes[0]!)
    if (best !== entry.size) setSize(entry.id, best)
  }
  const onResizeUp = () => {
    resizeStart.current = null
    setResizing(null)
  }
  const onResizeKey = (entry: Entry) => (event: KeyboardEvent<HTMLButtonElement>) => {
    const sizes = CARDS[entry.id]!.sizes
    const step = event.key === 'ArrowUp' ? -1 : event.key === 'ArrowDown' ? 1 : 0
    if (!step) return
    event.preventDefault()
    const next = sizes[sizes.indexOf(entry.size) + step]
    if (next) setSize(entry.id, next)
  }
  const aligned = (id: string) => {
    const column = columnOf(layout, id)
    const mine = bottomOf(layout[column]!)
    return Object.keys(layout).some((other) => other !== column && visible(layout[other]!).length > 0 && bottomOf(layout[other]!) === mine)
  }

  /* ---------- Removing, and bringing back from New element ---------- */
  const remove = (id: string) => {
    change((next) => void (entryOf(next, id).hidden = true))
    onAnnounce(`${CARDS[id]!.title} removed from the overview.`)
  }
  const addChosen = () => {
    change((next) => {
      for (const id of chosen) {
        const column = Object.keys(next).find((key) => next[key]!.some((other) => other.id === id))
        const from = column ? next[column]! : null
        const entry = from ? from.splice(from.findIndex((other) => other.id === id), 1)[0]! : { id, size: CARDS[id]!.sizes[0]! }
        next[shortest(next)]!.push({ ...entry, hidden: false })
      }
    })
    onAnnounce(chosen.length === 1 ? `${CARDS[chosen[0]!]!.title} added.` : `${chosen.length} modules added.`)
    setChosen([])
    setAdding(false)
  }

  const newIn = editing ? shortest(layout) : null
  return (
    <div ref={grid} className={className} style={style}>
      {Object.entries(layout).map(([column, entries]) => (
        <div key={column} className="edit-column" data-edit-column={column}>
          {visible(entries).map((entry) => {
            const card = CARDS[entry.id]!
            const Body = BODIES[entry.id]!
            const lifted = drag === entry.id
            return (
              <div
                key={entry.id}
                className="edit-card"
                data-edit-id={entry.id}
                data-size={entry.size}
                data-editing={editing || undefined}
                data-lifted={lifted || undefined}
                data-settling={settling === entry.id || undefined}
              >
                {lifted && <span className="edit-placeholder" />}
                <div className="edit-body">
                  {/* The card as the overview shows it; while the layout is edited it only moves */}
                  <div className="edit-scale" inert={editing || !interactive || undefined}>
                    <Body size={entry.size.toLowerCase()} />
                  </div>
                  {editing && (
                    <Handles
                      title={card.title}
                      size={entry.size}
                      preview={{ move: lifted || picked?.id === entry.id ? 'on' : undefined, resize: resizing === entry.id ? 'on' : undefined }}
                      guide={resizing === entry.id ? <Guide size={entry.size} aligned={aligned(entry.id)} /> : undefined}
                      onRemove={() => remove(entry.id)}
                      moveProps={{
                        'aria-pressed': picked?.id === entry.id,
                        onKeyDown: onMoveKey(entry.id),
                        onPointerDown: onMoveDown(entry.id),
                      }}
                      resizeProps={
                        card.sizes.length > 1
                          ? {
                              onPointerDown: onResizeDown(entry),
                              onPointerMove: onResizeMove(entry),
                              onPointerUp: onResizeUp,
                              onPointerCancel: onResizeUp,
                              onFocus: () => setResizing(entry.id),
                              onBlur: () => setResizing(null),
                              onKeyDown: onResizeKey(entry),
                            }
                          : { disabled: true, hidden: true }
                      }
                    />
                  )}
                </div>
              </div>
            )
          })}
          {newIn === column && (
            /* New element: a card of size S at the foot of the shortest column, only while the layout is edited */
            <div className="edit-card edit-new-slot">
              <div className="edit-scale">
                {adding ? (
                  <Card
                    titleAs="h3"
                    title="New element"
                    className="new-element"
                    data-open=""
                    actions={<IconButton size="small" label="Close" icon={<CloseIcon />} onClick={() => (setAdding(false), setChosen([]))} />}
                    footer={
                      <div className="new-element-foot">
                        <CardLink href={customizeHref}>All modules</CardLink>
                        {chosen.length > 0 && (
                          <Button size="small" iconEnd={<AddIcon />} onClick={addChosen}>
                            {chosen.length === 1 ? 'Add module' : `Add ${chosen.length} modules`}
                          </Button>
                        )}
                      </div>
                    }
                  >
                    <p className="new-element-lead">Choose one or more modules for your overview.</p>
                    <div className="new-element-list" role="group" aria-label="Modules">
                      {[...OPTIONAL, ...removed].map((id, index) => {
                        const [Icon, line] = INFO[id]!
                        const added = shown.includes(id)
                        const on = chosen.includes(id)
                        const toggle = () => !added && setChosen((current) => (on ? current.filter((other) => other !== id) : [...current, id]))
                        return (
                          <div key={id} style={{ display: 'contents' }}>
                            {index === OPTIONAL.length && <p className="new-element-section">Removed from your overview</p>}
                            <div
                              className="new-element-option"
                              role="checkbox"
                              aria-checked={added || on}
                              aria-disabled={added || undefined}
                              tabIndex={added ? -1 : 0}
                              data-added={added || undefined}
                              data-chosen={on || undefined}
                              style={{ ['--i' as string]: index }}
                              onClick={toggle}
                              onKeyDown={(event) => {
                                if (event.key === ' ' || event.key === 'Enter') {
                                  event.preventDefault()
                                  toggle()
                                }
                              }}
                            >
                              <span className="new-element-icon">
                                <Icon />
                              </span>
                              <span>
                                <span className="new-element-name">{CARDS[id]!.title}</span>
                                <span className="new-element-line">{line}</span>
                              </span>
                              <span className="new-element-mark">{added || on ? <InStockIcon key="on" /> : <AddCircleIcon key="off" />}</span>
                            </div>
                          </div>
                        )
                      })}
                    </div>
                  </Card>
                ) : (
                  <Card titleAs="h3" title="New element" size="s" className="new-element">
                    <button type="button" className="new-element-add" onClick={() => setAdding(true)}>
                      <span className="new-element-plus">
                        <AddIcon aria-hidden="true" />
                      </span>
                      Add a module to your overview
                    </button>
                  </Card>
                )}
              </div>
            </div>
          )}
        </div>
      ))}
    </div>
  )
}

function Editor() {
  const toast = useToast()
  const [mode, setMode] = useState<Mode>('desktop')
  /* Fit to width: the whole canvas laid out at its real size and drawn smaller with one scale, like the product's own
     fit; nothing reflows (a transform, not CSS zoom, which made Safari re-measure the cards at the zoomed size) */
  const [fit, setFit] = useState(false)
  const frame = useRef<HTMLDivElement>(null)
  const [room, setRoom] = useState(0)
  useLayoutEffect(() => {
    const el = frame.current
    if (!el) return
    const measure = () => setRoom(el.clientWidth)
    measure()
    const watch = new ResizeObserver(measure)
    watch.observe(el)
    return () => watch.disconnect()
  }, [])
  const canvasWidth = mode === 'tablet' ? 820 : 32 + 3 * 370 + 2 * 20 + 32
  const zoom = fit && room ? Math.min(1, room / canvasWidth) : 1
  /* The canvas's real height (a transform does not change it), so the fitted box is exactly the drawn size */
  const canvas = useRef<HTMLDivElement>(null)
  const [canvasHeight, setCanvasHeight] = useState(0)
  useLayoutEffect(() => {
    const el = canvas.current
    if (!el) return
    const measure = () => setCanvasHeight(el.offsetHeight)
    measure()
    const watch = new ResizeObserver(measure)
    watch.observe(el)
    return () => watch.disconnect()
  }, [])
  const [savedBy, setSavedBy] = useState<Record<Mode, Layout>>(() => ({ desktop: DEFAULTS_FOR.desktop(), tablet: DEFAULTS_FOR.tablet() }))
  const saved = savedBy[mode]
  const setSaved = (layout: Layout) => setSavedBy((current) => ({ ...current, [mode]: layout }))
  const [draft, setDraft] = useState<Layout | null>(null)
  const [ask, setAsk] = useState<'reset' | 'discard' | null>(null)
  const [announcement, setAnnouncement] = useState('')
  const editing = draft !== null
  const layout = draft ?? saved
  const dirty = editing && JSON.stringify(draft) !== JSON.stringify(saved)

  /* ---------- The bar ---------- */
  const enter = () => {
    setDraft(clone(saved))
    setAnnouncement('Editing layout. Drag a card by its top handle. Pull the bottom handle to change its size.')
  }
  const leave = () => setDraft(null)
  const save = () => {
    const previous = saved
    setSaved(draft!)
    leave()
    toast({ message: 'Layout saved', action: { label: 'Undo', onClick: () => setSaved(previous) } })
  }

  return (
    <div className="edit-demo">
      <div className="edit-demo-top">
        <SegmentedControl
          legend="Screen"
          hideLegend
          size="small"
          value={mode}
          onValueChange={(value) => !editing && setMode(value as Mode)}
          disabled={editing}
          options={[
            { value: 'desktop', label: 'Desktop · 1440' },
            { value: 'tablet', label: 'Tablet · 820' },
          ]}
        />
        <label className="edit-fit">
          <input type="checkbox" checked={fit} onChange={(event) => setFit(event.target.checked)} />
          Fit to width
        </label>
        {!editing && (
          <Button variant="outlined" icon={<EditIcon />} onClick={enter}>
            Edit layout
          </Button>
        )}
      </div>
      {/* At 1:1 the grid is wider than most pages: the frame scrolls sideways, the cards are never scaled */}
      <div ref={frame} className="edit-frame" data-scroll-region tabIndex={zoom < 1 ? undefined : 0} aria-label={zoom < 1 ? undefined : 'Overview, scrolls sideways'}>
      {/* Fitted, the canvas keeps its real layout and is drawn smaller as one picture; this box takes the drawn size */}
      <div className="edit-fit-box" style={zoom < 1 ? { width: canvasWidth * zoom, height: canvasHeight * zoom } : undefined}>
      <div ref={canvas} className="edit-canvas" data-mode={mode} style={{ ['--cols' as string]: GRIDS[mode].columns.length, ['--col' as string]: `${GRIDS[mode].width}px`, ...(zoom < 1 ? { transform: `scale(${zoom})`, transformOrigin: '0 0' } : {}) }}>
      <p className="edit-demo-title">Overview</p>
      <EditCards
        layout={layout}
        setLayout={(update) => setDraft((current) => (current ? update(current) : current))}
        editing={editing}
        zoom={zoom}
        mode={mode}
        onAnnounce={setAnnouncement}
      />
      </div>
      </div>
      </div>
      {editing && (
        <div className="edit-bar" role="toolbar" aria-label="Edit layout">
          <p className="edit-bar-text">
            <strong>Editing layout</strong>
            <span>Drag a card by its top handle. Pull the bottom handle to change its size.</span>
          </p>
          <div className="edit-bar-actions">
            <button type="button" className="edit-reset" onClick={() => setAsk('reset')}>
              Reset to default
            </button>
            <Button size="small" variant="outlined" className="edit-cancel" onClick={() => (dirty ? setAsk('discard') : leave())}>
              Cancel
            </Button>
            <Button size="small" disabled={!dirty} onClick={save}>
              Save layout
            </Button>
          </div>
        </div>
      )}
      <p className="visually-hidden" aria-live="polite">
        {announcement}
      </p>
      <Dialog
        open={ask !== null}
        onOpenChange={(open) => !open && setAsk(null)}
        title={ask === 'reset' ? 'Reset to default layout?' : 'Discard changes?'}
        description={ask === 'reset' ? 'All cards go back to their original position and size. Changes you have not saved will be lost.' : 'The changes you made to the layout will be lost.'}
        actions={
          <>
            <Button variant="text" onClick={() => setAsk(null)}>
              {ask === 'reset' ? 'Cancel' : 'Keep editing'}
            </Button>
            <Button
              onClick={() => {
                if (ask === 'reset') setDraft(DEFAULTS_FOR[mode]())
                else leave()
                setAsk(null)
              }}
            >
              {ask === 'reset' ? 'Reset' : 'Discard'}
            </Button>
          </>
        }
      />
    </div>
  )
}

export default function EditGrid() {
  return (
    <ToastProvider label="Layout notifications">
      <Editor />
    </ToastProvider>
  )
}
  • Real size. On a 1440 px desktop the overview has three columns of 370 px with 20 px between them and 32 px margins; on an 820 px tablet, the 60 px rail, 24 px margins and two columns of 346 px. The demo draws them at 1:1 and scrolls sideways when the page is narrower; Fit to width draws the whole canvas smaller with one zoom, like the product, never each card.
  • Edit layout turns the overview into a draft: each card gets a dashed edge, a handle on its top edge to move it, a remove button outside its top end corner and a handle on its bottom edge to resize it, with its size beside it. A bar rises at the foot: “Editing layout. Drag a card by its top handle. Pull the bottom handle to change its size.”, with Reset to default, Cancel and Save layout. Every state a card goes through is drawn on Cards.
  • Moving. A card lifts while dragged: it grows to 103 % in 200 ms and rises to elevation 4. The others make room in 320 ms; its drop placeholder shows where it will land. Dropped, it glides into its slot in 500 ms on the expressive spring.
  • Resizing steps through the sizes the card offers, never in between, and the card eases to its new height in 320 ms; a label over the handle names the size and says when the columns end level.
  • Tablets take their layout from the desktop’s: cards are read top to bottom, then left to right, and each goes into the shorter column. Handles grow to 44 × 20 px and the remove button to 28 px. When the number of columns changes, every card travels to its new place in 620 ms on cubic-bezier(.2,0,0,1).
  • New element waits at the foot of the shortest column: it brings back the cards that were removed.
  • Touch uses the same handles with a larger hit area; the handles of two neighboring cards share the gap between them.
  • Keyboard. Every handle is a button: Space picks a card up, the arrow keys move it, Space drops it, Escape puts it back.

Rules

  • A draft until saved. Save layout stays off until something changes. Cancel leaves at once when nothing changed, and asks before it discards changes.
  • Save, then Undo. Saving does not ask; its toast offers to undo.
  • Reset to default asks first. It puts every card back in its place and size; the draft can still be canceled.
  • Each size class keeps its own layout once edited there; what is shown is shared. Hiding a card on a phone hides it everywhere.
  • Moves are announced. “Inventory moved to position 2 of 6.”

Do and don’t

DoEvery edit is a draft: Reset, Cancel and Save layout wait at the foot.
Don’tEach move saved as it happens, with no way back but to move it again.
DoResizing steps through the sizes a card offers, so columns end level.
Don’tA free resize: every card a different height, and ragged columns.