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