HomeDocsComponentsBlocksChartsThemes
Introduction
  • Introduction
  • Installation
  • Themes
  • llms.txt
Components
  • Accordion
  • Alert
  • Alert Dialog
  • Amount
  • Aspect Ratio
  • Audio Player
  • Avatar
  • Avatar Upload
  • Badge
  • Banner
  • Breadcrumb
  • Button
  • Button Group
  • Calendar
  • Card
  • Carousel
  • Chart
  • Checkbox
  • Code Block
  • Collapsible
  • Color Picker
  • Combobox
  • Command
  • Context Menu
  • Copy Button
  • Countdown
  • Data Grid
  • Data Table
  • Date Picker
  • Date Range Picker
  • Description List
  • Device Mockup
  • Dialog
  • Direction
  • Dot Loader
  • Drawer
  • Dropdown Menu
  • Empty
  • Field
  • File Upload
  • Hover Card
  • Input
  • Input Group
  • Input OTP
  • Item
  • Kanban
  • Kbd
  • Label
  • Menubar
  • Meter
  • Multi Select
  • Native Select
  • Navigation Menu
  • Number Field
  • Pagination
  • Password Strength
  • Phone Input
  • Popover
  • Progress
  • Progress Circle
  • Radio Group
  • Rating
  • Resizable
  • Responsive Dialog
  • Rich Text Editor
  • Scroll Area
  • Segmented Control
  • Select
  • Separator
  • Sheet
  • Sidebar
  • Skeleton
  • Slider
  • Sortable
  • Spinner
  • Stat
  • Stepper
  • Switch
  • Table
  • Tabs
  • Tag Input
  • Terminal
  • Textarea
  • Time Field
  • Timeline
  • Toast
  • Toggle
  • Toggle Group
  • Tooltip
  • Tree
  • Typography
  • Video Player
  • Window Mockup
AI
  • Message Scroller
  • Message
  • Bubble
  • Marker
  • Attachment
  • Questionnaire
  • Composer
  • Reasoning
  • Tool Call
  • Approval Card
  • Citations
Blocks
  • Airtable
  • Airtable Kanban
  • ClickUp List
  • ClickUp Kanban
  • Repeatable Group
  • Command Palette
  • Sign In and Sign Up
  • Availability Scheduler
  • AI Chat
  • Action Bar
  • Notion
  • Banking
  • Lineup Builder
  • Sports Score
  • Strava
  • Sheets
  • Notices
  • Settings
  • Billing
Utilities
  • motion
  • sounds
  • scroll-fade
  • shimmer
Another UI. Built on shadcn/ui with Phosphor icons.

Airtable

PreviousNext

A full Airtable style base with tables, views, an editable grid, filters, grouping, summaries and Kanban, Calendar and Gallery views.

MCLOAPNK
Create
Company
Status
Categories
Distributor
Owner
Revenue
Last contacted
City
1
Alpine Churn
Active
B2BWholesale
Nordic Cold Chain
MCMaya Chen
$48,200
Oct 2, 2026
Zürich
2
Amber Scoop
Lead
GelatoB2B
Midland Dairy Lines
LOLiam Ortiz
Sep 28, 2026
Prague
3
Andes Snow Creamery
Active
GelatoCatering
Andean Freight
APAva Patel
$31,750
Oct 6, 2026
Quito
4
Apricot Atlas
Paused
SorbetImports
Sahara Frost
NKNoah Kim
$12,400
Sep 17, 2026
Algiers
5
Aurora Scoops
Active
GelatoSeasonal
Nordic Cold Chain
MCMaya Chen
$22,900
Oct 8, 2026
Reykjavík
6
Baltic Berry
Lead
Dairy-freeB2C
Nordic Cold Chain
APAva Patel
Oct 1, 2026
Tallinn
7
Black Sesame Social
Active
VeganCafe
Pacific Chill
LOLiam Ortiz
$18,650
Sep 30, 2026
Bandung
8
Blue Fig Gelato
Churned
GelatoCafe
Midland Dairy Lines
NKNoah Kim
$9,300
Sep 12, 2026
Florence
9
Boreal Batch
Active
LocalSeasonal
Andean Freight
MCMaya Chen
$27,100
Oct 5, 2026
Yellowknife
10
Cacao Norte
Paused
B2BWholesale
Andean Freight
APAva Patel
$15,800
Oct 14, 2026
Oaxaca
11
Cape Vanilla Co.
Lead
ImportsWholesale
Sahara Frost
LOLiam Ortiz
Oct 7, 2026
Cape Town
12
Cedar Spoon
Active
CafeLocal
Sahara Frost
NKNoah Kim
$20,450
Sep 24, 2026
Beirut
13
Coral Cone
Active
GelatoB2C
Midland Dairy Lines
APAva Patel
$16,900
Oct 12, 2026
Lisbon
14
Dune Date Ices
Lead
SorbetLocal
Sahara Frost
MCMaya Chen
Oct 20, 2026
Marrakesh
15
Ember Kulfi
Active
Dairy-freeCatering
Pacific Chill
LOLiam Ortiz
$24,300
Oct 9, 2026
Mumbai
16
Fjord Frost
Churned
SeasonalB2B
Nordic Cold Chain
NKNoah Kim
$6,100
Sep 3, 2026
Bergen
16 records
StatusCategoriesDistributor
Alpine Churn
ActiveB2BWholesaleNordic Cold Chain
Amber Scoop
LeadGelatoB2BMidland Dairy Lines
Andes Snow Creamery
ActiveGelatoCateringAndean Freight
Apricot Atlas
PausedSorbetImportsSahara Frost
Aurora Scoops
ActiveGelatoSeasonalNordic Cold Chain
Baltic Berry
LeadDairy-freeB2CNordic Cold Chain
Black Sesame Social
ActiveVeganCafePacific Chill
Blue Fig Gelato
ChurnedGelatoCafeMidland Dairy Lines
Boreal Batch
ActiveLocalSeasonalAndean Freight
Cacao Norte
PausedB2BWholesaleAndean Freight
Cape Vanilla Co.
LeadImportsWholesaleSahara Frost
Cedar Spoon
ActiveCafeLocalSahara Frost
Coral Cone
ActiveGelatoB2CMidland Dairy Lines
Dune Date Ices
LeadSorbetLocalSahara Frost
Ember Kulfi
ActiveDairy-freeCateringPacific Chill
Fjord Frost
ChurnedSeasonalB2BNordic Cold Chain

Installation#

pnpm dlx shadcn@latest add https://anotherui.dev/r/airtable.json

Features#

  • Base and tables. A base header with table tabs. Distributors link to Accounts, and the link shows on both sides.
  • Views. A views sidebar with Grid, Kanban, Calendar and Gallery views. Each view keeps its own fields, filters, sorts and groups.
  • One toolbar. Hide fields, Filter, Group, Sort, Row height and Search all sit in one row and light up when active.
  • Grid. Grid lines, row numbers, field type icons and a frozen first column. Drag headers to reorder and drag edges to resize.
  • Cell editing. Click a cell to select it and move with the arrow keys. Press Enter, double click or start typing to edit. Space opens the record.
  • Records. Expand any record into a side panel with every field. Right click a row to insert, duplicate or delete.
  • Filters. Build conditions per field type, joined with and or or.
  • Grouping and summaries. Group by any field with collapsible groups and subtotals. Pick a summary for each column in the footer.
  • Fields and records. Add a field from the + column and a record from the + row.
  • Kanban and Calendar. Drag cards between stacks, or drag records to another day.
  • Phones. A card list like the Airtable app, with tables in a bottom bar.
  • Saved. This demo saves to your browser, so changes survive a reload. Use Reset base in the base name menu to start over.

Usage#

With no props, the block shows the sample base. Pass your own tables and views to start from your data, and use onChange to save changes anywhere.

import { AirtableBlock, makeView, type TableDef } from "@/components/blocks/airtable"
 
const tables: TableDef[] = [
  {
    id: "tasks",
    name: "Tasks",
    fields: [
      { id: "name", name: "Task", type: "text", width: 240 },
      {
        id: "stage",
        name: "Stage",
        type: "singleSelect",
        options: ["Todo", "Doing", "Done"],
        colors: ["#64748b", "#f59e0b", "#10b981"],
        width: 120,
      },
      { id: "owner", name: "Owner", type: "user", options: ["Jo", "Sam"], width: 140 },
      { id: "due", name: "Due", type: "date", width: 140 },
    ],
    rows: [{ id: "t1", cells: { name: "Write the brief", stage: "Doing", owner: "Jo", due: "2026-10-14" } }],
  },
]
 
export function TaskBase() {
  return (
    <AirtableBlock
      name="Launch plan"
      tables={tables}
      views={[makeView(tables[0], "board", "Board", "kanban")]}
      storageKey="launch-plan"
      accent="oklch(55% 0.2 260)"
      onChange={({ tables, views }) => console.log(tables, views)}
    />
  )
}

Props#

PropTypeDefault
namestring"Gelato Distribution"
tablesTableDef[]Sample base
viewsViewDef[]A grid view per table
onChange({ tables, views }) => void
storageKeystringNot saved
accentCSS colorTheme primary
accentForegroundCSS colorTheme primary foreground
classNamestring

Field types are text, singleSelect, multiSelect, user, currency, date and link. Give select fields options and, if you want, colors in the same order. Pills mix each color with your theme's card and text colors, so they follow light, dark and custom themes. A link field points at another table with linkTable, and a field with via shows the other side of that link.

Built with#

Table, Checkbox, Badge, Avatar, Button, Input, Input Group, Select, Switch, Label, Popover, Command, Calendar, Sheet, Dropdown Menu and Context Menu. No new CSS.

Code#

components/blocks/airtable.tsx
"use client"

import * as React from "react"
import {
  ArrowCounterClockwiseIcon,
  ArrowsOutSimpleIcon,
  ArrowsVerticalIcon,
  CalendarBlankIcon,
  CaretCircleDownIcon,
  CaretDownIcon,
  CaretLeftIcon,
  CaretRightIcon,
  CaretUpIcon,
  CheckIcon,
  CopyIcon,
  CurrencyDollarIcon,
  DatabaseIcon,
  EyeSlashIcon,
  FunnelSimpleIcon,
  KanbanIcon,
  LinkSimpleIcon,
  ListChecksIcon,
  ListIcon,
  MagnifyingGlassIcon,
  PlusIcon,
  ShareNetworkIcon,
  SidebarSimpleIcon,
  SortAscendingIcon,
  SquaresFourIcon,
  StackIcon,
  TableIcon,
  TextAaIcon,
  TrashIcon,
  UserIcon,
  XIcon,
} from "@phosphor-icons/react"
import {
  DndContext,
  DragOverlay,
  MouseSensor,
  TouchSensor,
  useDraggable,
  useDroppable,
  useSensor,
  useSensors,
} from "@dnd-kit/core"
import { horizontalListSortingStrategy, SortableContext, useSortable } from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import { cn } from "cn"

import { cue } from "@/lib/sounds"
import { ScrollFadeArea } from "@/components/ui/scroll-fade-area"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Calendar } from "@/components/ui/calendar"
import { Card, CardContent, CardTitle } from "@/components/ui/card"
import { Checkbox } from "@/components/ui/checkbox"
import {
  Command,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
} from "@/components/ui/command"
import {
  ContextMenu,
  ContextMenuContent,
  ContextMenuItem,
  ContextMenuSeparator,
  ContextMenuTrigger,
} from "@/components/ui/context-menu"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
} from "@/components/ui/input-group"
import { Item, ItemContent, ItemGroup, ItemMedia, ItemTitle } from "@/components/ui/item"
import { Label } from "@/components/ui/label"
import {
  Popover,
  PopoverAnchor,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover"
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"
import {
  Sheet,
  SheetContent,
  SheetDescription,
  SheetFooter,
  SheetHeader,
  SheetTitle,
} from "@/components/ui/sheet"
import { Switch } from "@/components/ui/switch"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import {
  Table,
  TableBody,
  TableCell,
  TableFooter,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"

/* Types */

type FieldType =
  | "text"
  | "singleSelect"
  | "multiSelect"
  | "user"
  | "currency"
  | "date"
  | "link"

type CellValue = string | number | string[] | null

type FieldDef = {
  id: string
  name: string
  type: FieldType
  options?: string[]
  // Optional colors for the options, in the same order. Any CSS color.
  colors?: string[]
  // Linked records point at another table. With `via`, the field is the
  // reverse side of a link, so it is computed rather than stored.
  linkTable?: string
  via?: string
  width: number
}

type Row = { id: string; cells: { [fieldId: string]: CellValue } }

type TableDef = { id: string; name: string; fields: FieldDef[]; rows: Row[] }

type ViewType = "grid" | "kanban" | "calendar" | "gallery"

type Summary =
  | "none"
  | "filled"
  | "empty"
  | "unique"
  | "percent"
  | "sum"
  | "avg"
  | "min"
  | "max"
  | "earliest"
  | "latest"

type Condition = { id: string; field: string; op: string; value: string }

type RowHeight = "short" | "medium" | "tall"

type ViewDef = {
  id: string
  tableId: string
  name: string
  type: ViewType
  hidden: string[]
  order: string[]
  widths: { [fieldId: string]: number }
  filters: Condition[]
  conjunction: "and" | "or"
  sorts: { field: string; dir: "asc" | "desc" }[]
  groupBy: string | null
  collapsed: string[]
  rowHeight: RowHeight
  summaries: { [fieldId: string]: Summary }
}

type Tables = TableDef[]

export type {
  CellValue,
  Condition,
  FieldDef,
  FieldType,
  Row,
  RowHeight,
  Summary,
  TableDef,
  ViewDef,
  ViewType,
}

export type AirtableState = { tables: TableDef[]; views: ViewDef[] }

export type AirtableBlockProps = {
  /** Base name shown in the header. */
  name?: string
  /** Starting tables. The first field of each table is its primary field. */
  tables?: TableDef[]
  /** Starting views. Tables without a view get a grid view. */
  views?: ViewDef[]
  /** Called with the full state after every change. */
  onChange?: (state: AirtableState) => void
  /** Saves tables and views to localStorage under this key. */
  storageKey?: string
  /** Header color. Defaults to the theme's primary color. */
  accent?: string
  /** Text color on the header. Defaults to the theme's primary foreground. */
  accentForeground?: string
  className?: string
}

/* Sample base */

const USERS = ["Maya Chen", "Liam Ortiz", "Ava Patel", "Noah Kim"]

const ACCOUNT_DATA: [string, string, string, string[], string, string, number, string][] = [
  ["Alpine Churn", "Zürich", "Active", ["B2B", "Wholesale"], "d1", "Maya Chen", 48200, "2026-10-02"],
  ["Amber Scoop", "Prague", "Lead", ["Gelato", "B2B"], "d5", "Liam Ortiz", 0, "2026-09-28"],
  ["Andes Snow Creamery", "Quito", "Active", ["Gelato", "Catering"], "d2", "Ava Patel", 31750, "2026-10-06"],
  ["Apricot Atlas", "Algiers", "Paused", ["Sorbet", "Imports"], "d3", "Noah Kim", 12400, "2026-09-17"],
  ["Aurora Scoops", "Reykjavík", "Active", ["Gelato", "Seasonal"], "d1", "Maya Chen", 22900, "2026-10-08"],
  ["Baltic Berry", "Tallinn", "Lead", ["Dairy-free", "B2C"], "d1", "Ava Patel", 0, "2026-10-01"],
  ["Black Sesame Social", "Bandung", "Active", ["Vegan", "Cafe"], "d4", "Liam Ortiz", 18650, "2026-09-30"],
  ["Blue Fig Gelato", "Florence", "Churned", ["Gelato", "Cafe"], "d5", "Noah Kim", 9300, "2026-09-12"],
  ["Boreal Batch", "Yellowknife", "Active", ["Local", "Seasonal"], "d2", "Maya Chen", 27100, "2026-10-05"],
  ["Cacao Norte", "Oaxaca", "Paused", ["B2B", "Wholesale"], "d2", "Ava Patel", 15800, "2026-10-14"],
  ["Cape Vanilla Co.", "Cape Town", "Lead", ["Imports", "Wholesale"], "d3", "Liam Ortiz", 0, "2026-10-07"],
  ["Cedar Spoon", "Beirut", "Active", ["Cafe", "Local"], "d3", "Noah Kim", 20450, "2026-09-24"],
  ["Coral Cone", "Lisbon", "Active", ["Gelato", "B2C"], "d5", "Ava Patel", 16900, "2026-10-12"],
  ["Dune Date Ices", "Marrakesh", "Lead", ["Sorbet", "Local"], "d3", "Maya Chen", 0, "2026-10-20"],
  ["Ember Kulfi", "Mumbai", "Active", ["Dairy-free", "Catering"], "d4", "Liam Ortiz", 24300, "2026-10-09"],
  ["Fjord Frost", "Bergen", "Churned", ["Seasonal", "B2B"], "d1", "Noah Kim", 6100, "2026-09-03"],
]

const DISTRIBUTOR_DATA: [string, string, string, string][] = [
  ["Nordic Cold Chain", "Europe", "Maya Chen", "2021-04-12"],
  ["Andean Freight", "Americas", "Ava Patel", "2022-08-30"],
  ["Sahara Frost", "Africa", "Noah Kim", "2023-02-14"],
  ["Pacific Chill", "Asia", "Liam Ortiz", "2024-06-01"],
  ["Midland Dairy Lines", "Europe", "Ava Patel", "2020-11-19"],
]

export const SAMPLE_TABLES: Tables = [
  {
    id: "accounts",
    name: "Accounts",
    fields: [
      { id: "name", name: "Company", type: "text", width: 210 },
      { id: "status", name: "Status", type: "singleSelect", options: ["Lead", "Active", "Paused", "Churned"], width: 120 },
      {
        id: "categories",
        name: "Categories",
        type: "multiSelect",
        options: ["B2B", "Wholesale", "Gelato", "Catering", "Sorbet", "Imports", "Seasonal", "Dairy-free", "B2C", "Vegan", "Cafe", "Local"],
        width: 190,
      },
      { id: "distributor", name: "Distributor", type: "link", linkTable: "distributors", width: 180 },
      { id: "owner", name: "Owner", type: "user", options: USERS, width: 150 },
      { id: "revenue", name: "Revenue", type: "currency", width: 130 },
      { id: "contacted", name: "Last contacted", type: "date", width: 140 },
      { id: "city", name: "City", type: "text", width: 130 },
    ],
    rows: ACCOUNT_DATA.map(([name, city, status, categories, distributor, owner, revenue, contacted], i) => ({
      id: `a${i + 1}`,
      cells: { name, city, status, categories, distributor: [distributor], owner, revenue: revenue || null, contacted },
    })),
  },
  {
    id: "distributors",
    name: "Distributors",
    fields: [
      { id: "name", name: "Distributor", type: "text", width: 200 },
      { id: "region", name: "Region", type: "singleSelect", options: ["Europe", "Americas", "Africa", "Asia"], width: 120 },
      { id: "contact", name: "Contact", type: "user", options: USERS, width: 150 },
      { id: "accounts", name: "Accounts", type: "link", linkTable: "accounts", via: "distributor", width: 300 },
      { id: "since", name: "Partner since", type: "date", width: 140 },
    ],
    rows: DISTRIBUTOR_DATA.map(([name, region, contact, since], i) => ({
      id: `d${i + 1}`,
      cells: { name, region, contact, since },
    })),
  },
]

export function makeView(table: TableDef, id: string, name: string, type: ViewType, extra: Partial<ViewDef> = {}): ViewDef {
  return {
    id,
    tableId: table.id,
    name,
    type,
    hidden: [],
    order: table.fields.map((f) => f.id),
    widths: Object.fromEntries(table.fields.map((f) => [f.id, f.width])),
    filters: [],
    conjunction: "and",
    sorts: [],
    groupBy: null,
    collapsed: [],
    rowHeight: "short",
    summaries: Object.fromEntries(
      table.fields.filter((f) => f.type === "currency").map((f) => [f.id, "sum" as Summary])
    ),
    ...extra,
  }
}

const [ACCOUNTS, DISTRIBUTORS] = SAMPLE_TABLES

export const SAMPLE_VIEWS: ViewDef[] = [
  makeView(ACCOUNTS, "all", "All accounts", "grid"),
  makeView(ACCOUNTS, "by-owner", "Grouped by owner", "grid", {
    groupBy: "owner",
    hidden: ["city"],
    sorts: [{ field: "revenue", dir: "desc" }],
  }),
  makeView(ACCOUNTS, "active", "Active accounts", "grid", {
    filters: [{ id: "c1", field: "status", op: "is", value: "Active" }],
    rowHeight: "medium",
  }),
  makeView(ACCOUNTS, "pipeline", "Pipeline", "kanban", { hidden: ["city", "contacted"] }),
  makeView(ACCOUNTS, "follow-ups", "Follow-up calendar", "calendar"),
  makeView(ACCOUNTS, "gallery", "Account gallery", "gallery", { hidden: ["city"] }),
  makeView(DISTRIBUTORS, "d-all", "All distributors", "grid", { rowHeight: "medium" }),
  makeView(DISTRIBUTORS, "d-gallery", "Distributor gallery", "gallery"),
]

/* Styling */

// Option colors. Each one is mixed with the theme's card and foreground
// colors, so pills follow light, dark and custom themes. Any CSS color works.
export const PALETTE = [
  "oklch(68.5% 0.169 237.323)",
  "oklch(69.6% 0.17 162.48)",
  "oklch(76.9% 0.188 70.08)",
  "oklch(55.2% 0.016 285.938)",
  "oklch(60.6% 0.25 292.717)",
  "oklch(64.5% 0.246 16.439)",
  "oklch(70.4% 0.14 182.503)",
  "oklch(70.5% 0.213 47.604)",
  "oklch(65.6% 0.241 354.308)",
  "oklch(76.8% 0.233 130.85)",
  "oklch(58.5% 0.233 277.117)",
  "oklch(71.5% 0.143 215.221)",
]

export const TINT =
  "bg-[color-mix(in_oklab,var(--tint)_18%,var(--card))] text-[color-mix(in_oklab,var(--tint)_55%,var(--foreground))]"

export const tint = (color: string) => ({ "--tint": color }) as React.CSSProperties

const FIELD_ICON: { [t in FieldType]: React.ElementType } = {
  text: TextAaIcon,
  singleSelect: CaretCircleDownIcon,
  multiSelect: ListChecksIcon,
  user: UserIcon,
  currency: CurrencyDollarIcon,
  date: CalendarBlankIcon,
  link: LinkSimpleIcon,
}

const TYPE_LABEL: { [t in FieldType]: string } = {
  text: "Single line text",
  singleSelect: "Single select",
  multiSelect: "Multiple select",
  user: "User",
  currency: "Currency",
  date: "Date",
  link: "Link to another record",
}

export const VIEW_ICON: { [t in ViewType]: { icon: React.ElementType; color: string; label: string } } = {
  grid: { icon: TableIcon, color: "text-sky-600 dark:text-sky-400", label: "Grid" },
  kanban: { icon: KanbanIcon, color: "text-emerald-600 dark:text-emerald-400", label: "Kanban" },
  calendar: { icon: CalendarBlankIcon, color: "text-orange-600 dark:text-orange-400", label: "Calendar" },
  gallery: { icon: SquaresFourIcon, color: "text-violet-600 dark:text-violet-400", label: "Gallery" },
}

const ROW_H: { [h in RowHeight]: number } = { short: 32, medium: 56, tall: 96 }
const NUM_W = 72
const ADD_W = 96

// Solid backgrounds so the frozen columns hide what scrolls under them.
const CELL_BG =
  "bg-card group-hover/row:bg-[color-mix(in_oklab,var(--muted)_70%,var(--card))] group-data-[state=selected]/row:bg-[color-mix(in_oklab,var(--color-sky-500)_10%,var(--card))]"
const HEAD_BG = "bg-[color-mix(in_oklab,var(--muted)_75%,var(--card))]"

/* Value helpers */

export const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 })

export const uid = () => Math.random().toString(36).slice(2, 9)

export function initials(name: string) {
  return name
    .split(/\s+/)
    .map((w) => w[0])
    .join("")
    .slice(0, 2)
    .toUpperCase()
}

function formatDate(iso: string, year = false) {
  return new Date(iso + "T12:00:00").toLocaleDateString("en-US", {
    month: "short",
    day: "numeric",
    ...(year ? { year: "numeric" } : {}),
  })
}

function toIso(d: Date) {
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
}

export function isEmpty(v: CellValue | undefined) {
  return v == null || v === "" || (Array.isArray(v) && v.length === 0)
}

function asList(v: CellValue | undefined): string[] {
  if (Array.isArray(v)) return v
  return isEmpty(v) ? [] : [String(v)]
}

export function pillColor(field: FieldDef, option: string) {
  const i = Math.max(0, field.options?.indexOf(option) ?? 0)
  return field.colors?.[i] ?? PALETTE[i % PALETTE.length]
}

function userColor(name: string) {
  let h = 0
  for (const ch of name) h = (h * 31 + ch.charCodeAt(0)) >>> 0
  return PALETTE[h % PALETTE.length]
}

function tableById(tables: Tables, id?: string) {
  return tables.find((t) => t.id === id)
}

export function primaryText(table: TableDef | undefined, row: Row) {
  if (!table) return ""
  const v = row.cells[table.fields[0].id]
  return isEmpty(v) ? "" : String(v)
}

export function getValue(tables: Tables, row: Row, field: FieldDef): CellValue {
  if (field.via) {
    const linked = tableById(tables, field.linkTable)
    return linked
      ? linked.rows.filter((r) => asList(r.cells[field.via!]).includes(row.id)).map((r) => r.id)
      : []
  }
  return row.cells[field.id] ?? null
}

export function displayText(tables: Tables, field: FieldDef, v: CellValue): string {
  if (isEmpty(v)) return ""
  switch (field.type) {
    case "link": {
      const linked = tableById(tables, field.linkTable)
      return asList(v)
        .map((id) => {
          const r = linked?.rows.find((x) => x.id === id)
          return r ? primaryText(linked, r) : ""
        })
        .join(", ")
    }
    case "multiSelect":
      return asList(v).join(", ")
    case "currency":
      return money.format(Number(v))
    case "date":
      return formatDate(String(v), true)
    default:
      return String(v)
  }
}

function optionsFor(tables: Tables, field: FieldDef): { value: string; label: string }[] {
  if (field.type === "user") return (field.options ?? USERS).map((u) => ({ value: u, label: u }))
  if (field.type === "link") {
    const linked = tableById(tables, field.linkTable)
    return (linked?.rows ?? []).map((r) => ({
      value: r.id,
      label: primaryText(linked, r) || "Unnamed record",
    }))
  }
  return (field.options ?? []).map((o) => ({ value: o, label: o }))
}

function emptyFor(field: FieldDef): CellValue {
  return field.type === "multiSelect" || field.type === "link" ? [] : null
}

/* Filters, sorting, summaries */

const OPS: { [t in FieldType]: string[] } = {
  text: ["contains", "doesNotContain", "is", "isEmpty", "isNotEmpty"],
  singleSelect: ["is", "isNot", "isEmpty", "isNotEmpty"],
  user: ["is", "isNot", "isEmpty", "isNotEmpty"],
  multiSelect: ["has", "hasNot", "isEmpty", "isNotEmpty"],
  link: ["has", "hasNot", "isEmpty", "isNotEmpty"],
  currency: ["eq", "neq", "gt", "lt", "gte", "lte", "isEmpty", "isNotEmpty"],
  date: ["is", "before", "after", "isEmpty", "isNotEmpty"],
}

const OP_LABEL: { [op: string]: string } = {
  contains: "contains",
  doesNotContain: "does not contain",
  is: "is",
  isNot: "is not",
  has: "has",
  hasNot: "does not have",
  eq: "=",
  neq: "≠",
  gt: ">",
  lt: "<",
  gte: "≥",
  lte: "≤",
  before: "is before",
  after: "is after",
  isEmpty: "is empty",
  isNotEmpty: "is not empty",
}

const needsValue = (op: string) => op !== "isEmpty" && op !== "isNotEmpty"
export const isComplete = (c: Condition) => !needsValue(c.op) || c.value !== ""

export function matches(tables: Tables, table: TableDef, row: Row, c: Condition) {
  const field = table.fields.find((f) => f.id === c.field)
  if (!field) return true
  const v = getValue(tables, row, field)
  if (c.op === "isEmpty") return isEmpty(v)
  if (c.op === "isNotEmpty") return !isEmpty(v)
  const text = displayText(tables, field, v).toLowerCase()
  const want = c.value.toLowerCase()
  const n = Number(v ?? 0)
  const m = Number(c.value)
  switch (c.op) {
    case "contains":
      return text.includes(want)
    case "doesNotContain":
      return !text.includes(want)
    case "is":
      return String(v ?? "").toLowerCase() === want
    case "isNot":
      return String(v ?? "").toLowerCase() !== want
    case "has":
      return asList(v).includes(c.value)
    case "hasNot":
      return !asList(v).includes(c.value)
    case "eq":
      return n === m
    case "neq":
      return n !== m
    case "gt":
      return n > m
    case "lt":
      return n < m
    case "gte":
      return n >= m
    case "lte":
      return n <= m
    case "before":
      return !isEmpty(v) && String(v) < c.value
    case "after":
      return !isEmpty(v) && String(v) > c.value
  }
  return true
}

export function compare(tables: Tables, field: FieldDef, a: Row, b: Row) {
  const av = getValue(tables, a, field)
  const bv = getValue(tables, b, field)
  if (isEmpty(av) && isEmpty(bv)) return 0
  if (isEmpty(av)) return 1
  if (isEmpty(bv)) return -1
  if (field.type === "currency") return Number(av) - Number(bv)
  if (field.type === "singleSelect" && field.options)
    return field.options.indexOf(String(av)) - field.options.indexOf(String(bv))
  return displayText(tables, field, av).localeCompare(displayText(tables, field, bv))
}

function sortLabels(field?: FieldDef): [string, string] {
  switch (field?.type) {
    case "currency":
      return ["1 → 9", "9 → 1"]
    case "date":
      return ["Old → new", "New → old"]
    case "singleSelect":
      return ["First → last", "Last → first"]
    default:
      return ["A → Z", "Z → A"]
  }
}

const SUMMARY_LABEL: { [s in Summary]: string } = {
  none: "None",
  filled: "Filled",
  empty: "Empty",
  unique: "Unique",
  percent: "Percent filled",
  sum: "Sum",
  avg: "Average",
  min: "Min",
  max: "Max",
  earliest: "Earliest",
  latest: "Latest",
}

function summaryOptions(field: FieldDef): Summary[] {
  const base: Summary[] = ["none", "filled", "empty", "unique", "percent"]
  if (field.type === "currency") return [...base, "sum", "avg", "min", "max"]
  if (field.type === "date") return [...base, "earliest", "latest"]
  return base
}

function summarize(tables: Tables, field: FieldDef, rows: Row[], kind: Summary) {
  const values = rows.map((r) => getValue(tables, r, field))
  const filled = values.filter((v) => !isEmpty(v))
  const nums = filled.map(Number)
  const dates = filled.map(String).sort()
  switch (kind) {
    case "filled":
      return String(filled.length)
    case "empty":
      return String(values.length - filled.length)
    case "unique":
      return String(new Set(filled.map((v) => displayText(tables, field, v))).size)
    case "percent":
      return values.length ? `${Math.round((filled.length / values.length) * 100)}%` : "0%"
    case "sum":
      return money.format(nums.reduce((t, x) => t + x, 0))
    case "avg":
      return money.format(nums.length ? nums.reduce((t, x) => t + x, 0) / nums.length : 0)
    case "min":
      return nums.length ? money.format(Math.min(...nums)) : ""
    case "max":
      return nums.length ? money.format(Math.max(...nums)) : ""
    case "earliest":
      return dates.length ? formatDate(dates[0], true) : ""
    case "latest":
      return dates.length ? formatDate(dates[dates.length - 1], true) : ""
  }
  return ""
}

export function visibleFields(table: TableDef, view: ViewDef) {
  const primary = table.fields[0]
  const rest = view.order
    .map((id) => table.fields.find((f) => f.id === id))
    .filter((f): f is FieldDef => !!f && f.id !== primary.id && !view.hidden.includes(f.id))
  return [primary, ...rest]
}

/* Small display pieces */

// Single choice menus show a real Checkbox on the picked item.
const ChoiceContext = React.createContext<{ value: string; onChange: (value: string) => void } | null>(null)

export function MenuChoices({
  value,
  onValueChange,
  children,
}: {
  value: string
  onValueChange: (value: string) => void
  children: React.ReactNode
}) {
  return <ChoiceContext.Provider value={{ value, onChange: onValueChange }}>{children}</ChoiceContext.Provider>
}

export function MenuChoice({ value, children }: { value: string; children: React.ReactNode }) {
  const choices = React.useContext(ChoiceContext)
  const on = choices?.value === value
  return (
    <DropdownMenuItem role="menuitemradio" aria-checked={on} onSelect={() => choices?.onChange(value)}>
      {children}
      <CheckIcon weight="bold" aria-hidden className={cn("ml-auto", !on && "invisible")} />
    </DropdownMenuItem>
  )
}

export function FieldIcon({ type, className }: { type: FieldType; className?: string }) {
  const Icon = FIELD_ICON[type]
  return <Icon weight={type === "singleSelect" ? "bold" : "duotone"} className={cn("size-3.5 shrink-0", className)} />
}

export function Pill({ color, className, children }: { color: string; className?: string; children: React.ReactNode }) {
  return (
    <Badge
      variant="secondary"
      style={tint(color)}
      className={cn("h-5 shrink-0 rounded-full border-0 px-2 font-normal", TINT, className)}
    >
      {children}
    </Badge>
  )
}

export function UserChip({ name }: { name: string }) {
  return (
    <span className="flex min-w-0 items-center gap-1.5">
      <Avatar className="size-5">
        <AvatarFallback style={tint(userColor(name))} className={cn("text-[0.6rem] font-medium", TINT)}>
          {initials(name)}
        </AvatarFallback>
      </Avatar>
      <span className="truncate">{name}</span>
    </span>
  )
}

export function CellDisplay({
  tables,
  field,
  value,
  wrap = false,
  primary = false,
}: {
  tables: Tables
  field: FieldDef
  value: CellValue
  wrap?: boolean
  primary?: boolean
}) {
  if (isEmpty(value)) return null
  switch (field.type) {
    case "singleSelect":
      return <Pill color={pillColor(field, String(value))}>{String(value)}</Pill>
    case "multiSelect":
      return (
        <span className={cn("flex min-w-0 gap-1", wrap ? "flex-wrap" : "overflow-hidden")}>
          {asList(value).map((o) => (
            <Pill key={o} color={pillColor(field, o)}>
              {o}
            </Pill>
          ))}
        </span>
      )
    case "link": {
      const linked = tableById(tables, field.linkTable)
      return (
        <span className={cn("flex min-w-0 gap-1", wrap ? "flex-wrap" : "overflow-hidden")}>
          {asList(value).map((id) => {
            const r = linked?.rows.find((x) => x.id === id)
            return (
              <Badge
                key={id}
                variant="outline"
                className="h-5 shrink-0 rounded-md border-0 bg-sky-500/10 px-1.5 font-normal text-sky-900 dark:text-sky-100"
              >
                {r ? primaryText(linked, r) || "Unnamed record" : "Unknown"}
              </Badge>
            )
          })}
        </span>
      )
    }
    case "user":
      return <UserChip name={String(value)} />
    case "currency":
      return <span className="block w-full text-right tabular-nums">{money.format(Number(value))}</span>
    case "date":
      return <span className="tabular-nums">{formatDate(String(value), true)}</span>
    default:
      return (
        <span className={cn(wrap ? "line-clamp-3 whitespace-normal" : "truncate", primary && "font-medium")}>
          {String(value)}
        </span>
      )
  }
}

/* Pickers and editors */

function OptionPicker({
  tables,
  field,
  value,
  multi,
  onChange,
  onDone,
}: {
  tables: Tables
  field: FieldDef
  value: string[]
  multi: boolean
  onChange: (next: string[]) => void
  onDone?: () => void
}) {
  return (
    <Command>
      <CommandInput placeholder={field.type === "link" ? "Find a record" : "Find an option"} />
      <CommandList>
        <CommandEmpty>Nothing found.</CommandEmpty>
        <CommandGroup>
          {optionsFor(tables, field).map((o) => {
            const on = value.includes(o.value)
            return (
              <CommandItem
                key={o.value}
                value={o.label}
                onSelect={() => {
                  if (multi) onChange(on ? value.filter((v) => v !== o.value) : [...value, o.value])
                  else {
                    onChange(on ? [] : [o.value])
                    onDone?.()
                  }
                }}
              >
                {field.type === "user" ? (
                  <UserChip name={o.value} />
                ) : field.type === "link" ? (
                  <span className="truncate">{o.label}</span>
                ) : (
                  <Pill color={pillColor(field, o.value)}>{o.label}</Pill>
                )}
                {on && <CheckIcon weight="bold" className="ml-auto" />}
              </CommandItem>
            )
          })}
        </CommandGroup>
      </CommandList>
    </Command>
  )
}

function CellEditor({
  tables,
  field,
  value,
  initial,
  onCommit,
  onClose,
}: {
  tables: Tables
  field: FieldDef
  value: CellValue
  initial?: string
  onCommit: (v: CellValue) => void
  onClose: () => void
}) {
  const done = React.useRef(false)
  const multi = field.type === "multiSelect" || field.type === "link"

  if (field.type === "text" || field.type === "currency") {
    const commit = (raw: string) => {
      if (done.current) return
      done.current = true
      if (field.type === "currency") {
        const n = Number(raw.replace(/[^0-9.-]/g, ""))
        onCommit(raw.trim() === "" || Number.isNaN(n) ? null : n)
      } else onCommit(raw)
      onClose()
    }
    return (
      <Input
        autoFocus
        data-cell=""
        aria-label={field.name}
        defaultValue={initial ?? (isEmpty(value) ? "" : String(value))}
        inputMode={field.type === "currency" ? "decimal" : undefined}
        className={cn(
          "absolute inset-0 z-[3] h-full w-full bg-card px-2 text-sm focus-visible:ring-0",
          field.type === "currency" && "text-right tabular-nums"
        )}
        onKeyDown={(e) => {
          e.stopPropagation()
          if (e.key === "Enter" || e.key === "Tab") {
            e.preventDefault()
            commit(e.currentTarget.value)
          }
          if (e.key === "Escape") {
            done.current = true
            onClose()
          }
        }}
        onBlur={(e) => commit(e.currentTarget.value)}
      />
    )
  }

  return (
    <Popover open onOpenChange={(open) => !open && onClose()}>
      <PopoverAnchor asChild>
        <span className="pointer-events-none absolute inset-0" />
      </PopoverAnchor>
      <PopoverContent
        align="start"
        className={cn("p-0", field.type === "date" ? "w-auto" : "w-60")}
        onKeyDown={(e) => e.stopPropagation()}
      >
        {field.type === "date" ? (
          <Calendar
            mode="single"
            selected={isEmpty(value) ? undefined : new Date(String(value) + "T12:00:00")}
            defaultMonth={isEmpty(value) ? undefined : new Date(String(value) + "T12:00:00")}
            onSelect={(d) => {
              onCommit(d ? toIso(d) : null)
              onClose()
            }}
          />
        ) : (
          <OptionPicker
            tables={tables}
            field={field}
            value={asList(value)}
            multi={multi}
            onChange={(next) => onCommit(multi ? next : (next[0] ?? null))}
            onDone={onClose}
          />
        )}
      </PopoverContent>
    </Popover>
  )
}

/* Expanded record */

function FieldInput({
  tables,
  field,
  row,
  setCell,
}: {
  tables: Tables
  field: FieldDef
  row: Row
  setCell: (rowId: string, fieldId: string, v: CellValue) => void
}) {
  const value = getValue(tables, row, field)
  const set = (v: CellValue) => setCell(row.id, field.id, v)

  if (field.via) {
    return (
      <div className="flex min-h-8 flex-wrap items-center gap-1">
        <CellDisplay tables={tables} field={field} value={value} wrap />
        {isEmpty(value) && <span className="text-muted-foreground">No linked records</span>}
      </div>
    )
  }

  switch (field.type) {
    case "text":
      return <Input value={isEmpty(value) ? "" : String(value)} onChange={(e) => set(e.target.value)} />
    case "currency":
      return (
        <Input
          type="number"
          value={isEmpty(value) ? "" : String(value)}
          onChange={(e) => set(e.target.value === "" ? null : Number(e.target.value))}
        />
      )
    case "singleSelect":
    case "user":
      return (
        <Select value={isEmpty(value) ? "" : String(value)} onValueChange={(v) => set(v)}>
          <SelectTrigger className="w-full">
            <SelectValue placeholder="Choose an option" />
          </SelectTrigger>
          <SelectContent>
            {optionsFor(tables, field).map((o) => (
              <SelectItem key={o.value} value={o.value}>
                {field.type === "user" ? (
                  <UserChip name={o.value} />
                ) : (
                  <Pill color={pillColor(field, o.value)}>{o.label}</Pill>
                )}
              </SelectItem>
            ))}
          </SelectContent>
        </Select>
      )
    case "date":
      return (
        <Popover>
          <PopoverTrigger asChild>
            <Button type="button" variant="outline" className="w-full justify-start font-normal">
              <CalendarBlankIcon weight="duotone" />
              {isEmpty(value) ? (
                <span className="text-muted-foreground">Pick a date</span>
              ) : (
                formatDate(String(value), true)
              )}
            </Button>
          </PopoverTrigger>
          <PopoverContent align="start" className="w-auto p-0">
            <Calendar
              mode="single"
              selected={isEmpty(value) ? undefined : new Date(String(value) + "T12:00:00")}
              defaultMonth={isEmpty(value) ? undefined : new Date(String(value) + "T12:00:00")}
              onSelect={(d) => set(d ? toIso(d) : null)}
            />
          </PopoverContent>
        </Popover>
      )
    default: {
      const list = asList(value)
      return (
        <div className="flex min-h-8 flex-wrap items-center gap-1">
          {list.map((v) => (
            <span key={v} className="flex items-center">
              <CellDisplay tables={tables} field={field} value={[v]} />
              <Button
                type="button"
                variant="ghost"
                size="icon-xs"
                aria-label="Remove"
                className="size-5"
                onClick={() => set(list.filter((x) => x !== v))}
              >
                <XIcon weight="bold" />
              </Button>
            </span>
          ))}
          <Popover>
            <PopoverTrigger asChild>
              <Button type="button" variant="ghost" size="xs" className="text-sky-700 dark:text-sky-300">
                <PlusIcon weight="bold" />
                {field.type === "link" ? "Link record" : "Add"}
              </Button>
            </PopoverTrigger>
            <PopoverContent align="start" className="w-60 p-0">
              <OptionPicker tables={tables} field={field} value={list} multi onChange={(next) => set(next)} />
            </PopoverContent>
          </Popover>
        </div>
      )
    }
  }
}

export function RecordSheet({
  tables,
  table,
  rowId,
  order,
  onNavigate,
  onClose,
  setCell,
  onDelete,
}: {
  tables: Tables
  table: TableDef
  rowId: string | null
  order: string[]
  onNavigate: (id: string) => void
  onClose: () => void
  setCell: (rowId: string, fieldId: string, v: CellValue) => void
  onDelete: (id: string) => void
}) {
  const row = table.rows.find((r) => r.id === rowId)
  const i = row ? order.indexOf(row.id) : -1
  return (
    <Sheet open={!!row} onOpenChange={(open) => !open && onClose()}>
      <SheetContent className="w-full gap-0 p-0 sm:max-w-lg">
        {row && (
          <>
            <SheetHeader className="gap-2 border-b">
              <div className="flex items-center gap-1 pr-8">
                <Button
                  type="button"
                  variant="ghost"
                  size="icon-sm"
                  aria-label="Previous record"
                  disabled={i <= 0}
                  onClick={() => onNavigate(order[i - 1])}
                >
                  <CaretUpIcon weight="bold" />
                </Button>
                <Button
                  type="button"
                  variant="ghost"
                  size="icon-sm"
                  aria-label="Next record"
                  disabled={i < 0 || i >= order.length - 1}
                  onClick={() => onNavigate(order[i + 1])}
                >
                  <CaretDownIcon weight="bold" />
                </Button>
                {i >= 0 && (
                  <span className="text-xs text-muted-foreground tabular-nums">
                    {i + 1} of {order.length}
                  </span>
                )}
              </div>
              <SheetTitle className="text-xl">{primaryText(table, row) || "Unnamed record"}</SheetTitle>
              <SheetDescription>{table.name}</SheetDescription>
            </SheetHeader>
            <div className="flex flex-1 flex-col gap-5 overflow-y-auto p-4">
              {table.fields.map((f) => (
                <div key={f.id} className="flex flex-col gap-1.5">
                  <Label className="font-normal text-muted-foreground">
                    <FieldIcon type={f.type} />
                    {f.name}
                  </Label>
                  <FieldInput tables={tables} field={f} row={row} setCell={setCell} />
                </div>
              ))}
            </div>
            <SheetFooter className="flex-row border-t">
              <Button
                type="button"
                variant="destructive"
                onClick={() => {
                  onDelete(row.id)
                  onClose()
                }}
              >
                <TrashIcon weight="duotone" />
                Delete record
              </Button>
            </SheetFooter>
          </>
        )}
      </SheetContent>
    </Sheet>
  )
}

/* Toolbar controls */

export function ToolbarButton({
  active,
  tint,
  icon: Icon,
  label,
  weight = "duotone",
  className,
  ...props
}: React.ComponentProps<typeof Button> & {
  active?: boolean
  tint: string
  icon: React.ElementType
  label: string
  // Lines and arrows read better bold, everything else is duotone.
  weight?: "bold" | "duotone"
}) {
  return (
    <Button
      type="button"
      variant="ghost"
      size="sm"
      aria-label={label}
      // Icon-only on phones, so trim the padding and keep the row from scrolling.
      className={cn("shrink-0 font-normal max-sm:px-2!", active && tint, className)}
      {...props}
    >
      <Icon weight={weight} />
      <span className="hidden sm:inline">{label}</span>
    </Button>
  )
}

function HideFields({ table, view, update }: { table: TableDef; view: ViewDef; update: (p: Partial<ViewDef>) => void }) {
  const fields = table.fields.slice(1)
  const n = view.hidden.length
  return (
    <Popover>
      <PopoverTrigger asChild>
        <ToolbarButton
          icon={EyeSlashIcon}
          label={n ? `${n} hidden field${n > 1 ? "s" : ""}` : "Hide fields"}
          active={n > 0}
          tint="bg-sky-500/15 text-sky-900 hover:bg-sky-500/20 dark:text-sky-100"
          aria-label="Hide fields"
        />
      </PopoverTrigger>
      <PopoverContent align="start" className="w-64 p-2">
        <div className="flex flex-col">
          {fields.map((f) => (
            <label key={f.id} className="flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted">
              <Switch
                checked={!view.hidden.includes(f.id)}
                onCheckedChange={(on) =>
                  update({ hidden: on ? view.hidden.filter((h) => h !== f.id) : [...view.hidden, f.id] })
                }
              />
              <FieldIcon type={f.type} className="text-muted-foreground" />
              <span className="truncate">{f.name}</span>
            </label>
          ))}
        </div>
        <div className="mt-2 flex gap-2 border-t pt-2">
          <Button type="button" variant="ghost" size="xs" className="flex-1" onClick={() => update({ hidden: fields.map((f) => f.id) })}>
            Hide all
          </Button>
          <Button type="button" variant="ghost" size="xs" className="flex-1" onClick={() => update({ hidden: [] })}>
            Show all
          </Button>
        </div>
      </PopoverContent>
    </Popover>
  )
}

function ValueInput({
  tables,
  field,
  value,
  onChange,
}: {
  tables: Tables
  field: FieldDef
  value: string
  onChange: (v: string) => void
}) {
  if (field.type === "text" || field.type === "currency" || field.type === "date") {
    return (
      <Input
        className="h-7 w-full sm:w-40"
        type={field.type === "currency" ? "number" : field.type === "date" ? "date" : "text"}
        placeholder="Enter a value"
        value={value}
        onChange={(e) => onChange(e.target.value)}
      />
    )
  }
  return (
    <Select value={value} onValueChange={onChange}>
      <SelectTrigger size="sm" className="w-full sm:w-40">
        <SelectValue placeholder="Select an option" />
      </SelectTrigger>
      <SelectContent>
        {optionsFor(tables, field).map((o) => (
          <SelectItem key={o.value} value={o.value}>
            {o.label}
          </SelectItem>
        ))}
      </SelectContent>
    </Select>
  )
}

export function FilterBuilder({
  tables,
  table,
  view,
  update,
}: {
  tables: Tables
  table: TableDef
  view: ViewDef
  update: (p: Partial<ViewDef>) => void
}) {
  const active = view.filters.filter(isComplete)
  const first = table.fields.find((f) => f.id === active[0]?.field)
  const label = !active.length
    ? "Filter"
    : active.length === 1 && first
      ? `Filtered by ${first.name}`
      : `${active.length} filters`
  const setCond = (id: string, patch: Partial<Condition>) =>
    update({ filters: view.filters.map((c) => (c.id === id ? { ...c, ...patch } : c)) })

  return (
    <Popover>
      <PopoverTrigger asChild>
        <ToolbarButton
          icon={FunnelSimpleIcon}
          weight="bold"
          label={label}
          active={active.length > 0}
          tint="bg-emerald-500/15 text-emerald-900 hover:bg-emerald-500/20 dark:text-emerald-100"
          aria-label="Filter"
        />
      </PopoverTrigger>
      <PopoverContent align="start" className="w-[min(40rem,calc(100vw-2rem))] p-3">
        <div className="flex flex-col gap-2">
          {view.filters.length === 0 ? (
            <p className="text-sm text-muted-foreground">No filter conditions are applied to this view.</p>
          ) : (
            <p className="text-xs font-medium text-muted-foreground">In this view, show records</p>
          )}
          {view.filters.map((c, i) => {
            const field = table.fields.find((f) => f.id === c.field) ?? table.fields[0]
            return (
              <div key={c.id} className="flex flex-wrap items-center gap-2 border-b pb-3 sm:flex-nowrap sm:border-0 sm:pb-0">
                <div className="w-full shrink-0 text-sm text-muted-foreground sm:w-16">
                  {i === 0 ? (
                    "Where"
                  ) : i === 1 ? (
                    <Select value={view.conjunction} onValueChange={(v) => update({ conjunction: v as "and" | "or" })}>
                      <SelectTrigger size="sm" className="w-16">
                        <SelectValue />
                      </SelectTrigger>
                      <SelectContent>
                        <SelectItem value="and">and</SelectItem>
                        <SelectItem value="or">or</SelectItem>
                      </SelectContent>
                    </Select>
                  ) : (
                    <span className="pl-2.5">{view.conjunction}</span>
                  )}
                </div>
                <Select
                  value={field.id}
                  onValueChange={(id) => {
                    const f = table.fields.find((x) => x.id === id)!
                    setCond(c.id, { field: id, op: OPS[f.type][0], value: "" })
                  }}
                >
                  <SelectTrigger size="sm" className="min-w-0 flex-1 sm:w-36 sm:flex-none">
                    <SelectValue />
                  </SelectTrigger>
                  <SelectContent>
                    {table.fields.map((f) => (
                      <SelectItem key={f.id} value={f.id}>
                        <FieldIcon type={f.type} className="text-muted-foreground" />
                        {f.name}
                      </SelectItem>
                    ))}
                  </SelectContent>
                </Select>
                <Select value={c.op} onValueChange={(op) => setCond(c.id, { op })}>
                  <SelectTrigger size="sm" className="min-w-0 flex-1 sm:w-32 sm:flex-none">
                    <SelectValue />
                  </SelectTrigger>
                  <SelectContent>
                    {OPS[field.type].map((op) => (
                      <SelectItem key={op} value={op}>
                        {OP_LABEL[op]}
                      </SelectItem>
                    ))}
                  </SelectContent>
                </Select>
                {needsValue(c.op) && (
                  <div className="order-last w-full min-w-0 sm:order-none sm:w-auto">
                    <ValueInput tables={tables} field={field} value={c.value} onChange={(value) => setCond(c.id, { value })} />
                  </div>
                )}
                <Button
                  type="button"
                  variant="ghost"
                  size="icon-sm"
                  aria-label="Remove condition"
                  className="ml-auto"
                  onClick={() => update({ filters: view.filters.filter((x) => x.id !== c.id) })}
                >
                  <XIcon weight="bold" />
                </Button>
              </div>
            )
          })}
          <div>
            <Button
              type="button"
              variant="ghost"
              size="sm"
              className="text-sky-700 dark:text-sky-300"
              onClick={() => {
                const f = table.fields[0]
                update({ filters: [...view.filters, { id: uid(), field: f.id, op: OPS[f.type][0], value: "" }] })
              }}
            >
              <PlusIcon weight="bold" />
              Add condition
            </Button>
          </div>
        </div>
      </PopoverContent>
    </Popover>
  )
}

function GroupMenu({
  table,
  view,
  update,
  groupKeys,
}: {
  table: TableDef
  view: ViewDef
  update: (p: Partial<ViewDef>) => void
  groupKeys: string[]
}) {
  const field = table.fields.find((f) => f.id === view.groupBy)
  return (
    <Popover>
      <PopoverTrigger asChild>
        <ToolbarButton
          icon={StackIcon}
          label={field ? `Grouped by ${field.name}` : "Group"}
          active={!!field}
          tint="bg-violet-500/15 text-violet-900 hover:bg-violet-500/20 dark:text-violet-100"
          aria-label="Group"
        />
      </PopoverTrigger>
      <PopoverContent align="start" className="w-72 p-3">
        <div className="flex flex-col gap-3">
          <p className="text-xs font-medium text-muted-foreground">Group by</p>
          <div className="flex items-center gap-2">
            <Select
              value={view.groupBy ?? "none"}
              onValueChange={(v) => update({ groupBy: v === "none" ? null : v, collapsed: [] })}
            >
              <SelectTrigger size="sm" className="flex-1">
                <SelectValue />
              </SelectTrigger>
              <SelectContent>
                <SelectItem value="none">No grouping</SelectItem>
                {table.fields
                  .filter((f) => f.type !== "currency")
                  .map((f) => (
                    <SelectItem key={f.id} value={f.id}>
                      <FieldIcon type={f.type} className="text-muted-foreground" />
                      {f.name}
                    </SelectItem>
                  ))}
              </SelectContent>
            </Select>
            {field && (
              <Button
                type="button"
                variant="ghost"
                size="icon-sm"
                aria-label="Remove grouping"
                onClick={() => update({ groupBy: null, collapsed: [] })}
              >
                <XIcon weight="bold" />
              </Button>
            )}
          </div>
          {field && (
            <div className="flex gap-2">
              <Button type="button" variant="outline" size="xs" className="flex-1" onClick={() => update({ collapsed: groupKeys })}>
                Collapse all
              </Button>
              <Button type="button" variant="outline" size="xs" className="flex-1" onClick={() => update({ collapsed: [] })}>
                Expand all
              </Button>
            </div>
          )}
        </div>
      </PopoverContent>
    </Popover>
  )
}

export function SortMenu({ table, view, update }: { table: TableDef; view: ViewDef; update: (p: Partial<ViewDef>) => void }) {
  const n = view.sorts.length
  const unused = table.fields.filter((f) => !view.sorts.some((s) => s.field === f.id))
  return (
    <Popover>
      <PopoverTrigger asChild>
        <ToolbarButton
          icon={SortAscendingIcon}
          weight="bold"
          label={n ? `Sorted by ${n} field${n > 1 ? "s" : ""}` : "Sort"}
          active={n > 0}
          tint="bg-orange-500/15 text-orange-900 hover:bg-orange-500/20 dark:text-orange-100"
          aria-label="Sort"
        />
      </PopoverTrigger>
      <PopoverContent align="start" className="w-[min(24rem,calc(100vw-2rem))] p-3">
        <div className="flex flex-col gap-2">
          <p className="text-xs font-medium text-muted-foreground">Sort by</p>
          {view.sorts.map((s, i) => {
            const field = table.fields.find((f) => f.id === s.field)
            const [up, down] = sortLabels(field)
            return (
              <div key={s.field} className="flex items-center gap-2">
                <Select
                  value={s.field}
                  onValueChange={(f) => update({ sorts: view.sorts.map((x, j) => (j === i ? { ...x, field: f } : x)) })}
                >
                  <SelectTrigger size="sm" className="min-w-0 flex-1">
                    <SelectValue />
                  </SelectTrigger>
                  <SelectContent>
                    {table.fields
                      .filter((f) => f.id === s.field || !view.sorts.some((x) => x.field === f.id))
                      .map((f) => (
                        <SelectItem key={f.id} value={f.id}>
                          <FieldIcon type={f.type} className="text-muted-foreground" />
                          {f.name}
                        </SelectItem>
                      ))}
                  </SelectContent>
                </Select>
                <Select
                  value={s.dir}
                  onValueChange={(d) =>
                    update({ sorts: view.sorts.map((x, j) => (j === i ? { ...x, dir: d as "asc" | "desc" } : x)) })
                  }
                >
                  <SelectTrigger size="sm" className="w-32">
                    <SelectValue />
                  </SelectTrigger>
                  <SelectContent>
                    <SelectItem value="asc">{up}</SelectItem>
                    <SelectItem value="desc">{down}</SelectItem>
                  </SelectContent>
                </Select>
                <Button
                  type="button"
                  variant="ghost"
                  size="icon-sm"
                  aria-label="Remove sort"
                  onClick={() => update({ sorts: view.sorts.filter((_, j) => j !== i) })}
                >
                  <XIcon weight="bold" />
                </Button>
              </div>
            )
          })}
          {unused.length > 0 && (
            <div>
              <Button
                type="button"
                variant="ghost"
                size="sm"
                className="text-sky-700 dark:text-sky-300"
                onClick={() => update({ sorts: [...view.sorts, { field: unused[0].id, dir: "asc" }] })}
              >
                <PlusIcon weight="bold" />
                {n ? "Add another sort" : "Pick a field to sort by"}
              </Button>
            </div>
          )}
        </div>
      </PopoverContent>
    </Popover>
  )
}

function RowHeightMenu({ view, update }: { view: ViewDef; update: (p: Partial<ViewDef>) => void }) {
  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <ToolbarButton icon={ArrowsVerticalIcon}
          weight="bold" label="Row height" tint="" aria-label="Row height" />
      </DropdownMenuTrigger>
      <DropdownMenuContent align="start">
        <DropdownMenuLabel>Row height</DropdownMenuLabel>
        <MenuChoices value={view.rowHeight} onValueChange={(v) => update({ rowHeight: v as RowHeight })}>
          <MenuChoice value="short">Short</MenuChoice>
          <MenuChoice value="medium">Medium</MenuChoice>
          <MenuChoice value="tall">Tall</MenuChoice>
        </MenuChoices>
      </DropdownMenuContent>
    </DropdownMenu>
  )
}

function AddField({ onAdd }: { onAdd: (f: Omit<FieldDef, "id" | "width">) => void }) {
  const [open, setOpen] = React.useState(false)
  const [name, setName] = React.useState("")
  const [type, setType] = React.useState<FieldType>("text")
  const [options, setOptions] = React.useState("")
  const hasOptions = type === "singleSelect" || type === "multiSelect"
  return (
    <Popover open={open} onOpenChange={setOpen}>
      <PopoverTrigger asChild>
        <Button type="button" variant="ghost" size="sm" aria-label="Add field" className="h-8 w-full rounded-none">
          <PlusIcon weight="bold" />
        </Button>
      </PopoverTrigger>
      <PopoverContent align="end" className="w-72 p-3">
        <form
          className="flex flex-col gap-3"
          onSubmit={(e) => {
            e.preventDefault()
            onAdd({
              name: name.trim() || "Untitled field",
              type,
              options: hasOptions
                ? options
                    .split(",")
                    .map((o) => o.trim())
                    .filter(Boolean)
                : undefined,
            })
            setName("")
            setOptions("")
            setType("text")
            setOpen(false)
          }}
        >
          <Input autoFocus placeholder="Field name" value={name} onChange={(e) => setName(e.target.value)} />
          <Select value={type} onValueChange={(v) => setType(v as FieldType)}>
            <SelectTrigger className="w-full">
              <SelectValue />
            </SelectTrigger>
            <SelectContent>
              {(Object.keys(TYPE_LABEL) as FieldType[])
                .filter((t) => t !== "link")
                .map((t) => (
                  <SelectItem key={t} value={t}>
                    <FieldIcon type={t} className="text-muted-foreground" />
                    {TYPE_LABEL[t]}
                  </SelectItem>
                ))}
            </SelectContent>
          </Select>
          {hasOptions && (
            <Input
              placeholder="Options, separated by commas"
              value={options}
              onChange={(e) => setOptions(e.target.value)}
            />
          )}
          <div className="flex justify-end gap-2">
            <Button type="button" variant="ghost" size="sm" onClick={() => setOpen(false)}>
              Cancel
            </Button>
            <Button type="submit" size="sm">
              Create field
            </Button>
          </div>
        </form>
      </PopoverContent>
    </Popover>
  )
}

/* Views */

type Group = { key: string; value: CellValue; rows: Row[] }

type ViewProps = {
  tables: Tables
  table: TableDef
  view: ViewDef
  rows: Row[]
  update: (p: Partial<ViewDef> | ((v: ViewDef) => Partial<ViewDef>)) => void
  setCell: (rowId: string, fieldId: string, v: CellValue) => void
  addRow: (cells?: Row["cells"], near?: { id: string; offset: 0 | 1 }) => string
  deleteRows: (ids: string[]) => void
  duplicateRow: (id: string) => void
  addField: (f: Omit<FieldDef, "id" | "width">) => void
  onExpand: (id: string) => void
  selected: string[]
  setSelected: React.Dispatch<React.SetStateAction<string[]>>
}

function groupRows(tables: Tables, field: FieldDef | undefined, rows: Row[]): Group[] | null {
  if (!field) return null
  const map = new Map<string, Group>()
  for (const r of rows) {
    const v = getValue(tables, r, field)
    const key = displayText(tables, field, v)
    if (!map.has(key)) map.set(key, { key, value: v, rows: [] })
    map.get(key)!.rows.push(r)
  }
  const rank = (g: Group) =>
    g.key === "" ? Infinity : field.type === "singleSelect" && field.options ? field.options.indexOf(String(g.value)) : 0
  return [...map.values()].sort((a, b) => rank(a) - rank(b) || a.key.localeCompare(b.key))
}

// A header cell that drags along the row to reorder columns (dnd-kit).
function SortableHead({
  id,
  disabled,
  className,
  style,
  children,
}: {
  id: string
  disabled?: boolean
  className?: string
  style?: React.CSSProperties
  children: React.ReactNode
}) {
  const { setNodeRef, attributes, listeners, transform, transition, isDragging } = useSortable({ id, disabled })
  return (
    <TableHead
      ref={setNodeRef}
      {...attributes}
      {...listeners}
      className={cn(className, !disabled && "cursor-grab active:cursor-grabbing", isDragging && "z-[3] opacity-90 shadow-lg")}
      style={{ ...style, transform: CSS.Translate.toString(transform ? { ...transform, y: 0 } : null), transition }}
    >
      {children}
    </TableHead>
  )
}

function GridView(props: ViewProps) {
  const {
    tables,
    table,
    view,
    rows,
    update,
    setCell,
    addRow,
    deleteRows,
    duplicateRow,
    addField,
    onExpand,
    selected,
    setSelected,
  } = props
  const fields = visibleFields(table, view)
  const groupField = table.fields.find((f) => f.id === view.groupBy)
  const groups = groupRows(tables, groupField, rows)
  const flat = groups ? groups.flatMap((g) => (view.collapsed.includes(g.key) ? [] : g.rows)) : rows
  const number = new Map(flat.map((r, i) => [r.id, i + 1]))

  const [active, setActive] = React.useState<{ row: string; field: string } | null>(null)
  const [editing, setEditing] = React.useState<{ initial?: string } | null>(null)
  const [scrolled, setScrolled] = React.useState(false)
  const headSensors = useDragSensors()
  const headDndId = React.useId()
  const gridRef = React.useRef<HTMLDivElement>(null)
  const resizing = React.useRef(false)

  const width = (f: FieldDef) => view.widths[f.id] ?? f.width
  const total = NUM_W + fields.reduce((t, f) => t + width(f), 0) + ADD_W
  const height = ROW_H[view.rowHeight]
  const wrap = view.rowHeight !== "short"
  const allSelected = flat.length > 0 && flat.every((r) => selected.includes(r.id))
  const someSelected = flat.some((r) => selected.includes(r.id))

  React.useEffect(() => {
    if (!active) return
    gridRef.current
      ?.querySelector(`[data-cell="${active.row}:${active.field}"]`)
      ?.scrollIntoView({ block: "nearest", inline: "nearest" })
  }, [active])

  const focusGrid = () => requestAnimationFrame(() => gridRef.current?.focus({ preventScroll: true }))
  const stopEdit = () => {
    setEditing(null)
    focusGrid()
  }

  const move = (dr: number, dc: number) => {
    if (!flat.length) return
    if (!active) return setActive({ row: flat[0].id, field: fields[0].id })
    const ri = Math.max(0, flat.findIndex((r) => r.id === active.row))
    const ci = Math.max(0, fields.findIndex((f) => f.id === active.field))
    const nr = Math.min(flat.length - 1, Math.max(0, ri + dr))
    const nc = Math.min(fields.length - 1, Math.max(0, ci + dc))
    setActive({ row: flat[nr].id, field: fields[nc].id })
  }

  const onKeyDown = (e: React.KeyboardEvent) => {
    if (editing || !active) return
    const field = fields.find((f) => f.id === active.field)
    const editable = !!field && !field.via
    switch (e.key) {
      case "ArrowUp":
        e.preventDefault()
        return move(-1, 0)
      case "ArrowDown":
        e.preventDefault()
        return move(1, 0)
      case "ArrowLeft":
        e.preventDefault()
        return move(0, -1)
      case "ArrowRight":
        e.preventDefault()
        return move(0, 1)
      case "Tab":
        e.preventDefault()
        return move(0, e.shiftKey ? -1 : 1)
      case "Enter":
        e.preventDefault()
        if (editable) setEditing({})
        return
      case " ":
        e.preventDefault()
        return onExpand(active.row)
      case "Escape":
        return setActive(null)
      case "Backspace":
      case "Delete":
        if (field && editable) setCell(active.row, field.id, emptyFor(field))
        return
    }
    if (
      field &&
      editable &&
      e.key.length === 1 &&
      !e.metaKey &&
      !e.ctrlKey &&
      !e.altKey &&
      (field.type === "text" || field.type === "currency")
    ) {
      e.preventDefault()
      setEditing({ initial: e.key })
    }
  }

  const reorder = (from: string, to: string) => {
    if (from === to) return
    update((v) => {
      const order = v.order.filter((id) => id !== from)
      order.splice(order.indexOf(to), 0, from)
      return { order }
    })
  }

  const startResize = (id: string, startW: number) => (e: React.PointerEvent) => {
    e.preventDefault()
    e.stopPropagation()
    resizing.current = true
    const startX = e.clientX
    const onMove = (ev: PointerEvent) =>
      update((v) => ({ widths: { ...v.widths, [id]: Math.max(80, Math.round(startW + ev.clientX - startX)) } }))
    const onUp = () => {
      resizing.current = false
      window.removeEventListener("pointermove", onMove)
      window.removeEventListener("pointerup", onUp)
    }
    window.addEventListener("pointermove", onMove)
    window.addEventListener("pointerup", onUp)
  }

  // The primary field stays frozen next to the row numbers.
  const frozen = (i: number) =>
    i === 0 ? cn("sticky z-[1]", scrolled && "shadow-[6px_0_8px_-6px_rgb(0_0_0/0.3)]") : ""

  const summaryCell = (f: FieldDef, list: Row[]) => {
    const kind = view.summaries[f.id] ?? "none"
    return kind === "none" ? null : (
      <span className="text-xs text-muted-foreground">
        {SUMMARY_LABEL[kind]}{" "}
        <span className="font-medium text-foreground tabular-nums">{summarize(tables, f, list, kind)}</span>
      </span>
    )
  }

  const renderRow = (r: Row) => {
    const isSelected = selected.includes(r.id)
    return (
      <ContextMenu key={r.id}>
        <ContextMenuTrigger asChild>
          <TableRow
            data-state={isSelected ? "selected" : undefined}
            className="group/row border-0 hover:bg-transparent data-[state=selected]:bg-transparent"
          >
            <TableCell className={cn("sticky left-0 z-[1] border-b p-0 align-top", CELL_BG)} style={{ height }}>
              <div className="flex h-8 items-center gap-1 pr-1 pl-2">
                <span
                  className={cn(
                    "w-5 text-center text-xs text-muted-foreground tabular-nums group-hover/row:hidden",
                    isSelected && "hidden"
                  )}
                >
                  {number.get(r.id)}
                </span>
                <Checkbox
                  aria-label={`Select ${primaryText(table, r) || "record"}`}
                  className={cn("mx-0.5 hidden group-hover/row:flex", isSelected && "flex")}
                  checked={isSelected}
                  onCheckedChange={(on) => setSelected((s) => (on ? [...s, r.id] : s.filter((id) => id !== r.id)))}
                />
                <Button
                  type="button"
                  variant="ghost"
                  size="icon-xs"
                  aria-label="Expand record"
                  className="ml-auto text-sky-600 opacity-0 group-hover/row:opacity-100 focus-visible:opacity-100 dark:text-sky-400"
                  onClick={() => onExpand(r.id)}
                >
                  <ArrowsOutSimpleIcon weight="bold" />
                </Button>
              </div>
            </TableCell>
            {fields.map((f, i) => {
              const isActive = active?.row === r.id && active.field === f.id
              const value = getValue(tables, r, f)
              return (
                <TableCell
                  key={f.id}
                  data-cell={`${r.id}:${f.id}`}
                  className={cn("relative border-r border-b p-0", wrap ? "align-top" : "align-middle", CELL_BG, frozen(i))}
                  style={{ height, left: i === 0 ? NUM_W : undefined }}
                  onClick={() => {
                    if (!isActive) setEditing(null)
                    setActive({ row: r.id, field: f.id })
                  }}
                  onDoubleClick={() => {
                    setActive({ row: r.id, field: f.id })
                    if (!f.via) setEditing({})
                  }}
                >
                  <div
                    className={cn("overflow-hidden px-2", wrap ? "py-1.5" : "flex items-center")}
                    style={{ height: height - 1 }}
                  >
                    <CellDisplay tables={tables} field={f} value={value} wrap={wrap} primary={i === 0} />
                  </div>
                  {isActive && (
                    <span className="pointer-events-none absolute inset-0 z-[2] rounded-[2px] ring-2 ring-sky-500 ring-inset" />
                  )}
                  {isActive && editing && (
                    <CellEditor
                      tables={tables}
                      field={f}
                      value={value}
                      initial={editing.initial}
                      onCommit={(v) => setCell(r.id, f.id, v)}
                      onClose={stopEdit}
                    />
                  )}
                </TableCell>
              )
            })}
            <TableCell className={cn("border-b", CELL_BG)} />
          </TableRow>
        </ContextMenuTrigger>
        <ContextMenuContent>
          <ContextMenuItem onSelect={() => onExpand(r.id)}>
            <ArrowsOutSimpleIcon weight="bold" />
            Expand record
          </ContextMenuItem>
          <ContextMenuItem onSelect={() => addRow({}, { id: r.id, offset: 0 })}>
            <CaretUpIcon weight="bold" />
            Insert record above
          </ContextMenuItem>
          <ContextMenuItem onSelect={() => addRow({}, { id: r.id, offset: 1 })}>
            <CaretDownIcon weight="bold" />
            Insert record below
          </ContextMenuItem>
          <ContextMenuItem onSelect={() => duplicateRow(r.id)}>
            <CopyIcon weight="duotone" />
            Duplicate record
          </ContextMenuItem>
          <ContextMenuSeparator />
          <ContextMenuItem variant="destructive" onSelect={() => deleteRows([r.id])}>
            <TrashIcon weight="duotone" />
            Delete record
          </ContextMenuItem>
        </ContextMenuContent>
      </ContextMenu>
    )
  }

  return (
    <ScrollFadeArea
      ref={gridRef}
      tabIndex={0}
      role="grid"
      aria-label={view.name}
      fade="y"
      className="max-h-[38rem] outline-none focus-visible:ring-0"
      onKeyDown={onKeyDown}
      onScrollCapture={(e) => {
        const el = e.target as HTMLElement
        // Only the table scrolls sideways; this wrapper only scrolls down.
        if (el !== e.currentTarget) setScrolled(el.scrollLeft > 0)
      }}
    >
      {/* Columns reorder with dnd-kit: drag a header along the row. */}
      <DndContext
        id={headDndId}
        sensors={headSensors}
        onDragStart={() => cue("select", { force: true })}
        onDragEnd={({ active, over }) => {
          if (!over || active.id === over.id) return
          reorder(String(active.id), String(over.id))
          cue("tap", { emphasis: "strong", force: true })
        }}
      >
      <Table className="table-fixed border-separate border-spacing-0 text-[13px]" style={{ width: total }}>
        <colgroup>
          <col style={{ width: NUM_W }} />
          {fields.map((f) => (
            <col key={f.id} style={{ width: width(f) }} />
          ))}
          <col style={{ width: ADD_W }} />
        </colgroup>
        <TableHeader>
          <TableRow className="border-0 hover:bg-transparent">
            <TableHead className={cn("sticky left-0 z-[1] h-8 border-b px-0", HEAD_BG)}>
              <div className="flex items-center pl-2.5">
                <Checkbox
                  aria-label="Select all"
                  checked={allSelected ? true : someSelected ? "indeterminate" : false}
                  onCheckedChange={(on) => setSelected(on ? flat.map((r) => r.id) : [])}
                />
              </div>
            </TableHead>
            <SortableContext items={fields.slice(1).map((f) => f.id)} strategy={horizontalListSortingStrategy}>
            {fields.map((f, i) => {
              const [up, down] = sortLabels(f)
              return (
                <SortableHead
                  key={f.id}
                  id={f.id}
                  disabled={i === 0}
                  className={cn("group/head relative h-8 border-r border-b px-2 font-normal", HEAD_BG, frozen(i))}
                  style={{ left: i === 0 ? NUM_W : undefined }}
                >
                  <div className={cn("flex items-center gap-1.5", f.type === "currency" && "flex-row-reverse")}>
                    <FieldIcon type={f.type} className="text-muted-foreground" />
                    <span className="truncate">{f.name}</span>
                    <DropdownMenu>
                      <DropdownMenuTrigger asChild>
                        <Button
                          type="button"
                          variant="ghost"
                          size="icon-xs"
                          aria-label={`${f.name} options`}
                          className={cn(
                            "size-5 opacity-0 group-hover/head:opacity-100 data-[state=open]:opacity-100",
                            f.type === "currency" ? "mr-auto" : "ml-auto"
                          )}
                        >
                          <CaretDownIcon weight="bold" />
                        </Button>
                      </DropdownMenuTrigger>
                      <DropdownMenuContent align="start" className="w-52">
                        <DropdownMenuItem onSelect={() => update({ sorts: [{ field: f.id, dir: "asc" }] })}>
                          <SortAscendingIcon weight="bold" />
                          Sort {up}
                        </DropdownMenuItem>
                        <DropdownMenuItem onSelect={() => update({ sorts: [{ field: f.id, dir: "desc" }] })}>
                          <SortAscendingIcon weight="bold" className="-scale-y-100" />
                          Sort {down}
                        </DropdownMenuItem>
                        <DropdownMenuSeparator />
                        <DropdownMenuItem
                          onSelect={() =>
                            update((v) => ({
                              filters: [...v.filters, { id: uid(), field: f.id, op: OPS[f.type][0], value: "" }],
                            }))
                          }
                        >
                          <FunnelSimpleIcon weight="bold" />
                          Filter by this field
                        </DropdownMenuItem>
                        {f.type !== "currency" && (
                          <DropdownMenuItem onSelect={() => update({ groupBy: f.id, collapsed: [] })}>
                            <StackIcon weight="duotone" />
                            Group by this field
                          </DropdownMenuItem>
                        )}
                        {i > 0 && (
                          <DropdownMenuItem onSelect={() => update((v) => ({ hidden: [...v.hidden, f.id] }))}>
                            <EyeSlashIcon weight="duotone" />
                            Hide field
                          </DropdownMenuItem>
                        )}
                      </DropdownMenuContent>
                    </DropdownMenu>
                  </div>
                  <div
                    aria-hidden
                    onPointerDown={startResize(f.id, width(f))}
                    className="absolute top-0 -right-1 z-10 h-full w-2 cursor-col-resize hover:bg-sky-500/50"
                  />
                </SortableHead>
              )
            })}
            </SortableContext>
            <TableHead className={cn("h-8 border-b p-0", HEAD_BG)}>
              <AddField onAdd={addField} />
            </TableHead>
          </TableRow>
        </TableHeader>
        <TableBody>
          {groups
            ? groups.map((g) => {
                const collapsed = view.collapsed.includes(g.key)
                return (
                  <React.Fragment key={g.key}>
                    <TableRow className="border-0 hover:bg-transparent">
                      <TableCell colSpan={2} className={cn("sticky left-0 z-[1] h-10 border-b p-0", HEAD_BG)}>
                        <Button
                          type="button"
                          variant="ghost"
                          className="h-10 w-full justify-start gap-2 rounded-none px-2.5 font-normal"
                          onClick={() =>
                            update((v) => ({
                              collapsed: collapsed ? v.collapsed.filter((k) => k !== g.key) : [...v.collapsed, g.key],
                            }))
                          }
                        >
                          {collapsed ? (
                            <CaretRightIcon weight="bold" className="size-3.5 shrink-0" />
                          ) : (
                            <CaretDownIcon weight="bold" className="size-3.5 shrink-0" />
                          )}
                          <span className="text-xs text-muted-foreground">{groupField!.name}</span>
                          <span className="flex min-w-0">
                            {g.key === "" ? (
                              <span className="text-muted-foreground">Empty</span>
                            ) : (
                              <CellDisplay tables={tables} field={groupField!} value={g.value} />
                            )}
                          </span>
                          <span className="ml-auto text-xs text-muted-foreground tabular-nums">{g.rows.length}</span>
                        </Button>
                      </TableCell>
                      {fields.slice(1).map((f) => (
                        <TableCell
                          key={f.id}
                          className={cn("h-10 border-b px-2", HEAD_BG, f.type === "currency" && "text-right")}
                        >
                          {summaryCell(f, g.rows)}
                        </TableCell>
                      ))}
                      <TableCell className={cn("border-b", HEAD_BG)} />
                    </TableRow>
                    {!collapsed && g.rows.map(renderRow)}
                  </React.Fragment>
                )
              })
            : rows.map(renderRow)}
          <TableRow className="group/row border-0 hover:bg-transparent">
            <TableCell colSpan={2} className={cn("sticky left-0 z-[1] border-b p-0", CELL_BG)}>
              <Button
                type="button"
                variant="ghost"
                size="sm"
                className="h-8 w-full justify-start rounded-none font-normal text-muted-foreground"
                onClick={() => {
                  const id = addRow()
                  setActive({ row: id, field: fields[0].id })
                  focusGrid()
                }}
              >
                <PlusIcon weight="bold" />
                Add record
              </Button>
            </TableCell>
            <TableCell colSpan={fields.length} className={cn("border-b", CELL_BG)} />
          </TableRow>
        </TableBody>
        <TableFooter className="border-t-0 bg-transparent font-normal">
          <TableRow className="border-0 hover:bg-transparent">
            <TableCell className={cn("sticky left-0 z-[1] h-9 p-0", HEAD_BG)} />
            {fields.map((f, i) =>
              i === 0 ? (
                <TableCell key={f.id} className={cn("h-9 px-2", HEAD_BG, frozen(0))} style={{ left: NUM_W }}>
                  <span className="text-xs text-muted-foreground">
                    <span className="font-medium text-foreground tabular-nums">{rows.length}</span>{" "}
                    {rows.length === 1 ? "record" : "records"}
                  </span>
                </TableCell>
              ) : (
                <TableCell key={f.id} className={cn("group/sum h-9 p-0", HEAD_BG)}>
                  <DropdownMenu>
                    <DropdownMenuTrigger asChild>
                      <Button
                        type="button"
                        variant="ghost"
                        size="sm"
                        className={cn(
                          "h-9 w-full rounded-none px-2 font-normal",
                          f.type === "currency" ? "justify-end" : "justify-start"
                        )}
                      >
                        {summaryCell(f, rows) ?? (
                          <span className="text-xs text-muted-foreground opacity-0 group-hover/sum:opacity-100">
                            Summarize
                          </span>
                        )}
                      </Button>
                    </DropdownMenuTrigger>
                    <DropdownMenuContent align={f.type === "currency" ? "end" : "start"}>
                      <DropdownMenuLabel>Summarize {f.name}</DropdownMenuLabel>
                      <MenuChoices
                        value={view.summaries[f.id] ?? "none"}
                        onValueChange={(s) => update((v) => ({ summaries: { ...v.summaries, [f.id]: s as Summary } }))}
                      >
                        {summaryOptions(f).map((s) => (
                          <MenuChoice key={s} value={s}>
                            {SUMMARY_LABEL[s]}
                          </MenuChoice>
                        ))}
                      </MenuChoices>
                    </DropdownMenuContent>
                  </DropdownMenu>
                </TableCell>
              )
            )}
            <TableCell className={HEAD_BG} />
          </TableRow>
        </TableFooter>
      </Table>
      </DndContext>
    </ScrollFadeArea>
  )
}

// Phones get the Airtable app layout: a field row on top and a card per record.
function PhoneList({ tables, table, view, rows, onExpand }: ViewProps) {
  const fields = visibleFields(table, view).slice(1, 4)
  return (
    <div className="flex flex-col bg-muted/40">
      <div className="grid grid-cols-3 gap-2 border-b bg-card px-5 py-2 text-xs text-muted-foreground">
        {fields.map((f) => (
          <span key={f.id} className="flex min-w-0 items-center gap-1">
            <FieldIcon type={f.type} />
            <span className="truncate">{f.name}</span>
          </span>
        ))}
      </div>
      <ItemGroup className="gap-2 p-2">
        {rows.map((r) => (
          <Item
            key={r.id}
            variant="outline"
            size="sm"
            role="button"
            tabIndex={0}
            onClick={() => onExpand(r.id)}
            onKeyDown={(e) => e.key === "Enter" && onExpand(r.id)}
            className="cursor-pointer bg-card"
          >
            <ItemContent className="min-w-0 gap-2">
              <ItemTitle className="w-full truncate">{primaryText(table, r) || "Unnamed record"}</ItemTitle>
              <div className="grid grid-cols-3 gap-2 text-xs">
                {fields.map((f) => (
                  // Long pill lists fade out instead of cutting a word in half.
                  <span key={f.id} className="flex min-w-0 overflow-hidden [mask-image:linear-gradient(to_right,black_80%,transparent)]">
                    <CellDisplay tables={tables} field={f} value={getValue(tables, r, f)} />
                  </span>
                ))}
              </div>
            </ItemContent>
          </Item>
        ))}
        {rows.length === 0 && <p className="py-10 text-center text-muted-foreground">No records match this view.</p>}
      </ItemGroup>
    </div>
  )
}

/* Drag and drop for the Kanban and Calendar views, on dnd-kit */

function useDragSensors() {
  return useSensors(
    useSensor(MouseSensor, { activationConstraint: { distance: 5 } }),
    useSensor(TouchSensor, { activationConstraint: { delay: 180, tolerance: 6 } })
  )
}

function DropZone({
  id,
  className,
  overClassName,
  children,
}: {
  id: string
  className?: string
  overClassName?: string
  children: React.ReactNode
}) {
  const { setNodeRef, isOver } = useDroppable({ id })
  return (
    <div ref={setNodeRef} className={cn(className, isOver && overClassName)}>
      {children}
    </div>
  )
}

function useDragItem(id: string) {
  const { setNodeRef, attributes, listeners, isDragging } = useDraggable({ id })
  return { ref: setNodeRef, dragProps: { ...attributes, ...listeners }, isDragging }
}

function RecordCard({
  tables,
  table,
  row,
  fields,
  onExpand,
  overlay,
}: {
  tables: Tables
  table: TableDef
  row: Row
  fields: FieldDef[]
  onExpand?: () => void
  overlay?: boolean
}) {
  const drag = useDragItem(row.id)
  return (
    <Card
      ref={overlay ? undefined : drag.ref}
      size="sm"
      {...(overlay ? {} : drag.dragProps)}
      role="button"
      tabIndex={0}
      onClick={onExpand}
      onKeyDown={(e) => e.key === "Enter" && onExpand?.()}
      className={cn(
        "cursor-grab gap-2 px-3 text-[13px] select-none active:cursor-grabbing",
        drag.isDragging && !overlay && "opacity-40",
        overlay && "rotate-2 shadow-xl ring-2 ring-sky-500/40"
      )}
    >
      <CardTitle className="text-[13px]">{primaryText(table, row) || "Unnamed record"}</CardTitle>
      {fields.map((f) => {
        const v = getValue(tables, row, f)
        return isEmpty(v) ? null : (
          <span key={f.id} className="flex flex-col gap-0.5">
            <span className="text-[11px] text-muted-foreground">{f.name}</span>
            <span className="flex min-w-0">
              <CellDisplay tables={tables} field={f} value={v} wrap />
            </span>
          </span>
        )
      })}
    </Card>
  )
}

function KanbanView({ tables, table, view, rows, setCell, addRow, onExpand }: ViewProps) {
  const stack = table.fields.find((f) => f.type === "singleSelect")
  const [drag, setDrag] = React.useState<string | null>(null)
  const sensors = useDragSensors()
  const dndId = React.useId()
  if (!stack) return <p className="p-6 text-muted-foreground">Add a single select field to stack records.</p>
  const fields = visibleFields(table, view)
    .slice(1)
    .filter((f) => f.id !== stack.id)
    .slice(0, 4)
  const dragged = rows.find((r) => r.id === drag)
  return (
    <DndContext
      id={dndId}
      sensors={sensors}
      onDragStart={({ active }) => {
        setDrag(String(active.id))
        cue("select", { force: true })
      }}
      onDragEnd={({ active, over }) => {
        setDrag(null)
        if (!over) return cue("close", { emphasis: "subtle", force: true })
        setCell(String(active.id), stack.id, String(over.id).slice("stack:".length) || null)
        cue("tap", { emphasis: "strong", force: true })
      }}
      onDragCancel={() => setDrag(null)}
    >
      <div className="flex min-h-[30rem] gap-3 overflow-x-auto bg-muted/40 p-3">
        {[...(stack.options ?? []), ""].map((s) => {
          const cards = rows.filter((r) => String(r.cells[stack.id] ?? "") === s)
          if (s === "" && cards.length === 0) return null
          return (
            <DropZone
              key={s || "empty"}
              id={`stack:${s}`}
              className="flex w-64 shrink-0 flex-col gap-2 self-start rounded-[calc(var(--ui-radius)+0.5rem)] bg-muted/70 p-2 transition-shadow"
              overClassName="ring-2 ring-sky-500"
            >
              <div className="flex items-center gap-2 px-1 py-0.5">
                {s ? <Pill color={pillColor(stack, s)}>{s}</Pill> : <span className="text-muted-foreground">Uncategorized</span>}
                <span className="text-xs text-muted-foreground tabular-nums">{cards.length}</span>
              </div>
              {cards.map((r) => (
                <RecordCard key={r.id} tables={tables} table={table} row={r} fields={fields} onExpand={() => onExpand(r.id)} />
              ))}
              <Button
                type="button"
                variant="ghost"
                size="sm"
                className="justify-start font-normal text-muted-foreground"
                onClick={() => onExpand(addRow({ [stack.id]: s || null }))}
              >
                <PlusIcon weight="bold" />
                Add record
              </Button>
            </DropZone>
          )
        })}
      </div>
      <DragOverlay dropAnimation={{ duration: 220, easing: "cubic-bezier(0.22, 1, 0.36, 1)" }}>
        {dragged ? (
          <div className="w-64">
            <RecordCard tables={tables} table={table} row={dragged} fields={fields} overlay />
          </div>
        ) : null}
      </DragOverlay>
    </DndContext>
  )
}

const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]

// Calendar records are secondary Buttons tinted with their status colour, by
// pointing the button's --primary at the tint.
function RecordChip({
  row,
  label,
  color,
  onExpand,
  draggable = true,
  className,
}: {
  row: Row
  label: string
  color: string
  onExpand: () => void
  draggable?: boolean
  className?: string
}) {
  const drag = useDragItem(row.id)
  return (
    <Button
      ref={draggable ? drag.ref : undefined}
      variant="secondary"
      size="xs"
      onClick={onExpand}
      style={{ "--primary": `color-mix(in oklab, ${color} 70%, var(--foreground))` } as React.CSSProperties}
      className={cn("w-full justify-start font-normal", drag.isDragging && "opacity-40", className)}
      {...(draggable ? drag.dragProps : {})}
    >
      <span className="truncate">{label}</span>
    </Button>
  )
}

function CalendarView({ table, rows, setCell, onExpand }: ViewProps) {
  const dateField = table.fields.find((f) => f.type === "date")
  const colorField = table.fields.find((f) => f.type === "singleSelect")
  const [month, setMonth] = React.useState({ y: 2026, m: 9 })
  const [drag, setDrag] = React.useState<string | null>(null)
  const sensors = useDragSensors()
  const dndId = React.useId()
  if (!dateField) return <p className="p-6 text-muted-foreground">Add a date field to use the calendar.</p>

  const first = new Date(month.y, month.m, 1)
  const days = Array.from({ length: 42 }, (_, i) => new Date(month.y, month.m, 1 - first.getDay() + i))
  const byDay = new Map<string, Row[]>()
  for (const r of rows) {
    const d = r.cells[dateField.id]
    if (!isEmpty(d)) byDay.set(String(d), [...(byDay.get(String(d)) ?? []), r])
  }
  const undated = rows.filter((r) => isEmpty(r.cells[dateField.id])).length
  const shift = (n: number) =>
    setMonth(({ y, m }) => ({ y: m + n < 0 ? y - 1 : m + n > 11 ? y + 1 : y, m: (m + n + 12) % 12 }))
  const colorOf = (r: Row) => (colorField ? pillColor(colorField, String(r.cells[colorField.id] ?? "")) : PALETTE[0])
  const agenda = days.filter((d) => d.getMonth() === month.m && byDay.has(toIso(d)))
  const dragged = rows.find((r) => r.id === drag)

  return (
    <div className="flex flex-col">
      <div className="flex items-center gap-1 border-b px-3 py-2">
        <Button type="button" variant="ghost" size="icon-sm" aria-label="Previous month" onClick={() => shift(-1)}>
          <CaretLeftIcon weight="bold" />
        </Button>
        <Button type="button" variant="ghost" size="icon-sm" aria-label="Next month" onClick={() => shift(1)}>
          <CaretRightIcon weight="bold" />
        </Button>
        <span className="ml-1 font-medium">{first.toLocaleDateString("en-US", { month: "long", year: "numeric" })}</span>
        <span className="ml-auto truncate pl-2 text-xs text-muted-foreground">
          By {dateField.name}
          {undated > 0 && `, ${undated} without a date`}
        </span>
      </div>
      <DndContext
        id={dndId}
        sensors={sensors}
        onDragStart={({ active }) => {
          setDrag(String(active.id))
          cue("select", { force: true })
        }}
        onDragEnd={({ active, over }) => {
          setDrag(null)
          if (!over) return cue("close", { emphasis: "subtle", force: true })
          setCell(String(active.id), dateField.id, String(over.id).slice("day:".length))
          cue("tap", { emphasis: "strong", force: true })
        }}
        onDragCancel={() => setDrag(null)}
      >
        <div className="hidden grid-cols-7 md:grid">
          {WEEKDAYS.map((d) => (
            <div key={d} className={cn("border-r border-b px-2 py-1 text-xs text-muted-foreground last:border-r-0", HEAD_BG)}>
              {d}
            </div>
          ))}
          {days.map((d, i) => {
            const iso = toIso(d)
            return (
              <DropZone
                key={iso}
                id={`day:${iso}`}
                className={cn(
                  "flex min-h-28 min-w-0 flex-col gap-1 border-b p-1.5",
                  i % 7 !== 6 && "border-r",
                  d.getMonth() !== month.m && "bg-muted/40"
                )}
                overClassName="bg-sky-500/10"
              >
                <span
                  className={cn("px-1 text-xs tabular-nums", d.getMonth() === month.m ? "text-foreground" : "text-muted-foreground")}
                >
                  {d.getDate()}
                </span>
                {(byDay.get(iso) ?? []).map((r) => (
                  <RecordChip
                    key={r.id}
                    row={r}
                    label={primaryText(table, r) || "Unnamed record"}
                    color={colorOf(r)}
                    onExpand={() => onExpand(r.id)}
                  />
                ))}
              </DropZone>
            )
          })}
        </div>
        <DragOverlay dropAnimation={{ duration: 220, easing: "cubic-bezier(0.22, 1, 0.36, 1)" }}>
          {dragged ? (
            <div className="w-36 rotate-2 shadow-lg">
              <RecordChip
                row={dragged}
                label={primaryText(table, dragged) || "Unnamed record"}
                color={colorOf(dragged)}
                onExpand={() => {}}
                draggable={false}
              />
            </div>
          ) : null}
        </DragOverlay>
      </DndContext>
      <ItemGroup className="md:hidden">
        {agenda.map((d) => (
          <Item key={toIso(d)} size="sm" className="items-start rounded-none border-0 border-b">
            <ItemMedia className="w-10 flex-col">
              <span className="text-[11px] text-muted-foreground uppercase">{WEEKDAYS[d.getDay()]}</span>
              <span className="text-lg font-medium tabular-nums">{d.getDate()}</span>
            </ItemMedia>
            <ItemContent className="min-w-0 gap-1">
              {(byDay.get(toIso(d)) ?? []).map((r) => (
                <RecordChip
                  key={r.id}
                  row={r}
                  label={primaryText(table, r) || "Unnamed record"}
                  color={colorOf(r)}
                  onExpand={() => onExpand(r.id)}
                  draggable={false}
                  className="h-8 text-sm"
                />
              ))}
            </ItemContent>
          </Item>
        ))}
        {agenda.length === 0 && <p className="py-10 text-center text-muted-foreground">Nothing this month.</p>}
      </ItemGroup>
    </div>
  )
}

function GalleryView({ tables, table, view, rows, onExpand }: ViewProps) {
  const colorField = table.fields.find((f) => f.type === "singleSelect")
  const fields = visibleFields(table, view).slice(1, 6)
  return (
    <div className="grid content-start gap-3 bg-muted/40 p-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
      {rows.map((r) => {
        const name = primaryText(table, r) || "Unnamed record"
        const color = colorField ? pillColor(colorField, String(r.cells[colorField.id] ?? "")) : PALETTE[0]
        return (
          <Card
            key={r.id}
            size="sm"
            role="button"
            tabIndex={0}
            onClick={() => onExpand(r.id)}
            onKeyDown={(e) => e.key === "Enter" && onExpand(r.id)}
            className="cursor-pointer gap-0 pt-0 text-[13px]"
          >
            <div
              style={tint(color)}
              className="flex h-24 w-full items-center justify-center bg-[color-mix(in_oklab,var(--tint)_35%,var(--card))] text-2xl font-semibold text-[color-mix(in_oklab,var(--tint)_45%,var(--foreground))]"
            >
              {initials(name)}
            </div>
            <CardContent className="flex flex-col gap-2 pt-3">
              <CardTitle className="truncate text-[13px]">{name}</CardTitle>
              {fields.map((f) => (
                <span key={f.id} className="flex flex-col gap-0.5">
                  <span className="text-[11px] text-muted-foreground">{f.name}</span>
                  <span className="flex min-h-5 min-w-0 items-center">
                    <CellDisplay tables={tables} field={f} value={getValue(tables, r, f)} />
                  </span>
                </span>
              ))}
            </CardContent>
          </Card>
        )
      })}
      {rows.length === 0 && (
        <p className="col-span-full py-10 text-center text-muted-foreground">No records match this view.</p>
      )}
    </div>
  )
}

/* The base */

// Every table needs at least one view, so add a grid view where one is missing.
function withViews(tables: TableDef[], views: ViewDef[]) {
  const missing = tables.filter((t) => !views.some((v) => v.tableId === t.id))
  return [...views, ...missing.map((t) => makeView(t, `${t.id}-grid`, "Grid view", "grid"))]
}

function readSaved(key: string): AirtableState | null {
  try {
    const saved = JSON.parse(window.localStorage.getItem(key) ?? "null")
    return saved && Array.isArray(saved.tables) && Array.isArray(saved.views) && saved.tables.length
      ? { tables: saved.tables, views: saved.views }
      : null
  } catch {
    return null
  }
}

export function AirtableBlock({
  name = "Gelato Distribution",
  tables: initialTables = SAMPLE_TABLES,
  views: initialViews,
  onChange,
  storageKey,
  accent,
  accentForeground,
  className,
}: AirtableBlockProps) {
  const initial = React.useMemo(
    () => ({
      tables: initialTables,
      views: withViews(initialTables, initialViews ?? (initialTables === SAMPLE_TABLES ? SAMPLE_VIEWS : [])),
    }),
    [initialTables, initialViews]
  )
  const [tables, setTables] = React.useState(initial.tables)
  const [views, setViews] = React.useState(initial.views)
  const [tableId, setTableId] = React.useState(initial.tables[0]?.id)
  const [viewFor, setViewFor] = React.useState<{ [tableId: string]: string }>({})
  // Saving waits until saved state has been read, or it would overwrite it.
  const [ready, setReady] = React.useState(!storageKey)
  const changed = React.useRef(false)
  const onChangeRef = React.useRef(onChange)
  onChangeRef.current = onChange

  // Saved state loads after mount so the server and first client render match.
  React.useEffect(() => {
    if (!storageKey) return
    const saved = readSaved(storageKey)
    if (saved) {
      setTables(saved.tables)
      setViews(withViews(saved.tables, saved.views))
      setTableId((id) => (saved.tables.some((t) => t.id === id) ? id : saved.tables[0].id))
    }
    setReady(true)
  }, [storageKey])

  React.useEffect(() => {
    if (!ready) return
    if (storageKey) {
      try {
        window.localStorage.setItem(storageKey, JSON.stringify({ tables, views }))
      } catch {
        // Storage can be full or blocked. The base still works without it.
      }
    }
    if (changed.current) onChangeRef.current?.({ tables, views })
    changed.current = true
  }, [tables, views, storageKey, ready])

  const reset = () => {
    if (storageKey) {
      try {
        window.localStorage.removeItem(storageKey)
      } catch {}
    }
    setTables(initial.tables)
    setViews(initial.views)
    setTableId(initial.tables[0]?.id)
    setViewFor({})
    setSelected([])
    setExpanded(null)
  }

  const collaborators = [
    ...new Set(tables.flatMap((t) => t.fields.filter((f) => f.type === "user").flatMap((f) => f.options ?? USERS))),
  ].slice(0, 5)
  const [sidebar, setSidebar] = React.useState(true)
  const [query, setQuery] = React.useState("")
  const [searchOpen, setSearchOpen] = React.useState(false)
  const [expanded, setExpanded] = React.useState<string | null>(null)
  const [selected, setSelected] = React.useState<string[]>([])

  const table = tables.find((t) => t.id === tableId) ?? tables[0]
  const tableViews = views.filter((v) => v.tableId === table.id)
  const view = tableViews.find((v) => v.id === viewFor[table.id]) ?? tableViews[0]

  const update = (p: Partial<ViewDef> | ((v: ViewDef) => Partial<ViewDef>)) =>
    setViews((vs) => vs.map((v) => (v.id === view.id ? { ...v, ...(typeof p === "function" ? p(v) : p) } : v)))

  const updateTable = (fn: (t: TableDef) => TableDef) =>
    setTables((ts) => ts.map((t) => (t.id === table.id ? fn(t) : t)))

  const setCell = (rowId: string, fieldId: string, v: CellValue) =>
    updateTable((t) => ({
      ...t,
      rows: t.rows.map((r) => (r.id === rowId ? { ...r, cells: { ...r.cells, [fieldId]: v } } : r)),
    }))

  const addRow = (cells: Row["cells"] = {}, near?: { id: string; offset: 0 | 1 }) => {
    const id = `${table.id}-${uid()}`
    updateTable((t) => {
      const next = [...t.rows]
      const at = near ? next.findIndex((r) => r.id === near.id) + near.offset : next.length
      next.splice(at, 0, { id, cells })
      return { ...t, rows: next }
    })
    return id
  }

  const deleteRows = (ids: string[]) => {
    updateTable((t) => ({ ...t, rows: t.rows.filter((r) => !ids.includes(r.id)) }))
    setSelected((s) => s.filter((id) => !ids.includes(id)))
  }

  const duplicateRow = (id: string) => {
    const row = table.rows.find((r) => r.id === id)
    if (row) addRow({ ...row.cells }, { id, offset: 1 })
  }

  const addField = (f: Omit<FieldDef, "id" | "width">) => {
    const field: FieldDef = { ...f, id: `fld-${uid()}`, width: 160 }
    updateTable((t) => ({ ...t, fields: [...t.fields, field] }))
    setViews((vs) =>
      vs.map((v) =>
        v.tableId === table.id
          ? { ...v, order: [...v.order, field.id], widths: { ...v.widths, [field.id]: field.width } }
          : v
      )
    )
  }

  const pickView = (id: string) => {
    setViewFor((m) => ({ ...m, [table.id]: id }))
    setSelected([])
  }

  const addView = (type: ViewType) => {
    const n = tableViews.filter((v) => v.type === type).length + 1
    const v = makeView(table, `v-${uid()}`, `${VIEW_ICON[type].label} ${n}`, type)
    setViews((vs) => [...vs, v])
    pickView(v.id)
  }

  const pickTable = (id: string) => {
    setTableId(id)
    setSelected([])
  }

  const addTable = () => {
    const id = `t-${uid()}`
    const t: TableDef = {
      id,
      name: `Table ${tables.length + 1}`,
      fields: [
        { id: "name", name: "Name", type: "text", width: 200 },
        { id: "notes", name: "Notes", type: "text", width: 240 },
        { id: "status", name: "Status", type: "singleSelect", options: ["Todo", "In progress", "Done"], width: 130 },
      ],
      rows: [1, 2, 3].map((i) => ({ id: `${id}-${i}`, cells: {} })),
    }
    const v = makeView(t, `v-${uid()}`, "Grid view", "grid")
    setTables((ts) => [...ts, t])
    setViews((vs) => [...vs, v])
    setViewFor((m) => ({ ...m, [id]: v.id }))
    pickTable(id)
  }

  const rows = React.useMemo(() => {
    const q = query.trim().toLowerCase()
    const conds = view.filters.filter(isComplete)
    const list = table.rows.filter((r) => {
      const ok =
        !conds.length ||
        (view.conjunction === "and"
          ? conds.every((c) => matches(tables, table, r, c))
          : conds.some((c) => matches(tables, table, r, c)))
      if (!ok) return false
      return (
        !q || table.fields.some((f) => displayText(tables, f, getValue(tables, r, f)).toLowerCase().includes(q))
      )
    })
    if (!view.sorts.length) return list
    return [...list].sort((a, b) => {
      for (const s of view.sorts) {
        const f = table.fields.find((x) => x.id === s.field)
        if (!f) continue
        const c = compare(tables, f, a, b)
        if (c) return s.dir === "asc" ? c : -c
      }
      return 0
    })
  }, [tables, table, view, query])

  const groups = view.type === "grid" ? groupRows(tables, table.fields.find((f) => f.id === view.groupBy), rows) : null
  const order = (groups ? groups.flatMap((g) => g.rows) : rows).map((r) => r.id)
  const viewProps: ViewProps = {
    tables,
    table,
    view,
    rows,
    update,
    setCell,
    addRow,
    deleteRows,
    duplicateRow,
    addField,
    onExpand: setExpanded,
    selected,
    setSelected,
  }
  const ViewIcon = VIEW_ICON[view.type].icon

  return (
    <Card
      className={cn("w-full gap-0 py-0 text-sm", className)}
      style={
        {
          "--at-accent": accent ?? "var(--primary)",
          "--at-accent-fg": accentForeground ?? (accent ? "white" : "var(--primary-foreground)"),
        } as React.CSSProperties
      }
    >
      {/* Base header */}
      <div className="flex items-center gap-2 bg-(--at-accent) px-3 py-2 text-(--at-accent-fg)">
        <span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-[color-mix(in_oklab,var(--at-accent-fg)_15%,transparent)]">
          <DatabaseIcon weight="duotone" className="size-4" />
        </span>
        <DropdownMenu>
          <DropdownMenuTrigger asChild>
            <Button
              type="button"
              variant="ghost"
              size="sm"
              className="min-w-0 font-semibold"
              style={{ "--primary": "var(--at-accent-fg)" } as React.CSSProperties}
            >
              <span className="truncate">{name}</span>
              <CaretDownIcon weight="bold" className="opacity-70" />
            </Button>
          </DropdownMenuTrigger>
          <DropdownMenuContent align="start" className="w-56">
            <DropdownMenuLabel>{name}</DropdownMenuLabel>
            <DropdownMenuItem onSelect={reset}>
              <ArrowCounterClockwiseIcon weight="bold" />
              Reset base
            </DropdownMenuItem>
          </DropdownMenuContent>
        </DropdownMenu>
        {/* Header buttons take their color from the header, not the theme primary. */}
        <div
          className="ml-auto flex items-center gap-2"
          style={{ "--primary": "var(--at-accent-fg)" } as React.CSSProperties}
        >
          <div className="hidden -space-x-1.5 sm:flex">
            {collaborators.map((u) => (
              <Avatar key={u} className="size-6 ring-2 ring-(--at-accent)">
                <AvatarFallback className="bg-(--at-accent-fg) text-[0.6rem] font-medium text-(--at-accent)">
                  {initials(u)}
                </AvatarFallback>
              </Avatar>
            ))}
          </div>
          <Button
            type="button"
            size="icon-sm"
            variant="secondary"
            aria-label="Search"
            className="md:hidden"
            onClick={() => setSearchOpen((o) => !o)}
          >
            <MagnifyingGlassIcon weight="duotone" />
          </Button>
          <Button type="button" size="icon-sm" variant="secondary" aria-label="Add record" className="md:hidden" onClick={() => setExpanded(addRow())}>
            <PlusIcon weight="bold" />
          </Button>
          <Button type="button" size="sm" variant="secondary" className="hidden md:inline-flex">
            <ShareNetworkIcon weight="duotone" />
            <span className="hidden sm:inline">Share</span>
          </Button>
        </div>
      </div>

      {/* Table tabs: the Tabs primitive in its line style, coloured by the header */}
      <Tabs value={table.id} onValueChange={pickTable} className="hidden md:block">
        <div className="flex items-center gap-1 overflow-x-auto bg-[color-mix(in_oklab,var(--at-accent)_88%,var(--at-accent-fg))] px-2 [scrollbar-width:none]">
          <TabsList
            variant="line"
            className="h-10"
            style={
              {
                "--foreground": "var(--at-accent-fg)",
                "--muted-foreground": "color-mix(in oklab, var(--at-accent-fg) 75%, transparent)",
              } as React.CSSProperties
            }
          >
            {tables.map((t) => (
              <TabsTrigger key={t.id} value={t.id} className="px-3">
                {t.name}
              </TabsTrigger>
            ))}
          </TabsList>
          <Button
            type="button"
            variant="ghost"
            size="sm"
            onClick={addTable}
            className="shrink-0 font-normal"
            style={{ "--primary": "var(--at-accent-fg)" } as React.CSSProperties}
          >
            <PlusIcon weight="bold" />
            Add or import
          </Button>
        </div>
      </Tabs>

      {/* Every view control lives in this one row */}
      <div className="@container/at flex items-center gap-1 overflow-x-auto border-b px-2 py-1.5 [scrollbar-width:none]">
        <Button
          type="button"
          variant={sidebar ? "secondary" : "ghost"}
          size="sm"
          aria-label="Views"
          className="hidden shrink-0 font-normal md:inline-flex"
          onClick={() => setSidebar((s) => !s)}
        >
          <SidebarSimpleIcon weight="duotone" />
          <span className="hidden @6xl/at:inline">Views</span>
        </Button>
        <DropdownMenu>
          <DropdownMenuTrigger asChild>
            <Button type="button" variant="ghost" size="sm" className="shrink-0">
              <ViewIcon weight="duotone" className={VIEW_ICON[view.type].color} />
              <span className="max-w-20 truncate sm:max-w-none">{view.name}</span>
              <CaretDownIcon weight="bold" />
            </Button>
          </DropdownMenuTrigger>
          <DropdownMenuContent align="start" className="w-56">
            <DropdownMenuLabel>Views</DropdownMenuLabel>
            <MenuChoices value={view.id} onValueChange={pickView}>
              {tableViews.map((v) => {
                const Icon = VIEW_ICON[v.type].icon
                return (
                  <MenuChoice key={v.id} value={v.id}>
                    <Icon weight="duotone" className={VIEW_ICON[v.type].color} />
                    {v.name}
                  </MenuChoice>
                )
              })}
            </MenuChoices>
            <DropdownMenuSeparator />
            <DropdownMenuLabel>Create</DropdownMenuLabel>
            {(Object.keys(VIEW_ICON) as ViewType[]).map((t) => (
              <DropdownMenuItem key={t} onSelect={() => addView(t)}>
                <PlusIcon weight="bold" />
                {VIEW_ICON[t].label}
              </DropdownMenuItem>
            ))}
          </DropdownMenuContent>
        </DropdownMenu>
        <div className="mx-1 h-5 w-px shrink-0 bg-border max-sm:hidden" />
        <HideFields table={table} view={view} update={update} />
        <FilterBuilder tables={tables} table={table} view={view} update={update} />
        {view.type === "grid" && (
          <GroupMenu table={table} view={view} update={update} groupKeys={groups?.map((g) => g.key) ?? []} />
        )}
        <SortMenu table={table} view={view} update={update} />
        {view.type === "grid" && <RowHeightMenu view={view} update={update} />}
        <div className="ml-auto hidden shrink-0 items-center gap-1 pl-2 md:flex">
          <InputGroup className="w-32 @6xl/at:w-48">
            <InputGroupAddon>
              <MagnifyingGlassIcon weight="duotone" />
            </InputGroupAddon>
            <InputGroupInput placeholder="Search" value={query} onChange={(e) => setQuery(e.target.value)} />
          </InputGroup>
          <Button type="button" size="sm" aria-label="Add record" onClick={() => setExpanded(addRow())}>
            <PlusIcon weight="bold" />
            <span className="hidden @6xl/at:inline">Add record</span>
          </Button>
        </div>
      </div>

      {searchOpen && (
        <div className="border-b p-2 md:hidden">
          <InputGroup>
            <InputGroupAddon>
              <MagnifyingGlassIcon weight="duotone" />
            </InputGroupAddon>
            <InputGroupInput autoFocus placeholder="Search records" value={query} onChange={(e) => setQuery(e.target.value)} />
          </InputGroup>
        </div>
      )}

      {selected.length > 0 && view.type === "grid" && (
        <div className="hidden items-center gap-2 border-b bg-sky-500/10 px-3 py-1.5 md:flex">
          <span className="font-medium">{selected.length} selected</span>
          <Button type="button" variant="destructive" size="xs" onClick={() => deleteRows(selected)}>
            <TrashIcon weight="duotone" />
            Delete
          </Button>
          <Button
            type="button"
            variant="ghost"
            size="icon-xs"
            className="ml-auto"
            aria-label="Clear selection"
            onClick={() => setSelected([])}
          >
            <XIcon weight="bold" />
          </Button>
        </div>
      )}

      <div className="flex min-h-[30rem]">
        {sidebar && (
          <aside className="hidden w-56 shrink-0 flex-col gap-0.5 border-r p-2 md:flex">
            {tableViews.map((v) => {
              const Icon = VIEW_ICON[v.type].icon
              return (
                <Button
                  type="button"
                  key={v.id}
                  variant={v.id === view.id ? "secondary" : "ghost"}
                  size="sm"
                  className="justify-start font-normal"
                  onClick={() => pickView(v.id)}
                >
                  <Icon weight="duotone" className={VIEW_ICON[v.type].color} />
                  <span className="truncate">{v.name}</span>
                </Button>
              )
            })}
            <div className="mt-auto flex flex-col gap-0.5 border-t pt-2">
              <span className="px-2 py-1 text-xs text-muted-foreground">Create</span>
              {(Object.keys(VIEW_ICON) as ViewType[]).map((t) => {
                const Icon = VIEW_ICON[t].icon
                return (
                  <Button
                    type="button"
                    key={t}
                    variant="ghost"
                    size="sm"
                    className="justify-start font-normal"
                    onClick={() => addView(t)}
                  >
                    <Icon weight="duotone" className={VIEW_ICON[t].color} />
                    {VIEW_ICON[t].label}
                    <PlusIcon weight="bold" className="ml-auto text-muted-foreground" />
                  </Button>
                )
              })}
            </div>
          </aside>
        )}
        <div className="min-w-0 flex-1">
          {view.type === "grid" && (
            <>
              <div className="hidden md:block">
                <GridView key={view.id} {...viewProps} />
              </div>
              <div className="md:hidden">
                <PhoneList {...viewProps} />
              </div>
            </>
          )}
          {view.type === "kanban" && <KanbanView key={view.id} {...viewProps} />}
          {view.type === "calendar" && <CalendarView key={view.id} {...viewProps} />}
          {view.type === "gallery" && <GalleryView {...viewProps} />}
        </div>
      </div>

      {/* Phones switch tables from the bottom bar, like the Airtable app */}
      <div className="flex items-center gap-1 overflow-x-auto border-t px-2 py-1.5 [scrollbar-width:none] md:hidden">
        <ListIcon weight="bold" className="mx-1 size-4 shrink-0 text-muted-foreground" />
        {tables.map((t) => (
          <Button
            type="button"
            key={t.id}
            size="sm"
            variant={t.id === table.id ? "default" : "ghost"}
            className="shrink-0"
            onClick={() => pickTable(t.id)}
          >
            {t.name}
          </Button>
        ))}
        <Button type="button" variant="ghost" size="icon-sm" className="shrink-0" aria-label="Add table" onClick={addTable}>
          <PlusIcon weight="bold" />
        </Button>
      </div>

      <RecordSheet
        tables={tables}
        table={table}
        rowId={expanded}
        order={order}
        onNavigate={setExpanded}
        onClose={() => setExpanded(null)}
        setCell={setCell}
        onDelete={(id) => deleteRows([id])}
      />
    </Card>
  )
}
RTLAirtable Kanban

On This Page

InstallationFeaturesUsagePropsBuilt withCode