{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "floorplan-1",
  "title": "Floorplan 1: restaurant floor",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A restaurant floor plan: tables where they stand, worded as free, seated, reserved or needs clearing, a waiting list, seat a party at a table that fits, clear or clean a table, all undone with the reason if it fails.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/state-view.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/floorplan-1/floorplan-1.tsx",
      "content": "// Ballmac UI: Floor plan 1. https://ui.ballmac.com/blocks/floorplan-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { Armchair, Clock, Loader2, Sparkles, Users } from \"lucide-react\"\n\nimport { Button } from \"@/components/ballmac/button\"\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { frameProps, StateView, type StateProps } from \"@/components/ballmac/state-view\"\nimport { cn } from \"@/lib/utils\"\n\ntype Floorplan1Status = \"free\" | \"seated\" | \"reserved\" | \"dirty\"\n\ntype Floorplan1Table = {\n  id: string\n  /** For example \"T4\". */\n  name: string\n  seats: number\n  /** Where the middle of the table is on the plan, as a percentage from the left and from the top. */\n  x: number\n  y: number\n  shape?: \"round\" | \"square\" | \"wide\"\n  status: Floorplan1Status\n  /** Who is sitting here, for a seated table. */\n  party?: { name: string; size: number; since: string }\n  /** Who the table is held for, for a reserved table. */\n  reservation?: { name: string; size: number; at: string }\n}\n\ntype Floorplan1Party = { id: string; name: string; size: number; waited: string }\n\ntype Floorplan1Props = Omit<React.ComponentProps<\"section\">, \"title\"> &\n  StateProps & {\n    tables?: Floorplan1Table[]\n    /** Parties waiting for a table. */\n    waiting?: Floorplan1Party[]\n    title?: string\n    /** Called when a waiting party or a reservation is seated. Reject and the table goes back with the message. */\n    onSeat?: (tableId: string, party: { name: string; size: number }) => void | Promise<void>\n    /** Called when a table is cleared (it becomes dirty) or marked clean (it becomes free). */\n    onStatus?: (tableId: string, status: Floorplan1Status) => void | Promise<void>\n  }\n\nconst tables: Floorplan1Table[] = [\n  { id: \"t1\", name: \"T1\", seats: 2, x: 12, y: 18, shape: \"round\", status: \"seated\", party: { name: \"Okafor\", size: 2, since: \"18:05\" } },\n  { id: \"t2\", name: \"T2\", seats: 2, x: 12, y: 46, shape: \"round\", status: \"free\" },\n  { id: \"t3\", name: \"T3\", seats: 4, x: 12, y: 76, shape: \"square\", status: \"reserved\", reservation: { name: \"Marsh\", size: 4, at: \"19:30\" } },\n  { id: \"t4\", name: \"T4\", seats: 4, x: 38, y: 24, shape: \"square\", status: \"seated\", party: { name: \"Weber\", size: 3, since: \"18:20\" } },\n  { id: \"t5\", name: \"T5\", seats: 4, x: 38, y: 58, shape: \"square\", status: \"dirty\" },\n  { id: \"t6\", name: \"T6\", seats: 6, x: 66, y: 22, shape: \"wide\", status: \"free\" },\n  { id: \"t7\", name: \"T7\", seats: 6, x: 66, y: 56, shape: \"wide\", status: \"seated\", party: { name: \"Rossi\", size: 5, since: \"17:50\" } },\n  { id: \"t8\", name: \"T8\", seats: 8, x: 84, y: 82, shape: \"wide\", status: \"reserved\", reservation: { name: \"Haddad\", size: 8, at: \"20:00\" } },\n  { id: \"t9\", name: \"Bar 1\", seats: 2, x: 92, y: 20, shape: \"round\", status: \"free\" },\n]\nconst waiting: Floorplan1Party[] = [\n  { id: \"w1\", name: \"Lopez\", size: 2, waited: \"12 min\" },\n  { id: \"w2\", name: \"Reed\", size: 5, waited: \"6 min\" },\n  { id: \"w3\", name: \"Tanaka\", size: 3, waited: \"2 min\" },\n]\n\nconst STATUS: Record<Floorplan1Status, { label: string; tone: string }> = {\n  free: { label: \"Free\", tone: \"bg-card\" },\n  seated: { label: \"Seated\", tone: \"bg-chart-1/20 border-chart-1/60\" },\n  reserved: { label: \"Reserved\", tone: \"bg-chart-3/15 border-chart-3/60 border-dashed\" },\n  dirty: { label: \"Needs clearing\", tone: \"bg-muted border-dashed\" },\n}\nconst SIZE: Record<NonNullable<Floorplan1Table[\"shape\"]>, string> = { round: \"size-[4.6rem] rounded-full\", square: \"h-[5rem] w-[5rem] rounded-xl\", wide: \"h-[5rem] w-[8rem] rounded-xl\" }\n\nfunction Skel() {\n  return (\n    <div aria-hidden=\"true\" className=\"grid gap-4 lg:grid-cols-[minmax(0,1fr)_16rem]\">\n      <Skeleton className=\"aspect-[16/10] rounded-2xl\" />\n      <Skeleton className=\"h-72 rounded-2xl\" />\n    </div>\n  )\n}\n\n/**\n * A restaurant floor plan for the host: every table where it is in the room, with its state in words (free, seated, reserved, needs clearing), who is at it and\n * since when, and the waiting list beside it. Pick a table to seat a party that fits, seat a reservation, clear it, or mark it clean. Changes show at once\n * and go back with the reason if they fail.\n */\nfunction Floorplan1({ tables: tablesProp = tables, waiting: waitingProp = waiting, title = \"Floor\", onSeat, onStatus, state = \"ready\", onRetry, emptyState, errorState, className, ...props }: Floorplan1Props) {\n  const id = React.useId()\n  const [list, setList] = React.useState(tablesProp)\n  React.useEffect(() => setList(tablesProp), [tablesProp])\n  const [queue, setQueue] = React.useState(waitingProp)\n  React.useEffect(() => setQueue(waitingProp), [waitingProp])\n  const [sel, setSel] = React.useState<string | null>(null)\n  const [pick, setPick] = React.useState(\"\")\n  const [busy, setBusy] = React.useState(false)\n  const [note, setNote] = React.useState<{ ok: boolean; text: string } | null>(null)\n  const view = state === \"ready\" && list.length === 0 ? \"empty\" : state\n  const t = list.find((x) => x.id === sel)\n  const count = (s: Floorplan1Status) => list.filter((x) => x.status === s).length\n  const fits = (tb: Floorplan1Table) => queue.filter((p) => p.size <= tb.seats)\n\n  async function change(tb: Floorplan1Table, next: Floorplan1Table, work: () => void | Promise<void>, ok: string, after?: () => void) {\n    setBusy(true)\n    setNote(null)\n    setList((l) => l.map((x) => (x.id === tb.id ? next : x)))\n    try {\n      await work()\n      after?.()\n      setNote({ ok: true, text: ok })\n    } catch (e) {\n      setList((l) => l.map((x) => (x.id === tb.id ? tb : x)))\n      setNote({ ok: false, text: e instanceof Error ? e.message : `${tb.name} could not be changed, so it is as it was.` })\n    } finally {\n      setBusy(false)\n    }\n  }\n  const seatParty = (tb: Floorplan1Table) => {\n    const p = queue.find((x) => x.id === pick)\n    if (!p) return\n    void change(tb, { ...tb, status: \"seated\", party: { name: p.name, size: p.size, since: \"now\" } }, () => onSeat?.(tb.id, { name: p.name, size: p.size }), `${p.name} (${p.size}) is seated at ${tb.name}.`, () => (setQueue((q) => q.filter((x) => x.id !== p.id)), setPick(\"\")))\n  }\n\n  return (\n    <section data-slot=\"floorplan-1\" data-state={state} aria-labelledby={`${id}-title`} className={cn(\"mx-auto w-full max-w-6xl px-4 py-10 sm:px-6\", className)} {...frameProps(props)}>\n      <header className=\"mb-6\">\n        <h1 id={`${id}-title`} className=\"text-3xl font-semibold tracking-[-0.035em] text-balance\">\n          {title}\n        </h1>\n        <p aria-live=\"polite\" className=\"text-muted-foreground mt-2 tabular-nums\">{count(\"free\")} free · {count(\"seated\")} seated · {count(\"reserved\")} reserved · {count(\"dirty\")} to clear · {queue.length} waiting</p>\n      </header>\n      <StateView state={view} onRetry={onRetry} label=\"Loading the floor\" loading={<Skel />} empty={{ title: \"No tables yet\", description: \"Add the tables of the room and they will appear on the plan.\", ...emptyState }} error={{ title: \"The floor could not be loaded\", description: \"No table was changed. Check your connection and try again.\", ...errorState }}>\n        <div className=\"grid gap-4 lg:grid-cols-[minmax(0,1fr)_17rem]\">\n          <div className=\"grid min-w-0 gap-4\">\n            <div role=\"group\" aria-label=\"Tables on the floor\" className=\"bg-muted/30 relative aspect-[16/10] min-h-[22rem] w-full overflow-hidden rounded-2xl border\" style={{ backgroundImage: \"radial-gradient(var(--border) 1px, transparent 1px)\", backgroundSize: \"22px 22px\" }}>\n              {list.map((tb) => {\n                const st = STATUS[tb.status]\n                const on = sel === tb.id\n                return (\n                  <button\n                    key={tb.id}\n                    type=\"button\"\n                    data-status={tb.status}\n                    aria-pressed={on}\n                    aria-label={`${tb.name}, seats ${tb.seats}, ${st.label.toLowerCase()}${tb.party ? `, ${tb.party.name} party of ${tb.party.size}` : \"\"}${tb.reservation ? `, held for ${tb.reservation.name} at ${tb.reservation.at}` : \"\"}`}\n                    onClick={() => { setSel(on ? null : tb.id); setPick(\"\"); setNote(null) }}\n                    className={cn(\"focus-visible:ring-ring/50 absolute flex -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center border text-center outline-none transition-shadow focus-visible:z-10 focus-visible:ring-[3px] motion-reduce:transition-none\", SIZE[tb.shape ?? \"square\"], st.tone, on ? \"ring-foreground z-10 shadow-lg ring-2\" : \"hover:shadow-md\")}\n                    style={{ left: `${tb.x}%`, top: `${tb.y}%` }}\n                  >\n                    <span className=\"text-sm font-semibold\">{tb.name}</span>\n                    <span className=\"text-muted-foreground flex items-center gap-1 text-[11px]\"><Armchair aria-hidden=\"true\" className=\"size-3\" />{tb.seats}</span>\n                    <span className=\"text-[10px] leading-tight font-medium\">{tb.status === \"seated\" && tb.party ? `${tb.party.name} · ${tb.party.since}` : tb.status === \"reserved\" && tb.reservation ? `${tb.reservation.name} · ${tb.reservation.at}` : st.label}</span>\n                  </button>\n                )\n              })}\n            </div>\n            <ul className=\"text-muted-foreground flex flex-wrap gap-x-5 gap-y-1.5 text-xs\" aria-label=\"Legend\">\n              {(Object.keys(STATUS) as Floorplan1Status[]).map((k) => (\n                <li key={k} className=\"flex items-center gap-2\"><span aria-hidden=\"true\" className={cn(\"size-4 rounded border\", STATUS[k].tone)} />{STATUS[k].label}</li>\n              ))}\n            </ul>\n            <div aria-live=\"polite\" className=\"bg-card min-h-[6.5rem] rounded-xl border p-4\">\n              {t ? (\n                <div className=\"grid gap-2.5\">\n                  <p className=\"font-medium\">{t.name} <span className=\"text-muted-foreground font-normal\">· seats {t.seats} · {STATUS[t.status].label.toLowerCase()}</span></p>\n                  {note && <p role={note.ok ? \"status\" : \"alert\"} className={cn(\"text-sm font-medium\", !note.ok && \"text-destructive\")}>{note.text}</p>}\n                  {t.status === \"free\" && (\n                    <div className=\"flex flex-wrap items-center gap-2\">\n                      <label className=\"flex items-center gap-2 text-sm\"><span className=\"sr-only\">Party to seat at {t.name}</span>\n                        <select aria-label={`Party to seat at ${t.name}`} value={pick} onChange={(e) => setPick(e.target.value)} className=\"bg-background focus-visible:ring-ring/50 h-9 rounded-md border px-2 text-sm outline-none focus-visible:ring-[3px]\">\n                          <option value=\"\">{fits(t).length ? \"Choose a waiting party\" : \"Nobody waiting fits\"}</option>\n                          {fits(t).map((p) => <option key={p.id} value={p.id}>{p.name}, {p.size}, waited {p.waited}</option>)}\n                        </select>\n                      </label>\n                      <Button size=\"sm\" disabled={!pick || busy} onClick={() => seatParty(t)}>{busy && <Loader2 aria-hidden=\"true\" className=\"animate-spin\" />} Seat at {t.name}</Button>\n                    </div>\n                  )}\n                  {t.status === \"reserved\" && t.reservation && (\n                    <div className=\"flex flex-wrap items-center gap-3 text-sm\">\n                      <span className=\"flex items-center gap-1.5\"><Clock aria-hidden=\"true\" className=\"size-4\" /> {t.reservation.name}, party of {t.reservation.size}, at {t.reservation.at}</span>\n                      <Button size=\"sm\" variant=\"outline\" disabled={busy} onClick={() => void change(t, { ...t, status: \"seated\", party: { name: t.reservation!.name, size: t.reservation!.size, since: \"now\" }, reservation: undefined }, () => onSeat?.(t.id, { name: t.reservation!.name, size: t.reservation!.size }), `${t.reservation!.name} is seated at ${t.name}.`)}>Seat them now</Button>\n                    </div>\n                  )}\n                  {t.status === \"seated\" && t.party && (\n                    <div className=\"flex flex-wrap items-center gap-3 text-sm\">\n                      <span className=\"flex items-center gap-1.5\"><Users aria-hidden=\"true\" className=\"size-4\" /> {t.party.name}, party of {t.party.size}, since {t.party.since}</span>\n                      <Button size=\"sm\" variant=\"outline\" disabled={busy} onClick={() => void change(t, { ...t, status: \"dirty\", party: undefined }, () => onStatus?.(t.id, \"dirty\"), `${t.name} is cleared and needs clearing.`)}>Clear table</Button>\n                    </div>\n                  )}\n                  {t.status === \"dirty\" && (\n                    <Button size=\"sm\" variant=\"outline\" className=\"w-fit\" disabled={busy} onClick={() => void change(t, { ...t, status: \"free\" }, () => onStatus?.(t.id, \"free\"), `${t.name} is free.`)}><Sparkles aria-hidden=\"true\" /> Mark clean</Button>\n                  )}\n                </div>\n              ) : (\n                <p className=\"text-muted-foreground text-sm\">Select a table to seat a party, clear it or mark it clean.</p>\n              )}\n            </div>\n          </div>\n          <aside aria-labelledby={`${id}-wait`} className=\"bg-card h-fit rounded-xl border p-4\">\n            <h2 id={`${id}-wait`} className=\"mb-3 flex items-center justify-between text-sm font-semibold\">Waiting list <span className=\"text-muted-foreground font-mono text-xs tabular-nums\">{queue.length}</span></h2>\n            {queue.length ? (\n              <ul className=\"divide-y\">\n                {queue.map((p) => (\n                  <li key={p.id} className=\"flex items-center justify-between gap-3 py-2.5 text-sm\">\n                    <span><span className=\"font-medium\">{p.name}</span> <span className=\"text-muted-foreground\">· party of {p.size}</span></span>\n                    <span className=\"text-muted-foreground text-xs tabular-nums\">{p.waited}</span>\n                  </li>\n                ))}\n              </ul>\n            ) : (\n              <p className=\"text-muted-foreground text-sm\">Nobody is waiting.</p>\n            )}\n          </aside>\n        </div>\n      </StateView>\n    </section>\n  )\n}\n\nexport { Floorplan1, type Floorplan1Party, type Floorplan1Props, type Floorplan1Status, type Floorplan1Table }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/floorplan-1/floorplan-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "restaurant",
      "floor plan",
      "tables",
      "seating",
      "host",
      "reservations",
      "waitlist",
      "hospitality"
    ],
    "version": "1.0.0",
    "updated": "2026-10-24",
    "tests": true,
    "ai": {
      "summary": "Restaurant floor. <Floorplan1 tables={[{ id, name, seats, x, y, shape?: 'round'|'square'|'wide', status: 'free'|'seated'|'reserved'|'dirty', party?, reservation? }]} waiting={[{ id, name, size, waited }]} onSeat={(tableId, party) => Promise} onStatus={(tableId, status) => Promise} />. x and y are percentages. Only free or reserved tables that fit the party can be chosen. Changes show at once and are undone if the handler rejects.",
      "whenToUse": [
        "A host stand or front-of-house screen",
        "Any room plan where things have a place and a state (tables, desks, beds)"
      ],
      "whenNotToUse": [
        "Booking by customers; use salon-1",
        "Seat selection for an event with hundreds of seats"
      ],
      "composesWith": [
        "salon-1",
        "queues-1",
        "kitchen-1"
      ],
      "a11y": [
        {
          "keys": "Table",
          "action": "Each table is a button named with its name, seats and state in words, not only colour"
        },
        {
          "keys": "Seat",
          "action": "A labelled list of waiting parties; a party that does not fit is not offered"
        },
        {
          "keys": "Status",
          "action": "Changes and failures are announced in a polite live region"
        },
        {
          "keys": "Legend",
          "action": "State is also written on the table, so colour is never the only signal"
        }
      ],
      "customization": [
        "tables, waiting and title",
        "onSeat and onStatus",
        "Table shape and position are data"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "floorplan-1-loading",
        "trigger": "state=\"loading\"",
        "note": "Skeleton plan under the real heading, announced as loading."
      },
      {
        "id": "empty",
        "title": "No tables set up",
        "example": "floorplan-1-empty",
        "trigger": "state=\"empty\"",
        "note": "Says no tables are set up; emptyState sets the copy."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "floorplan-1-error",
        "trigger": "state=\"error\"",
        "note": "An alert saying nothing was changed, with Try again (onRetry). A seat or clear that fails goes back and says why."
      }
    ],
    "examples": [
      "floorplan-1-demo",
      "floorplan-1-busy",
      "floorplan-1-loading",
      "floorplan-1-empty",
      "floorplan-1-error"
    ],
    "url": "https://ui.ballmac.com/components/floorplan-1"
  },
  "categories": [
    "blocks",
    "floorplan"
  ],
  "type": "registry:block"
}