makersclaw 0.4.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/package.json +1 -1
  2. package/r/annotate.json +3 -3
  3. package/r/approval-request.json +4 -4
  4. package/r/audio-player.json +2 -2
  5. package/r/auth-form.json +2 -2
  6. package/r/board.json +2 -2
  7. package/r/calendar-view.json +9 -9
  8. package/r/cart.json +2 -2
  9. package/r/chart.json +7 -7
  10. package/r/chat.json +3 -3
  11. package/r/code-editor.json +2 -2
  12. package/r/comment-box.json +5 -5
  13. package/r/contact-section.json +2 -2
  14. package/r/core.json +5 -5
  15. package/r/csv-import.json +3 -3
  16. package/r/data-table.json +4 -4
  17. package/r/diagram.json +2 -2
  18. package/r/doc-editor.json +2 -2
  19. package/r/doc-page.json +3 -3
  20. package/r/email-render.json +2 -2
  21. package/r/faq.json +2 -2
  22. package/r/file-upload.json +2 -2
  23. package/r/flow-canvas.json +4 -4
  24. package/r/footer.json +2 -2
  25. package/r/gantt.json +3 -3
  26. package/r/job-panel.json +2 -2
  27. package/r/json-editor.json +3 -3
  28. package/r/map.json +2 -2
  29. package/r/menubar.json +2 -2
  30. package/r/mind-map.json +6 -6
  31. package/r/navigation-menu.json +2 -2
  32. package/r/order-detail.json +2 -2
  33. package/r/org-chart.json +4 -4
  34. package/r/page-builder.json +2 -2
  35. package/r/payment-methods.json +2 -2
  36. package/r/pdf-document.json +2 -2
  37. package/r/pdf-viewer.json +3 -3
  38. package/r/product-detail.json +2 -2
  39. package/r/product-grid.json +3 -3
  40. package/r/record-form.json +2 -2
  41. package/r/record-notes.json +6 -6
  42. package/r/registry.json +121 -121
  43. package/r/search-dialog.json +2 -2
  44. package/r/settings-list.json +2 -2
  45. package/r/setup-checklist.json +2 -2
  46. package/r/share-dialog.json +2 -2
  47. package/r/sidebar.json +4 -4
  48. package/r/signature-pad.json +3 -3
  49. package/r/sketch-canvas.json +2 -2
  50. package/r/spreadsheet.json +3 -3
  51. package/r/stat-cards.json +3 -3
  52. package/r/task-list.json +3 -3
  53. package/r/task-model.json +2 -2
  54. package/r/testimonials.json +2 -2
  55. package/r/transaction-list.json +2 -2
  56. package/r/whiteboard.json +3 -3
  57. package/registry/blocks/approval-request/README.md +2 -1
  58. package/registry/blocks/approval-request/approval-request.jsx +5 -3
  59. package/registry/blocks/calendar-view/README.md +8 -0
  60. package/registry/blocks/calendar-view/agenda.jsx +5 -4
  61. package/registry/blocks/calendar-view/calendar.jsx +23 -8
  62. package/registry/blocks/calendar-view/demo.jsx +2 -0
  63. package/registry/blocks/calendar-view/month.jsx +6 -5
  64. package/registry/blocks/calendar-view/time-grid.jsx +9 -7
  65. package/registry/blocks/calendar-view/tray.jsx +5 -4
  66. package/registry/blocks/chart/README.md +9 -4
  67. package/registry/blocks/chart/cartesian.jsx +31 -24
  68. package/registry/blocks/chart/core.jsx +21 -0
  69. package/registry/blocks/chart/pie.jsx +15 -10
  70. package/registry/blocks/chart/sparkline.jsx +32 -14
  71. package/registry/blocks/chat/message.jsx +4 -4
  72. package/registry/blocks/comment-box/README.md +1 -1
  73. package/registry/blocks/comment-box/comment-time.jsx +4 -6
  74. package/registry/blocks/data-table/README.md +4 -0
  75. package/registry/blocks/data-table/data-table.jsx +20 -6
  76. package/registry/blocks/flow-canvas/edges.jsx +4 -2
  77. package/registry/blocks/flow-canvas/theme.js +59 -48
  78. package/registry/blocks/mind-map/README.md +9 -2
  79. package/registry/blocks/mind-map/mind-map.jsx +51 -12
  80. package/registry/blocks/mind-map/tree.js +33 -0
  81. package/registry/blocks/org-chart/org-chart.jsx +1 -1
  82. package/registry/blocks/pdf-viewer/notes.jsx +4 -3
  83. package/registry/blocks/record-notes/README.md +1 -1
  84. package/registry/blocks/record-notes/record-notes.jsx +4 -5
  85. package/registry/blocks/sidebar/sidebar-panel.jsx +4 -4
  86. package/registry/blocks/sidebar/sidebar.jsx +13 -13
  87. package/registry/blocks/signature-pad/signature.js +8 -1
  88. package/registry/blocks/spreadsheet/cells.jsx +2 -2
  89. package/templates/next/files/README.md +77 -19
  90. package/templates/next/files/app/auto-refresh.jsx +39 -74
  91. package/templates/next/files/app/frame.js +197 -6
  92. package/templates/next/files/check/run.mjs +96 -11
  93. package/templates/next/files/components/ui/calendar.jsx +24 -0
  94. package/templates/next/files/components/ui/field.jsx +12 -3
  95. package/templates/next/files/components/ui/index.js +1 -0
  96. package/templates/next/files/components/ui/sheet.jsx +38 -2
  97. package/templates/next/files/components/ui/toggle.jsx +20 -2
  98. package/templates/next/files/lib/actions.js +63 -5
  99. package/templates/next/files/lib/data.js +4 -1
  100. package/templates/next/files/lib/db.js +18 -1
  101. package/templates/next/files/lib/hooks.js +52 -17
  102. package/templates/next/template.json +2 -2
package/r/data-table.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "title": "Data table",
6
6
  "description": "Records as a table the founder can work: sort by a column (shift for several), search every column, faceted filters with counts, column visibility, row selection with a bulk-actions bar, a menu per row or its first actions as buttons in the row (async, with a spinner and a failure notice), row click, pagination with a page size, loading, empty and no-matches states, and cards on a phone. Opt in to expandable rows, row groups with subtotals, an aligned totals row, arrow-key navigation and a sticky first column. Client-side over rows, or driven by a server.",
7
7
  "registryDependencies": [
8
- "https://cdn.jsdelivr.net/npm/makersclaw@0.4.0/r/core.json"
8
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.5.2/r/core.json"
9
9
  ],
10
10
  "dependencies": [],
11
11
  "files": [
@@ -19,7 +19,7 @@
19
19
  "path": "registry/blocks/data-table/data-table.jsx",
20
20
  "type": "registry:component",
21
21
  "target": "@components/makersclaw/blocks/data-table/data-table.jsx",
22
- "content": "\"use client\";\n\nimport { isValidElement, useEffect, useId, useMemo, useState } from \"react\";\nimport { isSample } from \"../../parts\";\nimport {\n Button,\n Checkbox,\n Combobox,\n Empty,\n Icon,\n IconButton,\n InputGroup,\n Menu,\n Notice,\n Pagination,\n Skeleton,\n Table,\n TableBody,\n TableCell,\n TableFooter,\n TableHead,\n TableHeader,\n TableRow,\n Tooltip,\n cx,\n} from \"../../ui\";\nimport { facetOptions, groupKeyOf, groupRows, groupValueOf, nextSort, processRows, useControllable } from \"./model\";\n\n// DataTable — records as a table the founder can work: sort by a column\n// (shift for more than one), search every column, narrow by facets with\n// counts, pick which columns show, select rows and act on them together,\n// act on one from its menu, open one with a click, move a page at a\n// time. Client-side over `rows` by default; with `manual` the rows are\n// already the page a server chose, and the table only reports what the\n// founder asked for. Every piece of state (search, filters, sort,\n// selection, page, page size, hidden columns) is controlled when its\n// value prop is given and the table's own otherwise.\n//\n// The block owns the behaviour a hand-drawn table gets wrong: a sort that\n// keeps blanks last, a select-all that means this page, a page that goes\n// back to 1 when the filter changes, \"no matches\" told apart from \"no\n// rows\", a bulk action that can fail and says so, examples that are\n// never acted on, and a 390px window where the table scrolls inside\n// itself (or turns into cards). The surface owns what a cell says\n// (`cell`), what a row can do (`rowActions`, `bulkActions`) and how a row\n// is painted (`rowTone`, `rowClassName`).\n//\n// <DataTable rows={invoices} columns={COLUMNS}\n// facets={[{ key: \"status\", label: \"Status\" }]}\n// bulkActions={[{ label: \"Mark paid\", onSelect: markPaid }]}\n// rowActions={(r) => [{ label: \"Open\", onSelect: () => open(r) }]}\n// onRowClick={open} />\n//\n// Opt-in on top: a row's first actions as buttons in the row\n// (`inlineActions`), rows that open under themselves (`renderExpanded`), rows\n// filed under group headings with a subtotal each (`groupBy`,\n// `groupSummary`, `groupFooter`), an aligned totals row (`totals`), arrow\n// keys over the rows (`keyboard`) and a first column that stays put while\n// a wide table scrolls sideways (`stickyFirstColumn`).\n//\n// README.md beside this file lists every prop.\n\nconst TONES = {\n success: \"bg-success/10 [tr:hover>&]:bg-success/15\",\n info: \"bg-info/10 [tr:hover>&]:bg-info/15\",\n warning: \"bg-warning/10 [tr:hover>&]:bg-warning/15\",\n danger: \"bg-destructive/10 [tr:hover>&]:bg-destructive/15\",\n highlighted: \"bg-primary/5 [tr:hover>&]:bg-primary/10\",\n};\nconst CARD_TONES = {\n success: \"border-success/40 bg-success/10\",\n info: \"border-info/40 bg-info/10\",\n warning: \"border-warning/50 bg-warning/10\",\n danger: \"border-destructive/50 bg-destructive/10\",\n highlighted: \"border-primary/40 bg-primary/5\",\n};\n// A highlighted row (a pinned or best row, not a status) carries a bar\n// at its leading edge as well as the tint.\nconst BAR = \"shadow-[inset_3px_0_0_var(--color-primary)]\";\n// A sticky cell is opaque (the columns scroll under it), so a tone is a\n// flat gradient layered over the card instead of a see-through fill.\nconst STICKY_TONES = {\n success: \"bg-linear-to-r from-success/10 to-success/10 [tr:hover>&]:from-success/15 [tr:hover>&]:to-success/15\",\n info: \"bg-linear-to-r from-info/10 to-info/10 [tr:hover>&]:from-info/15 [tr:hover>&]:to-info/15\",\n warning: \"bg-linear-to-r from-warning/10 to-warning/10 [tr:hover>&]:from-warning/15 [tr:hover>&]:to-warning/15\",\n danger: \"bg-linear-to-r from-destructive/10 to-destructive/10 [tr:hover>&]:from-destructive/15 [tr:hover>&]:to-destructive/15\",\n highlighted: \"bg-linear-to-r from-primary/5 to-primary/5 [tr:hover>&]:from-primary/10 [tr:hover>&]:to-primary/10\",\n};\nconst STICKY_HOVER = \"[tr:hover>&]:bg-linear-to-r [tr:hover>&]:from-muted/50 [tr:hover>&]:to-muted/50\";\nconst STICKY_EDGE = \"shadow-[inset_-1px_0_0_var(--color-border)]\";\nconst BAR_EDGE = \"shadow-[inset_3px_0_0_var(--color-primary),inset_-1px_0_0_var(--color-border)]\";\n// The leading columns' widths, so a sticky first column knows its offset.\nconst EXPANDER_W = 2.5; // rem — w-10\nconst SELECT_W = 3; // rem — w-12\nconst INTERACTIVE = \"button, a, input, select, textarea, label, [role=menuitem], [role=menu], [popover]\";\nconst NO_FILTERS = {};\nconst NONE = [];\n\nfunction humanize(key) {\n const text = String(key ?? \"\").replace(/[_-]+/g, \" \").trim();\n return text ? text.charAt(0).toUpperCase() + text.slice(1) : \"\";\n}\n\nfunction plural(n, one, many = `${one}s`) {\n return `${n.toLocaleString()} ${n === 1 ? one : many}`;\n}\n\n// A per-column map ({ amount: \"$4,200\" }) rather than one node.\nconst isColumnMap = (v) => Boolean(v) && typeof v === \"object\" && !Array.isArray(v) && !isValidElement(v);\nconst domId = (s) => String(s).replace(/[^\\w-]/g, \"_\");\n\nexport function DataTable({\n rows,\n columns,\n idColumn = \"id\",\n label = \"Table\",\n // search\n searchable = true,\n searchPlaceholder = \"Search…\",\n search,\n defaultSearch = \"\",\n onSearchChange,\n // facets\n facets,\n filters,\n defaultFilters = NO_FILTERS,\n onFiltersChange,\n // sort\n sort,\n defaultSort = NONE,\n onSortChange,\n multiSort = true,\n // selection\n selectable,\n selection,\n defaultSelection = NONE,\n onSelectionChange,\n bulkActions,\n // columns\n hiddenColumns,\n defaultHiddenColumns,\n onHiddenColumnsChange,\n // pagination\n page,\n defaultPage = 1,\n onPageChange,\n pageSize,\n defaultPageSize = 10,\n onPageSizeChange,\n pageSizes = [10, 25, 50, 100],\n paginate = true,\n // server mode\n manual = false,\n rowCount,\n // rows\n rowActions,\n inlineActions,\n onRowClick,\n rowTone,\n rowClassName,\n // expandable rows\n renderExpanded,\n canExpand,\n expanded,\n defaultExpanded = NONE,\n onExpandedChange,\n // groups\n groupBy,\n groupOrder,\n groupLabel,\n groupSummary,\n groupFooter,\n // totals\n totals,\n totalsLabel = \"Total\",\n // keyboard and layout\n keyboard = false,\n stickyFirstColumn = false,\n // slots and states\n toolbar,\n footer,\n loading = false,\n error,\n emptyTitle = \"Nothing here yet\",\n emptyBody,\n emptyAction,\n emptyIcon = \"inbox\",\n noun = \"row\",\n mobile = \"scroll\",\n cardTitle,\n variant = \"outline\",\n className,\n}) {\n const uid = useId().replace(/[^\\w-]/g, \"\");\n const all = rows ?? NONE;\n const cols = useMemo(() => (columns ?? []).map((c) => ({ ...c, key: String(c.key), header: c.header ?? humanize(c.key) })), [columns]);\n const facetList = useMemo(\n () =>\n (facets ?? []).map((f) => {\n const col = cols.find((c) => c.key === String(f.key));\n return { ...f, key: String(f.key), label: f.label ?? (typeof col?.header === \"string\" ? col.header : humanize(f.key)) };\n }),\n [facets, cols],\n );\n const keyOf = (row) => String(row?.[idColumn]);\n\n const [query, setQuery] = useControllable(search, defaultSearch, onSearchChange);\n const [chosen, setChosen] = useControllable(filters, defaultFilters, onFiltersChange);\n const [order, setOrder] = useControllable(sort, defaultSort, onSortChange);\n const [picked, setPicked] = useControllable(selection, defaultSelection, onSelectionChange);\n const [hidden, setHidden] = useControllable(\n hiddenColumns,\n defaultHiddenColumns ?? cols.filter((c) => c.hidden).map((c) => c.key),\n onHiddenColumnsChange,\n );\n const [current, setPage] = useControllable(page, defaultPage, onPageChange);\n const [size, setSize] = useControllable(pageSize, defaultPageSize, onPageSizeChange);\n const [bulkError, setBulkError] = useState(null);\n const [bulkNote, setBulkNote] = useState(null);\n const [running, setRunning] = useState(null);\n const [announce, setAnnounce] = useState(\"\");\n const [openIds, setOpenIds] = useControllable(expanded, defaultExpanded, onExpandedChange);\n const [activeId, setActiveId] = useState(null);\n const [rowBusy, setRowBusy] = useState(null);\n const [rowError, setRowError] = useState(null);\n\n const canSelect = selectable ?? Boolean(bulkActions || onSelectionChange || selection);\n const hiddenSet = useMemo(() => new Set((hidden ?? NONE).map(String)), [hidden]);\n const shown = cols.filter((c) => !hiddenSet.has(c.key));\n const pickedSet = useMemo(() => new Set((picked ?? NONE).map(String)), [picked]);\n const filterState = chosen ?? NO_FILTERS;\n const activeFilters = facetList.filter((f) => (filterState[f.key] ?? []).length > 0).length;\n const narrowed = Boolean(String(query ?? \"\").trim()) || activeFilters > 0;\n\n // Client mode: search, facets, sort, then a page. Manual: the rows are\n // the page already, and `rowCount` is how many the server has.\n const matched = useMemo(\n () => (manual ? all : processRows(all, { query, filters: filterState, facets: facetList, sort: order, columns: cols })),\n [manual, all, query, filterState, facetList, order, cols],\n );\n const total = manual ? rowCount ?? all.length : matched.length;\n const per = paginate ? Math.max(1, Number(size) || 10) : Math.max(1, total);\n const pageCount = Math.max(1, Math.ceil(total / per));\n const pageNow = Math.min(Math.max(1, Number(current) || 1), pageCount);\n // Groups: sort first, then file each row under its group, so a sort\n // orders rows within their groups. Groups keep their place (`groupOrder`,\n // else the order they first appear in `rows`) whatever the sort.\n const grouped = groupBy !== undefined && groupBy !== null && groupBy !== false;\n const groups = useMemo(() => {\n if (!grouped) return null;\n const seen = all.map((r) => groupKeyOf(groupValueOf(groupBy, r)));\n const order = typeof groupOrder === \"function\" ? groupOrder : [...(Array.isArray(groupOrder) ? groupOrder : NONE), ...seen];\n return groupRows(matched, groupBy, order);\n }, [grouped, groupBy, groupOrder, matched, all]);\n const arranged = useMemo(() => (groups ? groups.flatMap((g) => g.rows) : matched), [groups, matched]);\n const visible = manual || !paginate ? arranged : arranged.slice((pageNow - 1) * per, pageNow * per);\n\n // A page past the end (the filter narrowed the rows) goes back to the last.\n useEffect(() => {\n if (!loading && Number(current) > pageCount) setPage(pageCount);\n }, [current, pageCount, loading]); // eslint-disable-line react-hooks/exhaustive-deps\n\n useEffect(() => {\n if (!loading && narrowed) setAnnounce(total === 0 ? \"No matches\" : `${plural(total, noun)} match`);\n }, [total, narrowed, loading, noun]);\n\n // ---- changes that move back to page 1 ---------------------------------\n\n function changeQuery(v) {\n setQuery(v);\n if (pageNow !== 1) setPage(1);\n }\n function changeFacet(key, values) {\n setChosen((f) => {\n const next = { ...(f ?? {}) };\n if (values.length) next[key] = values;\n else delete next[key];\n return next;\n });\n if (pageNow !== 1) setPage(1);\n }\n function clearFilters() {\n setQuery(\"\");\n setChosen({});\n if (pageNow !== 1) setPage(1);\n }\n function pressSort(e, key) {\n setOrder((s) => nextSort(s, key, multiSort && e?.shiftKey));\n if (pageNow !== 1) setPage(1);\n }\n\n // ---- selection ---------------------------------------------------------\n\n const pageIds = visible.map(keyOf);\n const pagePicked = pageIds.filter((id) => pickedSet.has(id)).length;\n const allOnPage = pageIds.length > 0 && pagePicked === pageIds.length;\n const someOnPage = pagePicked > 0 && !allOnPage;\n\n function toggleRow(row) {\n const id = keyOf(row);\n setPicked((p) => {\n const list = (p ?? []).map(String);\n return list.includes(id) ? list.filter((x) => x !== id) : [...list, id];\n });\n }\n function togglePage() {\n setPicked((p) => {\n const list = (p ?? []).map(String);\n if (allOnPage) return list.filter((id) => !pageIds.includes(id));\n return [...list, ...pageIds.filter((id) => !list.includes(id))];\n });\n }\n function selectAllMatches() {\n setPicked((p) => [...new Set([...(p ?? []).map(String), ...matched.map(keyOf)])]);\n }\n function clearSelection() {\n setPicked([]);\n setBulkNote(null);\n }\n\n // ---- expandable rows ---------------------------------------------------\n\n const expanding = typeof renderExpanded === \"function\";\n const openSet = useMemo(() => new Set((openIds ?? NONE).map(String)), [openIds]);\n const expandable = (row) => expanding && (canExpand ? Boolean(canExpand(row)) : true);\n function toggleOpen(id, force) {\n setOpenIds((o) => {\n const list = (o ?? []).map(String);\n const on = force ?? !list.includes(id);\n if (on === list.includes(id)) return list;\n return on ? [...list, id] : list.filter((x) => x !== id);\n });\n }\n\n const selectedRows = useMemo(() => all.filter((r) => pickedSet.has(keyOf(r))), [all, pickedSet]); // eslint-disable-line react-hooks/exhaustive-deps\n const count = manual ? pickedSet.size : selectedRows.length;\n const bulkItems = (typeof bulkActions === \"function\" ? bulkActions(selectedRows) : bulkActions ?? NONE).filter(Boolean);\n\n async function runBulk(action) {\n // Examples are shown, never acted on: they are not rows.\n const real = selectedRows.filter((r) => !isSample(r));\n setBulkError(null);\n setBulkNote(null);\n if (real.length === 0 && selectedRows.length > 0) {\n setBulkNote(\"These are examples. Nothing was changed.\");\n return;\n }\n setRunning(action.label);\n let result;\n try {\n result = await action.onSelect?.(real, manual ? [...pickedSet] : real.map(keyOf));\n } catch (e) {\n result = { ok: false, error: e?.message ?? String(e) };\n }\n setRunning(null);\n if (result && result.ok === false) {\n setBulkError(`${action.label} failed: ${result.error ?? \"nothing was changed\"}`);\n return;\n }\n if (action.keepSelection !== true) setPicked([]);\n setAnnounce(`${action.label}: done for ${plural(real.length, noun)}`);\n }\n\n // ---- drawing -----------------------------------------------------------\n\n const toneOf = (row) => (rowTone ? rowTone(row) : null);\n const hasActions = typeof rowActions === \"function\";\n // Inline buttons widen the actions column past the menu's w-12.\n const inlining = hasActions && Boolean(inlineActions);\n // A click opens the row (`onRowClick`); with no onRowClick, a row that\n // expands opens its details instead.\n const activate = typeof onRowClick === \"function\" ? onRowClick : expanding ? (row) => expandable(row) && toggleOpen(keyOf(row)) : null;\n const clickable = Boolean(activate);\n const lead = (expanding ? 1 : 0) + (canSelect ? 1 : 0);\n const width = lead + shown.length + (hasActions ? 1 : 0);\n const titleOf = (row) => {\n if (cardTitle) return cardTitle(row);\n const first = shown[0];\n return first ? (first.cell ? first.cell(row) : row?.[first.key]) : keyOf(row);\n };\n const nameOf = (row) => {\n const first = shown[0];\n const v = first ? row?.[first.key] : null;\n return typeof v === \"string\" || typeof v === \"number\" ? String(v) : keyOf(row);\n };\n\n function onRowActivate(e, row) {\n if (!clickable) return;\n if (e.target.closest?.(INTERACTIVE)) return;\n if (e.type === \"keydown\") {\n if (e.key !== \"Enter\" && e.key !== \" \") return;\n if (e.target !== e.currentTarget) return;\n // With the keyboard grid, Space selects (the grid's handler does it).\n if (e.key === \" \" && keyboard && canSelect) return;\n e.preventDefault();\n }\n activate(row);\n }\n\n // ---- keyboard grid -------------------------------------------------------\n // One row is in the tab order at a time (roving tabindex): the last one\n // focused, else the first. Arrows, Home and End move it; Space selects;\n // Enter opens (as a click does); → and ← open and close a row's details;\n // Esc clears the selection.\n\n const byId = new Map(visible.map((r) => [keyOf(r), r]));\n const tabStop = keyboard ? (activeId !== null && byId.has(activeId) ? activeId : visible.length ? keyOf(visible[0]) : null) : null;\n\n function onGridKey(e) {\n if (!keyboard) return;\n if (e.target.closest?.(\"[role=menu], [popover], input:not([type=checkbox]), textarea, select\")) return;\n if (e.key === \"Escape\") {\n if (canSelect && count > 0) {\n e.preventDefault();\n clearSelection();\n setAnnounce(\"Selection cleared\");\n }\n return;\n }\n const el = e.target.closest?.(\"[data-row]\");\n if (!el || el !== e.target) return;\n const list = [...e.currentTarget.querySelectorAll(\"[data-row]\")];\n const at = list.indexOf(el);\n const row = byId.get(el.dataset.row);\n let to = null;\n if (e.key === \"ArrowDown\") to = at + 1;\n else if (e.key === \"ArrowUp\") to = at - 1;\n else if (e.key === \"Home\") to = 0;\n else if (e.key === \"End\") to = list.length - 1;\n else if (e.key === \" \" && canSelect && row) {\n e.preventDefault();\n const on = !pickedSet.has(el.dataset.row);\n toggleRow(row);\n setAnnounce(`${nameOf(row)} ${on ? \"selected\" : \"deselected\"}`);\n return;\n } else if ((e.key === \"ArrowRight\" || e.key === \"ArrowLeft\") && row && expandable(row)) {\n e.preventDefault();\n toggleOpen(el.dataset.row, e.key === \"ArrowRight\");\n return;\n }\n if (to === null) return;\n e.preventDefault();\n const next = list[Math.max(0, Math.min(list.length - 1, to))];\n if (next) {\n next.focus();\n setActiveId(next.dataset.row);\n }\n }\n\n // What a row (or card) needs to be clicked, focused and moved through.\n function rowProps(row, id) {\n return {\n tabIndex: keyboard ? (id === tabStop ? 0 : -1) : clickable ? 0 : undefined,\n onClick: clickable ? (e) => onRowActivate(e, row) : undefined,\n onKeyDown: clickable ? (e) => onRowActivate(e, row) : undefined,\n onFocus: keyboard ? (e) => e.target === e.currentTarget && setActiveId(id) : undefined,\n };\n }\n // The ring is a layer over the whole row, above sticky cells (an outline\n // on the row would be painted under them).\n const focusRing =\n \"relative outline-hidden focus-visible:after:pointer-events-none focus-visible:after:absolute focus-visible:after:inset-0 focus-visible:after:z-[3] focus-visible:after:ring-2 focus-visible:after:ring-inset focus-visible:after:ring-ring\";\n\n // ---- sticky first column -------------------------------------------------\n\n const stickyBg = variant === \"plain\" ? \"bg-background\" : \"bg-card\";\n const offsets = { expander: 0, select: expanding ? EXPANDER_W : 0, first: (expanding ? EXPANDER_W : 0) + (canSelect ? SELECT_W : 0) };\n // Classes and style for a cell in a leading slot (\"expander\" | \"select\" |\n // \"first\"). `layer` is what the cell paints: \"head\", \"foot\", \"body\".\n function stick(slot, layer, { tone, selected, bar } = {}) {\n if (!stickyFirstColumn) return { className: undefined, style: undefined };\n const edge = slot === \"first\";\n const paint =\n layer === \"head\"\n ? stickyBg\n : layer === \"foot\"\n ? cx(stickyBg, \"bg-linear-to-r from-muted/50 to-muted/50\")\n : layer === \"sub\"\n ? \"bg-muted\"\n : cx(selected ? \"bg-muted\" : stickyBg, tone && STICKY_TONES[tone] ? STICKY_TONES[tone] : !selected && STICKY_HOVER);\n return {\n className: cx(\"sticky\", layer === \"head\" ? \"z-[2]\" : \"z-[1]\", paint, edge && (bar ? BAR_EDGE : STICKY_EDGE)),\n style: { left: `${offsets[slot]}rem` },\n };\n }\n\n function checkbox(row) {\n return (\n <Checkbox\n checked={pickedSet.has(keyOf(row))}\n onChange={() => toggleRow(row)}\n aria-label={`Select ${nameOf(row)}`}\n className=\"px-1\"\n />\n );\n }\n\n // A row's actions, split: the ones drawn as buttons in the row\n // (`inlineActions` of them from the front, or any with `inline: true`)\n // and the rest, which stay in the \"…\" menu. Separators and labels stay\n // in the menu, tidied so none leads, trails or doubles.\n function splitActions(row) {\n const items = (hasActions ? rowActions(row) ?? NONE : NONE).filter(Boolean);\n let left = inlineActions === true ? Infinity : Math.max(0, Number(inlineActions) || 0);\n const inline = [];\n const menu = [];\n for (const a of items) {\n const plain = a.type === undefined || a.type === \"item\";\n const pick = plain && a.inline !== false && (a.inline === true || left > 0);\n if (pick) {\n inline.push(a);\n if (a.inline !== true) left--;\n } else menu.push(a);\n }\n const tidy = menu.filter((a, i, all) => {\n if (a.type !== \"separator\") return true;\n const prev = all.slice(0, i).filter((x) => x.type !== \"separator\").length;\n const next = all[i + 1];\n return prev > 0 && next && next.type !== \"separator\";\n });\n return { inline, menu: inline.length ? tidy : items };\n }\n\n // An inline action may be async: its button spins, the row's other\n // buttons wait, and { ok: false, error } (or a throw) is said above the\n // table. The menu's items keep their own behaviour.\n async function runRowAction(row, action) {\n const id = keyOf(row);\n setRowError(null);\n setRowBusy({ id, label: action.label });\n let result;\n try {\n result = await action.onSelect?.(row);\n } catch (e) {\n result = { ok: false, error: e?.message ?? String(e) };\n }\n setRowBusy(null);\n if (result && result.ok === false) {\n setRowError(`${action.label} failed for ${nameOf(row)}: ${result.error ?? \"nothing was changed\"}`);\n return;\n }\n setAnnounce(`${action.label}: done for ${nameOf(row)}`);\n }\n\n function inlineButton(row, a, card) {\n const id = keyOf(row);\n const busy = rowBusy?.id === id;\n const props = {\n variant: a.tone === \"danger\" ? \"danger\" : \"secondary\",\n loading: busy && rowBusy.label === a.label,\n disabled: (busy && rowBusy.label !== a.label) || a.disabled,\n onClick: () => runRowAction(row, a),\n };\n // On a card there is room for the words; in a row, the glyph and a tip.\n if (card || !a.icon) {\n return (\n <Button key={a.label} {...props} size=\"md\" icon={a.icon}>\n {a.label}\n </Button>\n );\n }\n return (\n <Tooltip key={a.label} label={a.label}>\n <IconButton {...props} icon={a.icon} label={`${a.label}: ${nameOf(row)}`} size=\"md\" />\n </Tooltip>\n );\n }\n\n function rowMenu(row, split = splitActions(row)) {\n return <Menu items={split.menu} label={`Actions for ${nameOf(row)}`} size=\"md\" />;\n }\n\n function actionsCell(row) {\n const split = splitActions(row);\n if (!split.inline.length) return rowMenu(row, split);\n return (\n <div className=\"flex items-center justify-end gap-1.5\">\n {split.inline.map((a) => inlineButton(row, a, false))}\n {rowMenu(row, split)}\n </div>\n );\n }\n\n const empty = !loading && (manual ? total === 0 && !narrowed : all.length === 0) ? (\n <Empty icon={emptyIcon} title={emptyTitle} body={emptyBody} action={emptyAction} />\n ) : null;\n const noMatches = !loading && !empty && visible.length === 0 ? (\n <Empty\n icon=\"search\"\n title=\"No matches\"\n body={narrowed ? \"Nothing matches the search and filters.\" : `No ${noun}s on this page.`}\n action={\n narrowed ? (\n <Button size=\"sm\" icon=\"x\" onClick={clearFilters}>\n Clear filters\n </Button>\n ) : null\n }\n />\n ) : null;\n\n const skeletonRows = Array.from({ length: Math.min(per, 8) }, (_, i) => i);\n\n // The rows on this page, in runs of one group each (one run, no heading,\n // when the table is not grouped). A group's summary and subtotal are\n // over all of its rows, not only this page's; its subtotal shows on the\n // page where the group ends.\n const groupByKey = useMemo(() => new Map((groups ?? NONE).map((g) => [g.key, g])), [groups]);\n const runs = [];\n for (const row of visible) {\n const k = grouped ? groupKeyOf(groupValueOf(groupBy, row)) : null;\n let run = runs[runs.length - 1];\n if (!run || run.key !== k) runs.push((run = { key: k, group: groupByKey.get(k), rows: [] }));\n run.rows.push(row);\n }\n const labelOf = (g) => (groupLabel ? groupLabel(g.value, g.rows) : g.key === \"\" ? \"None\" : g.key);\n const totalsMap = typeof totals === \"function\" ? totals(manual ? all : matched) : totals;\n const showTotals = isColumnMap(totalsMap) && !loading && visible.length > 0;\n\n // One aligned row of per-column values (a subtotal, the totals): blank\n // under the leading and trailing controls, the label in the first\n // column when the map has nothing for it.\n function alignedCells(map, labelText, layer) {\n return (\n <>\n {expanding ? <TableCell className={cx(\"w-10 px-1\", stick(\"expander\", layer).className)} style={stick(\"expander\", layer).style} /> : null}\n {canSelect ? <TableCell className={cx(\"w-12\", stick(\"select\", layer).className)} style={stick(\"select\", layer).style} /> : null}\n {shown.map((c, j) => {\n const s = j === 0 ? stick(\"first\", layer) : {};\n const has = Object.prototype.hasOwnProperty.call(map, c.key);\n return (\n <TableCell key={c.key} numeric={c.align === \"end\" || c.numeric} className={cx(s.className, c.className)} style={s.style}>\n {has ? map[c.key] : j === 0 ? labelText : null}\n </TableCell>\n );\n })}\n {hasActions ? <TableCell className=\"w-12\" /> : null}\n </>\n );\n }\n\n function dataRow(row) {\n const id = keyOf(row);\n const tone = toneOf(row);\n const tint = tone ? TONES[tone] : null;\n const selected = pickedSet.has(id);\n const canOpen = expandable(row);\n const open = canOpen && openSet.has(id);\n const xid = `${uid}-x-${domId(id)}`;\n const sx = stick(\"expander\", \"body\", { tone, selected });\n const ss = stick(\"select\", \"body\", { tone, selected });\n const sf = stick(\"first\", \"body\", { tone, selected, bar: tone === \"highlighted\" && lead === 0 });\n const barFirst = tone === \"highlighted\" && lead === 0;\n const out = [\n <TableRow\n key={id}\n data-row={id}\n data-tone={tone && tone !== \"default\" ? tone : undefined}\n data-open={open ? \"\" : undefined}\n selected={selected}\n {...rowProps(row, id)}\n className={cx(\n clickable && \"cursor-pointer\",\n (clickable || keyboard) && cx(focusRing, \"focus-visible:bg-muted\"),\n open && \"border-b-0\",\n rowClassName?.(row),\n )}\n >\n {expanding ? (\n <TableCell className={cx(\"w-10 px-1 py-0\", stickyFirstColumn ? sx.className : tint, tone === \"highlighted\" && BAR)} style={sx.style}>\n {canOpen ? (\n // raw: a bare icon toggle whose glyph turns; no kit Button draws a rotating icon.\n <button\n type=\"button\"\n aria-expanded={open}\n aria-controls={open ? xid : undefined}\n aria-label={`Details for ${nameOf(row)}`}\n onClick={() => toggleOpen(id)}\n className=\"inline-flex size-8 items-center justify-center rounded-md text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n >\n <Icon name=\"chevron-right\" className={cx(\"transition-transform duration-150\", open && \"rotate-90\")} />\n </button>\n ) : null}\n </TableCell>\n ) : null}\n {canSelect ? (\n <TableCell className={cx(\"w-12 py-0\", stickyFirstColumn ? ss.className : tint, tone === \"highlighted\" && !expanding && BAR)} style={ss.style}>\n {checkbox(row)}\n </TableCell>\n ) : null}\n {shown.map((c, j) => {\n const s = j === 0 ? sf : {};\n return (\n <TableCell\n key={c.key}\n numeric={c.align === \"end\" || c.numeric}\n className={cx(j === 0 && stickyFirstColumn ? s.className : tint, barFirst && j === 0 && !stickyFirstColumn && BAR, c.className)}\n style={s.style}\n >\n {c.cell ? c.cell(row) : row?.[c.key] ?? \"\"}\n </TableCell>\n );\n })}\n {hasActions ? <TableCell className={cx(inlining ? \"whitespace-nowrap py-1.5\" : \"w-12 py-0\", \"text-right\", tint)}>{actionsCell(row)}</TableCell> : null}\n </TableRow>,\n ];\n if (open) {\n out.push(\n <TableRow key={`${id}-x`} className=\"hover:bg-transparent\">\n {/* raw: a bare cell — TableCell's padding would widen the table past the box. */}\n <td id={xid} colSpan={width} className=\"bg-muted/30 p-0 align-top\">\n <div className={cx(\"sticky left-0 w-[100cqw] px-4 py-3\", expanding && \"sm:pl-12\")}>{renderExpanded(row)}</div>\n </td>\n </TableRow>,\n );\n }\n return out;\n }\n\n function groupHead(run, i) {\n const g = run.group ?? { key: run.key, value: run.key, rows: run.rows };\n const summary = groupSummary ? groupSummary(g.rows) : null;\n return (\n <TableRow key={`${run.key}-h`} data-group={run.key} className={cx(\"bg-muted/40 hover:bg-muted/40\", i > 0 && \"border-t\")}>\n <th scope=\"rowgroup\" colSpan={width} className=\"h-10 px-3 text-left align-middle text-sm font-normal\">\n <div className=\"sticky left-3 flex w-[calc(100cqw-1.5rem)] items-center justify-between gap-3\">\n <span className=\"flex min-w-0 items-center gap-2\">\n <span className=\"font-semibold text-foreground\">{labelOf(g)}</span>\n <span data-nums className=\"text-xs text-muted-foreground\">\n {g.rows.length.toLocaleString()}\n </span>\n </span>\n {summary !== null && summary !== undefined && summary !== false ? (\n <span data-nums className=\"shrink-0 whitespace-nowrap text-muted-foreground\">\n {summary}\n </span>\n ) : null}\n </div>\n </th>\n </TableRow>\n );\n }\n\n function groupFoot(run) {\n const g = run.group;\n if (!groupFooter || !g || run.rows[run.rows.length - 1] !== g.rows[g.rows.length - 1]) return null;\n const content = groupFooter(g.rows);\n if (content === null || content === undefined || content === false) return null;\n return (\n <TableRow key={`${run.key}-f`} data-subtotal={run.key} className=\"bg-muted font-semibold hover:bg-muted\">\n {isColumnMap(content) ? (\n alignedCells(content, \"Subtotal\", \"sub\")\n ) : (\n <TableCell colSpan={width} className=\"text-right\">\n {content}\n </TableCell>\n )}\n </TableRow>\n );\n }\n\n // The table's scroll box is a size container: a details row or a group\n // heading spans the whole (maybe wider) table but is drawn at the width\n // you can see (100cqw), pinned to its left edge.\n const table = (\n <div className=\"min-w-0 max-w-full\" onKeyDown={keyboard ? onGridKey : undefined}>\n <Table variant={variant} aria-label={label} aria-busy={loading || undefined} className=\"@container\">\n <TableHeader>\n <TableRow>\n {expanding ? (\n <TableHead className={cx(\"w-10 px-1\", stick(\"expander\", \"head\").className)} style={stick(\"expander\", \"head\").style}>\n <span className=\"sr-only\">Details</span>\n </TableHead>\n ) : null}\n {canSelect ? (\n <TableHead className={cx(\"w-12 py-0\", stick(\"select\", \"head\").className)} style={stick(\"select\", \"head\").style}>\n <Checkbox\n checked={allOnPage}\n ref={(el) => {\n if (el) el.indeterminate = someOnPage;\n }}\n disabled={loading || visible.length === 0}\n onChange={togglePage}\n aria-label={allOnPage ? \"Deselect this page\" : \"Select this page\"}\n className=\"px-1\"\n />\n </TableHead>\n ) : null}\n {shown.map((c, j) => {\n const at = (order ?? NONE).findIndex((s) => s.key === c.key);\n const dir = at >= 0 ? order[at].dir : \"none\";\n const s = j === 0 ? stick(\"first\", \"head\") : {};\n return (\n <TableHead\n key={c.key}\n numeric={c.align === \"end\" || c.numeric}\n sort={c.sortable ? dir : undefined}\n onSort={c.sortable ? (e) => pressSort(e, c.key) : undefined}\n style={c.width || s.style ? { ...(c.width ? { width: c.width } : null), ...s.style } : undefined}\n sortIndex={c.sortable && at >= 0 && order.length > 1 ? at + 1 : undefined}\n className={cx(s.className, c.headerClassName)}\n title={c.sortable && multiSort ? \"Shift-click to sort by more than one column\" : undefined}\n >\n {c.header}\n </TableHead>\n );\n })}\n {hasActions ? (\n <TableHead className={inlining ? \"text-right\" : \"w-12\"}>\n <span className=\"sr-only\">Actions</span>\n </TableHead>\n ) : null}\n </TableRow>\n </TableHeader>\n {loading ? (\n <TableBody>\n {skeletonRows.map((i) => (\n <TableRow key={`s${i}`}>\n {expanding ? <TableCell className=\"w-10 px-1\" /> : null}\n {canSelect ? (\n <TableCell className=\"py-0\">\n <Skeleton shape=\"rect\" className=\"ml-1 size-4 rounded-sm\" />\n </TableCell>\n ) : null}\n {shown.map((c, j) => (\n <TableCell key={c.key} numeric={c.align === \"end\" || c.numeric}>\n <Skeleton className={cx(\"h-4\", c.align === \"end\" || c.numeric ? \"ml-auto w-16\" : j === 0 ? \"w-32\" : \"w-24\")} />\n </TableCell>\n ))}\n {hasActions ? <TableCell /> : null}\n </TableRow>\n ))}\n </TableBody>\n ) : grouped ? (\n // One <tbody> per group: its heading is a rowgroup header.\n runs.map((run, i) => (\n <TableBody key={run.key}>\n {groupHead(run, i)}\n {run.rows.flatMap(dataRow)}\n {groupFoot(run)}\n </TableBody>\n ))\n ) : (\n <TableBody>{visible.flatMap(dataRow)}</TableBody>\n )}\n {showTotals ? (\n <TableFooter>\n <TableRow data-totals=\"\" className=\"font-semibold hover:bg-transparent\">\n {alignedCells(totalsMap, totalsLabel, \"foot\")}\n </TableRow>\n </TableFooter>\n ) : null}\n </Table>\n </div>\n );\n\n // Phone cards: the same rows, one card each, below 640px. Group headings\n // and subtotals, a row's details and the totals come along.\n function pairs(map) {\n return shown\n .filter((c) => Object.prototype.hasOwnProperty.call(map, c.key))\n .map((c) => (\n <div key={c.key} className=\"min-w-0\">\n <dt className=\"text-xs font-normal text-muted-foreground\">{c.header}</dt>\n <dd data-nums className=\"truncate\">\n {map[c.key]}\n </dd>\n </div>\n ));\n }\n\n function card(row) {\n const id = keyOf(row);\n const tone = toneOf(row);\n const rest = cardTitle ? shown : shown.slice(1);\n const canOpen = expandable(row);\n const open = canOpen && openSet.has(id);\n const xid = `${uid}-cx-${domId(id)}`;\n const split = hasActions ? splitActions(row) : { inline: NONE, menu: NONE };\n return (\n <li\n key={id}\n data-row={id}\n data-tone={tone && tone !== \"default\" ? tone : undefined}\n data-selected={pickedSet.has(id) ? \"\" : undefined}\n {...rowProps(row, id)}\n className={cx(\n \"rounded-lg border border-border bg-card p-3 text-sm text-card-foreground data-[selected]:ring-2 data-[selected]:ring-ring\",\n tone && CARD_TONES[tone],\n clickable && \"cursor-pointer\",\n (clickable || keyboard) && \"outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n rowClassName?.(row),\n )}\n >\n <div className=\"flex items-start gap-1\">\n {canSelect ? <div className=\"-my-2 -ml-1 shrink-0\">{checkbox(row)}</div> : null}\n <div className=\"min-w-0 flex-1 font-medium\">{titleOf(row)}</div>\n {canOpen ? (\n // raw: the same details toggle, on a card.\n <button\n type=\"button\"\n aria-expanded={open}\n aria-controls={open ? xid : undefined}\n aria-label={`Details for ${nameOf(row)}`}\n onClick={() => toggleOpen(id)}\n className=\"-my-1 inline-flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n >\n <Icon name=\"chevron-down\" className={cx(\"transition-transform duration-150\", open && \"rotate-180\")} />\n </button>\n ) : null}\n {hasActions ? <div className=\"-my-1 -mr-1 shrink-0\">{rowMenu(row, split)}</div> : null}\n </div>\n {rest.length ? (\n <dl className=\"mt-2 grid grid-cols-2 gap-x-3 gap-y-2\">\n {rest.map((c) => (\n <div key={c.key} className=\"min-w-0\">\n <dt className=\"text-xs text-muted-foreground\">{c.header}</dt>\n <dd className=\"truncate\">{c.cell ? c.cell(row) : row?.[c.key] ?? \"\"}</dd>\n </div>\n ))}\n </dl>\n ) : null}\n {split.inline.length ? <div className=\"mt-3 flex flex-wrap gap-2\">{split.inline.map((a) => inlineButton(row, a, true))}</div> : null}\n {open ? (\n <div id={xid} className=\"mt-3 border-t border-border pt-3\">\n {renderExpanded(row)}\n </div>\n ) : null}\n </li>\n );\n }\n\n const cards = (\n <ul className=\"grid gap-2\" aria-label={label} aria-busy={loading || undefined} onKeyDown={keyboard ? onGridKey : undefined}>\n {loading\n ? skeletonRows.slice(0, 4).map((i) => (\n <li key={`s${i}`} className=\"grid gap-2 rounded-lg border border-border bg-card p-3\">\n <Skeleton className=\"h-4 w-1/2\" />\n <Skeleton className=\"h-3 w-3/4\" />\n <Skeleton className=\"h-3 w-2/3\" />\n </li>\n ))\n : runs.flatMap((run) => {\n if (!grouped) return run.rows.map(card);\n const g = run.group ?? { key: run.key, value: run.key, rows: run.rows };\n const summary = groupSummary ? groupSummary(g.rows) : null;\n const ends = run.group && run.rows[run.rows.length - 1] === g.rows[g.rows.length - 1];\n const foot = groupFooter && ends ? groupFooter(g.rows) : null;\n return [\n <li key={`${run.key}-h`} data-group={run.key} className=\"flex items-baseline justify-between gap-3 px-1 pt-2 text-sm\">\n <span className=\"flex min-w-0 items-baseline gap-2\">\n <span className=\"truncate font-semibold\">{labelOf(g)}</span>\n <span data-nums className=\"text-xs text-muted-foreground\">\n {g.rows.length.toLocaleString()}\n </span>\n </span>\n {summary !== null && summary !== undefined && summary !== false ? (\n <span data-nums className=\"shrink-0 text-muted-foreground\">\n {summary}\n </span>\n ) : null}\n </li>,\n ...run.rows.map(card),\n foot !== null && foot !== undefined && foot !== false ? (\n <li key={`${run.key}-f`} data-subtotal={run.key} className=\"rounded-lg bg-muted px-3 py-2 text-sm font-semibold\">\n {isColumnMap(foot) ? <dl className=\"grid grid-cols-2 gap-x-3 gap-y-1\">{pairs(foot)}</dl> : foot}\n </li>\n ) : null,\n ];\n })}\n {showTotals ? (\n <li data-totals=\"\" className=\"rounded-lg border border-border bg-muted/50 px-3 py-2 text-sm font-semibold\">\n {totalsLabel ? <p className=\"mb-1\">{totalsLabel}</p> : null}\n <dl className=\"grid grid-cols-2 gap-x-3 gap-y-1\">{pairs(totalsMap)}</dl>\n </li>\n ) : null}\n </ul>\n );\n\n const body = empty ?? noMatches;\n const hideable = cols.filter((c) => c.hideable !== false);\n const from = total === 0 ? 0 : (pageNow - 1) * per + 1;\n const to = Math.min(total, pageNow * per);\n\n return (\n <div className={cx(\"grid min-w-0 max-w-full gap-3\", className)}>\n {error ? <Notice tone=\"danger\">{error}</Notice> : null}\n\n {/* The toolbar: search, facets, the surface's own, the columns menu. */}\n <div className=\"flex min-w-0 flex-wrap items-center gap-2\">\n {searchable ? (\n <div className=\"w-full sm:w-64\">\n <InputGroup\n icon=\"search\"\n type=\"search\"\n value={query ?? \"\"}\n onChange={(e) => changeQuery(e.target.value)}\n placeholder={searchPlaceholder}\n aria-label={`Search ${label}`}\n disabled={!manual && all.length === 0 && !loading}\n />\n </div>\n ) : null}\n {facetList.map((f) => {\n const options = facetOptions(f, all, filterState, facetList, query, cols);\n return (\n <div key={f.key} className=\"min-w-0 flex-1 basis-40 sm:w-52 sm:flex-none\">\n <Combobox\n multiple\n searchable={options.length > 7}\n options={options}\n value={(filterState[f.key] ?? NONE).map(String)}\n onValueChange={(values) => changeFacet(f.key, values)}\n placeholder={f.label}\n label={f.label}\n disabled={!manual && all.length === 0}\n />\n </div>\n );\n })}\n {narrowed ? (\n <Button variant=\"quiet\" size=\"sm\" icon=\"x\" onClick={clearFilters}>\n Reset\n </Button>\n ) : null}\n <div className=\"ml-auto flex flex-wrap items-center gap-2\">\n {toolbar}\n {hideable.length > 1 ? (\n <Menu\n label=\"Columns, show or hide\"\n size=\"md\"\n trigger={\n <>\n <Icon name=\"eye\" />\n <span>Columns</span>\n </>\n }\n className=\"border border-border\"\n items={[\n { type: \"label\", label: \"Show columns\" },\n ...hideable.map((c) => {\n const on = !hiddenSet.has(c.key);\n return {\n type: \"checkbox\",\n label: typeof c.header === \"string\" ? c.header : humanize(c.key),\n checked: on,\n // The last one shown stays: a table of no columns is nothing.\n disabled: on && shown.length <= 1,\n onCheckedChange: (next) =>\n setHidden((h) => {\n const list = (h ?? []).map(String);\n return next ? list.filter((k) => k !== c.key) : [...list, c.key];\n }),\n };\n }),\n ]}\n />\n ) : null}\n </div>\n </div>\n\n {/* The bulk bar: what is selected, and what can be done to it. */}\n {canSelect && count > 0 ? (\n <div\n role=\"region\"\n aria-label=\"Selected rows\"\n className=\"flex flex-wrap items-center gap-2 rounded-lg border border-border bg-muted px-3 py-2 text-sm\"\n >\n <span data-nums className=\"font-medium\">\n {count.toLocaleString()} selected\n </span>\n {!manual && allOnPage && count < total ? (\n <Button variant=\"link\" size=\"sm\" onClick={selectAllMatches}>\n Select all {total.toLocaleString()}\n </Button>\n ) : null}\n <Button variant=\"quiet\" size=\"sm\" onClick={clearSelection}>\n Clear\n </Button>\n <div className=\"ml-auto flex flex-wrap items-center gap-2\">\n {bulkItems.map((a) => (\n <Button\n key={a.label}\n size=\"sm\"\n icon={a.icon}\n variant={a.tone === \"danger\" ? \"danger\" : \"secondary\"}\n loading={running === a.label}\n disabled={Boolean(running && running !== a.label) || a.disabled}\n onClick={() => runBulk(a)}\n >\n {a.label}\n </Button>\n ))}\n </div>\n </div>\n ) : null}\n {bulkError ? (\n <Notice tone=\"danger\" onDismiss={() => setBulkError(null)}>\n {bulkError}\n </Notice>\n ) : null}\n {rowError ? (\n <Notice tone=\"danger\" onDismiss={() => setRowError(null)}>\n {rowError}\n </Notice>\n ) : null}\n {bulkNote ? (\n <Notice tone=\"info\" onDismiss={() => setBulkNote(null)}>\n {bulkNote}\n </Notice>\n ) : null}\n\n {body ?? (mobile === \"cards\" ? (\n <>\n <div className=\"grid gap-1 sm:hidden\">\n {canSelect && !loading && visible.length ? (\n <Checkbox\n checked={allOnPage}\n ref={(el) => {\n if (el) el.indeterminate = someOnPage;\n }}\n onChange={togglePage}\n label=\"Select this page\"\n className=\"px-1 text-muted-foreground\"\n />\n ) : null}\n {cards}\n </div>\n <div className=\"hidden sm:block\">{table}</div>\n </>\n ) : (\n table\n ))}\n\n {footer}\n\n {paginate && !body && (total > 0 || loading) ? (\n <div className=\"flex flex-wrap items-center gap-x-4 gap-y-2\">\n <p data-nums className=\"w-full text-sm text-muted-foreground sm:w-auto\" id={`${uid}-range`}>\n {loading ? \"Loading…\" : `${from.toLocaleString()}–${to.toLocaleString()} of ${plural(total, noun)}`}\n </p>\n <Pagination\n page={pageNow}\n pageCount={pageCount}\n onPageChange={setPage}\n pageSize={per}\n pageSizes={pageSizes}\n onPageSizeChange={(n) => {\n setSize(n);\n if (pageNow !== 1) setPage(1);\n }}\n size=\"sm\"\n label={`${label} pages`}\n className=\"min-w-0 flex-1\"\n />\n </div>\n ) : null}\n\n <p aria-live=\"polite\" className=\"sr-only\">\n {announce}\n </p>\n </div>\n );\n}\n"
22
+ "content": "\"use client\";\n\nimport { isValidElement, useEffect, useId, useMemo, useState } from \"react\";\nimport { isSample } from \"../../parts\";\nimport {\n Button,\n Checkbox,\n Combobox,\n Empty,\n Icon,\n IconButton,\n InputGroup,\n Menu,\n Notice,\n Pagination,\n Skeleton,\n Table,\n TableBody,\n TableCell,\n TableFooter,\n TableHead,\n TableHeader,\n TableRow,\n Tooltip,\n cx,\n} from \"../../ui\";\nimport { facetOptions, groupKeyOf, groupRows, groupValueOf, nextSort, processRows, useControllable } from \"./model\";\n\n// DataTable — records as a table the founder can work: sort by a column\n// (shift for more than one), search every column, narrow by facets with\n// counts, pick which columns show, select rows and act on them together,\n// act on one from its menu, open one with a click, move a page at a\n// time. Client-side over `rows` by default; with `manual` the rows are\n// already the page a server chose, and the table only reports what the\n// founder asked for. Every piece of state (search, filters, sort,\n// selection, page, page size, hidden columns) is controlled when its\n// value prop is given and the table's own otherwise.\n//\n// The block owns the behaviour a hand-drawn table gets wrong: a sort that\n// keeps blanks last, a select-all that means this page, a page that goes\n// back to 1 when the filter changes, \"no matches\" told apart from \"no\n// rows\", a bulk action that can fail and says so, examples that are\n// never acted on, and a 390px window where the table scrolls inside\n// itself (or turns into cards). The surface owns what a cell says\n// (`cell`), what a row can do (`rowActions`, `bulkActions`) and how a row\n// is painted (`rowTone`, `rowClassName`).\n//\n// <DataTable rows={invoices} columns={COLUMNS}\n// facets={[{ key: \"status\", label: \"Status\" }]}\n// bulkActions={[{ label: \"Mark paid\", onSelect: markPaid }]}\n// rowActions={(r) => [{ label: \"Open\", onSelect: () => open(r) }]}\n// onRowClick={open} />\n//\n// Opt-in on top: a row's first actions as buttons in the row\n// (`inlineActions`), rows that open under themselves (`renderExpanded`), rows\n// filed under group headings with a subtotal each (`groupBy`,\n// `groupSummary`, `groupFooter`), an aligned totals row (`totals`), arrow\n// keys over the rows (`keyboard`) and a first column that stays put while\n// a wide table scrolls sideways (`stickyFirstColumn`).\n//\n// README.md beside this file lists every prop.\n\nconst TONES = {\n success: \"bg-success/10 [tr:hover>&]:bg-success/15\",\n info: \"bg-info/10 [tr:hover>&]:bg-info/15\",\n warning: \"bg-warning/10 [tr:hover>&]:bg-warning/15\",\n danger: \"bg-destructive/10 [tr:hover>&]:bg-destructive/15\",\n highlighted: \"bg-primary/5 [tr:hover>&]:bg-primary/10\",\n};\nconst CARD_TONES = {\n success: \"border-success/40 bg-success/10\",\n info: \"border-info/40 bg-info/10\",\n warning: \"border-warning/50 bg-warning/10\",\n danger: \"border-destructive/50 bg-destructive/10\",\n highlighted: \"border-primary/40 bg-primary/5\",\n};\n// A highlighted row (a pinned or best row, not a status) carries a bar\n// at its leading edge as well as the tint.\nconst BAR = \"shadow-[inset_3px_0_0_var(--color-primary)]\";\n// A sticky cell is opaque (the columns scroll under it), so a tone is a\n// flat gradient layered over the card instead of a see-through fill.\nconst STICKY_TONES = {\n success: \"bg-linear-to-r from-success/10 to-success/10 [tr:hover>&]:from-success/15 [tr:hover>&]:to-success/15\",\n info: \"bg-linear-to-r from-info/10 to-info/10 [tr:hover>&]:from-info/15 [tr:hover>&]:to-info/15\",\n warning: \"bg-linear-to-r from-warning/10 to-warning/10 [tr:hover>&]:from-warning/15 [tr:hover>&]:to-warning/15\",\n danger: \"bg-linear-to-r from-destructive/10 to-destructive/10 [tr:hover>&]:from-destructive/15 [tr:hover>&]:to-destructive/15\",\n highlighted: \"bg-linear-to-r from-primary/5 to-primary/5 [tr:hover>&]:from-primary/10 [tr:hover>&]:to-primary/10\",\n};\nconst STICKY_HOVER = \"[tr:hover>&]:bg-linear-to-r [tr:hover>&]:from-muted/50 [tr:hover>&]:to-muted/50\";\nconst STICKY_EDGE = \"shadow-[inset_-1px_0_0_var(--color-border)]\";\nconst BAR_EDGE = \"shadow-[inset_3px_0_0_var(--color-primary),inset_-1px_0_0_var(--color-border)]\";\n// The leading columns' widths, so a sticky first column knows its offset.\nconst EXPANDER_W = 2.5; // rem — w-10\nconst SELECT_W = 3; // rem — w-12\nconst INTERACTIVE = \"button, a, input, select, textarea, label, [role=menuitem], [role=menu], [popover]\";\nconst NO_FILTERS = {};\nconst NONE = [];\n\nfunction humanize(key) {\n const text = String(key ?? \"\").replace(/[_-]+/g, \" \").trim();\n return text ? text.charAt(0).toUpperCase() + text.slice(1) : \"\";\n}\n\n/** A noun's plural by the regular English endings: \"invoice\" → \"invoices\",\n * \"company\" → \"companies\", \"address\" → \"addresses\", \"batch\" → \"batches\",\n * \"analysis\" → \"analyses\". Anything else is the table's `nounPlural`\n * (\"person\" → \"people\"). Until 0.5.1 it only added \"s\": \"11 companys\". */\nexport function pluralOf(noun) {\n const s = String(noun ?? \"\");\n if (/[^aeiou]y$/.test(s)) return `${s.slice(0, -1)}ies`;\n if (/sis$/.test(s)) return `${s.slice(0, -2)}es`;\n if (/(?:s|x|z|ch|sh)$/.test(s)) return `${s}es`;\n return `${s}s`;\n}\n\nfunction plural(n, one, many = pluralOf(one)) {\n return `${n.toLocaleString()} ${n === 1 ? one : many}`;\n}\n\n// A per-column map ({ amount: \"$4,200\" }) rather than one node.\nconst isColumnMap = (v) => Boolean(v) && typeof v === \"object\" && !Array.isArray(v) && !isValidElement(v);\nconst domId = (s) => String(s).replace(/[^\\w-]/g, \"_\");\n\nexport function DataTable({\n rows,\n columns,\n idColumn = \"id\",\n label = \"Table\",\n // search\n searchable = true,\n searchPlaceholder = \"Search…\",\n search,\n defaultSearch = \"\",\n onSearchChange,\n // facets\n facets,\n filters,\n defaultFilters = NO_FILTERS,\n onFiltersChange,\n // sort\n sort,\n defaultSort = NONE,\n onSortChange,\n multiSort = true,\n // selection\n selectable,\n selection,\n defaultSelection = NONE,\n onSelectionChange,\n bulkActions,\n // columns\n hiddenColumns,\n defaultHiddenColumns,\n onHiddenColumnsChange,\n // pagination\n page,\n defaultPage = 1,\n onPageChange,\n pageSize,\n defaultPageSize = 10,\n onPageSizeChange,\n pageSizes = [10, 25, 50, 100],\n paginate = true,\n // server mode\n manual = false,\n rowCount,\n // rows\n rowActions,\n inlineActions,\n onRowClick,\n rowTone,\n rowClassName,\n // expandable rows\n renderExpanded,\n canExpand,\n expanded,\n defaultExpanded = NONE,\n onExpandedChange,\n // groups\n groupBy,\n groupOrder,\n groupLabel,\n groupSummary,\n groupFooter,\n // totals\n totals,\n totalsLabel = \"Total\",\n // keyboard and layout\n keyboard = false,\n stickyFirstColumn = false,\n // slots and states\n toolbar,\n footer,\n loading = false,\n error,\n emptyTitle = \"Nothing here yet\",\n emptyBody,\n emptyAction,\n emptyIcon = \"inbox\",\n noun = \"row\",\n nounPlural,\n mobile = \"scroll\",\n cardTitle,\n variant = \"outline\",\n className,\n}) {\n const uid = useId().replace(/[^\\w-]/g, \"\");\n const nouns = nounPlural || pluralOf(noun);\n const all = rows ?? NONE;\n const cols = useMemo(() => (columns ?? []).map((c) => ({ ...c, key: String(c.key), header: c.header ?? humanize(c.key) })), [columns]);\n const facetList = useMemo(\n () =>\n (facets ?? []).map((f) => {\n const col = cols.find((c) => c.key === String(f.key));\n return { ...f, key: String(f.key), label: f.label ?? (typeof col?.header === \"string\" ? col.header : humanize(f.key)) };\n }),\n [facets, cols],\n );\n const keyOf = (row) => String(row?.[idColumn]);\n\n const [query, setQuery] = useControllable(search, defaultSearch, onSearchChange);\n const [chosen, setChosen] = useControllable(filters, defaultFilters, onFiltersChange);\n const [order, setOrder] = useControllable(sort, defaultSort, onSortChange);\n const [picked, setPicked] = useControllable(selection, defaultSelection, onSelectionChange);\n const [hidden, setHidden] = useControllable(\n hiddenColumns,\n defaultHiddenColumns ?? cols.filter((c) => c.hidden).map((c) => c.key),\n onHiddenColumnsChange,\n );\n const [current, setPage] = useControllable(page, defaultPage, onPageChange);\n const [size, setSize] = useControllable(pageSize, defaultPageSize, onPageSizeChange);\n const [bulkError, setBulkError] = useState(null);\n const [bulkNote, setBulkNote] = useState(null);\n const [running, setRunning] = useState(null);\n const [announce, setAnnounce] = useState(\"\");\n const [openIds, setOpenIds] = useControllable(expanded, defaultExpanded, onExpandedChange);\n const [activeId, setActiveId] = useState(null);\n const [rowBusy, setRowBusy] = useState(null);\n const [rowError, setRowError] = useState(null);\n\n const canSelect = selectable ?? Boolean(bulkActions || onSelectionChange || selection);\n const hiddenSet = useMemo(() => new Set((hidden ?? NONE).map(String)), [hidden]);\n const shown = cols.filter((c) => !hiddenSet.has(c.key));\n const pickedSet = useMemo(() => new Set((picked ?? NONE).map(String)), [picked]);\n const filterState = chosen ?? NO_FILTERS;\n const activeFilters = facetList.filter((f) => (filterState[f.key] ?? []).length > 0).length;\n const narrowed = Boolean(String(query ?? \"\").trim()) || activeFilters > 0;\n\n // Client mode: search, facets, sort, then a page. Manual: the rows are\n // the page already, and `rowCount` is how many the server has.\n const matched = useMemo(\n () => (manual ? all : processRows(all, { query, filters: filterState, facets: facetList, sort: order, columns: cols })),\n [manual, all, query, filterState, facetList, order, cols],\n );\n const total = manual ? rowCount ?? all.length : matched.length;\n const per = paginate ? Math.max(1, Number(size) || 10) : Math.max(1, total);\n const pageCount = Math.max(1, Math.ceil(total / per));\n const pageNow = Math.min(Math.max(1, Number(current) || 1), pageCount);\n // Groups: sort first, then file each row under its group, so a sort\n // orders rows within their groups. Groups keep their place (`groupOrder`,\n // else the order they first appear in `rows`) whatever the sort.\n const grouped = groupBy !== undefined && groupBy !== null && groupBy !== false;\n const groups = useMemo(() => {\n if (!grouped) return null;\n const seen = all.map((r) => groupKeyOf(groupValueOf(groupBy, r)));\n const order = typeof groupOrder === \"function\" ? groupOrder : [...(Array.isArray(groupOrder) ? groupOrder : NONE), ...seen];\n return groupRows(matched, groupBy, order);\n }, [grouped, groupBy, groupOrder, matched, all]);\n const arranged = useMemo(() => (groups ? groups.flatMap((g) => g.rows) : matched), [groups, matched]);\n const visible = manual || !paginate ? arranged : arranged.slice((pageNow - 1) * per, pageNow * per);\n\n // A page past the end (the filter narrowed the rows) goes back to the last.\n useEffect(() => {\n if (!loading && Number(current) > pageCount) setPage(pageCount);\n }, [current, pageCount, loading]); // eslint-disable-line react-hooks/exhaustive-deps\n\n useEffect(() => {\n if (!loading && narrowed) setAnnounce(total === 0 ? \"No matches\" : `${plural(total, noun, nouns)} match`);\n }, [total, narrowed, loading, noun, nouns]);\n\n // ---- changes that move back to page 1 ---------------------------------\n\n function changeQuery(v) {\n setQuery(v);\n if (pageNow !== 1) setPage(1);\n }\n function changeFacet(key, values) {\n setChosen((f) => {\n const next = { ...(f ?? {}) };\n if (values.length) next[key] = values;\n else delete next[key];\n return next;\n });\n if (pageNow !== 1) setPage(1);\n }\n function clearFilters() {\n setQuery(\"\");\n setChosen({});\n if (pageNow !== 1) setPage(1);\n }\n function pressSort(e, key) {\n setOrder((s) => nextSort(s, key, multiSort && e?.shiftKey));\n if (pageNow !== 1) setPage(1);\n }\n\n // ---- selection ---------------------------------------------------------\n\n const pageIds = visible.map(keyOf);\n const pagePicked = pageIds.filter((id) => pickedSet.has(id)).length;\n const allOnPage = pageIds.length > 0 && pagePicked === pageIds.length;\n const someOnPage = pagePicked > 0 && !allOnPage;\n\n function toggleRow(row) {\n const id = keyOf(row);\n setPicked((p) => {\n const list = (p ?? []).map(String);\n return list.includes(id) ? list.filter((x) => x !== id) : [...list, id];\n });\n }\n function togglePage() {\n setPicked((p) => {\n const list = (p ?? []).map(String);\n if (allOnPage) return list.filter((id) => !pageIds.includes(id));\n return [...list, ...pageIds.filter((id) => !list.includes(id))];\n });\n }\n function selectAllMatches() {\n setPicked((p) => [...new Set([...(p ?? []).map(String), ...matched.map(keyOf)])]);\n }\n function clearSelection() {\n setPicked([]);\n setBulkNote(null);\n }\n\n // ---- expandable rows ---------------------------------------------------\n\n const expanding = typeof renderExpanded === \"function\";\n const openSet = useMemo(() => new Set((openIds ?? NONE).map(String)), [openIds]);\n const expandable = (row) => expanding && (canExpand ? Boolean(canExpand(row)) : true);\n function toggleOpen(id, force) {\n setOpenIds((o) => {\n const list = (o ?? []).map(String);\n const on = force ?? !list.includes(id);\n if (on === list.includes(id)) return list;\n return on ? [...list, id] : list.filter((x) => x !== id);\n });\n }\n\n const selectedRows = useMemo(() => all.filter((r) => pickedSet.has(keyOf(r))), [all, pickedSet]); // eslint-disable-line react-hooks/exhaustive-deps\n const count = manual ? pickedSet.size : selectedRows.length;\n const bulkItems = (typeof bulkActions === \"function\" ? bulkActions(selectedRows) : bulkActions ?? NONE).filter(Boolean);\n\n async function runBulk(action) {\n // Examples are shown, never acted on: they are not rows.\n const real = selectedRows.filter((r) => !isSample(r));\n setBulkError(null);\n setBulkNote(null);\n if (real.length === 0 && selectedRows.length > 0) {\n setBulkNote(\"These are examples. Nothing was changed.\");\n return;\n }\n setRunning(action.label);\n let result;\n try {\n result = await action.onSelect?.(real, manual ? [...pickedSet] : real.map(keyOf));\n } catch (e) {\n result = { ok: false, error: e?.message ?? String(e) };\n }\n setRunning(null);\n if (result && result.ok === false) {\n setBulkError(`${action.label} failed: ${result.error ?? \"nothing was changed\"}`);\n return;\n }\n if (action.keepSelection !== true) setPicked([]);\n setAnnounce(`${action.label}: done for ${plural(real.length, noun, nouns)}`);\n }\n\n // ---- drawing -----------------------------------------------------------\n\n const toneOf = (row) => (rowTone ? rowTone(row) : null);\n const hasActions = typeof rowActions === \"function\";\n // Inline buttons widen the actions column past the menu's w-12.\n const inlining = hasActions && Boolean(inlineActions);\n // A click opens the row (`onRowClick`); with no onRowClick, a row that\n // expands opens its details instead.\n const activate = typeof onRowClick === \"function\" ? onRowClick : expanding ? (row) => expandable(row) && toggleOpen(keyOf(row)) : null;\n const clickable = Boolean(activate);\n const lead = (expanding ? 1 : 0) + (canSelect ? 1 : 0);\n const width = lead + shown.length + (hasActions ? 1 : 0);\n const titleOf = (row) => {\n if (cardTitle) return cardTitle(row);\n const first = shown[0];\n return first ? (first.cell ? first.cell(row) : row?.[first.key]) : keyOf(row);\n };\n const nameOf = (row) => {\n const first = shown[0];\n const v = first ? row?.[first.key] : null;\n return typeof v === \"string\" || typeof v === \"number\" ? String(v) : keyOf(row);\n };\n\n function onRowActivate(e, row) {\n if (!clickable) return;\n if (e.target.closest?.(INTERACTIVE)) return;\n if (e.type === \"keydown\") {\n if (e.key !== \"Enter\" && e.key !== \" \") return;\n if (e.target !== e.currentTarget) return;\n // With the keyboard grid, Space selects (the grid's handler does it).\n if (e.key === \" \" && keyboard && canSelect) return;\n e.preventDefault();\n }\n activate(row);\n }\n\n // ---- keyboard grid -------------------------------------------------------\n // One row is in the tab order at a time (roving tabindex): the last one\n // focused, else the first. Arrows, Home and End move it; Space selects;\n // Enter opens (as a click does); → and ← open and close a row's details;\n // Esc clears the selection.\n\n const byId = new Map(visible.map((r) => [keyOf(r), r]));\n const tabStop = keyboard ? (activeId !== null && byId.has(activeId) ? activeId : visible.length ? keyOf(visible[0]) : null) : null;\n\n function onGridKey(e) {\n if (!keyboard) return;\n if (e.target.closest?.(\"[role=menu], [popover], input:not([type=checkbox]), textarea, select\")) return;\n if (e.key === \"Escape\") {\n if (canSelect && count > 0) {\n e.preventDefault();\n clearSelection();\n setAnnounce(\"Selection cleared\");\n }\n return;\n }\n const el = e.target.closest?.(\"[data-row]\");\n if (!el || el !== e.target) return;\n const list = [...e.currentTarget.querySelectorAll(\"[data-row]\")];\n const at = list.indexOf(el);\n const row = byId.get(el.dataset.row);\n let to = null;\n if (e.key === \"ArrowDown\") to = at + 1;\n else if (e.key === \"ArrowUp\") to = at - 1;\n else if (e.key === \"Home\") to = 0;\n else if (e.key === \"End\") to = list.length - 1;\n else if (e.key === \" \" && canSelect && row) {\n e.preventDefault();\n const on = !pickedSet.has(el.dataset.row);\n toggleRow(row);\n setAnnounce(`${nameOf(row)} ${on ? \"selected\" : \"deselected\"}`);\n return;\n } else if ((e.key === \"ArrowRight\" || e.key === \"ArrowLeft\") && row && expandable(row)) {\n e.preventDefault();\n toggleOpen(el.dataset.row, e.key === \"ArrowRight\");\n return;\n }\n if (to === null) return;\n e.preventDefault();\n const next = list[Math.max(0, Math.min(list.length - 1, to))];\n if (next) {\n next.focus();\n setActiveId(next.dataset.row);\n }\n }\n\n // What a row (or card) needs to be clicked, focused and moved through.\n function rowProps(row, id) {\n return {\n tabIndex: keyboard ? (id === tabStop ? 0 : -1) : clickable ? 0 : undefined,\n onClick: clickable ? (e) => onRowActivate(e, row) : undefined,\n onKeyDown: clickable ? (e) => onRowActivate(e, row) : undefined,\n onFocus: keyboard ? (e) => e.target === e.currentTarget && setActiveId(id) : undefined,\n };\n }\n // The ring is a layer over the whole row, above sticky cells (an outline\n // on the row would be painted under them).\n const focusRing =\n \"relative outline-hidden focus-visible:after:pointer-events-none focus-visible:after:absolute focus-visible:after:inset-0 focus-visible:after:z-[3] focus-visible:after:ring-2 focus-visible:after:ring-inset focus-visible:after:ring-ring\";\n\n // ---- sticky first column -------------------------------------------------\n\n const stickyBg = variant === \"plain\" ? \"bg-background\" : \"bg-card\";\n const offsets = { expander: 0, select: expanding ? EXPANDER_W : 0, first: (expanding ? EXPANDER_W : 0) + (canSelect ? SELECT_W : 0) };\n // Classes and style for a cell in a leading slot (\"expander\" | \"select\" |\n // \"first\"). `layer` is what the cell paints: \"head\", \"foot\", \"body\".\n function stick(slot, layer, { tone, selected, bar } = {}) {\n if (!stickyFirstColumn) return { className: undefined, style: undefined };\n const edge = slot === \"first\";\n const paint =\n layer === \"head\"\n ? stickyBg\n : layer === \"foot\"\n ? cx(stickyBg, \"bg-linear-to-r from-muted/50 to-muted/50\")\n : layer === \"sub\"\n ? \"bg-muted\"\n : cx(selected ? \"bg-muted\" : stickyBg, tone && STICKY_TONES[tone] ? STICKY_TONES[tone] : !selected && STICKY_HOVER);\n return {\n className: cx(\"sticky\", layer === \"head\" ? \"z-[2]\" : \"z-[1]\", paint, edge && (bar ? BAR_EDGE : STICKY_EDGE)),\n style: { left: `${offsets[slot]}rem` },\n };\n }\n\n function checkbox(row) {\n return (\n <Checkbox\n checked={pickedSet.has(keyOf(row))}\n onChange={() => toggleRow(row)}\n aria-label={`Select ${nameOf(row)}`}\n className=\"px-1\"\n />\n );\n }\n\n // A row's actions, split: the ones drawn as buttons in the row\n // (`inlineActions` of them from the front, or any with `inline: true`)\n // and the rest, which stay in the \"…\" menu. Separators and labels stay\n // in the menu, tidied so none leads, trails or doubles.\n function splitActions(row) {\n const items = (hasActions ? rowActions(row) ?? NONE : NONE).filter(Boolean);\n let left = inlineActions === true ? Infinity : Math.max(0, Number(inlineActions) || 0);\n const inline = [];\n const menu = [];\n for (const a of items) {\n const plain = a.type === undefined || a.type === \"item\";\n const pick = plain && a.inline !== false && (a.inline === true || left > 0);\n if (pick) {\n inline.push(a);\n if (a.inline !== true) left--;\n } else menu.push(a);\n }\n const tidy = menu.filter((a, i, all) => {\n if (a.type !== \"separator\") return true;\n const prev = all.slice(0, i).filter((x) => x.type !== \"separator\").length;\n const next = all[i + 1];\n return prev > 0 && next && next.type !== \"separator\";\n });\n return { inline, menu: inline.length ? tidy : items };\n }\n\n // An inline action may be async: its button spins, the row's other\n // buttons wait, and { ok: false, error } (or a throw) is said above the\n // table. The menu's items keep their own behaviour.\n async function runRowAction(row, action) {\n const id = keyOf(row);\n setRowError(null);\n setRowBusy({ id, label: action.label });\n let result;\n try {\n result = await action.onSelect?.(row);\n } catch (e) {\n result = { ok: false, error: e?.message ?? String(e) };\n }\n setRowBusy(null);\n if (result && result.ok === false) {\n setRowError(`${action.label} failed for ${nameOf(row)}: ${result.error ?? \"nothing was changed\"}`);\n return;\n }\n setAnnounce(`${action.label}: done for ${nameOf(row)}`);\n }\n\n function inlineButton(row, a, card) {\n const id = keyOf(row);\n const busy = rowBusy?.id === id;\n const props = {\n variant: a.tone === \"danger\" ? \"danger\" : \"secondary\",\n loading: busy && rowBusy.label === a.label,\n disabled: (busy && rowBusy.label !== a.label) || a.disabled,\n onClick: () => runRowAction(row, a),\n };\n // On a card there is room for the words; in a row, the glyph and a tip.\n if (card || !a.icon) {\n return (\n <Button key={a.label} {...props} size=\"md\" icon={a.icon}>\n {a.label}\n </Button>\n );\n }\n return (\n <Tooltip key={a.label} label={a.label}>\n <IconButton {...props} icon={a.icon} label={`${a.label}: ${nameOf(row)}`} size=\"md\" />\n </Tooltip>\n );\n }\n\n function rowMenu(row, split = splitActions(row)) {\n return <Menu items={split.menu} label={`Actions for ${nameOf(row)}`} size=\"md\" />;\n }\n\n function actionsCell(row) {\n const split = splitActions(row);\n if (!split.inline.length) return rowMenu(row, split);\n return (\n <div className=\"flex items-center justify-end gap-1.5\">\n {split.inline.map((a) => inlineButton(row, a, false))}\n {rowMenu(row, split)}\n </div>\n );\n }\n\n const empty = !loading && (manual ? total === 0 && !narrowed : all.length === 0) ? (\n <Empty icon={emptyIcon} title={emptyTitle} body={emptyBody} action={emptyAction} />\n ) : null;\n const noMatches = !loading && !empty && visible.length === 0 ? (\n <Empty\n icon=\"search\"\n title=\"No matches\"\n body={narrowed ? \"Nothing matches the search and filters.\" : `No ${nouns} on this page.`}\n action={\n narrowed ? (\n <Button size=\"sm\" icon=\"x\" onClick={clearFilters}>\n Clear filters\n </Button>\n ) : null\n }\n />\n ) : null;\n\n const skeletonRows = Array.from({ length: Math.min(per, 8) }, (_, i) => i);\n\n // The rows on this page, in runs of one group each (one run, no heading,\n // when the table is not grouped). A group's summary and subtotal are\n // over all of its rows, not only this page's; its subtotal shows on the\n // page where the group ends.\n const groupByKey = useMemo(() => new Map((groups ?? NONE).map((g) => [g.key, g])), [groups]);\n const runs = [];\n for (const row of visible) {\n const k = grouped ? groupKeyOf(groupValueOf(groupBy, row)) : null;\n let run = runs[runs.length - 1];\n if (!run || run.key !== k) runs.push((run = { key: k, group: groupByKey.get(k), rows: [] }));\n run.rows.push(row);\n }\n const labelOf = (g) => (groupLabel ? groupLabel(g.value, g.rows) : g.key === \"\" ? \"None\" : g.key);\n const totalsMap = typeof totals === \"function\" ? totals(manual ? all : matched) : totals;\n const showTotals = isColumnMap(totalsMap) && !loading && visible.length > 0;\n\n // One aligned row of per-column values (a subtotal, the totals): blank\n // under the leading and trailing controls, the label in the first\n // column when the map has nothing for it.\n function alignedCells(map, labelText, layer) {\n return (\n <>\n {expanding ? <TableCell className={cx(\"w-10 px-1\", stick(\"expander\", layer).className)} style={stick(\"expander\", layer).style} /> : null}\n {canSelect ? <TableCell className={cx(\"w-12\", stick(\"select\", layer).className)} style={stick(\"select\", layer).style} /> : null}\n {shown.map((c, j) => {\n const s = j === 0 ? stick(\"first\", layer) : {};\n const has = Object.prototype.hasOwnProperty.call(map, c.key);\n return (\n <TableCell key={c.key} numeric={c.align === \"end\" || c.numeric} className={cx(s.className, c.className)} style={s.style}>\n {has ? map[c.key] : j === 0 ? labelText : null}\n </TableCell>\n );\n })}\n {hasActions ? <TableCell className=\"w-12\" /> : null}\n </>\n );\n }\n\n function dataRow(row) {\n const id = keyOf(row);\n const tone = toneOf(row);\n const tint = tone ? TONES[tone] : null;\n const selected = pickedSet.has(id);\n const canOpen = expandable(row);\n const open = canOpen && openSet.has(id);\n const xid = `${uid}-x-${domId(id)}`;\n const sx = stick(\"expander\", \"body\", { tone, selected });\n const ss = stick(\"select\", \"body\", { tone, selected });\n const sf = stick(\"first\", \"body\", { tone, selected, bar: tone === \"highlighted\" && lead === 0 });\n const barFirst = tone === \"highlighted\" && lead === 0;\n const out = [\n <TableRow\n key={id}\n data-row={id}\n data-tone={tone && tone !== \"default\" ? tone : undefined}\n data-open={open ? \"\" : undefined}\n selected={selected}\n {...rowProps(row, id)}\n className={cx(\n clickable && \"cursor-pointer\",\n (clickable || keyboard) && cx(focusRing, \"focus-visible:bg-muted\"),\n open && \"border-b-0\",\n rowClassName?.(row),\n )}\n >\n {expanding ? (\n <TableCell className={cx(\"w-10 px-1 py-0\", stickyFirstColumn ? sx.className : tint, tone === \"highlighted\" && BAR)} style={sx.style}>\n {canOpen ? (\n // raw: a bare icon toggle whose glyph turns; no kit Button draws a rotating icon.\n <button\n type=\"button\"\n aria-expanded={open}\n aria-controls={open ? xid : undefined}\n aria-label={`Details for ${nameOf(row)}`}\n onClick={() => toggleOpen(id)}\n className=\"inline-flex size-8 items-center justify-center rounded-md text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n >\n <Icon name=\"chevron-right\" className={cx(\"transition-transform duration-150\", open && \"rotate-90\")} />\n </button>\n ) : null}\n </TableCell>\n ) : null}\n {canSelect ? (\n <TableCell className={cx(\"w-12 py-0\", stickyFirstColumn ? ss.className : tint, tone === \"highlighted\" && !expanding && BAR)} style={ss.style}>\n {checkbox(row)}\n </TableCell>\n ) : null}\n {shown.map((c, j) => {\n const s = j === 0 ? sf : {};\n return (\n <TableCell\n key={c.key}\n numeric={c.align === \"end\" || c.numeric}\n className={cx(j === 0 && stickyFirstColumn ? s.className : tint, barFirst && j === 0 && !stickyFirstColumn && BAR, c.className)}\n style={s.style}\n >\n {c.cell ? c.cell(row) : row?.[c.key] ?? \"\"}\n </TableCell>\n );\n })}\n {hasActions ? <TableCell className={cx(inlining ? \"whitespace-nowrap py-1.5\" : \"w-12 py-0\", \"text-right\", tint)}>{actionsCell(row)}</TableCell> : null}\n </TableRow>,\n ];\n if (open) {\n out.push(\n <TableRow key={`${id}-x`} className=\"hover:bg-transparent\">\n {/* raw: a bare cell — TableCell's padding would widen the table past the box. */}\n <td id={xid} colSpan={width} className=\"bg-muted/30 p-0 align-top\">\n <div className={cx(\"sticky left-0 w-[100cqw] px-4 py-3\", expanding && \"sm:pl-12\")}>{renderExpanded(row)}</div>\n </td>\n </TableRow>,\n );\n }\n return out;\n }\n\n function groupHead(run, i) {\n const g = run.group ?? { key: run.key, value: run.key, rows: run.rows };\n const summary = groupSummary ? groupSummary(g.rows) : null;\n return (\n <TableRow key={`${run.key}-h`} data-group={run.key} className={cx(\"bg-muted/40 hover:bg-muted/40\", i > 0 && \"border-t\")}>\n <th scope=\"rowgroup\" colSpan={width} className=\"h-10 px-3 text-left align-middle text-sm font-normal\">\n <div className=\"sticky left-3 flex w-[calc(100cqw-1.5rem)] items-center justify-between gap-3\">\n <span className=\"flex min-w-0 items-center gap-2\">\n <span className=\"font-semibold text-foreground\">{labelOf(g)}</span>\n <span data-nums className=\"text-xs text-muted-foreground\">\n {g.rows.length.toLocaleString()}\n </span>\n </span>\n {summary !== null && summary !== undefined && summary !== false ? (\n <span data-nums className=\"shrink-0 whitespace-nowrap text-muted-foreground\">\n {summary}\n </span>\n ) : null}\n </div>\n </th>\n </TableRow>\n );\n }\n\n function groupFoot(run) {\n const g = run.group;\n if (!groupFooter || !g || run.rows[run.rows.length - 1] !== g.rows[g.rows.length - 1]) return null;\n const content = groupFooter(g.rows);\n if (content === null || content === undefined || content === false) return null;\n return (\n <TableRow key={`${run.key}-f`} data-subtotal={run.key} className=\"bg-muted font-semibold hover:bg-muted\">\n {isColumnMap(content) ? (\n alignedCells(content, \"Subtotal\", \"sub\")\n ) : (\n <TableCell colSpan={width} className=\"text-right\">\n {content}\n </TableCell>\n )}\n </TableRow>\n );\n }\n\n // The table's scroll box is a size container: a details row or a group\n // heading spans the whole (maybe wider) table but is drawn at the width\n // you can see (100cqw), pinned to its left edge.\n const table = (\n <div className=\"min-w-0 max-w-full\" onKeyDown={keyboard ? onGridKey : undefined}>\n <Table variant={variant} aria-label={label} aria-busy={loading || undefined} className=\"@container\">\n <TableHeader>\n <TableRow>\n {expanding ? (\n <TableHead className={cx(\"w-10 px-1\", stick(\"expander\", \"head\").className)} style={stick(\"expander\", \"head\").style}>\n <span className=\"sr-only\">Details</span>\n </TableHead>\n ) : null}\n {canSelect ? (\n <TableHead className={cx(\"w-12 py-0\", stick(\"select\", \"head\").className)} style={stick(\"select\", \"head\").style}>\n <Checkbox\n checked={allOnPage}\n ref={(el) => {\n if (el) el.indeterminate = someOnPage;\n }}\n disabled={loading || visible.length === 0}\n onChange={togglePage}\n aria-label={allOnPage ? \"Deselect this page\" : \"Select this page\"}\n className=\"px-1\"\n />\n </TableHead>\n ) : null}\n {shown.map((c, j) => {\n const at = (order ?? NONE).findIndex((s) => s.key === c.key);\n const dir = at >= 0 ? order[at].dir : \"none\";\n const s = j === 0 ? stick(\"first\", \"head\") : {};\n return (\n <TableHead\n key={c.key}\n numeric={c.align === \"end\" || c.numeric}\n sort={c.sortable ? dir : undefined}\n onSort={c.sortable ? (e) => pressSort(e, c.key) : undefined}\n style={c.width || s.style ? { ...(c.width ? { width: c.width } : null), ...s.style } : undefined}\n sortIndex={c.sortable && at >= 0 && order.length > 1 ? at + 1 : undefined}\n className={cx(s.className, c.headerClassName)}\n title={c.sortable && multiSort ? \"Shift-click to sort by more than one column\" : undefined}\n >\n {c.header}\n </TableHead>\n );\n })}\n {hasActions ? (\n <TableHead className={inlining ? \"text-right\" : \"w-12\"}>\n <span className=\"sr-only\">Actions</span>\n </TableHead>\n ) : null}\n </TableRow>\n </TableHeader>\n {loading ? (\n <TableBody>\n {skeletonRows.map((i) => (\n <TableRow key={`s${i}`}>\n {expanding ? <TableCell className=\"w-10 px-1\" /> : null}\n {canSelect ? (\n <TableCell className=\"py-0\">\n <Skeleton shape=\"rect\" className=\"ml-1 size-4 rounded-sm\" />\n </TableCell>\n ) : null}\n {shown.map((c, j) => (\n <TableCell key={c.key} numeric={c.align === \"end\" || c.numeric}>\n <Skeleton className={cx(\"h-4\", c.align === \"end\" || c.numeric ? \"ml-auto w-16\" : j === 0 ? \"w-32\" : \"w-24\")} />\n </TableCell>\n ))}\n {hasActions ? <TableCell /> : null}\n </TableRow>\n ))}\n </TableBody>\n ) : grouped ? (\n // One <tbody> per group: its heading is a rowgroup header.\n runs.map((run, i) => (\n <TableBody key={run.key}>\n {groupHead(run, i)}\n {run.rows.flatMap(dataRow)}\n {groupFoot(run)}\n </TableBody>\n ))\n ) : (\n <TableBody>{visible.flatMap(dataRow)}</TableBody>\n )}\n {showTotals ? (\n <TableFooter>\n <TableRow data-totals=\"\" className=\"font-semibold hover:bg-transparent\">\n {alignedCells(totalsMap, totalsLabel, \"foot\")}\n </TableRow>\n </TableFooter>\n ) : null}\n </Table>\n </div>\n );\n\n // Phone cards: the same rows, one card each, below 640px. Group headings\n // and subtotals, a row's details and the totals come along.\n function pairs(map) {\n return shown\n .filter((c) => Object.prototype.hasOwnProperty.call(map, c.key))\n .map((c) => (\n <div key={c.key} className=\"min-w-0\">\n <dt className=\"text-xs font-normal text-muted-foreground\">{c.header}</dt>\n <dd data-nums className=\"truncate\">\n {map[c.key]}\n </dd>\n </div>\n ));\n }\n\n function card(row) {\n const id = keyOf(row);\n const tone = toneOf(row);\n const rest = cardTitle ? shown : shown.slice(1);\n const canOpen = expandable(row);\n const open = canOpen && openSet.has(id);\n const xid = `${uid}-cx-${domId(id)}`;\n const split = hasActions ? splitActions(row) : { inline: NONE, menu: NONE };\n return (\n <li\n key={id}\n data-row={id}\n data-tone={tone && tone !== \"default\" ? tone : undefined}\n data-selected={pickedSet.has(id) ? \"\" : undefined}\n {...rowProps(row, id)}\n className={cx(\n \"rounded-lg border border-border bg-card p-3 text-sm text-card-foreground data-[selected]:ring-2 data-[selected]:ring-ring\",\n tone && CARD_TONES[tone],\n clickable && \"cursor-pointer\",\n (clickable || keyboard) && \"outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n rowClassName?.(row),\n )}\n >\n <div className=\"flex items-start gap-1\">\n {canSelect ? <div className=\"-my-2 -ml-1 shrink-0\">{checkbox(row)}</div> : null}\n <div className=\"min-w-0 flex-1 font-medium\">{titleOf(row)}</div>\n {canOpen ? (\n // raw: the same details toggle, on a card.\n <button\n type=\"button\"\n aria-expanded={open}\n aria-controls={open ? xid : undefined}\n aria-label={`Details for ${nameOf(row)}`}\n onClick={() => toggleOpen(id)}\n className=\"-my-1 inline-flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n >\n <Icon name=\"chevron-down\" className={cx(\"transition-transform duration-150\", open && \"rotate-180\")} />\n </button>\n ) : null}\n {hasActions ? <div className=\"-my-1 -mr-1 shrink-0\">{rowMenu(row, split)}</div> : null}\n </div>\n {rest.length ? (\n <dl className=\"mt-2 grid grid-cols-2 gap-x-3 gap-y-2\">\n {rest.map((c) => (\n <div key={c.key} className=\"min-w-0\">\n <dt className=\"text-xs text-muted-foreground\">{c.header}</dt>\n <dd className=\"truncate\">{c.cell ? c.cell(row) : row?.[c.key] ?? \"\"}</dd>\n </div>\n ))}\n </dl>\n ) : null}\n {split.inline.length ? <div className=\"mt-3 flex flex-wrap gap-2\">{split.inline.map((a) => inlineButton(row, a, true))}</div> : null}\n {open ? (\n <div id={xid} className=\"mt-3 border-t border-border pt-3\">\n {renderExpanded(row)}\n </div>\n ) : null}\n </li>\n );\n }\n\n const cards = (\n <ul className=\"grid gap-2\" aria-label={label} aria-busy={loading || undefined} onKeyDown={keyboard ? onGridKey : undefined}>\n {loading\n ? skeletonRows.slice(0, 4).map((i) => (\n <li key={`s${i}`} className=\"grid gap-2 rounded-lg border border-border bg-card p-3\">\n <Skeleton className=\"h-4 w-1/2\" />\n <Skeleton className=\"h-3 w-3/4\" />\n <Skeleton className=\"h-3 w-2/3\" />\n </li>\n ))\n : runs.flatMap((run) => {\n if (!grouped) return run.rows.map(card);\n const g = run.group ?? { key: run.key, value: run.key, rows: run.rows };\n const summary = groupSummary ? groupSummary(g.rows) : null;\n const ends = run.group && run.rows[run.rows.length - 1] === g.rows[g.rows.length - 1];\n const foot = groupFooter && ends ? groupFooter(g.rows) : null;\n return [\n <li key={`${run.key}-h`} data-group={run.key} className=\"flex items-baseline justify-between gap-3 px-1 pt-2 text-sm\">\n <span className=\"flex min-w-0 items-baseline gap-2\">\n <span className=\"truncate font-semibold\">{labelOf(g)}</span>\n <span data-nums className=\"text-xs text-muted-foreground\">\n {g.rows.length.toLocaleString()}\n </span>\n </span>\n {summary !== null && summary !== undefined && summary !== false ? (\n <span data-nums className=\"shrink-0 text-muted-foreground\">\n {summary}\n </span>\n ) : null}\n </li>,\n ...run.rows.map(card),\n foot !== null && foot !== undefined && foot !== false ? (\n <li key={`${run.key}-f`} data-subtotal={run.key} className=\"rounded-lg bg-muted px-3 py-2 text-sm font-semibold\">\n {isColumnMap(foot) ? <dl className=\"grid grid-cols-2 gap-x-3 gap-y-1\">{pairs(foot)}</dl> : foot}\n </li>\n ) : null,\n ];\n })}\n {showTotals ? (\n <li data-totals=\"\" className=\"rounded-lg border border-border bg-muted/50 px-3 py-2 text-sm font-semibold\">\n {totalsLabel ? <p className=\"mb-1\">{totalsLabel}</p> : null}\n <dl className=\"grid grid-cols-2 gap-x-3 gap-y-1\">{pairs(totalsMap)}</dl>\n </li>\n ) : null}\n </ul>\n );\n\n const body = empty ?? noMatches;\n const hideable = cols.filter((c) => c.hideable !== false);\n const from = total === 0 ? 0 : (pageNow - 1) * per + 1;\n const to = Math.min(total, pageNow * per);\n\n return (\n <div className={cx(\"grid min-w-0 max-w-full gap-3\", className)}>\n {error ? <Notice tone=\"danger\">{error}</Notice> : null}\n\n {/* The toolbar: search, facets, the surface's own, the columns menu. */}\n <div className=\"flex min-w-0 flex-wrap items-center gap-2\">\n {searchable ? (\n <div className=\"w-full sm:w-64\">\n <InputGroup\n icon=\"search\"\n type=\"search\"\n value={query ?? \"\"}\n onChange={(e) => changeQuery(e.target.value)}\n placeholder={searchPlaceholder}\n aria-label={`Search ${label}`}\n disabled={!manual && all.length === 0 && !loading}\n />\n </div>\n ) : null}\n {facetList.map((f) => {\n const options = facetOptions(f, all, filterState, facetList, query, cols);\n return (\n <div key={f.key} className=\"min-w-0 flex-1 basis-40 sm:w-52 sm:flex-none\">\n <Combobox\n multiple\n searchable={options.length > 7}\n options={options}\n value={(filterState[f.key] ?? NONE).map(String)}\n onValueChange={(values) => changeFacet(f.key, values)}\n placeholder={f.label}\n label={f.label}\n disabled={!manual && all.length === 0}\n />\n </div>\n );\n })}\n {narrowed ? (\n <Button variant=\"quiet\" size=\"sm\" icon=\"x\" onClick={clearFilters}>\n Reset\n </Button>\n ) : null}\n <div className=\"ml-auto flex flex-wrap items-center gap-2\">\n {toolbar}\n {hideable.length > 1 ? (\n <Menu\n label=\"Columns, show or hide\"\n size=\"md\"\n trigger={\n <>\n <Icon name=\"eye\" />\n <span>Columns</span>\n </>\n }\n className=\"border border-border\"\n items={[\n { type: \"label\", label: \"Show columns\" },\n ...hideable.map((c) => {\n const on = !hiddenSet.has(c.key);\n return {\n type: \"checkbox\",\n label: typeof c.header === \"string\" ? c.header : humanize(c.key),\n checked: on,\n // The last one shown stays: a table of no columns is nothing.\n disabled: on && shown.length <= 1,\n onCheckedChange: (next) =>\n setHidden((h) => {\n const list = (h ?? []).map(String);\n return next ? list.filter((k) => k !== c.key) : [...list, c.key];\n }),\n };\n }),\n ]}\n />\n ) : null}\n </div>\n </div>\n\n {/* The bulk bar: what is selected, and what can be done to it. */}\n {canSelect && count > 0 ? (\n <div\n role=\"region\"\n aria-label=\"Selected rows\"\n className=\"flex flex-wrap items-center gap-2 rounded-lg border border-border bg-muted px-3 py-2 text-sm\"\n >\n <span data-nums className=\"font-medium\">\n {count.toLocaleString()} selected\n </span>\n {!manual && allOnPage && count < total ? (\n <Button variant=\"link\" size=\"sm\" onClick={selectAllMatches}>\n Select all {total.toLocaleString()}\n </Button>\n ) : null}\n <Button variant=\"quiet\" size=\"sm\" onClick={clearSelection}>\n Clear\n </Button>\n <div className=\"ml-auto flex flex-wrap items-center gap-2\">\n {bulkItems.map((a) => (\n <Button\n key={a.label}\n size=\"sm\"\n icon={a.icon}\n variant={a.tone === \"danger\" ? \"danger\" : \"secondary\"}\n loading={running === a.label}\n disabled={Boolean(running && running !== a.label) || a.disabled}\n onClick={() => runBulk(a)}\n >\n {a.label}\n </Button>\n ))}\n </div>\n </div>\n ) : null}\n {bulkError ? (\n <Notice tone=\"danger\" onDismiss={() => setBulkError(null)}>\n {bulkError}\n </Notice>\n ) : null}\n {rowError ? (\n <Notice tone=\"danger\" onDismiss={() => setRowError(null)}>\n {rowError}\n </Notice>\n ) : null}\n {bulkNote ? (\n <Notice tone=\"info\" onDismiss={() => setBulkNote(null)}>\n {bulkNote}\n </Notice>\n ) : null}\n\n {body ?? (mobile === \"cards\" ? (\n <>\n <div className=\"grid gap-1 sm:hidden\">\n {canSelect && !loading && visible.length ? (\n <Checkbox\n checked={allOnPage}\n ref={(el) => {\n if (el) el.indeterminate = someOnPage;\n }}\n onChange={togglePage}\n label=\"Select this page\"\n className=\"px-1 text-muted-foreground\"\n />\n ) : null}\n {cards}\n </div>\n <div className=\"hidden sm:block\">{table}</div>\n </>\n ) : (\n table\n ))}\n\n {footer}\n\n {paginate && !body && (total > 0 || loading) ? (\n <div className=\"flex flex-wrap items-center gap-x-4 gap-y-2\">\n <p data-nums className=\"w-full text-sm text-muted-foreground sm:w-auto\" id={`${uid}-range`}>\n {loading ? \"Loading…\" : `${from.toLocaleString()}–${to.toLocaleString()} of ${plural(total, noun, nouns)}`}\n </p>\n <Pagination\n page={pageNow}\n pageCount={pageCount}\n onPageChange={setPage}\n pageSize={per}\n pageSizes={pageSizes}\n onPageSizeChange={(n) => {\n setSize(n);\n if (pageNow !== 1) setPage(1);\n }}\n size=\"sm\"\n label={`${label} pages`}\n className=\"min-w-0 flex-1\"\n />\n </div>\n ) : null}\n\n <p aria-live=\"polite\" className=\"sr-only\">\n {announce}\n </p>\n </div>\n );\n}\n"
23
23
  },
24
24
  {
25
25
  "path": "registry/blocks/data-table/model.js",
@@ -31,7 +31,7 @@
31
31
  "path": "registry/blocks/data-table/README.md",
32
32
  "type": "registry:file",
33
33
  "target": "@components/makersclaw/blocks/data-table/README.md",
34
- "content": "# Data table\n\nRecords as a table the founder can work. Click a header to sort (shift-click\nto sort by more than one column), search every column, narrow by facets that\nshow how many rows each choice leaves, pick which columns show, select rows\nand act on them together from a bar that appears, act on one row from its\nmenu or from buttons in the row, open a row with a click, and move through\npages of a chosen size. Opt in to rows that open to show their details,\nrows filed under group headings with subtotals, an aligned totals row,\narrow keys over the rows, and a first column that stays put while a wide table scrolls sideways.\nLoading shows skeleton rows; \"no rows yet\" and \"nothing matches\" are two\ndifferent states, the second with a way out. It runs over `rows` in the\nbrowser, or — with `manual` — shows the page a server chose.\n\n```jsx\nimport { DataTable } from \"../components/blocks/data-table\"; // a Makersclaw app (\"./blocks/data-table\" from components/)\nimport { DataTable } from \"@/components/makersclaw/blocks/data-table\"; // a shadcn project\n```\n\nIt is a client component: render it from a `\"use client\"` component, and\nload the rows in the server surface (README §4).\n\n## The data\n\nAny array of objects with a unique id (`idColumn`, default `\"id\"`). Each\ncolumn reads one key of a row, or draws the cell itself with `cell(row)`.\n\n```js\nconst COLUMNS = [\n { key: \"number\", header: \"Invoice\", sortable: true, hideable: false },\n { key: \"customer\", header: \"Customer\", sortable: true, cell: (r) => <CustomerCell r={r} /> },\n { key: \"status\", header: \"Status\", sortable: true, cell: (r) => <Badge tone={TONE[r.status]}>{r.status}</Badge> },\n { key: \"amount\", header: \"Amount\", sortable: true, align: \"end\", cell: (r) => money(r.amount) },\n { key: \"method\", header: \"Method\", hidden: true },\n];\n```\n\nA column:\n\n- `key` — the row key it reads (and its id for sort, filters, visibility).\n- `header` — what the header says; default made from the key.\n- `cell(row)` — what the cell draws; default the value as text. Kit parts\n and tokens only.\n- `sortable` — the header becomes a sort button.\n- `sortValue(row)` — what it sorts by, when the cell is not the value\n (a status in its own order, a date string, a nested field). Numbers sort\n as numbers, strings naturally (\"INV-9\" before \"INV-10\"); blanks sort\n last either way.\n- `searchValue(row)` — what the search matches; default `sortValue` or\n the value. `searchable: false` leaves the column out of the search.\n- `align` — `\"start\"` (default) or `\"end\"` (amounts: right-aligned,\n tabular figures). `numeric: true` means the same.\n- `width` — a CSS width for the column (`\"8rem\"`).\n- `hideable` — default true; false keeps it out of the Columns menu.\n- `hidden` — starts hidden (the Columns menu can show it).\n- `className` · `headerClassName` — layout on its cells or its header.\n Never colour.\n\nAny other key on a column is kept and ignored, so a block built on this\none can carry its own (report-table's `kind`).\n\n## Props\n\n- `rows` — the records. Pass `useFixture(rows, SAMPLE).rows` and the\n examples show, sort and filter like rows, but a bulk action never\n receives them (they are not rows): selecting only examples and pressing\n one says so and changes nothing.\n- `columns` — as above.\n- `idColumn` — default `\"id\"`.\n- `label` — what the table is called to a screen reader, default `\"Table\"`.\n- `noun` — what a row is (\"invoice\"): used in \"1–10 of 60 invoices\", the\n facet counts and the announcements. Default `\"row\"`.\n\nSearch\n\n- `searchable` — default true: a search field with a glyph leads the\n toolbar. Every word must appear somewhere in the row.\n- `searchPlaceholder` — default `\"Search…\"`.\n- `search` · `defaultSearch` · `onSearchChange(text)` — the text.\n\nFacets\n\n- `facets` — `[{ key, label?, options?, value?(row) }]`: one multi-choice\n Combobox per facet, each option with the number of rows it would show\n at its right (counted over the search and the other facets). `options`\n `[{ value, label }]` fixes the choices and their order; without it they\n are the values found in `rows`. `value(row)` when the facet is not a\n plain key.\n- `filters` · `defaultFilters` · `onFiltersChange(filters)` — the chosen\n values, `{ status: [\"paid\", \"overdue\"], owner: [\"Maya\"] }`.\n\nSort\n\n- `sort` · `defaultSort` · `onSortChange(sort)` — `[{ key, dir: \"asc\" | \"desc\" }]`,\n the first entry sorts first. A press cycles a column asc → desc → off.\n- `multiSort` — default true: shift-click adds a column to the sort (each\n sorted header shows its place). False: one column at a time.\n\nSelection\n\n- `selectable` — checkboxes on every row and a select-this-page box in\n the header. On by default when `bulkActions`, `selection` or\n `onSelectionChange` is given.\n- `selection` · `defaultSelection` · `onSelectionChange(ids)` — the chosen\n ids, as strings. A selection survives search, filters and paging.\n- `bulkActions` — `[{ label, onSelect(rows, ids), icon?, tone?: \"danger\", disabled?, keepSelection? }]`,\n or `(selectedRows) => [...]`. With rows selected a bar shows the count,\n \"Select all N\" (when the whole page is chosen and more match), Clear,\n and these as buttons. `onSelect` may be async; return\n `{ ok: false, error }` (or throw) and the bar says why it failed and\n keeps the selection. Anything else clears the selection, unless\n `keepSelection: true` (an export).\n\nColumns\n\n- `hiddenColumns` · `defaultHiddenColumns` · `onHiddenColumnsChange(keys)` —\n the keys not shown. Default: the columns marked `hidden`. The Columns\n menu (checkbox items) sets it; the last column shown cannot be hidden.\n\nPages\n\n- `page` · `defaultPage` (1) · `onPageChange(page)` — 1-based. A new\n search, filter, sort or page size goes back to page 1; a page past the\n end (rows removed) goes to the last.\n- `pageSize` · `defaultPageSize` (10) · `onPageSizeChange(n)`, `pageSizes`\n (default `[10, 25, 50, 100]`) — the \"Rows per page\" choice.\n- `paginate` — default true; false shows every row and no pager.\n\nRows\n\n- `rowActions(row)` — the row's \"…\" menu, in kit `Menu` items\n (`[{ label, onSelect, icon?, tone?: \"danger\" }, { type: \"separator\" }, …]`;\n a falsy item is skipped). Nothing returned, no menu.\n- `inlineActions` — draw a row's first actions as buttons in the row\n instead of in the menu: a number (the first N of `rowActions(row)`) or\n `true` (all of them). An action with `inline: true` is always a button,\n one with `inline: false` never is; separators and labels stay in the\n menu, and the \"…\" menu holds whatever is left. A button with an `icon`\n is a square glyph button with a tooltip (on a phone card, the glyph and\n the words); without one it is a small text button (\"View\"). Its\n `onSelect(row)` may be async: the button spins and the row's other\n buttons wait, and `{ ok: false, error }` (or a throw) shows a danger\n notice above the table naming the action and the row. `tone: \"danger\"`\n and `disabled` work as in the menu. Default: none — every action in the\n menu, as before.\n- `onRowClick(row)` — a click on the row (not on its checkbox, menu or a\n link inside a cell), or Enter on a focused row: open your `Sheet`.\n- `rowTone(row)` — `\"success\" | \"info\" | \"warning\" | \"danger\" | \"highlighted\"`\n or nothing: a tint on the row's cells from the theme's status colours\n (hover deepens it). `\"highlighted\"` is a pinned or best row, not a\n status: a light primary tint and a bar at its leading edge. Status tones\n are for status only.\n- `rowClassName(row)` — more classes on the row (layout, weight). Never\n colour; that is `rowTone`.\n\nDetails (expandable rows)\n\n- `renderExpanded(row)` — what shows under a row when it is open. Adds a\n leading chevron column; the chevron turns when the row is open, and is\n a button with `aria-expanded`. A row with no `onRowClick` also opens on\n a click or Enter.\n- `canExpand(row)` — false leaves a row without a chevron. Default: every\n row can open.\n- `expanded` · `defaultExpanded` · `onExpandedChange(ids)` — the open ids,\n as strings.\n\nGroups\n\n- `groupBy` — a key or `(row) => value`: each group gets a heading row\n with its label and how many rows it holds. Sort first, then group: a\n sort orders rows inside their groups and never moves a group. Paging\n counts rows; a group split across pages repeats its heading.\n- `groupOrder` — the group values to put first, in order\n (`[\"overdue\", \"pending\"]`; the rest follow in the order they first\n appear in `rows`), or a compare function over two group values.\n Default: the order groups first appear in `rows`.\n- `groupLabel(value, rows)` — the heading's label. Default the value\n (`\"None\"` for a blank).\n- `groupSummary(rows)` — a node at the heading's right: a sum, a count.\n `rows` is the whole group, not only this page's part.\n- `groupFooter(rows)` — a subtotal row under the group, bold on a tinted\n background, on the page where the group ends. Return a map of column\n key to node (`{ amount: \"$4,200\" }`) for cells aligned under their\n columns (\"Subtotal\" fills the first column unless the map has it), or\n one node for a single right-aligned cell.\n\nTotals\n\n- `totals` — a map of column key to node, or `(rows) => map` over every\n row that matches the search and filters (all pages; in `manual`, the\n rows given): one row in the table's `<tfoot>`, each value under its\n column. `footer` is still the free node under the table.\n- `totalsLabel` — default `\"Total\"`, in the first column when the map\n has nothing for it; `null` for none.\n\nKeyboard\n\n- `keyboard` — arrow keys over the rows. One row is in the tab order at\n a time (the last one focused, else the first) and shows a focus ring.\n ↑ ↓ move, Home and End go to the first and last row on the page, Space\n selects the row (when `selectable`), Enter opens it as a click does,\n → and ← open and close its details, Esc clears the selection. Works on\n the phone cards too.\n\nLayout\n\n- `stickyFirstColumn` — the first shown column (and the chevron and\n checkbox before it) stays put while the table scrolls sideways, with an\n edge line. A wide table always scrolls inside its own box, never the\n page; details rows and group headings are drawn at the box's visible\n width.\n\nSlots and states\n\n- `toolbar` — your controls at the toolbar's end, before Columns: the one\n \"New invoice\" button, an export.\n- `footer` — a node under the table, above the pager: a total, a note.\n- `loading` — skeleton rows in place of the body (the toolbar stays).\n- `error` — a danger Notice above the table (a failed read).\n- `emptyTitle` · `emptyBody` · `emptyAction` · `emptyIcon` — the state for\n no rows at all. No matches has its own state with \"Clear filters\".\n- `mobile` — `\"scroll\"` (default): under 640px the table scrolls sideways\n inside its own box; the page never does. `\"cards\"`: under 640px each row\n is a card — the first shown column as its title, the others as\n label/value pairs, with its checkbox and menu. Pick cards when rows are\n read more than compared; scroll when columns are compared.\n- `cardTitle(row)` — the card's title instead of the first column (the\n card then lists every shown column).\n- `variant` — `\"outline\"` (a bordered card; default) or `\"plain\"` (no\n border, for a table already inside a `Card`).\n- `className` — layout around it (width, margin). Never colour.\n\n### Controlled or not — letting a server drive it\n\nEvery piece of state has three props: the value, a `default…` and an\n`on…Change`. Pass only the default (or nothing) and the table keeps it.\nPass the value and the table shows what you pass and calls `on…Change`\nwith what the founder asked for; you set it back. Each is independent, so\nyou can own just the page and leave the rest to the table.\n\nFor a table too big for the browser, set `manual`: the table stops\nsearching, filtering, sorting and slicing, `rows` is the current page, and\n`rowCount` is how many rows the server has. Own `search`, `filters`,\n`sort`, `page` and `pageSize`, re-read on each change, and give each facet\nits `options` with a `count` (`{ value, label, count }`), since the table\ncannot count rows it does not have. With `manual`, a bulk action's second\nargument, `ids`, is every selected id, including rows not on this page.\n\n```jsx\nconst [q, setQ] = useState({ search: \"\", filters: {}, sort: [], page: 1, pageSize: 25 });\n// …read rows + total for q on the server (a Server Action, a route)…\n<DataTable manual rows={pageRows} rowCount={total} loading={pending}\n search={q.search} onSearchChange={(search) => setQ((s) => ({ ...s, search }))}\n filters={q.filters} onFiltersChange={(filters) => setQ((s) => ({ ...s, filters }))}\n sort={q.sort} onSortChange={(sort) => setQ((s) => ({ ...s, sort }))}\n page={q.page} onPageChange={(page) => setQ((s) => ({ ...s, page }))}\n pageSize={q.pageSize} onPageSizeChange={(pageSize) => setQ((s) => ({ ...s, pageSize }))}\n columns={COLUMNS} facets={[{ key: \"status\", options: statusCounts }]} />\n```\n\nThe pure helpers the table uses are exported too — `processRows`,\n`searchRows`, `filterRows`, `sortRows`, `facetOptions`, `nextSort`,\n`groupRows`, `groupValueOf` and `useControllable` — so a surface can compute the same answer the table\nshows (a total over the filtered rows) without drawing it twice.\n\n## Example\n\n```jsx\n// components/Invoices.jsx — client: the table, the row sheet\n\"use client\";\nimport { useState } from \"react\";\nimport { updateRow } from \"../lib/actions\";\nimport { Badge, Button, Sheet } from \"./ui\";\nimport { FixtureNotice, useFixture } from \"./parts\";\nimport { DataTable } from \"./blocks/data-table\";\n\nconst TONE = { paid: \"success\", pending: \"info\", overdue: \"danger\", draft: \"neutral\" };\nconst COLUMNS = [\n { key: \"number\", header: \"Invoice\", sortable: true, hideable: false },\n { key: \"customer\", header: \"Customer\", sortable: true },\n { key: \"status\", header: \"Status\", sortable: true, cell: (r) => <Badge tone={TONE[r.status]}>{r.status}</Badge> },\n { key: \"amount\", header: \"Amount\", sortable: true, align: \"end\", cell: (r) => `$${r.amount.toLocaleString()}` },\n { key: \"due\", header: \"Due\", sortable: true },\n];\nconst SAMPLE = [\n { number: \"INV-1001\", customer: \"Northwind\", status: \"paid\", amount: 1200, due: \"2026-10-01\" },\n { number: \"INV-1002\", customer: \"Acme Ltd\", status: \"overdue\", amount: 450, due: \"2026-09-01\" },\n];\n\nexport default function Invoices({ invoices }) {\n const fixture = useFixture(invoices, SAMPLE);\n const [open, setOpen] = useState(null);\n const markPaid = (rows) =>\n Promise.all(rows.map((r) => updateRow({ source: \"public.invoices\", id: r.id, patch: { status: \"paid\" } })))\n .then((all) => all.find((r) => r.ok === false) ?? { ok: true });\n return (\n <div className=\"grid gap-4\">\n {fixture.isFixture ? <FixtureNotice onClear={fixture.clear} /> : null}\n <DataTable\n label=\"Invoices\"\n noun=\"invoice\"\n rows={fixture.rows}\n columns={COLUMNS}\n facets={[{ key: \"status\", label: \"Status\" }]}\n defaultSort={[{ key: \"due\", dir: \"asc\" }]}\n bulkActions={[{ label: \"Mark paid\", icon: \"check\", onSelect: markPaid }]}\n rowActions={(r) => [{ label: \"Open\", icon: \"eye\", onSelect: () => setOpen(r) }]}\n onRowClick={setOpen}\n rowTone={(r) => (r.status === \"overdue\" ? \"warning\" : null)}\n toolbar={<Button variant=\"primary\" icon=\"plus\">New invoice</Button>}\n mobile=\"cards\"\n emptyTitle=\"No invoices yet\"\n />\n <Sheet open={Boolean(open)} onClose={() => setOpen(null)} title={open?.number}>\n {/* the invoice's fields */}\n </Sheet>\n </div>\n );\n}\n```\n\n### Details, groups and totals\n\nA budget: line items under departments, sorted within them, a subtotal\nper department, a total, the months scrolling under the line item.\n\n```jsx\nconst MONTHS = [\"jan\", \"feb\", \"mar\"];\nconst money = (n) => `$${n.toLocaleString()}`;\nconst sumOf = (rows, key) => rows.reduce((s, r) => s + r[key], 0);\nconst sums = (rows) => Object.fromEntries([...MONTHS, \"total\"].map((k) => [k, money(sumOf(rows, k))]));\nconst COLUMNS = [\n { key: \"item\", header: \"Line item\", sortable: true },\n ...MONTHS.map((m) => ({ key: m, align: \"end\", sortable: true, cell: (r) => money(r[m]) })),\n { key: \"total\", align: \"end\", sortable: true, cell: (r) => money(r.total) },\n];\n\n<DataTable label=\"Budget\" noun=\"line item\" rows={lines} columns={COLUMNS}\n paginate={false} keyboard stickyFirstColumn\n groupBy=\"department\" groupOrder={[\"Engineering\"]}\n groupSummary={(rows) => `${money(sumOf(rows, \"total\"))} this quarter`}\n groupFooter={sums} totals={sums}\n renderExpanded={(r) => <p className=\"text-sm\">{r.note}</p>}\n canExpand={(r) => Boolean(r.note)}\n defaultSort={[{ key: \"total\", dir: \"desc\" }]} />\n```\n\n### Buttons in the row\n\nTasks whose actions depend on their state, the common ones as buttons in\nthe row and the rest in the menu:\n\n```jsx\n<DataTable label=\"Tasks\" noun=\"task\" rows={tasks} columns={COLUMNS}\n rowActions={(t) => [\n t.status === \"todo\" && { label: \"Start\", icon: \"play\", inline: true, onSelect: (r) => setStatus(r, \"doing\") },\n t.status === \"doing\" && { label: \"Complete\", icon: \"check\", inline: true, onSelect: (r) => setStatus(r, \"done\") },\n { label: \"Delete\", icon: \"trash\", tone: \"danger\", inline: true, onSelect: remove }, // async, may return { ok: false, error }\n { label: \"Copy ID\", icon: \"copy\", onSelect: copyId },\n ]} />\n```\n\n`setStatus` and `remove` return a promise of `{ ok }`, as a Server Action\ndoes; a failure is said above the table.\n\n## What it will not do\n\n- Edit a cell in place: open the row (`onRowClick`) in a `Sheet`.\n- Fetch: rows come in as props; with `manual` you read each page.\n- Collapse a group, pin more than the first column, resize or reorder\n columns, or virtualise thousands of rows — page them (`manual`)\n instead.\n- Colour a column: a cell says what it means with kit parts (`Badge`,\n `StatusDot`); a row's tone is `rowTone`, and only for status.\n\nA table it cannot be is your own component in `components/`, built from\nthe kit's `Table` parts — not an edit to this block. Its files are fixed:\n`npm run check` refuses a change, and `makersclaw add data-table` restores\nthem.\n\n## Credits\n\nThe row buttons and the orders example follow blocks.so\n(https://github.com/ephraimduncan/blocks, MIT) and Creative Tim UI\n(https://github.com/creativetimofficial/ui, MIT); this is a rewrite on the\nMakersclaw kit, not their code.\n"
34
+ "content": "# Data table\n\nRecords as a table the founder can work. Click a header to sort (shift-click\nto sort by more than one column), search every column, narrow by facets that\nshow how many rows each choice leaves, pick which columns show, select rows\nand act on them together from a bar that appears, act on one row from its\nmenu or from buttons in the row, open a row with a click, and move through\npages of a chosen size. Opt in to rows that open to show their details,\nrows filed under group headings with subtotals, an aligned totals row,\narrow keys over the rows, and a first column that stays put while a wide table scrolls sideways.\nLoading shows skeleton rows; \"no rows yet\" and \"nothing matches\" are two\ndifferent states, the second with a way out. It runs over `rows` in the\nbrowser, or — with `manual` — shows the page a server chose.\n\n```jsx\nimport { DataTable } from \"../components/blocks/data-table\"; // a Makersclaw app (\"./blocks/data-table\" from components/)\nimport { DataTable } from \"@/components/makersclaw/blocks/data-table\"; // a shadcn project\n```\n\nIt is a client component: render it from a `\"use client\"` component, and\nload the rows in the server surface (README §4).\n\n## The data\n\nAny array of objects with a unique id (`idColumn`, default `\"id\"`). Each\ncolumn reads one key of a row, or draws the cell itself with `cell(row)`.\n\n```js\nconst COLUMNS = [\n { key: \"number\", header: \"Invoice\", sortable: true, hideable: false },\n { key: \"customer\", header: \"Customer\", sortable: true, cell: (r) => <CustomerCell r={r} /> },\n { key: \"status\", header: \"Status\", sortable: true, cell: (r) => <Badge tone={TONE[r.status]}>{r.status}</Badge> },\n { key: \"amount\", header: \"Amount\", sortable: true, align: \"end\", cell: (r) => money(r.amount) },\n { key: \"method\", header: \"Method\", hidden: true },\n];\n```\n\nA column:\n\n- `key` — the row key it reads (and its id for sort, filters, visibility).\n- `header` — what the header says; default made from the key.\n- `cell(row)` — what the cell draws; default the value as text. Kit parts\n and tokens only.\n- `sortable` — the header becomes a sort button.\n- `sortValue(row)` — what it sorts by, when the cell is not the value\n (a status in its own order, a date string, a nested field). Numbers sort\n as numbers, strings naturally (\"INV-9\" before \"INV-10\"); blanks sort\n last either way.\n- `searchValue(row)` — what the search matches; default `sortValue` or\n the value. `searchable: false` leaves the column out of the search.\n- `align` — `\"start\"` (default) or `\"end\"` (amounts: right-aligned,\n tabular figures). `numeric: true` means the same.\n- `width` — a CSS width for the column (`\"8rem\"`).\n- `hideable` — default true; false keeps it out of the Columns menu.\n- `hidden` — starts hidden (the Columns menu can show it).\n- `className` · `headerClassName` — layout on its cells or its header.\n Never colour.\n\nAny other key on a column is kept and ignored, so a block built on this\none can carry its own (report-table's `kind`).\n\n## Props\n\n- `rows` — the records. Pass `useFixture(rows, SAMPLE).rows` and the\n examples show, sort and filter like rows, but a bulk action never\n receives them (they are not rows): selecting only examples and pressing\n one says so and changes nothing.\n- `columns` — as above.\n- `idColumn` — default `\"id\"`.\n- `label` — what the table is called to a screen reader, default `\"Table\"`.\n- `noun` — what a row is (\"invoice\"): used in \"1–10 of 60 invoices\", the\n facet counts and the announcements. Default `\"row\"`.\n- `nounPlural` — the plural, when the regular ending is wrong for it:\n `noun=\"person\" nounPlural=\"people\"`. Default: `noun` with the regular\n English ending — \"invoice\" → \"invoices\", \"company\" → \"companies\",\n \"address\" → \"addresses\", \"batch\" → \"batches\", \"analysis\" → \"analyses\".\n\nSearch\n\n- `searchable` — default true: a search field with a glyph leads the\n toolbar. Every word must appear somewhere in the row.\n- `searchPlaceholder` — default `\"Search…\"`.\n- `search` · `defaultSearch` · `onSearchChange(text)` — the text.\n\nFacets\n\n- `facets` — `[{ key, label?, options?, value?(row) }]`: one multi-choice\n Combobox per facet, each option with the number of rows it would show\n at its right (counted over the search and the other facets). `options`\n `[{ value, label }]` fixes the choices and their order; without it they\n are the values found in `rows`. `value(row)` when the facet is not a\n plain key.\n- `filters` · `defaultFilters` · `onFiltersChange(filters)` — the chosen\n values, `{ status: [\"paid\", \"overdue\"], owner: [\"Maya\"] }`.\n\nSort\n\n- `sort` · `defaultSort` · `onSortChange(sort)` — `[{ key, dir: \"asc\" | \"desc\" }]`,\n the first entry sorts first. A press cycles a column asc → desc → off.\n- `multiSort` — default true: shift-click adds a column to the sort (each\n sorted header shows its place). False: one column at a time.\n\nSelection\n\n- `selectable` — checkboxes on every row and a select-this-page box in\n the header. On by default when `bulkActions`, `selection` or\n `onSelectionChange` is given.\n- `selection` · `defaultSelection` · `onSelectionChange(ids)` — the chosen\n ids, as strings. A selection survives search, filters and paging.\n- `bulkActions` — `[{ label, onSelect(rows, ids), icon?, tone?: \"danger\", disabled?, keepSelection? }]`,\n or `(selectedRows) => [...]`. With rows selected a bar shows the count,\n \"Select all N\" (when the whole page is chosen and more match), Clear,\n and these as buttons. `onSelect` may be async; return\n `{ ok: false, error }` (or throw) and the bar says why it failed and\n keeps the selection. Anything else clears the selection, unless\n `keepSelection: true` (an export).\n\nColumns\n\n- `hiddenColumns` · `defaultHiddenColumns` · `onHiddenColumnsChange(keys)` —\n the keys not shown. Default: the columns marked `hidden`. The Columns\n menu (checkbox items) sets it; the last column shown cannot be hidden.\n\nPages\n\n- `page` · `defaultPage` (1) · `onPageChange(page)` — 1-based. A new\n search, filter, sort or page size goes back to page 1; a page past the\n end (rows removed) goes to the last.\n- `pageSize` · `defaultPageSize` (10) · `onPageSizeChange(n)`, `pageSizes`\n (default `[10, 25, 50, 100]`) — the \"Rows per page\" choice.\n- `paginate` — default true; false shows every row and no pager.\n\nRows\n\n- `rowActions(row)` — the row's \"…\" menu, in kit `Menu` items\n (`[{ label, onSelect, icon?, tone?: \"danger\" }, { type: \"separator\" }, …]`;\n a falsy item is skipped). Nothing returned, no menu.\n- `inlineActions` — draw a row's first actions as buttons in the row\n instead of in the menu: a number (the first N of `rowActions(row)`) or\n `true` (all of them). An action with `inline: true` is always a button,\n one with `inline: false` never is; separators and labels stay in the\n menu, and the \"…\" menu holds whatever is left. A button with an `icon`\n is a square glyph button with a tooltip (on a phone card, the glyph and\n the words); without one it is a small text button (\"View\"). Its\n `onSelect(row)` may be async: the button spins and the row's other\n buttons wait, and `{ ok: false, error }` (or a throw) shows a danger\n notice above the table naming the action and the row. `tone: \"danger\"`\n and `disabled` work as in the menu. Default: none — every action in the\n menu, as before.\n- `onRowClick(row)` — a click on the row (not on its checkbox, menu or a\n link inside a cell), or Enter on a focused row: open your `Sheet`.\n- `rowTone(row)` — `\"success\" | \"info\" | \"warning\" | \"danger\" | \"highlighted\"`\n or nothing: a tint on the row's cells from the theme's status colours\n (hover deepens it). `\"highlighted\"` is a pinned or best row, not a\n status: a light primary tint and a bar at its leading edge. Status tones\n are for status only.\n- `rowClassName(row)` — more classes on the row (layout, weight). Never\n colour; that is `rowTone`.\n\nDetails (expandable rows)\n\n- `renderExpanded(row)` — what shows under a row when it is open. Adds a\n leading chevron column; the chevron turns when the row is open, and is\n a button with `aria-expanded`. A row with no `onRowClick` also opens on\n a click or Enter.\n- `canExpand(row)` — false leaves a row without a chevron. Default: every\n row can open.\n- `expanded` · `defaultExpanded` · `onExpandedChange(ids)` — the open ids,\n as strings.\n\nGroups\n\n- `groupBy` — a key or `(row) => value`: each group gets a heading row\n with its label and how many rows it holds. Sort first, then group: a\n sort orders rows inside their groups and never moves a group. Paging\n counts rows; a group split across pages repeats its heading.\n- `groupOrder` — the group values to put first, in order\n (`[\"overdue\", \"pending\"]`; the rest follow in the order they first\n appear in `rows`), or a compare function over two group values.\n Default: the order groups first appear in `rows`.\n- `groupLabel(value, rows)` — the heading's label. Default the value\n (`\"None\"` for a blank).\n- `groupSummary(rows)` — a node at the heading's right: a sum, a count.\n `rows` is the whole group, not only this page's part.\n- `groupFooter(rows)` — a subtotal row under the group, bold on a tinted\n background, on the page where the group ends. Return a map of column\n key to node (`{ amount: \"$4,200\" }`) for cells aligned under their\n columns (\"Subtotal\" fills the first column unless the map has it), or\n one node for a single right-aligned cell.\n\nTotals\n\n- `totals` — a map of column key to node, or `(rows) => map` over every\n row that matches the search and filters (all pages; in `manual`, the\n rows given): one row in the table's `<tfoot>`, each value under its\n column. `footer` is still the free node under the table.\n- `totalsLabel` — default `\"Total\"`, in the first column when the map\n has nothing for it; `null` for none.\n\nKeyboard\n\n- `keyboard` — arrow keys over the rows. One row is in the tab order at\n a time (the last one focused, else the first) and shows a focus ring.\n ↑ ↓ move, Home and End go to the first and last row on the page, Space\n selects the row (when `selectable`), Enter opens it as a click does,\n → and ← open and close its details, Esc clears the selection. Works on\n the phone cards too.\n\nLayout\n\n- `stickyFirstColumn` — the first shown column (and the chevron and\n checkbox before it) stays put while the table scrolls sideways, with an\n edge line. A wide table always scrolls inside its own box, never the\n page; details rows and group headings are drawn at the box's visible\n width.\n\nSlots and states\n\n- `toolbar` — your controls at the toolbar's end, before Columns: the one\n \"New invoice\" button, an export.\n- `footer` — a node under the table, above the pager: a total, a note.\n- `loading` — skeleton rows in place of the body (the toolbar stays).\n- `error` — a danger Notice above the table (a failed read).\n- `emptyTitle` · `emptyBody` · `emptyAction` · `emptyIcon` — the state for\n no rows at all. No matches has its own state with \"Clear filters\".\n- `mobile` — `\"scroll\"` (default): under 640px the table scrolls sideways\n inside its own box; the page never does. `\"cards\"`: under 640px each row\n is a card — the first shown column as its title, the others as\n label/value pairs, with its checkbox and menu. Pick cards when rows are\n read more than compared; scroll when columns are compared.\n- `cardTitle(row)` — the card's title instead of the first column (the\n card then lists every shown column).\n- `variant` — `\"outline\"` (a bordered card; default) or `\"plain\"` (no\n border, for a table already inside a `Card`).\n- `className` — layout around it (width, margin). Never colour.\n\n### Controlled or not — letting a server drive it\n\nEvery piece of state has three props: the value, a `default…` and an\n`on…Change`. Pass only the default (or nothing) and the table keeps it.\nPass the value and the table shows what you pass and calls `on…Change`\nwith what the founder asked for; you set it back. Each is independent, so\nyou can own just the page and leave the rest to the table.\n\nFor a table too big for the browser, set `manual`: the table stops\nsearching, filtering, sorting and slicing, `rows` is the current page, and\n`rowCount` is how many rows the server has. Own `search`, `filters`,\n`sort`, `page` and `pageSize`, re-read on each change, and give each facet\nits `options` with a `count` (`{ value, label, count }`), since the table\ncannot count rows it does not have. With `manual`, a bulk action's second\nargument, `ids`, is every selected id, including rows not on this page.\n\n```jsx\nconst [q, setQ] = useState({ search: \"\", filters: {}, sort: [], page: 1, pageSize: 25 });\n// …read rows + total for q on the server (a Server Action, a route)…\n<DataTable manual rows={pageRows} rowCount={total} loading={pending}\n search={q.search} onSearchChange={(search) => setQ((s) => ({ ...s, search }))}\n filters={q.filters} onFiltersChange={(filters) => setQ((s) => ({ ...s, filters }))}\n sort={q.sort} onSortChange={(sort) => setQ((s) => ({ ...s, sort }))}\n page={q.page} onPageChange={(page) => setQ((s) => ({ ...s, page }))}\n pageSize={q.pageSize} onPageSizeChange={(pageSize) => setQ((s) => ({ ...s, pageSize }))}\n columns={COLUMNS} facets={[{ key: \"status\", options: statusCounts }]} />\n```\n\nThe pure helpers the table uses are exported too — `processRows`,\n`searchRows`, `filterRows`, `sortRows`, `facetOptions`, `nextSort`,\n`groupRows`, `groupValueOf` and `useControllable` — so a surface can compute the same answer the table\nshows (a total over the filtered rows) without drawing it twice.\n\n## Example\n\n```jsx\n// components/Invoices.jsx — client: the table, the row sheet\n\"use client\";\nimport { useState } from \"react\";\nimport { updateRow } from \"../lib/actions\";\nimport { Badge, Button, Sheet } from \"./ui\";\nimport { FixtureNotice, useFixture } from \"./parts\";\nimport { DataTable } from \"./blocks/data-table\";\n\nconst TONE = { paid: \"success\", pending: \"info\", overdue: \"danger\", draft: \"neutral\" };\nconst COLUMNS = [\n { key: \"number\", header: \"Invoice\", sortable: true, hideable: false },\n { key: \"customer\", header: \"Customer\", sortable: true },\n { key: \"status\", header: \"Status\", sortable: true, cell: (r) => <Badge tone={TONE[r.status]}>{r.status}</Badge> },\n { key: \"amount\", header: \"Amount\", sortable: true, align: \"end\", cell: (r) => `$${r.amount.toLocaleString()}` },\n { key: \"due\", header: \"Due\", sortable: true },\n];\nconst SAMPLE = [\n { number: \"INV-1001\", customer: \"Northwind\", status: \"paid\", amount: 1200, due: \"2026-10-01\" },\n { number: \"INV-1002\", customer: \"Acme Ltd\", status: \"overdue\", amount: 450, due: \"2026-09-01\" },\n];\n\nexport default function Invoices({ invoices }) {\n const fixture = useFixture(invoices, SAMPLE);\n const [open, setOpen] = useState(null);\n const markPaid = (rows) =>\n Promise.all(rows.map((r) => updateRow({ source: \"public.invoices\", id: r.id, patch: { status: \"paid\" } })))\n .then((all) => all.find((r) => r.ok === false) ?? { ok: true });\n return (\n <div className=\"grid gap-4\">\n {fixture.isFixture ? <FixtureNotice onClear={fixture.clear} /> : null}\n <DataTable\n label=\"Invoices\"\n noun=\"invoice\"\n rows={fixture.rows}\n columns={COLUMNS}\n facets={[{ key: \"status\", label: \"Status\" }]}\n defaultSort={[{ key: \"due\", dir: \"asc\" }]}\n bulkActions={[{ label: \"Mark paid\", icon: \"check\", onSelect: markPaid }]}\n rowActions={(r) => [{ label: \"Open\", icon: \"eye\", onSelect: () => setOpen(r) }]}\n onRowClick={setOpen}\n rowTone={(r) => (r.status === \"overdue\" ? \"warning\" : null)}\n toolbar={<Button variant=\"primary\" icon=\"plus\">New invoice</Button>}\n mobile=\"cards\"\n emptyTitle=\"No invoices yet\"\n />\n <Sheet open={Boolean(open)} onClose={() => setOpen(null)} title={open?.number}>\n {/* the invoice's fields */}\n </Sheet>\n </div>\n );\n}\n```\n\n### Details, groups and totals\n\nA budget: line items under departments, sorted within them, a subtotal\nper department, a total, the months scrolling under the line item.\n\n```jsx\nconst MONTHS = [\"jan\", \"feb\", \"mar\"];\nconst money = (n) => `$${n.toLocaleString()}`;\nconst sumOf = (rows, key) => rows.reduce((s, r) => s + r[key], 0);\nconst sums = (rows) => Object.fromEntries([...MONTHS, \"total\"].map((k) => [k, money(sumOf(rows, k))]));\nconst COLUMNS = [\n { key: \"item\", header: \"Line item\", sortable: true },\n ...MONTHS.map((m) => ({ key: m, align: \"end\", sortable: true, cell: (r) => money(r[m]) })),\n { key: \"total\", align: \"end\", sortable: true, cell: (r) => money(r.total) },\n];\n\n<DataTable label=\"Budget\" noun=\"line item\" rows={lines} columns={COLUMNS}\n paginate={false} keyboard stickyFirstColumn\n groupBy=\"department\" groupOrder={[\"Engineering\"]}\n groupSummary={(rows) => `${money(sumOf(rows, \"total\"))} this quarter`}\n groupFooter={sums} totals={sums}\n renderExpanded={(r) => <p className=\"text-sm\">{r.note}</p>}\n canExpand={(r) => Boolean(r.note)}\n defaultSort={[{ key: \"total\", dir: \"desc\" }]} />\n```\n\n### Buttons in the row\n\nTasks whose actions depend on their state, the common ones as buttons in\nthe row and the rest in the menu:\n\n```jsx\n<DataTable label=\"Tasks\" noun=\"task\" rows={tasks} columns={COLUMNS}\n rowActions={(t) => [\n t.status === \"todo\" && { label: \"Start\", icon: \"play\", inline: true, onSelect: (r) => setStatus(r, \"doing\") },\n t.status === \"doing\" && { label: \"Complete\", icon: \"check\", inline: true, onSelect: (r) => setStatus(r, \"done\") },\n { label: \"Delete\", icon: \"trash\", tone: \"danger\", inline: true, onSelect: remove }, // async, may return { ok: false, error }\n { label: \"Copy ID\", icon: \"copy\", onSelect: copyId },\n ]} />\n```\n\n`setStatus` and `remove` return a promise of `{ ok }`, as a Server Action\ndoes; a failure is said above the table.\n\n## What it will not do\n\n- Edit a cell in place: open the row (`onRowClick`) in a `Sheet`.\n- Fetch: rows come in as props; with `manual` you read each page.\n- Collapse a group, pin more than the first column, resize or reorder\n columns, or virtualise thousands of rows — page them (`manual`)\n instead.\n- Colour a column: a cell says what it means with kit parts (`Badge`,\n `StatusDot`); a row's tone is `rowTone`, and only for status.\n\nA table it cannot be is your own component in `components/`, built from\nthe kit's `Table` parts — not an edit to this block. Its files are fixed:\n`npm run check` refuses a change, and `makersclaw add data-table` restores\nthem.\n\n## Credits\n\nThe row buttons and the orders example follow blocks.so\n(https://github.com/ephraimduncan/blocks, MIT) and Creative Tim UI\n(https://github.com/creativetimofficial/ui, MIT); this is a rewrite on the\nMakersclaw kit, not their code.\n"
35
35
  }
36
36
  ],
37
37
  "categories": [
@@ -62,7 +62,7 @@
62
62
  "docs": "Installed to components/makersclaw/blocks/data-table. Import: import { DataTable, useControllable, processRows, sortRows, searchRows, filterRows, facetOptions, nextSort, groupRows, groupValueOf } from \"@/components/makersclaw/blocks/data-table\". Props and an example: components/makersclaw/blocks/data-table/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
63
63
  "meta": {
64
64
  "makersclaw": {
65
- "version": "0.4.0",
65
+ "version": "0.5.2",
66
66
  "useWhen": "A list of records to scan, find, sort and act on: invoices, customers, orders, leads, users, tickets. Not for records that move through stages (a board), a few key numbers (stats), or a grid of cards whose picture matters more than its columns."
67
67
  }
68
68
  }
package/r/diagram.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "title": "Diagram",
6
6
  "description": "Draws Mermaid text (flowcharts, sequence, class, state, ER, Gantt, pie, mind maps, timelines, git graphs…) in the workspace theme: the tokens are read from the page at run time and handed to Mermaid, so nodes, lines, text and chart colours are the theme's and follow light/dark. Zoom and pan (buttons, Cmd/Ctrl+scroll, pinch, drag, keys), fit to view, a Source tab with line numbers, copy the source or the SVG, download SVG. A mistake shows its line and Mermaid's words, and keeps the last drawing that worked. On the server the frame shows the Mermaid text.",
7
7
  "registryDependencies": [
8
- "https://cdn.jsdelivr.net/npm/makersclaw@0.4.0/r/core.json"
8
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.5.2/r/core.json"
9
9
  ],
10
10
  "dependencies": [
11
11
  "mermaid@12.0.0"
@@ -67,7 +67,7 @@
67
67
  "docs": "Installed to components/makersclaw/blocks/diagram. Import: import { Diagram, diagramKind, diagramTitle, describeError, mermaidTheme } from \"@/components/makersclaw/blocks/diagram\". Props and an example: components/makersclaw/blocks/diagram/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
68
68
  "meta": {
69
69
  "makersclaw": {
70
- "version": "0.4.0",
70
+ "version": "0.5.2",
71
71
  "useWhen": "A screen shows a diagram written as text: a process or decision flow an agent wrote, a sequence of API calls, a data model, a project timeline, an org or system sketch in docs, a mind map. Not for a board people arrange by hand (flow-canvas), freehand drawing (sketch-canvas), or charts of numbers (chart)."
72
72
  }
73
73
  }
package/r/doc-editor.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "title": "DocEditor",
6
6
  "description": "A Notion-style rich-text editor (BlockNote) painted in the workspace theme: headings, lists, checklists, quotes, code, tables, images and dividers from a / menu or markdown shortcuts, a formatting toolbar, drag handles; @mentions and {merge fields} as inline chips; a minimal mode for comment boxes with Cmd/Ctrl+Enter to submit. The document is BlockNote JSON; helpers turn it into markdown or plain text and read its checklist, fields and mentions.",
7
7
  "registryDependencies": [
8
- "https://cdn.jsdelivr.net/npm/makersclaw@0.4.0/r/core.json"
8
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.5.2/r/core.json"
9
9
  ],
10
10
  "dependencies": [
11
11
  "@blocknote/core@0.55.0",
@@ -75,7 +75,7 @@
75
75
  "docs": "Installed to components/makersclaw/blocks/doc-editor. Import: import { DocEditor, useDocEditor, docToMarkdown, docToText, emptyDoc, isDocEmpty, docChecklist, setChecklistItem, docFields, docMentions } from \"@/components/makersclaw/blocks/doc-editor\". Props and an example: components/makersclaw/blocks/doc-editor/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
76
76
  "meta": {
77
77
  "makersclaw": {
78
- "version": "0.4.0",
78
+ "version": "0.5.2",
79
79
  "useWhen": "A screen edits rich text: a wiki or doc page, notes on a record, a brief, meeting notes, an email body with merge fields, a comment field with mentions. The other text blocks (doc-page, record-notes, comment-box…) are built on it. Not for one line of plain text (the kit's Input) or a form of fields (record-form)."
80
80
  }
81
81
  }
package/r/doc-page.json CHANGED
@@ -5,8 +5,8 @@
5
5
  "title": "DocPage",
6
6
  "description": "A Notion-style page: an emoji icon (picked from a grid), a big plain title, and the rich-text editor under it (doc-editor: / menu, markdown shortcuts, @mentions). Edits autosave through an async onSave a moment after typing stops, with Saved / Saving… / Couldn't save + Retry in the top bar, Cmd/Ctrl+S to save now and unsent edits sent when the page is left. A read-only mode shows the same page with nothing to edit; slots for a breadcrumb, actions and a meta line.",
7
7
  "registryDependencies": [
8
- "https://cdn.jsdelivr.net/npm/makersclaw@0.4.0/r/core.json",
9
- "https://cdn.jsdelivr.net/npm/makersclaw@0.4.0/r/doc-editor.json"
8
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.5.2/r/core.json",
9
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.5.2/r/doc-editor.json"
10
10
  ],
11
11
  "dependencies": [],
12
12
  "files": [
@@ -54,7 +54,7 @@
54
54
  "docs": "Installed to components/makersclaw/blocks/doc-page. Import: import { DocPage } from \"@/components/makersclaw/blocks/doc-page\". Props and an example: components/makersclaw/blocks/doc-page/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
55
55
  "meta": {
56
56
  "makersclaw": {
57
- "version": "0.4.0",
57
+ "version": "0.5.2",
58
58
  "useWhen": "A screen is one document someone reads and writes: a wiki or handbook page, a playbook, a policy, a project page, a one-pager. The knowledge-base block puts a page tree beside it. Not for a short field of rich text inside a form (doc-editor), notes on a record (record-notes) or a comment (comment-box)."
59
59
  }
60
60
  }
@@ -5,7 +5,7 @@
5
5
  "title": "EmailRender",
6
6
  "description": "Email HTML from React (react-email) in the workspace theme: a layout with the 600px column and the inbox preview line, a header brand line, a footer with the address and unsubscribe link, buttons, headings and text, and DocEmail, which draws a doc-editor document as email markup (lists, quotes, code, tables, images, merge fields). Email cannot read CSS variables, so resolveEmailTheme reads the tokens where the editor sits and hands the email plain hex values; renderEmail returns { html, text }, in the browser for a preview and on the server for a send.",
7
7
  "registryDependencies": [
8
- "https://cdn.jsdelivr.net/npm/makersclaw@0.4.0/r/core.json"
8
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.5.2/r/core.json"
9
9
  ],
10
10
  "dependencies": [
11
11
  "@react-email/components@1.0.12",
@@ -66,7 +66,7 @@
66
66
  "docs": "Installed to components/makersclaw/blocks/email-render. Import: import { EmailLayout, EmailCard, EmailHeader, EmailFooter, EmailButton, EmailHeading, EmailText, EmailDivider, EMAIL_WIDTH, DocEmail, fieldText, renderEmail, emailPlainText, EMAIL_TOKENS, EMAIL_FALLBACK, resolveEmailTheme, emailColor, emailFontStack, emailThemeKey, luminance, toHex } from \"@/components/makersclaw/blocks/email-render\". Props and an example: components/makersclaw/blocks/email-render/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
67
67
  "meta": {
68
68
  "makersclaw": {
69
- "version": "0.4.0",
69
+ "version": "0.5.2",
70
70
  "useWhen": "Code that must produce an email's HTML: a newsletter, a transactional email (receipt, invite, reset), a digest, a notification, a test send from an editor. newsletter-builder is built on it. Not for writing the email (doc-editor or email-composer), sending it (your sender or the app's server) or a web page (page-builder)."
71
71
  }
72
72
  }
package/r/faq.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "title": "FAQ",
6
6
  "description": "A marketing section of questions and answers: an accordion (one open at a time, or several), a grid of cards with optional icons, or a plain list with every answer shown; the title above or beside them, and an optional \"need more help?\" call to action. Loading and empty states.",
7
7
  "registryDependencies": [
8
- "https://cdn.jsdelivr.net/npm/makersclaw@0.4.0/r/core.json"
8
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.5.2/r/core.json"
9
9
  ],
10
10
  "dependencies": [],
11
11
  "files": [
@@ -43,7 +43,7 @@
43
43
  "docs": "Installed to components/makersclaw/blocks/faq. Import: import { Faq } from \"@/components/makersclaw/blocks/faq\". Props and an example: components/makersclaw/blocks/faq/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
44
44
  "meta": {
45
45
  "makersclaw": {
46
- "version": "0.4.0",
46
+ "version": "0.5.2",
47
47
  "useWhen": "The questions section of a public page: a landing page, pricing, a help centre's front page, a product page. Not for a searchable help centre with articles and categories, or collapsible settings inside an app (use the kit's Accordion)."
48
48
  }
49
49
  }
@@ -5,7 +5,7 @@
5
5
  "title": "FileUpload",
6
6
  "description": "Files chosen or dropped, each uploaded through your async onUpload(file, { onProgress, signal }) with a progress bar, cancel while it runs, a retry when it fails and a remove once done. Variants: a list of file cards, one file at a time, Uploading/Finished sections, image thumbnails (one or two columns), and a profile picture.",
7
7
  "registryDependencies": [
8
- "https://cdn.jsdelivr.net/npm/makersclaw@0.4.0/r/core.json"
8
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.5.2/r/core.json"
9
9
  ],
10
10
  "dependencies": [],
11
11
  "files": [
@@ -47,7 +47,7 @@
47
47
  "docs": "Installed to components/makersclaw/blocks/file-upload. Import: import { FileUpload, formatSize, fileIcon } from \"@/components/makersclaw/blocks/file-upload\". Props and an example: components/makersclaw/blocks/file-upload/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
48
48
  "meta": {
49
49
  "makersclaw": {
50
- "version": "0.4.0",
50
+ "version": "0.5.2",
51
51
  "useWhen": "A form or page takes files the app stores: attachments on a record, a CSV to import, product photos, a logo, a profile picture. Not for showing files already stored with no way to add (a table or list), and not for a chat message's attachments (the chat block's composer has those)."
52
52
  }
53
53
  }