@protobase/ui 0.21.1 → 0.21.3
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/dist/app/current-address.js +2 -2
- package/dist/app/displays/display-controls.js +3 -4
- package/dist/app/displays/list-displays.js +58 -4
- package/dist/app/displays/record-card.js +2 -2
- package/dist/app/embedded-list/embedded-columns.js +1 -1
- package/dist/app/embedded-list/embedded-display.js +6 -2
- package/dist/app/embedded-list/embedded-list.js +1 -1
- package/dist/app/field-editor.js +1 -1
- package/dist/app/hand-order/hand-order.js +21 -0
- package/dist/app/hand-order/hand-sorted-list.js +98 -0
- package/dist/app/hand-order/sort-button.js +20 -0
- package/dist/app/hand-order/use-hand-order.js +105 -0
- package/dist/app/list-page.js +39 -12
- package/dist/app/pages/field-value.js +1 -1
- package/dist/app/record-page.js +2 -2
- package/dist/app/related-dots/related-dots-columns.js +2 -1
- package/dist/app/related-dots/related-dots.js +1 -1
- package/dist/app/related-section.js +37 -8
- package/dist/data-grid/cell-text.js +30 -0
- package/dist/data-grid/cells.js +27 -35
- package/dist/data-grid/content-widths.js +81 -0
- package/dist/data-grid/data-grid.js +58 -40
- package/dist/data-grid/default-pins.js +13 -0
- package/dist/data-grid/grid-row.js +16 -4
- package/dist/data-grid/pinned-storage.js +2 -1
- package/dist/data-grid/row-reorder.js +68 -0
- package/dist/data-grid/truncated-text.js +16 -0
- package/dist/data-grid/use-fitted-columns.js +28 -0
- package/dist/data-grid/use-pinned-columns.js +6 -5
- package/dist/live/columns-from-model.js +6 -6
- package/dist/package.js +1 -1
- package/dist/shell/assets/app-wZRscg1o.js +69 -0
- package/dist/shell/assets/index-C1fzTv_X.css +2 -0
- package/dist/shell/assets/{index-CW037BBI.js → index-RHKnIOOP.js} +1 -1
- package/dist/shell/assets/{maplibre-Ck9abcyu.js → maplibre-DN_v_fcJ.js} +1 -1
- package/dist/shell/assets/{markdown-content-BDjSKrC7.js → markdown-content-Be4vIiWm.js} +1 -1
- package/dist/shell/assets/{record-map-CKPZ2KJs.js → record-map-CVeshts3.js} +1 -1
- package/dist/shell/assets/{records-map-DadV3GNO.js → records-map-ClnWq-RH.js} +1 -1
- package/dist/shell/assets/{shared-protobase-ui-tJCYxj7E.js → shared-protobase-ui-DWCBNg3c.js} +1 -1
- package/dist/shell/index.html +4 -4
- package/dist/shell/protobase.shell.json +1 -1
- package/package.json +4 -4
- package/dist/shell/assets/app-DGVgRtZo.js +0 -69
- package/dist/shell/assets/index-Dc6xgHsz.css +0 -2
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import { Skeleton } from "../primitives/skeleton.js";
|
|
2
2
|
import { keys } from "../data/query-keys.js";
|
|
3
3
|
import { useClient } from "../data/api-provider.js";
|
|
4
|
-
import { useAdminMeta } from "./meta-gate.js";
|
|
4
|
+
import { useAdminMeta, usePermissions } from "./meta-gate.js";
|
|
5
5
|
import { Section } from "../record-view/section.js";
|
|
6
6
|
import { ErrorBanner } from "./error-banner.js";
|
|
7
7
|
import { listColumnNames } from "../live/columns-from-model.js";
|
|
8
8
|
import { EmbeddedDisplaySwitch, EmbeddedList } from "./embedded-list/embedded-list.js";
|
|
9
9
|
import { useEmbeddedDisplay } from "./embedded-list/use-embedded-display.js";
|
|
10
|
-
import {
|
|
10
|
+
import { relatedHandOrder } from "./hand-order/hand-order.js";
|
|
11
|
+
import { HandSortedList } from "./hand-order/hand-sorted-list.js";
|
|
12
|
+
import { SortButton } from "./hand-order/sort-button.js";
|
|
13
|
+
import { useState } from "react";
|
|
14
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
11
15
|
import { useQuery } from "@tanstack/react-query";
|
|
12
16
|
import { createWhere, printFilter } from "@protobase/schema";
|
|
13
17
|
//#region src/app/related-section.tsx
|
|
@@ -67,7 +71,8 @@ const Empty = ({ text }) => /* @__PURE__ */ jsx("p", {
|
|
|
67
71
|
});
|
|
68
72
|
/**
|
|
69
73
|
* A record page section listing the first records of another resource that point at this record, directly or through
|
|
70
|
-
* a join resource, in the list page's table or display, with a link to them all in the list.
|
|
74
|
+
* a join resource, in the list page's table or display, with a link to them all in the list. When they are ordered by
|
|
75
|
+
* hand within this record, **Sort** shows them all in their order, to be moved by their drag handles.
|
|
71
76
|
*/
|
|
72
77
|
const RelatedSection = ({ item, recordKey, owner }) => {
|
|
73
78
|
const { resources, views } = useAdminMeta();
|
|
@@ -75,16 +80,40 @@ const RelatedSection = ({ item, recordKey, owner }) => {
|
|
|
75
80
|
const view = model && views[model.name];
|
|
76
81
|
const match = useMatch(item, recordKey);
|
|
77
82
|
const [display, pick] = useEmbeddedDisplay(view, `related:${owner}:${item.title}`, item.display);
|
|
83
|
+
const permissions = usePermissions(item.resource);
|
|
84
|
+
const [sorting, setSorting] = useState(false);
|
|
78
85
|
if (!model) return null;
|
|
79
86
|
const columns = item.columns ?? listColumnNames(model, view).filter((name) => name !== item.field);
|
|
80
|
-
|
|
87
|
+
const handOrder = relatedHandOrder(item, model, permissions);
|
|
88
|
+
const displaySwitch = /* @__PURE__ */ jsx(EmbeddedDisplaySwitch, {
|
|
89
|
+
view,
|
|
90
|
+
value: display,
|
|
91
|
+
onChange: pick
|
|
92
|
+
});
|
|
93
|
+
const actions = handOrder ? /* @__PURE__ */ jsxs("div", {
|
|
94
|
+
className: "flex items-center gap-2",
|
|
95
|
+
children: [!sorting && displaySwitch, /* @__PURE__ */ jsx(SortButton, {
|
|
96
|
+
size: "sm",
|
|
97
|
+
sorting,
|
|
98
|
+
onChange: setSorting
|
|
99
|
+
})]
|
|
100
|
+
}) : displaySwitch;
|
|
101
|
+
if (sorting && handOrder) return /* @__PURE__ */ jsx(Section, {
|
|
81
102
|
title: item.title,
|
|
82
103
|
help: item.help,
|
|
83
|
-
actions
|
|
104
|
+
actions,
|
|
105
|
+
children: /* @__PURE__ */ jsx(HandSortedList, {
|
|
106
|
+
model,
|
|
84
107
|
view,
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
108
|
+
field: handOrder,
|
|
109
|
+
filter: printFilter(where$1.eq(item.field, recordKey)),
|
|
110
|
+
columns
|
|
111
|
+
})
|
|
112
|
+
});
|
|
113
|
+
return /* @__PURE__ */ jsx(Section, {
|
|
114
|
+
title: item.title,
|
|
115
|
+
help: item.help,
|
|
116
|
+
actions,
|
|
88
117
|
children: match.isPending ? /* @__PURE__ */ jsx(Loading, {}) : match.error ? /* @__PURE__ */ jsx(ErrorBanner, {
|
|
89
118
|
title: "Could not load",
|
|
90
119
|
error: match.error
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { formatMoney } from "../format/money.js";
|
|
2
|
+
import { formatInt, formatPercent, formatSigned } from "../format/number.js";
|
|
3
|
+
import { formatDate, formatDateTime } from "../format/date.js";
|
|
4
|
+
import { userById } from "../mocks/users.js";
|
|
5
|
+
import { fileShown } from "../live/file-values.js";
|
|
6
|
+
import { enumLabel } from "@protobase/schema";
|
|
7
|
+
//#region src/data-grid/cell-text.ts
|
|
8
|
+
/** A relation cell's text: the related record's name when the row carries it, else its key. */
|
|
9
|
+
const relationLabel = (value) => typeof value === "object" && value !== null && "name" in value ? String(value.name) : String(value);
|
|
10
|
+
/** The text a cell of `spec` shows for `value`; `undefined` when it shows only an icon, such as a ticked boolean. */
|
|
11
|
+
const cellText = (spec, value) => {
|
|
12
|
+
if (value === null || value === void 0) return "—";
|
|
13
|
+
switch (spec.kind) {
|
|
14
|
+
case "text":
|
|
15
|
+
case "id": return String(value);
|
|
16
|
+
case "money": return `${formatMoney(Number(value), spec.currency, spec.decimals)}${spec.suffix ?? ""}`;
|
|
17
|
+
case "date": return formatDate(Number(value));
|
|
18
|
+
case "datetime": return formatDateTime(Number(value));
|
|
19
|
+
case "number": return `${formatInt(Number(value))}${spec.suffix ?? ""}`;
|
|
20
|
+
case "signed": return `${formatSigned(Number(value))}${spec.suffix ?? ""}`;
|
|
21
|
+
case "percent": return Number(value) === 0 ? "—" : formatPercent(Number(value));
|
|
22
|
+
case "status": return enumLabel(String(value), spec.labels);
|
|
23
|
+
case "relation": return relationLabel(value);
|
|
24
|
+
case "boolean": return value ? void 0 : "No";
|
|
25
|
+
case "file": return fileShown(value)?.name ?? "—";
|
|
26
|
+
case "user": return userById(String(value)).name;
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
//#endregion
|
|
30
|
+
export { cellText, relationLabel };
|
package/dist/data-grid/cells.js
CHANGED
|
@@ -1,66 +1,61 @@
|
|
|
1
1
|
import { cn } from "../lib/cn.js";
|
|
2
2
|
import { Avatar } from "../primitives/avatar.js";
|
|
3
3
|
import { Badge } from "../primitives/badge.js";
|
|
4
|
-
import { formatMoney } from "../format/money.js";
|
|
5
|
-
import { formatInt, formatPercent, formatSigned } from "../format/number.js";
|
|
6
|
-
import { formatDate, formatDateTime } from "../format/date.js";
|
|
7
4
|
import { userById } from "../mocks/users.js";
|
|
8
5
|
import { fileShown, shownImage } from "../live/file-values.js";
|
|
6
|
+
import { cellText } from "./cell-text.js";
|
|
9
7
|
import { FileImage } from "../live/file-image.js";
|
|
10
8
|
import { valueColor } from "../live/value-color.js";
|
|
9
|
+
import { TruncatedText } from "./truncated-text.js";
|
|
11
10
|
import { Check, Paperclip } from "lucide-react";
|
|
12
11
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
13
|
-
import { enumLabel } from "@protobase/schema";
|
|
14
12
|
//#region src/data-grid/cells.tsx
|
|
15
13
|
const muted = (text) => /* @__PURE__ */ jsx("span", {
|
|
16
14
|
className: "text-faint-foreground",
|
|
17
15
|
children: text
|
|
18
16
|
});
|
|
19
|
-
const relationLabel = (value) => typeof value === "object" && value !== null && "name" in value ? String(value.name) : String(value);
|
|
20
17
|
const renderCell = (spec, value) => {
|
|
21
18
|
if (value === null || value === void 0) return muted("—");
|
|
19
|
+
const text = cellText(spec, value) ?? "";
|
|
22
20
|
switch (spec.kind) {
|
|
23
|
-
case "text": return /* @__PURE__ */ jsx(
|
|
24
|
-
|
|
25
|
-
|
|
21
|
+
case "text": return /* @__PURE__ */ jsx(TruncatedText, { text });
|
|
22
|
+
case "id": return /* @__PURE__ */ jsx(TruncatedText, {
|
|
23
|
+
text,
|
|
24
|
+
className: "font-mono text-xs text-muted-foreground"
|
|
26
25
|
});
|
|
27
|
-
case "
|
|
28
|
-
className: "truncate font-mono text-xs text-muted-foreground",
|
|
29
|
-
children: String(value)
|
|
30
|
-
});
|
|
31
|
-
case "money": return /* @__PURE__ */ jsxs("span", {
|
|
26
|
+
case "money": return /* @__PURE__ */ jsx("span", {
|
|
32
27
|
className: "tabular-nums",
|
|
33
|
-
children:
|
|
28
|
+
children: text
|
|
34
29
|
});
|
|
35
30
|
case "date": return /* @__PURE__ */ jsx("span", {
|
|
36
31
|
className: "tabular-nums text-muted-foreground",
|
|
37
|
-
children:
|
|
32
|
+
children: text
|
|
38
33
|
});
|
|
39
34
|
case "datetime": return /* @__PURE__ */ jsx("span", {
|
|
40
35
|
className: "tabular-nums text-muted-foreground",
|
|
41
|
-
children:
|
|
36
|
+
children: text
|
|
42
37
|
});
|
|
43
|
-
case "number": return /* @__PURE__ */
|
|
38
|
+
case "number": return /* @__PURE__ */ jsx("span", {
|
|
44
39
|
className: "tabular-nums",
|
|
45
|
-
children:
|
|
40
|
+
children: text
|
|
46
41
|
});
|
|
47
|
-
case "signed": return /* @__PURE__ */
|
|
42
|
+
case "signed": return /* @__PURE__ */ jsx("span", {
|
|
48
43
|
className: cn("tabular-nums", Number(value) < 0 ? "text-danger-text" : "text-success-text"),
|
|
49
|
-
children:
|
|
44
|
+
children: text
|
|
50
45
|
});
|
|
51
46
|
case "percent": return Number(value) === 0 ? muted("—") : /* @__PURE__ */ jsx("span", {
|
|
52
47
|
className: "tabular-nums",
|
|
53
|
-
children:
|
|
48
|
+
children: text
|
|
54
49
|
});
|
|
55
50
|
case "status": return /* @__PURE__ */ jsx(Badge, {
|
|
56
51
|
tone: spec.tones?.[String(value)] ?? "neutral",
|
|
57
52
|
color: valueColor(spec.colors, value),
|
|
58
53
|
shape: spec.shape,
|
|
59
|
-
children:
|
|
54
|
+
children: text
|
|
60
55
|
});
|
|
61
|
-
case "relation": return /* @__PURE__ */ jsx(
|
|
62
|
-
|
|
63
|
-
|
|
56
|
+
case "relation": return /* @__PURE__ */ jsx(TruncatedText, {
|
|
57
|
+
text,
|
|
58
|
+
className: "font-medium text-primary-text hover:underline"
|
|
64
59
|
});
|
|
65
60
|
case "boolean": return value ? /* @__PURE__ */ jsx("span", {
|
|
66
61
|
className: "inline-flex size-4 items-center justify-center rounded-full bg-success-soft text-success-text",
|
|
@@ -72,9 +67,9 @@ const renderCell = (spec, value) => {
|
|
|
72
67
|
case "file": {
|
|
73
68
|
const file = fileShown(value);
|
|
74
69
|
if (!file) return muted("—");
|
|
75
|
-
if ("missing" in file) return /* @__PURE__ */ jsx(
|
|
76
|
-
|
|
77
|
-
|
|
70
|
+
if ("missing" in file) return /* @__PURE__ */ jsx(TruncatedText, {
|
|
71
|
+
text: file.name,
|
|
72
|
+
className: "text-danger-text"
|
|
78
73
|
});
|
|
79
74
|
const image = shownImage(value, {
|
|
80
75
|
width: 20,
|
|
@@ -85,10 +80,7 @@ const renderCell = (spec, value) => {
|
|
|
85
80
|
children: [image ? /* @__PURE__ */ jsx(FileImage, {
|
|
86
81
|
image,
|
|
87
82
|
className: "size-5 shrink-0 rounded-sm object-cover"
|
|
88
|
-
}) : /* @__PURE__ */ jsx(Paperclip, { className: "size-3.5 shrink-0 text-muted-foreground" }), /* @__PURE__ */ jsx(
|
|
89
|
-
className: "truncate",
|
|
90
|
-
children: file.name
|
|
91
|
-
})]
|
|
83
|
+
}) : /* @__PURE__ */ jsx(Paperclip, { className: "size-3.5 shrink-0 text-muted-foreground" }), /* @__PURE__ */ jsx(TruncatedText, { text: file.name })]
|
|
92
84
|
});
|
|
93
85
|
}
|
|
94
86
|
case "user": {
|
|
@@ -100,9 +92,9 @@ const renderCell = (spec, value) => {
|
|
|
100
92
|
hue: user.hue,
|
|
101
93
|
size: "xs",
|
|
102
94
|
className: "ring-0"
|
|
103
|
-
}), /* @__PURE__ */ jsx(
|
|
104
|
-
|
|
105
|
-
|
|
95
|
+
}), /* @__PURE__ */ jsx(TruncatedText, {
|
|
96
|
+
text: user.name,
|
|
97
|
+
className: "text-muted-foreground"
|
|
106
98
|
})]
|
|
107
99
|
});
|
|
108
100
|
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { cellText } from "./cell-text.js";
|
|
2
|
+
//#region src/data-grid/content-widths.ts
|
|
3
|
+
/** Room for a header at 12px plus cell padding, the sort arrow and the column menu button. */
|
|
4
|
+
const headerWidth = (header, sortable) => Math.ceil(header.length * 7.2) + 24 + (sortable ? 16 : 0) + 28;
|
|
5
|
+
/** A status badge: dot, padding and the longest label. */
|
|
6
|
+
const badgeWidth = (labels) => Math.ceil(Math.max(0, ...labels.map((label) => label.length)) * 7.2) + 56;
|
|
7
|
+
/** Pixels per character: body text, the small monospace of identifiers, and tabular numbers. */
|
|
8
|
+
const charWidth = {
|
|
9
|
+
body: 7.5,
|
|
10
|
+
mono: 7.2,
|
|
11
|
+
numeric: 8
|
|
12
|
+
};
|
|
13
|
+
const cellPadding = 24;
|
|
14
|
+
const iconRoom = 24;
|
|
15
|
+
const checkWidth = 16;
|
|
16
|
+
/** Values cut short would read wrong, so their columns take their measured width whole. */
|
|
17
|
+
const wholeKinds = /* @__PURE__ */ new Set([
|
|
18
|
+
"status",
|
|
19
|
+
"money",
|
|
20
|
+
"number",
|
|
21
|
+
"signed",
|
|
22
|
+
"percent",
|
|
23
|
+
"date",
|
|
24
|
+
"datetime",
|
|
25
|
+
"boolean"
|
|
26
|
+
]);
|
|
27
|
+
const numericKinds = /* @__PURE__ */ new Set([
|
|
28
|
+
"money",
|
|
29
|
+
"number",
|
|
30
|
+
"signed",
|
|
31
|
+
"percent",
|
|
32
|
+
"date",
|
|
33
|
+
"datetime"
|
|
34
|
+
]);
|
|
35
|
+
const minWeight = 80;
|
|
36
|
+
const maxWeight = 400;
|
|
37
|
+
const maxIdWidth = 240;
|
|
38
|
+
const perChar = (kind) => kind === "id" ? charWidth.mono : numericKinds.has(kind) ? charWidth.numeric : charWidth.body;
|
|
39
|
+
const valueOf = (spec, row) => spec.value ? spec.value(row) : row[spec.id];
|
|
40
|
+
/** The width the widest of `rows`' values of `spec` takes, cell padding included. */
|
|
41
|
+
const contentWidth = (spec, rows) => {
|
|
42
|
+
const texts = rows.map((row) => cellText(spec, valueOf(spec, row)));
|
|
43
|
+
if (spec.kind === "status") return badgeWidth(texts.map((text) => text ?? ""));
|
|
44
|
+
const icon = spec.kind === "user" || spec.kind === "file" ? iconRoom : 0;
|
|
45
|
+
return Math.max(0, ...texts.map((text) => text === void 0 ? checkWidth : Math.ceil(text.length * perChar(spec.kind)))) + icon + cellPadding;
|
|
46
|
+
};
|
|
47
|
+
const clamp = (value, low, high) => Math.min(high, Math.max(low, value));
|
|
48
|
+
const fitted = (spec, content) => {
|
|
49
|
+
if (wholeKinds.has(spec.kind) && !spec.title) return {
|
|
50
|
+
...spec,
|
|
51
|
+
fixed: true,
|
|
52
|
+
width: Math.max(spec.minWidth ?? 0, content)
|
|
53
|
+
};
|
|
54
|
+
const weighted = {
|
|
55
|
+
...spec,
|
|
56
|
+
fixed: false,
|
|
57
|
+
width: clamp(content, minWeight, maxWeight)
|
|
58
|
+
};
|
|
59
|
+
if (spec.kind !== "id") return weighted;
|
|
60
|
+
return {
|
|
61
|
+
...weighted,
|
|
62
|
+
minWidth: Math.max(headerWidth(spec.header, spec.sortable ?? false), Math.min(content, maxIdWidth))
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* Columns sized by the values in `rows`: values that must stay whole get their widest value's width, text a share of the
|
|
67
|
+
* leftover space as large as its widest value, and the title column the largest share. Columns drawn by their own
|
|
68
|
+
* `render` keep their width, as their content cannot be estimated.
|
|
69
|
+
*/
|
|
70
|
+
const fitColumns = (specs, rows) => {
|
|
71
|
+
if (rows.length === 0) return specs;
|
|
72
|
+
const sized = specs.map((spec) => spec.render ? spec : fitted(spec, contentWidth(spec, rows)));
|
|
73
|
+
const shares = sized.filter((spec, index) => !spec.fixed && !spec.title && !specs[index].render).map((spec) => spec.width);
|
|
74
|
+
const largest = Math.max(0, ...shares);
|
|
75
|
+
return sized.map((spec, index) => spec.title && !specs[index].render ? {
|
|
76
|
+
...spec,
|
|
77
|
+
width: Math.max(spec.width, largest)
|
|
78
|
+
} : spec);
|
|
79
|
+
};
|
|
80
|
+
//#endregion
|
|
81
|
+
export { badgeWidth, contentWidth, fitColumns, headerWidth };
|
|
@@ -6,19 +6,26 @@ import { RowMenu, availableActions } from "./row-menu.js";
|
|
|
6
6
|
import { desktopMinScale, gridMinWidth, gutterWidthFor } from "./grid-template.js";
|
|
7
7
|
import { useHorizontalScroll } from "./use-horizontal-scroll.js";
|
|
8
8
|
import { usePinnedColumns } from "./use-pinned-columns.js";
|
|
9
|
+
import { defaultPins } from "./default-pins.js";
|
|
10
|
+
import { useFittedColumns } from "./use-fitted-columns.js";
|
|
9
11
|
import { GridHeader } from "./grid-header.js";
|
|
10
12
|
import { DataRow, SkeletonRow } from "./grid-row.js";
|
|
11
13
|
import { usePagedRows } from "./use-paged-rows.js";
|
|
12
14
|
import { useVirtualRows } from "./use-virtual-rows.js";
|
|
13
15
|
import { useCellEditing } from "./use-cell-editing.js";
|
|
16
|
+
import { ReorderRows, SortableRow } from "./row-reorder.js";
|
|
14
17
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
15
18
|
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
16
19
|
import { useTable } from "@tanstack/react-table";
|
|
17
20
|
//#region src/data-grid/data-grid.tsx
|
|
18
21
|
const headerHeight = 32;
|
|
19
|
-
const DataGrid = ({ columns: given, source, naturalSort, initialScrollIndex = 0, onScrollIndex, latencyMs = 150, sort: controlledSort, onSortChange, onOpenRow, rowActions, cellEditor, rowPatches, bulkActions, pinKey, defaultPinned, rowNumbers = true, compact = false, selectable = true, fit = false, scroll = "own", className }) => {
|
|
22
|
+
const DataGrid = ({ columns: given, source, naturalSort, initialScrollIndex = 0, onScrollIndex, latencyMs = 150, sort: controlledSort, onSortChange, onOpenRow, rowActions, cellEditor, rowPatches, bulkActions, pinKey, defaultPinned, rowNumbers = true, compact = false, selectable: selecting = true, reorder, fit = false, scroll = "own", className }) => {
|
|
23
|
+
const selectable = selecting && !reorder;
|
|
20
24
|
const rowHeight = compact ? 28 : 36;
|
|
21
|
-
const
|
|
25
|
+
const narrow = !useIsDesktop();
|
|
26
|
+
const pinsByDefault = (defaultPinned ?? (narrow ? [] : defaultPins(given))).join(" ");
|
|
27
|
+
const initialPins = useMemo(() => pinsByDefault ? pinsByDefault.split(" ") : [], [pinsByDefault]);
|
|
28
|
+
const [pinned, setPinned] = usePinnedColumns(pinKey, initialPins);
|
|
22
29
|
const columns = useMemo(() => {
|
|
23
30
|
return [...pinned.flatMap((id) => given.find((spec) => spec.id === id) ?? []), ...given.filter((spec) => !pinned.includes(spec.id))];
|
|
24
31
|
}, [given, pinned]);
|
|
@@ -58,7 +65,6 @@ const DataGrid = ({ columns: given, source, naturalSort, initialScrollIndex = 0,
|
|
|
58
65
|
initialIndex: startIndex,
|
|
59
66
|
inset: headerHeight
|
|
60
67
|
});
|
|
61
|
-
const narrow = !useIsDesktop();
|
|
62
68
|
const gutter = narrow || !rowNumbers ? 40 : gutterWidthFor(total);
|
|
63
69
|
const topIndex = Math.floor(Math.max(0, top) / rowHeight);
|
|
64
70
|
useEffect(() => onScrollIndex?.(topIndex), [topIndex, onScrollIndex]);
|
|
@@ -87,6 +93,7 @@ const DataGrid = ({ columns: given, source, naturalSort, initialScrollIndex = 0,
|
|
|
87
93
|
firstIndex,
|
|
88
94
|
lastIndex
|
|
89
95
|
]);
|
|
96
|
+
const sized = useFittedColumns(columns, data);
|
|
90
97
|
const table = useTable({
|
|
91
98
|
features,
|
|
92
99
|
columns: tableColumns,
|
|
@@ -143,7 +150,7 @@ const DataGrid = ({ columns: given, source, naturalSort, initialScrollIndex = 0,
|
|
|
143
150
|
onOpenRow,
|
|
144
151
|
container: gridRef
|
|
145
152
|
});
|
|
146
|
-
const minWidth = gridMinWidth(
|
|
153
|
+
const minWidth = gridMinWidth(sized, gutter, trailing, minScale);
|
|
147
154
|
const own = scroll === "own";
|
|
148
155
|
const header = /* @__PURE__ */ jsx("div", {
|
|
149
156
|
ref: headerRef,
|
|
@@ -152,7 +159,7 @@ const DataGrid = ({ columns: given, source, naturalSort, initialScrollIndex = 0,
|
|
|
152
159
|
style: { minWidth },
|
|
153
160
|
children: /* @__PURE__ */ jsx(GridHeader, {
|
|
154
161
|
cells: headerCells,
|
|
155
|
-
specs:
|
|
162
|
+
specs: sized,
|
|
156
163
|
gutter,
|
|
157
164
|
trailing,
|
|
158
165
|
minScale,
|
|
@@ -166,6 +173,46 @@ const DataGrid = ({ columns: given, source, naturalSort, initialScrollIndex = 0,
|
|
|
166
173
|
})
|
|
167
174
|
})
|
|
168
175
|
});
|
|
176
|
+
const rowsInView = loaded.map(({ index, offset, row }) => {
|
|
177
|
+
const frame = {
|
|
178
|
+
specs: sized,
|
|
179
|
+
gutter,
|
|
180
|
+
index,
|
|
181
|
+
offset,
|
|
182
|
+
height: rowHeight,
|
|
183
|
+
selectable,
|
|
184
|
+
trailing,
|
|
185
|
+
minScale,
|
|
186
|
+
scrolled: horizontal.scrolled,
|
|
187
|
+
pins,
|
|
188
|
+
lastPinned
|
|
189
|
+
};
|
|
190
|
+
const tableRow = row ? tableRows.get(row.id) : void 0;
|
|
191
|
+
if (!tableRow || !row) return /* @__PURE__ */ jsx(SkeletonRow, { ...frame }, `skeleton-${index}`);
|
|
192
|
+
const actions = trailing ? availableActions(rowActions, row) : [];
|
|
193
|
+
const props = {
|
|
194
|
+
...frame,
|
|
195
|
+
selected: tableRow.getIsSelected(),
|
|
196
|
+
onToggle: () => tableRow.toggleSelected(),
|
|
197
|
+
onOpen: onOpenRow ? () => onOpenRow(row) : void 0,
|
|
198
|
+
menu: actions.length > 0 ? /* @__PURE__ */ jsx(RowMenu, {
|
|
199
|
+
row,
|
|
200
|
+
actions
|
|
201
|
+
}) : void 0,
|
|
202
|
+
cells: tableRow.getAllCells().map((cell) => {
|
|
203
|
+
const content = /* @__PURE__ */ jsx(table.FlexRender, { cell });
|
|
204
|
+
return {
|
|
205
|
+
spec: columns.find((spec) => spec.id === cell.column.id),
|
|
206
|
+
...cellEditing ? cellEditing.cell(index, row, cell.column.id, content) : { content }
|
|
207
|
+
};
|
|
208
|
+
})
|
|
209
|
+
};
|
|
210
|
+
return reorder ? /* @__PURE__ */ jsx(SortableRow, {
|
|
211
|
+
...props,
|
|
212
|
+
row,
|
|
213
|
+
label: reorder.label(row)
|
|
214
|
+
}, row.id) : /* @__PURE__ */ jsx(DataRow, { ...props }, row.id);
|
|
215
|
+
});
|
|
169
216
|
const body = /* @__PURE__ */ jsx("div", {
|
|
170
217
|
ref: horizontal.attach,
|
|
171
218
|
onScroll: onBodyScroll,
|
|
@@ -177,41 +224,12 @@ const DataGrid = ({ columns: given, source, naturalSort, initialScrollIndex = 0,
|
|
|
177
224
|
minWidth,
|
|
178
225
|
height: geometry.contentHeight
|
|
179
226
|
},
|
|
180
|
-
children:
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
const content = /* @__PURE__ */ jsx(table.FlexRender, { cell });
|
|
208
|
-
return {
|
|
209
|
-
spec: columns.find((spec) => spec.id === cell.column.id),
|
|
210
|
-
...cellEditing && row ? cellEditing.cell(index, row, cell.column.id, content) : { content }
|
|
211
|
-
};
|
|
212
|
-
})
|
|
213
|
-
}, row?.id);
|
|
214
|
-
})
|
|
227
|
+
children: reorder ? /* @__PURE__ */ jsx(ReorderRows, {
|
|
228
|
+
ids: Array.from({ length: total }, (_, index) => patchedRow(index)?.id ?? `missing-${index}`),
|
|
229
|
+
rowAt: patchedRow,
|
|
230
|
+
reorder,
|
|
231
|
+
children: rowsInView
|
|
232
|
+
}) : rowsInView
|
|
215
233
|
})
|
|
216
234
|
});
|
|
217
235
|
return /* @__PURE__ */ jsxs("div", {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
//#region src/data-grid/default-pins.ts
|
|
2
|
+
/**
|
|
3
|
+
* The columns pinned until the user chooses: the leading columns through the title column, when it is first or second.
|
|
4
|
+
* Pinned columns move to the front, so a title further right stays unpinned rather than reorder the list; pins only
|
|
5
|
+
* take effect once the grid is too wide and scrolls sideways.
|
|
6
|
+
*/
|
|
7
|
+
const defaultPins = (specs) => {
|
|
8
|
+
const index = specs.findIndex((spec) => spec.title);
|
|
9
|
+
if (index < 0 || index > 1) return [];
|
|
10
|
+
return specs.slice(0, index + 1).map((spec) => spec.id);
|
|
11
|
+
};
|
|
12
|
+
//#endregion
|
|
13
|
+
export { defaultPins };
|
|
@@ -33,13 +33,25 @@ const Gutter = ({ width, index, selectable, selected, onToggle, edge }) => /* @_
|
|
|
33
33
|
children: formatInt(index + 1)
|
|
34
34
|
})]
|
|
35
35
|
});
|
|
36
|
-
const
|
|
36
|
+
const dragStyle = (frame, dragY, transition) => ({
|
|
37
|
+
gridTemplateColumns: gridTemplate(frame.specs, frame.gutter, frame.trailing, frame.minScale),
|
|
38
|
+
height: frame.height,
|
|
39
|
+
top: frame.offset,
|
|
40
|
+
transform: dragY === 0 ? void 0 : `translateY(${dragY}px)`,
|
|
41
|
+
transition
|
|
42
|
+
});
|
|
43
|
+
const DataRow = ({ cells, selected, onToggle, onOpen, menu, handle, rowRef, dragY, transition, dragging, ...frame }) => /* @__PURE__ */ jsxs("div", {
|
|
44
|
+
ref: rowRef,
|
|
37
45
|
role: "row",
|
|
38
46
|
onClick: onOpen,
|
|
39
|
-
className: cn("absolute inset-x-0 top-0 grid items-center border-b", selected ? "bg-primary-soft" : "bg-background hover:bg-surface", onOpen && "cursor-pointer"),
|
|
40
|
-
style: frameStyle(frame),
|
|
47
|
+
className: cn("absolute inset-x-0 top-0 grid items-center border-b", selected ? "bg-primary-soft" : "bg-background hover:bg-surface", onOpen && "cursor-pointer", dragging && "z-30 bg-surface shadow-pop"),
|
|
48
|
+
style: dragY === void 0 ? frameStyle(frame) : dragStyle(frame, dragY, transition),
|
|
41
49
|
children: [
|
|
42
|
-
/* @__PURE__ */ jsx(
|
|
50
|
+
handle ? /* @__PURE__ */ jsx("div", {
|
|
51
|
+
className: cn("sticky left-0 z-10 flex h-full items-center bg-inherit pl-1.5", frame.scrolled && !frame.lastPinned && "shadow-[1px_0_0_var(--border),4px_0_6px_-2px_rgb(0_0_0/0.12)]"),
|
|
52
|
+
style: { width: frame.gutter },
|
|
53
|
+
children: handle
|
|
54
|
+
}) : /* @__PURE__ */ jsx(Gutter, {
|
|
43
55
|
width: frame.gutter,
|
|
44
56
|
index: frame.index,
|
|
45
57
|
selectable: frame.selectable,
|
|
@@ -8,6 +8,7 @@ const readPinned = (storage, key) => guarded(() => {
|
|
|
8
8
|
const parsed = JSON.parse(raw);
|
|
9
9
|
return Array.isArray(parsed) && parsed.every((entry) => typeof entry === "string") ? parsed : void 0;
|
|
10
10
|
}, void 0);
|
|
11
|
-
|
|
11
|
+
/** Keeps an empty list too, so a column pinned by default and then unpinned stays unpinned. */
|
|
12
|
+
const writePinned = (storage, key, pinned) => guarded(() => storage.setItem(key, JSON.stringify(pinned)), void 0);
|
|
12
13
|
//#endregion
|
|
13
14
|
export { pinnedStorageKey, readPinned, writePinned };
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { DataRow } from "./grid-row.js";
|
|
2
|
+
import { GripVertical } from "lucide-react";
|
|
3
|
+
import { jsx } from "react/jsx-runtime";
|
|
4
|
+
import { DndContext, KeyboardSensor, MouseSensor, TouchSensor, closestCenter, useSensor, useSensors } from "@dnd-kit/core";
|
|
5
|
+
import { SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
|
6
|
+
//#region src/data-grid/row-reorder.tsx
|
|
7
|
+
const instructions = "To move a row, press Space on its handle, move it with the up and down arrow keys, and press Space again to drop it there. Escape puts it back.";
|
|
8
|
+
/** The rows of a grid that are moved by hand, `ids` in their order, each loaded or not, so the drop's index is the row's place in the whole list. */
|
|
9
|
+
const ReorderRows = ({ ids, rowAt, reorder, children }) => {
|
|
10
|
+
const sensors = useSensors(useSensor(MouseSensor, { activationConstraint: { distance: 4 } }), useSensor(TouchSensor, { activationConstraint: {
|
|
11
|
+
delay: 250,
|
|
12
|
+
tolerance: 5
|
|
13
|
+
} }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }));
|
|
14
|
+
const place = (id) => ids.indexOf(String(id));
|
|
15
|
+
const name = (id) => {
|
|
16
|
+
const row = rowAt(place(id));
|
|
17
|
+
return row ? reorder.label(row) : "row";
|
|
18
|
+
};
|
|
19
|
+
const announcements = {
|
|
20
|
+
onDragStart: ({ active }) => `Picked up ${name(active.id)}, row ${place(active.id) + 1} of ${ids.length}.`,
|
|
21
|
+
onDragOver: ({ active, over }) => over ? `${name(active.id)} is over row ${place(over.id) + 1} of ${ids.length}.` : void 0,
|
|
22
|
+
onDragEnd: ({ active, over }) => over ? `${name(active.id)} was dropped at row ${place(over.id) + 1} of ${ids.length}.` : `${name(active.id)} was put back.`,
|
|
23
|
+
onDragCancel: ({ active }) => `Moving ${name(active.id)} was cancelled. It is back at row ${place(active.id) + 1}.`
|
|
24
|
+
};
|
|
25
|
+
const end = ({ active, over }) => {
|
|
26
|
+
if (!over || active.id === over.id) return;
|
|
27
|
+
reorder.onMove(place(active.id), place(over.id));
|
|
28
|
+
};
|
|
29
|
+
return /* @__PURE__ */ jsx(DndContext, {
|
|
30
|
+
sensors,
|
|
31
|
+
collisionDetection: closestCenter,
|
|
32
|
+
onDragEnd: end,
|
|
33
|
+
accessibility: {
|
|
34
|
+
announcements,
|
|
35
|
+
screenReaderInstructions: { draggable: instructions }
|
|
36
|
+
},
|
|
37
|
+
children: /* @__PURE__ */ jsx(SortableContext, {
|
|
38
|
+
items: ids,
|
|
39
|
+
strategy: verticalListSortingStrategy,
|
|
40
|
+
children
|
|
41
|
+
})
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
/** A row moved by its handle, which takes the checkbox's place; it follows the pointer up and down only. */
|
|
45
|
+
const SortableRow = ({ row, label, ...props }) => {
|
|
46
|
+
const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition, isDragging } = useSortable({ id: row.id });
|
|
47
|
+
const handle = /* @__PURE__ */ jsx("button", {
|
|
48
|
+
ref: setActivatorNodeRef,
|
|
49
|
+
type: "button",
|
|
50
|
+
"aria-label": `Move ${label}`,
|
|
51
|
+
...attributes,
|
|
52
|
+
...listeners,
|
|
53
|
+
onClick: (event) => event.stopPropagation(),
|
|
54
|
+
style: { touchAction: "none" },
|
|
55
|
+
className: "flex size-7 cursor-grab items-center justify-center rounded text-faint-foreground hover:bg-muted hover:text-foreground focus-visible:text-foreground active:cursor-grabbing pointer-coarse:size-9",
|
|
56
|
+
children: /* @__PURE__ */ jsx(GripVertical, { className: "size-4" })
|
|
57
|
+
});
|
|
58
|
+
return /* @__PURE__ */ jsx(DataRow, {
|
|
59
|
+
...props,
|
|
60
|
+
rowRef: setNodeRef,
|
|
61
|
+
handle,
|
|
62
|
+
dragY: transform?.y ?? 0,
|
|
63
|
+
transition,
|
|
64
|
+
dragging: isDragging
|
|
65
|
+
});
|
|
66
|
+
};
|
|
67
|
+
//#endregion
|
|
68
|
+
export { ReorderRows, SortableRow };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { cn } from "../lib/cn.js";
|
|
2
|
+
import { jsx } from "react/jsx-runtime";
|
|
3
|
+
//#region src/data-grid/truncated-text.tsx
|
|
4
|
+
const offerWhole = (text) => (event) => {
|
|
5
|
+
const span = event.currentTarget;
|
|
6
|
+
if (span.scrollWidth > Math.ceil(span.getBoundingClientRect().width)) span.title = text;
|
|
7
|
+
else span.removeAttribute("title");
|
|
8
|
+
};
|
|
9
|
+
/** Text cut to one line with an ellipsis, that shows whole as a tooltip when hovered while cut. */
|
|
10
|
+
const TruncatedText = ({ text, className }) => /* @__PURE__ */ jsx("span", {
|
|
11
|
+
className: cn("truncate", className),
|
|
12
|
+
onMouseEnter: offerWhole(text),
|
|
13
|
+
children: text
|
|
14
|
+
});
|
|
15
|
+
//#endregion
|
|
16
|
+
export { TruncatedText };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { fitColumns } from "./content-widths.js";
|
|
2
|
+
import { useMemo, useState } from "react";
|
|
3
|
+
//#region src/data-grid/use-fitted-columns.ts
|
|
4
|
+
const sizesOf = (specs) => Object.fromEntries(specs.map(({ id, width, minWidth, fixed }) => [id, {
|
|
5
|
+
width,
|
|
6
|
+
minWidth,
|
|
7
|
+
fixed
|
|
8
|
+
}]));
|
|
9
|
+
/**
|
|
10
|
+
* `columns` sized by the first rows shown, fitted once per set of columns: later pages, filters and sorts keep those
|
|
11
|
+
* widths, so columns never jump while scrolling, and a longer value later on is cut, with the whole of it on hover.
|
|
12
|
+
*/
|
|
13
|
+
const useFittedColumns = (columns, rows) => {
|
|
14
|
+
const key = columns.map((spec) => spec.id).sort().join(" ");
|
|
15
|
+
const [fitted, setFitted] = useState();
|
|
16
|
+
const current = fitted?.key === key ? fitted : void 0;
|
|
17
|
+
if (!current && rows.length > 0) setFitted({
|
|
18
|
+
key,
|
|
19
|
+
sizes: sizesOf(fitColumns(columns, rows))
|
|
20
|
+
});
|
|
21
|
+
const sizes = current?.sizes;
|
|
22
|
+
return useMemo(() => sizes ? columns.map((spec) => ({
|
|
23
|
+
...spec,
|
|
24
|
+
...sizes[spec.id]
|
|
25
|
+
})) : columns, [columns, sizes]);
|
|
26
|
+
};
|
|
27
|
+
//#endregion
|
|
28
|
+
export { useFittedColumns };
|
|
@@ -3,14 +3,15 @@ import { useCallback, useState } from "react";
|
|
|
3
3
|
//#region src/data-grid/use-pinned-columns.ts
|
|
4
4
|
/**
|
|
5
5
|
* The ids of the columns pinned to the left, in pin order. With a `storageKey` the choice is kept in local storage
|
|
6
|
-
* (per user and resource); `initial` applies until the user has chosen.
|
|
6
|
+
* (per user and resource); `initial` applies until the user has chosen, as it is on each render.
|
|
7
7
|
*/
|
|
8
8
|
const usePinnedColumns = (storageKey, initial = []) => {
|
|
9
|
-
const [
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
const [chosen, setChosen] = useState(() => storageKey ? readPinned(localStorage, storageKey) : void 0);
|
|
10
|
+
const update = useCallback((next) => {
|
|
11
|
+
setChosen(next);
|
|
12
12
|
if (storageKey) writePinned(localStorage, storageKey, next);
|
|
13
|
-
}, [storageKey])
|
|
13
|
+
}, [storageKey]);
|
|
14
|
+
return [chosen ?? initial, update];
|
|
14
15
|
};
|
|
15
16
|
//#endregion
|
|
16
17
|
export { usePinnedColumns };
|