@protobase/ui 0.0.0-stage → 0.1.0
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.
- package/LICENSE +202 -0
- package/dist/activity-chart/activity-chart.js +125 -0
- package/dist/activity-chart/activity-math.js +11 -0
- package/dist/activity-chart/activity-tooltip.js +24 -0
- package/dist/app/action-icon.js +10 -0
- package/dist/app/app.d.ts +24 -0
- package/dist/app/app.js +118 -0
- package/dist/app/batch-failure.js +21 -0
- package/dist/app/breadcrumb-for.js +27 -0
- package/dist/app/create/create-page.js +118 -0
- package/dist/app/create/create-record-form.js +68 -0
- package/dist/app/create/create-values.js +52 -0
- package/dist/app/delete/dialogs.js +109 -0
- package/dist/app/delete/record-diff.js +17 -0
- package/dist/app/delete/use-delete-flow.js +193 -0
- package/dist/app/delete/use-restore.js +9 -0
- package/dist/app/draft-storage.js +18 -0
- package/dist/app/error-banner.js +28 -0
- package/dist/app/field-editor.js +88 -0
- package/dist/app/icons.js +27 -0
- package/dist/app/index.d.ts +2 -0
- package/dist/app/index.html +12 -0
- package/dist/app/list-chart.js +36 -0
- package/dist/app/list-order.js +15 -0
- package/dist/app/list-page.js +213 -0
- package/dist/app/list-search.js +30 -0
- package/dist/app/main.d.ts +1 -0
- package/dist/app/main.js +14 -0
- package/dist/app/menu-icons.js +21 -0
- package/dist/app/meta-gate.js +46 -0
- package/dist/app/nav-from-meta.js +65 -0
- package/dist/app/nav-recent.js +33 -0
- package/dist/app/pages/action-href.js +7 -0
- package/dist/app/pages/blocks/action.js +38 -0
- package/dist/app/pages/blocks/block-props.js +5 -0
- package/dist/app/pages/blocks/card-frame.js +34 -0
- package/dist/app/pages/blocks/card-row.js +55 -0
- package/dist/app/pages/blocks/card.js +19 -0
- package/dist/app/pages/blocks/field.js +45 -0
- package/dist/app/pages/blocks/grid.js +34 -0
- package/dist/app/pages/blocks/link.js +27 -0
- package/dist/app/pages/blocks/modal-form.js +149 -0
- package/dist/app/pages/blocks/page-block.js +20 -0
- package/dist/app/pages/blocks/progress.js +54 -0
- package/dist/app/pages/blocks/record-card.js +56 -0
- package/dist/app/pages/blocks/show.js +43 -0
- package/dist/app/pages/blocks/stat.js +78 -0
- package/dist/app/pages/blocks/table.js +65 -0
- package/dist/app/pages/composed-page.js +19 -0
- package/dist/app/pages/confirm-action.js +20 -0
- package/dist/app/pages/field-value.js +58 -0
- package/dist/app/pages/project-ui.d.ts +25 -0
- package/dist/app/pages/project-ui.js +13 -0
- package/dist/app/pages/record-actions.js +40 -0
- package/dist/app/pages/record-scope.d.ts +16 -0
- package/dist/app/pages/record-scope.js +12 -0
- package/dist/app/pages/record-table.js +110 -0
- package/dist/app/pages/render-node.js +84 -0
- package/dist/app/pages/use-layout-data.js +73 -0
- package/dist/app/pages/use-path-value.js +65 -0
- package/dist/app/pages/use-relation-labels.js +30 -0
- package/dist/app/pages/use-run-action.js +88 -0
- package/dist/app/permissions.js +15 -0
- package/dist/app/record-lines.js +39 -0
- package/dist/app/record-page.js +318 -0
- package/dist/app/related-section.js +126 -0
- package/dist/app/relation-picker.js +69 -0
- package/dist/app/router.js +155 -0
- package/dist/app/search/search-model.js +70 -0
- package/dist/app/search/use-global-search.js +68 -0
- package/dist/app/sensitive-field.js +65 -0
- package/dist/app/use-lines-draft.js +45 -0
- package/dist/app/use-lines.js +19 -0
- package/dist/app/use-live-save.js +54 -0
- package/dist/app/use-nav-recent.js +41 -0
- package/dist/app/use-record-editor.js +154 -0
- package/dist/app/use-related-records.js +31 -0
- package/dist/app/user-menu-from-meta.js +36 -0
- package/dist/app-shell/app-shell.d.ts +4 -0
- package/dist/app-shell/app-shell.js +57 -0
- package/dist/app-shell/breadcrumb.js +37 -0
- package/dist/app-shell/drawer.js +30 -0
- package/dist/app-shell/global-search.d.ts +1 -0
- package/dist/app-shell/global-search.js +145 -0
- package/dist/app-shell/index.d.ts +9 -0
- package/dist/app-shell/logo.js +46 -0
- package/dist/app-shell/nav-open-storage.js +10 -0
- package/dist/app-shell/nav.d.ts +2 -0
- package/dist/app-shell/nav.js +85 -0
- package/dist/app-shell/page-header.d.ts +1 -0
- package/dist/app-shell/page-header.js +17 -0
- package/dist/app-shell/profile-menu.d.ts +2 -0
- package/dist/app-shell/profile-menu.js +115 -0
- package/dist/app-shell/sidebar-group.js +94 -0
- package/dist/app-shell/sidebar-item.js +46 -0
- package/dist/app-shell/sidebar-mode.d.ts +4 -0
- package/dist/app-shell/sidebar-mode.js +13 -0
- package/dist/app-shell/sidebar.d.ts +2 -0
- package/dist/app-shell/sidebar.js +64 -0
- package/dist/app-shell/status-dot.d.ts +1 -0
- package/dist/app-shell/status-dot.js +20 -0
- package/dist/app-shell/top-bar.js +82 -0
- package/dist/auth/auth-gate.js +70 -0
- package/dist/auth/auth-layout.js +29 -0
- package/dist/auth/auth-provider.js +64 -0
- package/dist/auth/first-run-page.js +51 -0
- package/dist/auth/forgot-password-form.js +75 -0
- package/dist/auth/forgot-password-page.js +35 -0
- package/dist/auth/reset-link.js +29 -0
- package/dist/auth/reset-password-form.js +88 -0
- package/dist/auth/reset-password-page.js +43 -0
- package/dist/auth/sign-in-form.js +74 -0
- package/dist/auth/sign-in-page.js +38 -0
- package/dist/auth/user-to-shell.js +18 -0
- package/dist/data/api-provider.d.ts +54 -0
- package/dist/data/api-provider.js +53 -0
- package/dist/data/meta-data.d.ts +14 -0
- package/dist/data/meta-data.js +9 -0
- package/dist/data/query-keys.js +95 -0
- package/dist/data/use-aggregates.d.ts +8 -0
- package/dist/data/use-aggregates.js +43 -0
- package/dist/data/use-list.d.ts +231 -0
- package/dist/data/use-list.js +31 -0
- package/dist/data/use-meta.d.ts +8 -0
- package/dist/data/use-meta.js +35 -0
- package/dist/data/use-record.d.ts +6 -0
- package/dist/data/use-record.js +16 -0
- package/dist/data/use-update-record.d.ts +15 -0
- package/dist/data/use-update-record.js +26 -0
- package/dist/data-grid/cells.js +85 -0
- package/dist/data-grid/column-spec.js +4 -0
- package/dist/data-grid/columns.js +16 -0
- package/dist/data-grid/data-grid.js +238 -0
- package/dist/data-grid/geometry.js +32 -0
- package/dist/data-grid/grid-footer.js +33 -0
- package/dist/data-grid/grid-header.js +69 -0
- package/dist/data-grid/grid-row.js +83 -0
- package/dist/data-grid/grid-template.js +12 -0
- package/dist/data-grid/header-menu.js +52 -0
- package/dist/data-grid/page-cache.js +27 -0
- package/dist/data-grid/pinned-storage.js +13 -0
- package/dist/data-grid/row-menu.js +88 -0
- package/dist/data-grid/use-grid-scroll.js +80 -0
- package/dist/data-grid/use-horizontal-scroll.js +38 -0
- package/dist/data-grid/use-paged-rows.js +50 -0
- package/dist/data-grid/use-pinned-columns.js +16 -0
- package/dist/filter-panel/active-filter-chips.js +34 -0
- package/dist/filter-panel/date-presets.js +20 -0
- package/dist/filter-panel/facet-group.js +48 -0
- package/dist/filter-panel/filter-config.js +18 -0
- package/dist/filter-panel/filter-menu.js +41 -0
- package/dist/filter-panel/filter-panel.js +112 -0
- package/dist/filter-panel/filter-section.js +11 -0
- package/dist/filter-panel/filter-state.js +63 -0
- package/dist/filter-panel/range-filter.js +54 -0
- package/dist/filter-panel/range-math.js +11 -0
- package/dist/filter-panel/range-slider.css +32 -0
- package/dist/filter-panel/toggle-row.js +13 -0
- package/dist/filter-panel/use-filter-state.js +15 -0
- package/dist/format/date.js +37 -0
- package/dist/format/money.js +15 -0
- package/dist/format/number.js +12 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +10 -0
- package/dist/inline-child-table/columns.js +4 -0
- package/dist/inline-child-table/inline-child-table.js +120 -0
- package/dist/inline-child-table/line-menu.js +54 -0
- package/dist/inline-child-table/line-row.js +91 -0
- package/dist/inline-child-table/number-cell.js +25 -0
- package/dist/inline-child-table/reorder.js +10 -0
- package/dist/inline-child-table/totals-footer.js +42 -0
- package/dist/lib/cn.js +4 -0
- package/dist/lib/guarded.js +12 -0
- package/dist/lib/use-debounced.js +13 -0
- package/dist/lib/use-focus-trap.js +46 -0
- package/dist/lib/use-media-query.js +12 -0
- package/dist/live/columns-from-model.js +93 -0
- package/dist/live/display-value.js +24 -0
- package/dist/live/field-values.js +32 -0
- package/dist/live/filter-string.js +135 -0
- package/dist/live/grid-loader.js +63 -0
- package/dist/live/model-helpers.js +30 -0
- package/dist/live/naming.js +9 -0
- package/dist/live/relation-labels.js +53 -0
- package/dist/live/use-filter-config.js +88 -0
- package/dist/live/use-live-rows.js +45 -0
- package/dist/live/view-fields.js +35 -0
- package/dist/mocks/activity.js +24 -0
- package/dist/mocks/invoices.js +13 -0
- package/dist/mocks/products.js +167 -0
- package/dist/mocks/rng.js +20 -0
- package/dist/mocks/users.js +63 -0
- package/dist/primitives/avatar.js +22 -0
- package/dist/primitives/badge.d.ts +1 -0
- package/dist/primitives/badge.js +25 -0
- package/dist/primitives/button.js +23 -0
- package/dist/primitives/checkbox.js +21 -0
- package/dist/primitives/dialog.js +41 -0
- package/dist/primitives/input.js +16 -0
- package/dist/primitives/kbd.js +8 -0
- package/dist/primitives/segmented.js +14 -0
- package/dist/primitives/sheet.js +44 -0
- package/dist/primitives/skeleton.js +6 -0
- package/dist/primitives/spinner.js +10 -0
- package/dist/primitives/switch.js +14 -0
- package/dist/primitives/tooltip.js +13 -0
- package/dist/record-view/action-rail.js +28 -0
- package/dist/record-view/field.js +62 -0
- package/dist/record-view/record-header.js +50 -0
- package/dist/record-view/record-sidebar.js +58 -0
- package/dist/record-view/section.js +21 -0
- package/dist/styles.css +132 -0
- package/dist/toasts/toast-provider.js +69 -0
- package/dist/toasts/toast.js +45 -0
- package/package.json +45 -3
- 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 };
|