@protobase/ui 0.0.0-stage → 0.1.1

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 (232) hide show
  1. package/LICENSE +202 -0
  2. package/dist/activity-chart/activity-chart.js +125 -0
  3. package/dist/activity-chart/activity-math.js +11 -0
  4. package/dist/activity-chart/activity-tooltip.js +24 -0
  5. package/dist/app/action-icon.js +10 -0
  6. package/dist/app/app.d.ts +24 -0
  7. package/dist/app/app.js +123 -0
  8. package/dist/app/batch-failure.js +21 -0
  9. package/dist/app/breadcrumb-for.js +27 -0
  10. package/dist/app/create/create-page.js +118 -0
  11. package/dist/app/create/create-record-form.js +68 -0
  12. package/dist/app/create/create-values.js +52 -0
  13. package/dist/app/delete/dialogs.js +109 -0
  14. package/dist/app/delete/record-diff.js +17 -0
  15. package/dist/app/delete/use-delete-flow.js +193 -0
  16. package/dist/app/delete/use-restore.js +9 -0
  17. package/dist/app/draft-storage.js +18 -0
  18. package/dist/app/error-banner.js +28 -0
  19. package/dist/app/field-editor.js +88 -0
  20. package/dist/app/icons.js +27 -0
  21. package/dist/app/index.d.ts +2 -0
  22. package/dist/app/index.html +12 -0
  23. package/dist/app/list-chart.js +36 -0
  24. package/dist/app/list-order.js +15 -0
  25. package/dist/app/list-page.js +213 -0
  26. package/dist/app/list-search.js +30 -0
  27. package/dist/app/main.d.ts +1 -0
  28. package/dist/app/main.js +14 -0
  29. package/dist/app/menu-icons.js +21 -0
  30. package/dist/app/meta-gate.js +46 -0
  31. package/dist/app/nav-from-meta.js +65 -0
  32. package/dist/app/nav-recent.js +33 -0
  33. package/dist/app/pages/action-href.js +7 -0
  34. package/dist/app/pages/blocks/action.js +38 -0
  35. package/dist/app/pages/blocks/block-props.js +5 -0
  36. package/dist/app/pages/blocks/card-frame.js +34 -0
  37. package/dist/app/pages/blocks/card-row.js +55 -0
  38. package/dist/app/pages/blocks/card.js +19 -0
  39. package/dist/app/pages/blocks/field.js +45 -0
  40. package/dist/app/pages/blocks/grid.js +34 -0
  41. package/dist/app/pages/blocks/link.js +27 -0
  42. package/dist/app/pages/blocks/modal-form.js +149 -0
  43. package/dist/app/pages/blocks/page-block.js +20 -0
  44. package/dist/app/pages/blocks/progress.js +54 -0
  45. package/dist/app/pages/blocks/record-card.js +56 -0
  46. package/dist/app/pages/blocks/show.js +43 -0
  47. package/dist/app/pages/blocks/stat.js +78 -0
  48. package/dist/app/pages/blocks/table.js +65 -0
  49. package/dist/app/pages/composed-page.js +19 -0
  50. package/dist/app/pages/confirm-action.js +20 -0
  51. package/dist/app/pages/field-value.js +58 -0
  52. package/dist/app/pages/project-ui.d.ts +30 -0
  53. package/dist/app/pages/project-ui.js +13 -0
  54. package/dist/app/pages/record-actions.js +40 -0
  55. package/dist/app/pages/record-scope.d.ts +16 -0
  56. package/dist/app/pages/record-scope.js +12 -0
  57. package/dist/app/pages/record-table.js +110 -0
  58. package/dist/app/pages/render-node.js +84 -0
  59. package/dist/app/pages/use-layout-data.js +73 -0
  60. package/dist/app/pages/use-path-value.js +65 -0
  61. package/dist/app/pages/use-relation-labels.js +30 -0
  62. package/dist/app/pages/use-run-action.js +88 -0
  63. package/dist/app/permissions.js +15 -0
  64. package/dist/app/record-lines.js +39 -0
  65. package/dist/app/record-page.js +318 -0
  66. package/dist/app/related-section.js +126 -0
  67. package/dist/app/relation-picker.js +69 -0
  68. package/dist/app/router.js +155 -0
  69. package/dist/app/search/search-model.js +70 -0
  70. package/dist/app/search/use-global-search.js +68 -0
  71. package/dist/app/sensitive-field.js +65 -0
  72. package/dist/app/use-lines-draft.js +45 -0
  73. package/dist/app/use-lines.js +19 -0
  74. package/dist/app/use-live-save.js +54 -0
  75. package/dist/app/use-nav-recent.js +41 -0
  76. package/dist/app/use-record-editor.js +154 -0
  77. package/dist/app/use-related-records.js +31 -0
  78. package/dist/app/user-menu-from-meta.js +36 -0
  79. package/dist/app-shell/app-shell.d.ts +4 -0
  80. package/dist/app-shell/app-shell.js +57 -0
  81. package/dist/app-shell/breadcrumb.js +37 -0
  82. package/dist/app-shell/drawer.js +30 -0
  83. package/dist/app-shell/global-search.d.ts +1 -0
  84. package/dist/app-shell/global-search.js +145 -0
  85. package/dist/app-shell/index.d.ts +9 -0
  86. package/dist/app-shell/logo.js +46 -0
  87. package/dist/app-shell/nav-open-storage.js +10 -0
  88. package/dist/app-shell/nav.d.ts +2 -0
  89. package/dist/app-shell/nav.js +85 -0
  90. package/dist/app-shell/page-header.d.ts +1 -0
  91. package/dist/app-shell/page-header.js +17 -0
  92. package/dist/app-shell/profile-menu.d.ts +2 -0
  93. package/dist/app-shell/profile-menu.js +115 -0
  94. package/dist/app-shell/sidebar-group.js +94 -0
  95. package/dist/app-shell/sidebar-item.js +46 -0
  96. package/dist/app-shell/sidebar-mode.d.ts +4 -0
  97. package/dist/app-shell/sidebar-mode.js +13 -0
  98. package/dist/app-shell/sidebar.d.ts +2 -0
  99. package/dist/app-shell/sidebar.js +64 -0
  100. package/dist/app-shell/status-dot.d.ts +1 -0
  101. package/dist/app-shell/status-dot.js +20 -0
  102. package/dist/app-shell/top-bar.js +82 -0
  103. package/dist/assistant-dock/assistant-dock.d.ts +15 -0
  104. package/dist/assistant-dock/assistant-dock.js +81 -0
  105. package/dist/assistant-dock/chat-message.js +9 -0
  106. package/dist/assistant-dock/composer.js +39 -0
  107. package/dist/assistant-dock/dock-item.d.ts +4 -0
  108. package/dist/assistant-dock/dock-item.js +37 -0
  109. package/dist/assistant-dock/model.d.ts +84 -0
  110. package/dist/assistant-dock/model.js +26 -0
  111. package/dist/assistant-dock/plan-card.d.ts +13 -0
  112. package/dist/assistant-dock/plan-card.js +133 -0
  113. package/dist/assistant-dock/progress-card.js +18 -0
  114. package/dist/assistant-dock/query-card.js +52 -0
  115. package/dist/assistant-dock/queue-card.js +31 -0
  116. package/dist/assistant-dock/result-card.js +77 -0
  117. package/dist/assistant-panel/content.d.ts +7 -0
  118. package/dist/assistant-panel/step-list.js +30 -0
  119. package/dist/auth/auth-gate.js +70 -0
  120. package/dist/auth/auth-layout.js +29 -0
  121. package/dist/auth/auth-provider.js +71 -0
  122. package/dist/auth/first-run-page.js +51 -0
  123. package/dist/auth/forgot-password-form.js +75 -0
  124. package/dist/auth/forgot-password-page.js +35 -0
  125. package/dist/auth/reset-link.js +29 -0
  126. package/dist/auth/reset-password-form.js +88 -0
  127. package/dist/auth/reset-password-page.js +43 -0
  128. package/dist/auth/sign-in-form.js +90 -0
  129. package/dist/auth/sign-in-page.js +39 -0
  130. package/dist/auth/user-to-shell.js +18 -0
  131. package/dist/data/api-provider.d.ts +54 -0
  132. package/dist/data/api-provider.js +53 -0
  133. package/dist/data/meta-data.d.ts +14 -0
  134. package/dist/data/meta-data.js +9 -0
  135. package/dist/data/query-keys.js +95 -0
  136. package/dist/data/use-aggregates.d.ts +8 -0
  137. package/dist/data/use-aggregates.js +43 -0
  138. package/dist/data/use-list.d.ts +231 -0
  139. package/dist/data/use-list.js +31 -0
  140. package/dist/data/use-meta.d.ts +8 -0
  141. package/dist/data/use-meta.js +35 -0
  142. package/dist/data/use-record.d.ts +6 -0
  143. package/dist/data/use-record.js +16 -0
  144. package/dist/data/use-update-record.d.ts +15 -0
  145. package/dist/data/use-update-record.js +26 -0
  146. package/dist/data-grid/cells.js +85 -0
  147. package/dist/data-grid/column-spec.js +4 -0
  148. package/dist/data-grid/columns.js +16 -0
  149. package/dist/data-grid/data-grid.js +238 -0
  150. package/dist/data-grid/geometry.js +32 -0
  151. package/dist/data-grid/grid-footer.js +33 -0
  152. package/dist/data-grid/grid-header.js +69 -0
  153. package/dist/data-grid/grid-row.js +83 -0
  154. package/dist/data-grid/grid-template.js +12 -0
  155. package/dist/data-grid/header-menu.js +52 -0
  156. package/dist/data-grid/page-cache.js +27 -0
  157. package/dist/data-grid/pinned-storage.js +13 -0
  158. package/dist/data-grid/row-menu.js +88 -0
  159. package/dist/data-grid/use-grid-scroll.js +80 -0
  160. package/dist/data-grid/use-horizontal-scroll.js +38 -0
  161. package/dist/data-grid/use-paged-rows.js +50 -0
  162. package/dist/data-grid/use-pinned-columns.js +16 -0
  163. package/dist/filter-panel/active-filter-chips.js +34 -0
  164. package/dist/filter-panel/date-presets.js +20 -0
  165. package/dist/filter-panel/facet-group.js +48 -0
  166. package/dist/filter-panel/filter-config.js +18 -0
  167. package/dist/filter-panel/filter-menu.js +41 -0
  168. package/dist/filter-panel/filter-panel.js +112 -0
  169. package/dist/filter-panel/filter-section.js +11 -0
  170. package/dist/filter-panel/filter-state.js +63 -0
  171. package/dist/filter-panel/range-filter.js +54 -0
  172. package/dist/filter-panel/range-math.js +11 -0
  173. package/dist/filter-panel/range-slider.css +32 -0
  174. package/dist/filter-panel/toggle-row.js +13 -0
  175. package/dist/filter-panel/use-filter-state.js +15 -0
  176. package/dist/format/date.js +37 -0
  177. package/dist/format/money.js +15 -0
  178. package/dist/format/number.js +12 -0
  179. package/dist/index.d.ts +14 -0
  180. package/dist/index.js +11 -0
  181. package/dist/inline-child-table/columns.js +4 -0
  182. package/dist/inline-child-table/inline-child-table.js +120 -0
  183. package/dist/inline-child-table/line-menu.js +54 -0
  184. package/dist/inline-child-table/line-row.js +91 -0
  185. package/dist/inline-child-table/number-cell.js +25 -0
  186. package/dist/inline-child-table/reorder.js +10 -0
  187. package/dist/inline-child-table/totals-footer.js +42 -0
  188. package/dist/lib/cn.js +4 -0
  189. package/dist/lib/guarded.js +12 -0
  190. package/dist/lib/use-debounced.js +13 -0
  191. package/dist/lib/use-focus-trap.js +46 -0
  192. package/dist/lib/use-media-query.js +12 -0
  193. package/dist/live/columns-from-model.js +93 -0
  194. package/dist/live/display-value.js +24 -0
  195. package/dist/live/field-values.js +32 -0
  196. package/dist/live/filter-string.js +135 -0
  197. package/dist/live/grid-loader.js +63 -0
  198. package/dist/live/model-helpers.js +30 -0
  199. package/dist/live/naming.js +9 -0
  200. package/dist/live/relation-labels.js +53 -0
  201. package/dist/live/use-filter-config.js +88 -0
  202. package/dist/live/use-live-rows.js +45 -0
  203. package/dist/live/view-fields.js +35 -0
  204. package/dist/mocks/activity.js +24 -0
  205. package/dist/mocks/invoices.js +13 -0
  206. package/dist/mocks/products.js +167 -0
  207. package/dist/mocks/rng.js +20 -0
  208. package/dist/mocks/users.js +63 -0
  209. package/dist/primitives/avatar.js +22 -0
  210. package/dist/primitives/badge.d.ts +1 -0
  211. package/dist/primitives/badge.js +25 -0
  212. package/dist/primitives/button.js +23 -0
  213. package/dist/primitives/checkbox.js +21 -0
  214. package/dist/primitives/dialog.js +41 -0
  215. package/dist/primitives/input.js +16 -0
  216. package/dist/primitives/kbd.js +8 -0
  217. package/dist/primitives/segmented.js +14 -0
  218. package/dist/primitives/sheet.js +44 -0
  219. package/dist/primitives/skeleton.js +6 -0
  220. package/dist/primitives/spinner.js +10 -0
  221. package/dist/primitives/switch.js +14 -0
  222. package/dist/primitives/tooltip.js +13 -0
  223. package/dist/record-view/action-rail.js +28 -0
  224. package/dist/record-view/field.js +62 -0
  225. package/dist/record-view/record-header.js +50 -0
  226. package/dist/record-view/record-sidebar.js +58 -0
  227. package/dist/record-view/section.js +21 -0
  228. package/dist/styles.css +132 -0
  229. package/dist/toasts/toast-provider.js +69 -0
  230. package/dist/toasts/toast.js +45 -0
  231. package/package.json +45 -3
  232. package/README.md +0 -3
@@ -0,0 +1,238 @@
1
+ import { cn } from "../lib/cn.js";
2
+ import { useIsDesktop } from "../lib/use-media-query.js";
3
+ import { buildColumns, features } from "./columns.js";
4
+ import { GridFooter } from "./grid-footer.js";
5
+ import { RowMenu, availableActions } from "./row-menu.js";
6
+ import { desktopMinScale, gridMinWidth, gutterWidthFor } from "./grid-template.js";
7
+ import { useHorizontalScroll } from "./use-horizontal-scroll.js";
8
+ import { usePinnedColumns } from "./use-pinned-columns.js";
9
+ import { GridHeader } from "./grid-header.js";
10
+ import { DataRow, SkeletonRow } from "./grid-row.js";
11
+ import { windowFor } from "./geometry.js";
12
+ import { useGridScroll } from "./use-grid-scroll.js";
13
+ import { usePagedRows } from "./use-paged-rows.js";
14
+ import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
15
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
16
+ import { useVirtualizer } from "@tanstack/react-virtual";
17
+ import { useTable } from "@tanstack/react-table";
18
+ //#region src/data-grid/data-grid.tsx
19
+ const headerHeight = 32;
20
+ const DataGrid = ({ columns: given, source, naturalSort, initialScrollIndex = 0, latencyMs = 150, sort: controlledSort, onSortChange, onOpenRow, rowActions, bulkActions, pinKey, defaultPinned, rowNumbers = true, compact = false, selectable = true, className }) => {
21
+ const rowHeight = compact ? 28 : 36;
22
+ const [pinned, setPinned] = usePinnedColumns(pinKey, defaultPinned);
23
+ const columns = useMemo(() => {
24
+ return [...pinned.flatMap((id) => given.find((spec) => spec.id === id) ?? []), ...given.filter((spec) => !pinned.includes(spec.id))];
25
+ }, [given, pinned]);
26
+ const pinnedIds = columns.slice(0, columns.filter((spec) => pinned.includes(spec.id)).length).map((spec) => spec.id);
27
+ const { total } = source;
28
+ const [localSorting, setLocalSorting] = useState(naturalSort ? [{
29
+ id: naturalSort.columnId,
30
+ desc: naturalSort.direction === "desc"
31
+ }] : []);
32
+ const sorting = controlledSort ? [{
33
+ id: controlledSort.columnId,
34
+ desc: controlledSort.direction === "desc"
35
+ }] : localSorting;
36
+ const setSorting = (updater) => {
37
+ const next = typeof updater === "function" ? updater(sorting) : updater;
38
+ const first = next[0];
39
+ if (onSortChange && first) onSortChange({
40
+ columnId: first.id,
41
+ direction: first.desc ? "desc" : "asc"
42
+ });
43
+ if (!controlledSort) setLocalSorting(next);
44
+ };
45
+ const [rowSelection, setRowSelection] = useState({});
46
+ const tableColumns = useMemo(() => buildColumns(columns), [columns]);
47
+ const sort = sorting[0];
48
+ const reverse = Boolean(!controlledSort && naturalSort && sort && sort.id === naturalSort.columnId && (sort.desc ? "desc" : "asc") !== naturalSort.direction);
49
+ const sourceRowAt = source.rowAt;
50
+ const rowAt = useCallback((index) => sourceRowAt(reverse ? total - 1 - index : index), [
51
+ sourceRowAt,
52
+ reverse,
53
+ total
54
+ ]);
55
+ const { ref, onScroll, viewport, top, geometry } = useGridScroll(total, rowHeight, initialScrollIndex);
56
+ const narrow = !useIsDesktop();
57
+ const gutter = narrow || !rowNumbers ? 40 : gutterWidthFor(total);
58
+ const bodyHeight = Math.max(0, viewport - headerHeight);
59
+ const bodyRef = useRef(null);
60
+ const offsetListener = useRef(null);
61
+ const { base, count } = windowFor(total, top, rowHeight);
62
+ const localOffset = top - base * rowHeight;
63
+ const virtualizer = useVirtualizer({
64
+ count,
65
+ getScrollElement: () => bodyRef.current,
66
+ estimateSize: () => rowHeight,
67
+ overscan: 6,
68
+ initialRect: {
69
+ width: 0,
70
+ height: bodyHeight
71
+ },
72
+ observeElementOffset: (_, callback) => {
73
+ offsetListener.current = callback;
74
+ callback(localOffset, false);
75
+ return () => {
76
+ offsetListener.current = null;
77
+ };
78
+ }
79
+ });
80
+ useLayoutEffect(() => offsetListener.current?.(localOffset, false), [localOffset, base]);
81
+ const items = virtualizer.getVirtualItems();
82
+ const firstIndex = base + (items[0]?.index ?? 0);
83
+ const lastIndex = base + (items[items.length - 1]?.index ?? 0);
84
+ const getRow = usePagedRows({
85
+ total,
86
+ rowAt: sourceRowAt ? rowAt : void 0,
87
+ loadRows: source.loadRows,
88
+ latencyMs
89
+ }, firstIndex, lastIndex);
90
+ const loaded = items.map((item) => ({
91
+ item,
92
+ index: base + item.index,
93
+ row: getRow(base + item.index)
94
+ }));
95
+ const data = useMemo(() => loaded.flatMap(({ row }) => row ? [row] : []), [
96
+ getRow,
97
+ firstIndex,
98
+ lastIndex
99
+ ]);
100
+ const table = useTable({
101
+ features,
102
+ columns: tableColumns,
103
+ data,
104
+ state: {
105
+ sorting,
106
+ rowSelection
107
+ },
108
+ onSortingChange: setSorting,
109
+ onRowSelectionChange: setRowSelection,
110
+ manualSorting: true,
111
+ enableMultiSort: false,
112
+ enableSortingRemoval: false,
113
+ getRowId: (row) => row.id
114
+ });
115
+ const tableRows = new Map(table.getRowModel().rows.map((row) => [row.id, row]));
116
+ const selectedCount = Object.keys(rowSelection).length;
117
+ const selectedRows = useRef(/* @__PURE__ */ new Map());
118
+ for (const row of table.getRowModel().rows) if (row.getIsSelected()) selectedRows.current.set(row.id, row.original);
119
+ const trailing = rowActions && rowActions.length > 0 ? 40 : 0;
120
+ const minScale = narrow ? 1 : desktopMinScale;
121
+ const horizontal = useHorizontalScroll();
122
+ const [pins, setPins] = useState({});
123
+ useLayoutEffect(() => {
124
+ const header = horizontal.ref.current?.querySelector("[role=row]");
125
+ if (!header) return;
126
+ let left = gutter;
127
+ const next = {};
128
+ for (const id of pinnedIds) {
129
+ next[id] = left;
130
+ left += header.querySelector(`[data-col-id="${id}"]`)?.offsetWidth ?? 0;
131
+ }
132
+ if (JSON.stringify(pins) !== JSON.stringify(next)) setPins(next);
133
+ });
134
+ const lastPinned = pinnedIds.at(-1);
135
+ const togglePin = (id) => setPinned(pinned.includes(id) ? pinned.filter((entry) => entry !== id) : [...pinned, id]);
136
+ const headerCells = table.getHeaderGroups().flatMap((group) => group.headers.map((header) => ({
137
+ spec: columns.find((spec) => spec.id === header.column.id),
138
+ sorted: header.column.getIsSorted(),
139
+ onSort: (event) => header.column.getToggleSortingHandler()?.(event)
140
+ })));
141
+ const visibleRows = Math.max(1, Math.floor(bodyHeight / rowHeight));
142
+ const first = Math.min(total, Math.floor(top / rowHeight) + 1);
143
+ const last = Math.min(total, first + visibleRows - 1);
144
+ return /* @__PURE__ */ jsxs("div", {
145
+ className: cn("flex min-h-0 flex-col overflow-hidden rounded-lg border bg-background", className),
146
+ children: [/* @__PURE__ */ jsx("div", {
147
+ ref,
148
+ onScroll,
149
+ className: "relative min-h-0 flex-1 overflow-y-auto overflow-x-hidden",
150
+ role: "grid",
151
+ "aria-rowcount": total,
152
+ children: /* @__PURE__ */ jsx("div", {
153
+ style: { height: geometry.contentHeight || void 0 },
154
+ children: /* @__PURE__ */ jsx("div", {
155
+ className: "sticky top-0",
156
+ style: { height: viewport },
157
+ children: viewport > 0 && /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("div", {
158
+ ref: horizontal.attach,
159
+ onScroll: horizontal.onScroll,
160
+ className: "h-full overflow-x-auto overflow-y-hidden overscroll-x-contain [-webkit-overflow-scrolling:touch]",
161
+ children: /* @__PURE__ */ jsxs("div", {
162
+ className: "flex h-full flex-col",
163
+ style: { minWidth: gridMinWidth(columns, gutter, trailing, minScale) },
164
+ children: [/* @__PURE__ */ jsx(GridHeader, {
165
+ cells: headerCells,
166
+ specs: columns,
167
+ gutter,
168
+ trailing,
169
+ minScale,
170
+ pins,
171
+ lastPinned,
172
+ scrolled: horizontal.scrolled,
173
+ onTogglePin: togglePin,
174
+ selectable,
175
+ selectedCount,
176
+ onClearSelection: () => setRowSelection({})
177
+ }), /* @__PURE__ */ jsx("div", {
178
+ ref: bodyRef,
179
+ className: "relative min-h-0 flex-1 overflow-clip",
180
+ children: loaded.map(({ item, index, row }) => {
181
+ const frame = {
182
+ specs: columns,
183
+ gutter,
184
+ index,
185
+ offset: item.start - localOffset,
186
+ height: rowHeight,
187
+ selectable,
188
+ trailing,
189
+ minScale,
190
+ scrolled: horizontal.scrolled,
191
+ pins,
192
+ lastPinned
193
+ };
194
+ const tableRow = row ? tableRows.get(row.id) : void 0;
195
+ if (!tableRow) return /* @__PURE__ */ jsx(SkeletonRow, { ...frame }, `skeleton-${index}`);
196
+ const actions = trailing && row ? availableActions(rowActions, row) : [];
197
+ return /* @__PURE__ */ jsx(DataRow, {
198
+ ...frame,
199
+ selected: tableRow.getIsSelected(),
200
+ onToggle: () => tableRow.toggleSelected(),
201
+ onOpen: onOpenRow && row ? () => onOpenRow(row) : void 0,
202
+ menu: row && actions.length > 0 ? /* @__PURE__ */ jsx(RowMenu, {
203
+ row,
204
+ actions
205
+ }) : void 0,
206
+ cells: tableRow.getAllCells().map((cell) => ({
207
+ spec: columns.find((spec) => spec.id === cell.column.id),
208
+ content: /* @__PURE__ */ jsx(table.FlexRender, { cell })
209
+ }))
210
+ }, row?.id);
211
+ })
212
+ })]
213
+ })
214
+ }), horizontal.more && /* @__PURE__ */ jsx("div", {
215
+ "aria-hidden": true,
216
+ className: "pointer-events-none absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-foreground/15 to-transparent"
217
+ })] })
218
+ })
219
+ })
220
+ }), /* @__PURE__ */ jsx(GridFooter, {
221
+ total,
222
+ first,
223
+ last,
224
+ selectedCount,
225
+ bulkActions: bulkActions?.map((action) => ({
226
+ label: action.label(selectedCount),
227
+ destructive: action.destructive,
228
+ onRun: () => {
229
+ action.onRun(Object.keys(rowSelection).flatMap((id) => selectedRows.current.get(id) ?? []));
230
+ setRowSelection({});
231
+ selectedRows.current.clear();
232
+ }
233
+ }))
234
+ })]
235
+ });
236
+ };
237
+ //#endregion
238
+ export { DataGrid };
@@ -0,0 +1,32 @@
1
+ //#region src/data-grid/geometry.ts
2
+ /** Browsers cap element height (about 17M px in the strictest); stay well below it. */
3
+ const maxContentHeight = 1e7;
4
+ const chunk = 2e3;
5
+ const windowSize = 5e3;
6
+ const gridGeometry = (total, rowHeight, viewportHeight) => {
7
+ const trueHeight = total * rowHeight;
8
+ const contentHeight = Math.min(trueHeight, maxContentHeight);
9
+ return {
10
+ rowHeight,
11
+ trueHeight,
12
+ contentHeight,
13
+ scaled: trueHeight > contentHeight,
14
+ maxTop: Math.max(0, trueHeight - viewportHeight),
15
+ maxScroll: Math.max(0, contentHeight - viewportHeight)
16
+ };
17
+ };
18
+ /** Maps the native scroll position onto the virtual (true) pixel offset. */
19
+ const topFromScroll = (geometry, scrollTop) => geometry.maxScroll === 0 ? 0 : scrollTop / geometry.maxScroll * geometry.maxTop;
20
+ const scrollFromTop = (geometry, top) => geometry.maxTop === 0 ? 0 : top / geometry.maxTop * geometry.maxScroll;
21
+ const clampTop = (geometry, top) => Math.min(Math.max(0, top), geometry.maxTop);
22
+ /** The slice of rows handed to the virtualizer; it moves in whole chunks so row measurements stay small. */
23
+ const windowFor = (total, top, rowHeight) => {
24
+ const topRow = Math.floor(top / rowHeight);
25
+ const base = Math.min(Math.max(0, Math.floor(topRow / chunk) * chunk - chunk), Math.max(0, total - windowSize));
26
+ return {
27
+ base,
28
+ count: Math.min(windowSize, total - base)
29
+ };
30
+ };
31
+ //#endregion
32
+ export { clampTop, gridGeometry, maxContentHeight, scrollFromTop, topFromScroll, windowFor };
@@ -0,0 +1,33 @@
1
+ import { cn } from "../lib/cn.js";
2
+ import { formatApproxRows, formatInt } from "../format/number.js";
3
+ import { jsx, jsxs } from "react/jsx-runtime";
4
+ //#region src/data-grid/grid-footer.tsx
5
+ const GridFooter = ({ total, first, last, selectedCount, bulkActions = [] }) => /* @__PURE__ */ jsxs("div", {
6
+ className: "flex h-9 shrink-0 items-center justify-between border-t bg-surface px-3 text-xs text-muted-foreground",
7
+ children: [/* @__PURE__ */ jsxs("span", {
8
+ className: "flex items-center tabular-nums",
9
+ children: [
10
+ formatApproxRows(total),
11
+ selectedCount > 0 && /* @__PURE__ */ jsxs("span", {
12
+ className: "ml-3 font-medium text-primary-text",
13
+ children: [formatInt(selectedCount), " selected"]
14
+ }),
15
+ selectedCount > 0 && bulkActions.map((action) => /* @__PURE__ */ jsx("button", {
16
+ type: "button",
17
+ onClick: action.onRun,
18
+ className: cn("ml-3 rounded border bg-background px-2 py-0.5 font-medium hover:bg-muted", action.destructive ? "border-danger/40 text-danger-text" : "text-foreground"),
19
+ children: action.label
20
+ }, action.label))
21
+ ]
22
+ }), /* @__PURE__ */ jsxs("span", {
23
+ className: "tabular-nums",
24
+ children: [
25
+ "Rows ",
26
+ formatInt(first),
27
+ " – ",
28
+ formatInt(last)
29
+ ]
30
+ })]
31
+ });
32
+ //#endregion
33
+ export { GridFooter };
@@ -0,0 +1,69 @@
1
+ import { cn } from "../lib/cn.js";
2
+ import { gridTemplate } from "./grid-template.js";
3
+ import { Checkbox } from "../primitives/checkbox.js";
4
+ import { isRightAligned } from "./column-spec.js";
5
+ import { HeaderMenu } from "./header-menu.js";
6
+ import { jsx, jsxs } from "react/jsx-runtime";
7
+ import { ArrowDown, ArrowUp, ChevronsUpDown } from "lucide-react";
8
+ //#region src/data-grid/grid-header.tsx
9
+ const SortIcon = ({ sorted }) => {
10
+ if (sorted === "asc") return /* @__PURE__ */ jsx(ArrowUp, { className: "size-3 text-foreground" });
11
+ if (sorted === "desc") return /* @__PURE__ */ jsx(ArrowDown, { className: "size-3 text-foreground" });
12
+ return /* @__PURE__ */ jsx(ChevronsUpDown, { className: "size-3 opacity-0 group-hover/head:opacity-100" });
13
+ };
14
+ const GridHeader = ({ cells, specs, gutter, selectedCount, selectable, trailing, minScale, pins = {}, lastPinned, scrolled, onTogglePin, onClearSelection }) => /* @__PURE__ */ jsxs("div", {
15
+ className: "grid h-8 shrink-0 items-center border-b bg-surface text-xs font-medium text-muted-foreground",
16
+ style: { gridTemplateColumns: gridTemplate(specs, gutter, trailing, minScale) },
17
+ role: "row",
18
+ children: [
19
+ /* @__PURE__ */ jsxs("div", {
20
+ className: cn("sticky left-0 z-10 flex h-full items-center gap-2 bg-inherit pl-3", scrolled && !lastPinned && "shadow-[1px_0_0_var(--border),4px_0_6px_-2px_rgb(0_0_0/0.12)]"),
21
+ style: { width: gutter },
22
+ children: [selectable && /* @__PURE__ */ jsx(Checkbox, {
23
+ checked: false,
24
+ indeterminate: selectedCount > 0,
25
+ label: "Clear selection",
26
+ onChange: onClearSelection,
27
+ className: cn(selectedCount === 0 && "opacity-60")
28
+ }), gutter > 40 && /* @__PURE__ */ jsx("span", {
29
+ className: "w-6 text-right text-faint-foreground",
30
+ children: "#"
31
+ })]
32
+ }),
33
+ cells.map(({ spec, sorted, onSort }) => {
34
+ const pinned = spec.id in pins;
35
+ return /* @__PURE__ */ jsxs("div", {
36
+ "data-col-id": spec.id,
37
+ style: pinned ? { left: pins[spec.id] } : void 0,
38
+ className: cn("group/cell flex h-full min-w-0 items-center", pinned && "sticky z-10 bg-inherit", pinned && scrolled && spec.id === lastPinned && "shadow-[1px_0_0_var(--border),4px_0_6px_-2px_rgb(0_0_0/0.12)]", spec.highlight && "bg-primary-soft text-primary-text"),
39
+ children: [/* @__PURE__ */ jsxs("button", {
40
+ type: "button",
41
+ onClick: onSort,
42
+ disabled: !spec.sortable,
43
+ className: cn("group/head flex h-full min-w-0 flex-1 items-center gap-1 pl-3 pr-1 text-left", isRightAligned(spec.kind) && "justify-end", spec.sortable && "hover:text-foreground", sorted && "text-foreground"),
44
+ children: [
45
+ /* @__PURE__ */ jsx("span", {
46
+ className: "truncate",
47
+ children: spec.header
48
+ }),
49
+ spec.highlight && /* @__PURE__ */ jsx("span", {
50
+ className: "rounded bg-primary px-1 text-[10px] font-semibold uppercase leading-4 text-primary-foreground",
51
+ children: "New"
52
+ }),
53
+ spec.sortable && /* @__PURE__ */ jsx(SortIcon, { sorted })
54
+ ]
55
+ }), /* @__PURE__ */ jsx("div", {
56
+ className: "pr-1.5",
57
+ children: /* @__PURE__ */ jsx(HeaderMenu, {
58
+ header: spec.header,
59
+ pinned,
60
+ onTogglePin: () => onTogglePin(spec.id)
61
+ })
62
+ })]
63
+ }, spec.id);
64
+ }),
65
+ trailing ? /* @__PURE__ */ jsx("div", {}) : null
66
+ ]
67
+ });
68
+ //#endregion
69
+ export { GridHeader };
@@ -0,0 +1,83 @@
1
+ import { cn } from "../lib/cn.js";
2
+ import { formatInt } from "../format/number.js";
3
+ import { gridTemplate } from "./grid-template.js";
4
+ import { Checkbox } from "../primitives/checkbox.js";
5
+ import { isRightAligned } from "./column-spec.js";
6
+ import { Skeleton } from "../primitives/skeleton.js";
7
+ import { jsx, jsxs } from "react/jsx-runtime";
8
+ //#region src/data-grid/grid-row.tsx
9
+ const frameStyle = (frame) => ({
10
+ gridTemplateColumns: gridTemplate(frame.specs, frame.gutter, frame.trailing, frame.minScale),
11
+ height: frame.height,
12
+ transform: `translateY(${frame.offset}px)`
13
+ });
14
+ /** Pinned cells need a solid background; `bg-inherit` takes the row's, so hover and selection still show. */
15
+ const cellClass = (spec, frame) => cn("flex h-full min-w-0 items-center overflow-hidden whitespace-nowrap px-3", isRightAligned(spec.kind) && "justify-end", spec.highlight && "bg-primary-soft/60", frame.pins && spec.id in frame.pins && "sticky z-10 bg-inherit", frame.scrolled && spec.id === frame.lastPinned && "shadow-[1px_0_0_var(--border),4px_0_6px_-2px_rgb(0_0_0/0.12)]");
16
+ const pinStyle = (spec, pins) => pins && spec.id in pins ? { left: pins[spec.id] } : void 0;
17
+ /** The selection column never scrolls away, so rows can be selected from anywhere in a wide grid. */
18
+ const Gutter = ({ width, index, selectable, selected, onToggle, edge }) => /* @__PURE__ */ jsxs("div", {
19
+ className: cn("sticky left-0 z-10 flex h-full items-center gap-2 bg-inherit pl-3", edge && "shadow-[1px_0_0_var(--border),4px_0_6px_-2px_rgb(0_0_0/0.12)]"),
20
+ style: { width },
21
+ children: [selectable && /* @__PURE__ */ jsx("span", {
22
+ onClick: (event) => event.stopPropagation(),
23
+ children: /* @__PURE__ */ jsx(Checkbox, {
24
+ checked: Boolean(selected),
25
+ onChange: onToggle,
26
+ label: `Select row ${index + 1}`
27
+ })
28
+ }), width > 40 && /* @__PURE__ */ jsx("span", {
29
+ className: "min-w-6 text-right text-[11px] tabular-nums text-faint-foreground",
30
+ children: formatInt(index + 1)
31
+ })]
32
+ });
33
+ const DataRow = ({ cells, selected, onToggle, onOpen, menu, ...frame }) => /* @__PURE__ */ jsxs("div", {
34
+ role: "row",
35
+ onClick: onOpen,
36
+ className: cn("absolute inset-x-0 top-0 grid items-center border-b text-[13px]", selected ? "bg-primary-soft" : "bg-background hover:bg-surface", onOpen && "cursor-pointer"),
37
+ style: frameStyle(frame),
38
+ children: [
39
+ /* @__PURE__ */ jsx(Gutter, {
40
+ width: frame.gutter,
41
+ index: frame.index,
42
+ selectable: frame.selectable,
43
+ selected,
44
+ onToggle,
45
+ edge: frame.scrolled && !frame.lastPinned
46
+ }),
47
+ cells.map(({ spec, content }) => /* @__PURE__ */ jsx("div", {
48
+ role: "cell",
49
+ className: cellClass(spec, frame),
50
+ style: pinStyle(spec, frame.pins),
51
+ children: content
52
+ }, spec.id)),
53
+ menu && /* @__PURE__ */ jsx("div", {
54
+ className: "flex h-full items-center justify-center",
55
+ children: menu
56
+ })
57
+ ]
58
+ });
59
+ const skeletonWidths = [
60
+ "w-3/4",
61
+ "w-1/2",
62
+ "w-2/3",
63
+ "w-5/6",
64
+ "w-2/5"
65
+ ];
66
+ const SkeletonRow = (frame) => /* @__PURE__ */ jsxs("div", {
67
+ role: "row",
68
+ "aria-busy": true,
69
+ className: "absolute inset-x-0 top-0 grid items-center border-b bg-background",
70
+ style: frameStyle(frame),
71
+ children: [/* @__PURE__ */ jsx(Gutter, {
72
+ width: frame.gutter,
73
+ index: frame.index,
74
+ selectable: false,
75
+ edge: frame.scrolled && !frame.lastPinned
76
+ }), frame.specs.map((spec, column) => /* @__PURE__ */ jsx("div", {
77
+ className: cellClass(spec, frame),
78
+ style: pinStyle(spec, frame.pins),
79
+ children: /* @__PURE__ */ jsx(Skeleton, { className: cn("h-2.5", skeletonWidths[(frame.index + column) % skeletonWidths.length], isRightAligned(spec.kind) && "ml-auto") })
80
+ }, spec.id))]
81
+ });
82
+ //#endregion
83
+ export { DataRow, SkeletonRow };
@@ -0,0 +1,12 @@
1
+ //#region src/data-grid/grid-template.ts
2
+ /** Wide enough for the checkbox plus the largest row number, with thousands separators. */
3
+ const gutterWidthFor = (total) => 52 + Math.ceil(String(total).length * 1.33) * 7;
4
+ /** Columns may shrink to this share of their width on desktop; on narrow screens they keep it whole and the grid scrolls. */
5
+ const desktopMinScale = .6;
6
+ const minWidthOf = (spec, minScale) => Math.max(spec.minWidth ?? 0, spec.width * minScale);
7
+ const columnTemplate = (spec, minScale) => spec.fixed ? `${Math.max(spec.minWidth ?? 0, spec.width)}px` : `minmax(${minWidthOf(spec, minScale)}px, ${spec.width}fr)`;
8
+ const gridTemplate = (specs, gutter, trailing = 0, minScale = desktopMinScale) => `${gutter}px ${specs.map((spec) => columnTemplate(spec, minScale)).join(" ")}${trailing > 0 ? ` ${trailing}px` : ""}`;
9
+ /** The narrowest the grid can be before it has to scroll sideways. */
10
+ const gridMinWidth = (specs, gutter, trailing = 0, minScale = desktopMinScale) => gutter + trailing + specs.reduce((sum, spec) => sum + (spec.fixed ? Math.max(spec.minWidth ?? 0, spec.width) : minWidthOf(spec, minScale)), 0);
11
+ //#endregion
12
+ export { desktopMinScale, gridMinWidth, gridTemplate, gutterWidthFor };
@@ -0,0 +1,52 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { MoreHorizontal } from "lucide-react";
4
+ //#region src/data-grid/header-menu.tsx
5
+ /** The "..." in a column header: for now, pinning the column to the left edge. */
6
+ const HeaderMenu = ({ header, pinned, onTogglePin }) => {
7
+ const [open, setOpen] = useState(false);
8
+ const root = useRef(null);
9
+ useEffect(() => {
10
+ if (!open) return;
11
+ const close = (event) => {
12
+ if (event instanceof KeyboardEvent ? event.key === "Escape" : !root.current?.contains(event.target)) setOpen(false);
13
+ };
14
+ document.addEventListener("mousedown", close);
15
+ document.addEventListener("keydown", close);
16
+ return () => {
17
+ document.removeEventListener("mousedown", close);
18
+ document.removeEventListener("keydown", close);
19
+ };
20
+ }, [open]);
21
+ return /* @__PURE__ */ jsxs("div", {
22
+ ref: root,
23
+ className: "relative ml-auto shrink-0",
24
+ children: [/* @__PURE__ */ jsx("button", {
25
+ type: "button",
26
+ "aria-label": `${header} column menu`,
27
+ "aria-haspopup": "menu",
28
+ "aria-expanded": open,
29
+ onClick: () => setOpen(!open),
30
+ className: "relative inline-flex size-5 items-center justify-center rounded text-faint-foreground hover:bg-muted hover:text-foreground focus-visible:opacity-100 after:absolute after:-inset-2 after:content-[''] md:opacity-0 md:group-hover/cell:opacity-100 aria-expanded:opacity-100",
31
+ children: /* @__PURE__ */ jsx(MoreHorizontal, { className: "size-3.5" })
32
+ }), open && /* @__PURE__ */ jsx("ul", {
33
+ role: "menu",
34
+ className: "absolute right-0 top-full z-30 mt-1 min-w-32 rounded-md border bg-background py-1 text-[13px] font-normal text-foreground shadow-pop",
35
+ children: /* @__PURE__ */ jsx("li", {
36
+ role: "none",
37
+ children: /* @__PURE__ */ jsx("button", {
38
+ type: "button",
39
+ role: "menuitem",
40
+ className: "block min-h-9 w-full whitespace-nowrap px-3 py-1.5 text-left hover:bg-muted",
41
+ onClick: () => {
42
+ setOpen(false);
43
+ onTogglePin();
44
+ },
45
+ children: pinned ? "Unpin" : "Pin to left"
46
+ })
47
+ })
48
+ })]
49
+ });
50
+ };
51
+ //#endregion
52
+ export { HeaderMenu };
@@ -0,0 +1,27 @@
1
+ //#region src/data-grid/page-cache.ts
2
+ /** Holds fixed-size pages of rows; evicts the oldest pages beyond `maxPages`. */
3
+ const createPageCache = (pageSize, maxPages) => {
4
+ const pages = /* @__PURE__ */ new Map();
5
+ return {
6
+ get: (index) => pages.get(Math.floor(index / pageSize))?.[index % pageSize],
7
+ hasPage: (page) => pages.has(page),
8
+ setPage: (page, rows) => {
9
+ pages.delete(page);
10
+ pages.set(page, rows);
11
+ if (pages.size <= maxPages) return;
12
+ const oldest = pages.keys().next();
13
+ if (oldest.done) return;
14
+ pages.delete(oldest.value);
15
+ },
16
+ get size() {
17
+ return pages.size;
18
+ }
19
+ };
20
+ };
21
+ const pagesForRange = (start, end, pageSize) => {
22
+ const first = Math.floor(start / pageSize);
23
+ const last = Math.floor(end / pageSize);
24
+ return Array.from({ length: last - first + 1 }, (_, offset) => first + offset);
25
+ };
26
+ //#endregion
27
+ export { createPageCache, pagesForRange };
@@ -0,0 +1,13 @@
1
+ import { guarded } from "../lib/guarded.js";
2
+ //#region src/data-grid/pinned-storage.ts
3
+ const prefix = "protobase:pinned:";
4
+ const pinnedStorageKey = (userId, resource) => `${prefix}${userId}:${resource}`;
5
+ const readPinned = (storage, key) => guarded(() => {
6
+ const raw = storage.getItem(key);
7
+ if (raw === null) return void 0;
8
+ const parsed = JSON.parse(raw);
9
+ return Array.isArray(parsed) && parsed.every((entry) => typeof entry === "string") ? parsed : void 0;
10
+ }, void 0);
11
+ const writePinned = (storage, key, pinned) => guarded(() => pinned.length === 0 ? storage.removeItem(key) : storage.setItem(key, JSON.stringify(pinned)), void 0);
12
+ //#endregion
13
+ export { pinnedStorageKey, readPinned, writePinned };
@@ -0,0 +1,88 @@
1
+ import { cn } from "../lib/cn.js";
2
+ import { useEffect, useLayoutEffect, useRef, useState } from "react";
3
+ import { jsx, jsxs } from "react/jsx-runtime";
4
+ import { MoreHorizontal } from "lucide-react";
5
+ import { createPortal } from "react-dom";
6
+ //#region src/data-grid/row-menu.tsx
7
+ /** The actions that apply to a row; a row with none gets no menu at all. */
8
+ const availableActions = (actions, row) => actions.filter((action) => action.available?.(row) ?? true);
9
+ const gap = 4;
10
+ /** Below the button, or above it when the viewport has no room underneath. */
11
+ const placeMenu = (button, menuHeight) => {
12
+ const below = button.bottom + gap;
13
+ return {
14
+ top: below + menuHeight > window.innerHeight - gap ? Math.max(gap, button.top - gap - menuHeight) : below,
15
+ right: window.innerWidth - button.right
16
+ };
17
+ };
18
+ /**
19
+ * The "..." button at the end of a row, with a small menu of the row's actions. The menu is portalled to the body:
20
+ * every grid row is its own stacking context and the grid body clips, so a menu inside the row would sit under the
21
+ * rows below it.
22
+ */
23
+ const RowMenu = ({ row, actions }) => {
24
+ const [open, setOpen] = useState(false);
25
+ const [place, setPlace] = useState();
26
+ const button = useRef(null);
27
+ const menu = useRef(null);
28
+ useLayoutEffect(() => {
29
+ if (!open || !button.current || !menu.current) {
30
+ setPlace(void 0);
31
+ return;
32
+ }
33
+ setPlace(placeMenu(button.current.getBoundingClientRect(), menu.current.offsetHeight));
34
+ }, [open]);
35
+ useEffect(() => {
36
+ if (!open) return;
37
+ const inside = (target) => button.current?.contains(target) || menu.current?.contains(target);
38
+ const close = (event) => {
39
+ if (event instanceof KeyboardEvent ? event.key === "Escape" : !inside(event.target)) setOpen(false);
40
+ };
41
+ const dismiss = () => setOpen(false);
42
+ document.addEventListener("mousedown", close);
43
+ document.addEventListener("keydown", close);
44
+ document.addEventListener("scroll", dismiss, true);
45
+ window.addEventListener("resize", dismiss);
46
+ return () => {
47
+ document.removeEventListener("mousedown", close);
48
+ document.removeEventListener("keydown", close);
49
+ document.removeEventListener("scroll", dismiss, true);
50
+ window.removeEventListener("resize", dismiss);
51
+ };
52
+ }, [open]);
53
+ return /* @__PURE__ */ jsxs("div", {
54
+ className: "relative flex justify-center bg-inherit",
55
+ onClick: (event) => event.stopPropagation(),
56
+ children: [/* @__PURE__ */ jsx("button", {
57
+ ref: button,
58
+ type: "button",
59
+ "aria-label": "Row actions",
60
+ "aria-haspopup": "menu",
61
+ "aria-expanded": open,
62
+ onClick: () => setOpen(!open),
63
+ className: "inline-flex items-center justify-center rounded p-1 text-faint-foreground hover:bg-muted hover:text-foreground pointer-coarse:size-11",
64
+ children: /* @__PURE__ */ jsx(MoreHorizontal, { className: "size-4" })
65
+ }), open && createPortal(/* @__PURE__ */ jsx("ul", {
66
+ ref: menu,
67
+ role: "menu",
68
+ onClick: (event) => event.stopPropagation(),
69
+ style: place ?? { visibility: "hidden" },
70
+ className: "fixed z-50 min-w-36 rounded-md border bg-background py-1 text-[13px] shadow-pop",
71
+ children: actions.map((action) => /* @__PURE__ */ jsx("li", {
72
+ role: "none",
73
+ children: /* @__PURE__ */ jsx("button", {
74
+ type: "button",
75
+ role: "menuitem",
76
+ className: cn("block w-full px-3 py-1.5 text-left hover:bg-muted", action.destructive && "text-danger-text"),
77
+ onClick: () => {
78
+ setOpen(false);
79
+ action.onSelect(row);
80
+ },
81
+ children: action.label
82
+ })
83
+ }, action.label))
84
+ }), document.body)]
85
+ });
86
+ };
87
+ //#endregion
88
+ export { RowMenu, availableActions };