@protobase/ui 0.21.2 → 0.21.4

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 (49) hide show
  1. package/dist/app/app.js +13 -1
  2. package/dist/app/current-address.js +2 -2
  3. package/dist/app/delete/blocked-delete.js +34 -0
  4. package/dist/app/delete/use-delete-flow.js +20 -2
  5. package/dist/app/displays/display-controls.js +3 -4
  6. package/dist/app/displays/list-cards.js +2 -2
  7. package/dist/app/displays/list-displays.js +58 -4
  8. package/dist/app/displays/record-card.js +2 -2
  9. package/dist/app/document-title.js +27 -0
  10. package/dist/app/embedded-list/embedded-columns.js +1 -1
  11. package/dist/app/embedded-list/embedded-display.js +6 -2
  12. package/dist/app/embedded-list/embedded-list.js +11 -2
  13. package/dist/app/field-editor.js +3 -6
  14. package/dist/app/hand-order/hand-sorted-list.js +10 -1
  15. package/dist/app/list-empty-state.js +31 -0
  16. package/dist/app/list-empty.js +23 -0
  17. package/dist/app/list-page.js +34 -7
  18. package/dist/app/numeric-input.js +21 -0
  19. package/dist/app/pages/field-value.js +1 -1
  20. package/dist/app/record-page.js +2 -2
  21. package/dist/app/related-dots/related-dots-columns.js +2 -1
  22. package/dist/app/related-dots/related-dots.js +1 -1
  23. package/dist/data/meta-data.d.ts +2 -0
  24. package/dist/data/use-meta.js +1 -0
  25. package/dist/data-grid/cell-text.js +30 -0
  26. package/dist/data-grid/cells.js +27 -35
  27. package/dist/data-grid/content-widths.js +81 -0
  28. package/dist/data-grid/data-grid.js +20 -7
  29. package/dist/data-grid/default-pins.js +13 -0
  30. package/dist/data-grid/grid-row.js +5 -1
  31. package/dist/data-grid/pinned-storage.js +2 -1
  32. package/dist/data-grid/truncated-text.js +16 -0
  33. package/dist/data-grid/use-fitted-columns.js +28 -0
  34. package/dist/data-grid/use-pinned-columns.js +6 -5
  35. package/dist/live/columns-from-model.js +6 -6
  36. package/dist/package.js +1 -1
  37. package/dist/shell/assets/app-CcsrYoRP.js +69 -0
  38. package/dist/shell/assets/index-C1fzTv_X.css +2 -0
  39. package/dist/shell/assets/{index-7lQIOMrK.js → index-DQ-VSGee.js} +1 -1
  40. package/dist/shell/assets/{maplibre-DEejTZ7I.js → maplibre-4azlIk9w.js} +1 -1
  41. package/dist/shell/assets/{markdown-content-ZzP9i4q4.js → markdown-content-DnIl00m7.js} +1 -1
  42. package/dist/shell/assets/{record-map-B-zsmXL2.js → record-map-CUEC6IyM.js} +1 -1
  43. package/dist/shell/assets/{records-map-Lj1MQgsW.js → records-map-ataN9m4I.js} +1 -1
  44. package/dist/shell/assets/{shared-protobase-ui-Bnd7T1j4.js → shared-protobase-ui-Cibu09RP.js} +1 -1
  45. package/dist/shell/index.html +4 -4
  46. package/dist/shell/protobase.shell.json +1 -1
  47. package/package.json +4 -4
  48. package/dist/shell/assets/app-BnuJWZWd.js +0 -69
  49. package/dist/shell/assets/index-B48ty05p.css +0 -2
@@ -3,13 +3,13 @@ import { keys } from "../../data/query-keys.js";
3
3
  import { useClient } from "../../data/api-provider.js";
4
4
  import { useAdminMeta } from "../meta-gate.js";
5
5
  import { fileShown, shownImage } from "../../live/file-values.js";
6
+ import { isMarkdown } from "../../markdown/markdown-hint.js";
6
7
  import { displayValue } from "../../live/display-value.js";
7
8
  import { AppLink } from "../use-app-address.js";
8
9
  import { FileImage } from "../../live/file-image.js";
9
10
  import { useShownWidget } from "../field-types/shown-widget.js";
10
11
  import { WidgetPart } from "../field-types/widget-part.js";
11
12
  import { Markdown } from "../../markdown/markdown.js";
12
- import { isMarkdown } from "../../markdown/markdown-hint.js";
13
13
  import { fetchLabels } from "../../live/relation-labels.js";
14
14
  import { ValueBadge } from "../value-badge.js";
15
15
  import { jsx, jsxs } from "react/jsx-runtime";
@@ -22,16 +22,16 @@ import { fromDraft, isRequiredOnEdit } from "../live/field-values.js";
22
22
  import { fieldHelp, helpShown, recordShown, whenShown } from "./record-mode/record-mode.js";
23
23
  import { isChosen, keyValue } from "./create/create-values.js";
24
24
  import { ErrorBanner } from "./error-banner.js";
25
+ import { isMarkdown } from "../markdown/markdown-hint.js";
26
+ import { toneFor } from "../live/columns-from-model.js";
25
27
  import { displayValue } from "../live/display-value.js";
26
28
  import { filledFields } from "./filled-fields.js";
27
29
  import { AppLink, useAppAddress } from "./use-app-address.js";
28
30
  import { shownWidget } from "./field-types/shown-widget.js";
29
31
  import { WidgetPart } from "./field-types/widget-part.js";
30
32
  import { Markdown } from "../markdown/markdown.js";
31
- import { isMarkdown } from "../markdown/markdown-hint.js";
32
33
  import { FieldEditor } from "./field-editor.js";
33
34
  import { can, fieldAccess } from "./permissions.js";
34
- import { toneFor } from "../live/columns-from-model.js";
35
35
  import { ValueBadge } from "./value-badge.js";
36
36
  import { FieldValue } from "./pages/field-value.js";
37
37
  import { fieldMessage } from "./field-errors.js";
@@ -1,5 +1,6 @@
1
1
  import { humanize } from "../../live/naming.js";
2
- import { headerWidth, listColumnNames } from "../../live/columns-from-model.js";
2
+ import { headerWidth } from "../../data-grid/content-widths.js";
3
+ import { listColumnNames } from "../../live/columns-from-model.js";
3
4
  import { RelatedDots } from "./related-dots.js";
4
5
  import { RecordCard } from "../displays/record-card.js";
5
6
  import { jsx } from "react/jsx-runtime";
@@ -2,10 +2,10 @@ import { cn } from "../../lib/cn.js";
2
2
  import { toneDots } from "../../primitives/badge.js";
3
3
  import { recordTitleField } from "../../live/model-helpers.js";
4
4
  import { humanize } from "../../live/naming.js";
5
+ import { toneFor } from "../../live/columns-from-model.js";
5
6
  import { displayValue } from "../../live/display-value.js";
6
7
  import { displayRow } from "../displays/list-displays.js";
7
8
  import { AppLink } from "../use-app-address.js";
8
- import { toneFor } from "../../live/columns-from-model.js";
9
9
  import { valueColor } from "../../live/value-color.js";
10
10
  import { placeCard } from "./card-place.js";
11
11
  import { dotCard } from "./dot-card.js";
@@ -4,6 +4,8 @@ import { PageModel } from "@protobase/layout";
4
4
  //#region src/data/meta-data.d.ts
5
5
  export type MetaData = {
6
6
  etag: string;
7
+ /** The app's name, which ends the tab titles. */
8
+ appName: string;
7
9
  resources: Record<string, ResourceModel>;
8
10
  views: Record<string, ViewModel>;
9
11
  /** Composed pages by name. */
@@ -26,6 +26,7 @@ const useMeta = () => {
26
26
  }
27
27
  return {
28
28
  etag: result.etag,
29
+ appName: result.meta.appName,
29
30
  resources: Object.fromEntries(result.meta.resources.map((model) => [model.name, model])),
30
31
  views: Object.fromEntries(result.meta.views.map((model) => [model.resource, model])),
31
32
  pages: Object.fromEntries((result.meta.pages ?? []).map((model) => [model.name, model])),
@@ -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 };
@@ -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("span", {
24
- className: "truncate",
25
- children: String(value)
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 "id": return /* @__PURE__ */ jsx("span", {
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: [formatMoney(Number(value), spec.currency, spec.decimals), spec.suffix]
28
+ children: text
34
29
  });
35
30
  case "date": return /* @__PURE__ */ jsx("span", {
36
31
  className: "tabular-nums text-muted-foreground",
37
- children: formatDate(Number(value))
32
+ children: text
38
33
  });
39
34
  case "datetime": return /* @__PURE__ */ jsx("span", {
40
35
  className: "tabular-nums text-muted-foreground",
41
- children: formatDateTime(Number(value))
36
+ children: text
42
37
  });
43
- case "number": return /* @__PURE__ */ jsxs("span", {
38
+ case "number": return /* @__PURE__ */ jsx("span", {
44
39
  className: "tabular-nums",
45
- children: [formatInt(Number(value)), spec.suffix]
40
+ children: text
46
41
  });
47
- case "signed": return /* @__PURE__ */ jsxs("span", {
42
+ case "signed": return /* @__PURE__ */ jsx("span", {
48
43
  className: cn("tabular-nums", Number(value) < 0 ? "text-danger-text" : "text-success-text"),
49
- children: [formatSigned(Number(value)), spec.suffix]
44
+ children: text
50
45
  });
51
46
  case "percent": return Number(value) === 0 ? muted("—") : /* @__PURE__ */ jsx("span", {
52
47
  className: "tabular-nums",
53
- children: formatPercent(Number(value))
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: enumLabel(String(value), spec.labels)
54
+ children: text
60
55
  });
61
- case "relation": return /* @__PURE__ */ jsx("span", {
62
- className: "truncate font-medium text-primary-text hover:underline",
63
- children: relationLabel(value)
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("span", {
76
- className: "truncate text-danger-text",
77
- children: file.name
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("span", {
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("span", {
104
- className: "truncate text-muted-foreground",
105
- children: user.name
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 };
@@ -1,11 +1,14 @@
1
1
  import { cn } from "../lib/cn.js";
2
2
  import { useIsDesktop } from "../lib/use-media-query.js";
3
+ import { RouteLink } from "../primitives/route-link.js";
3
4
  import { buildColumns, features } from "./columns.js";
4
5
  import { GridFooter } from "./grid-footer.js";
5
6
  import { RowMenu, availableActions } from "./row-menu.js";
6
7
  import { desktopMinScale, gridMinWidth, gutterWidthFor } from "./grid-template.js";
7
8
  import { useHorizontalScroll } from "./use-horizontal-scroll.js";
8
9
  import { usePinnedColumns } from "./use-pinned-columns.js";
10
+ import { defaultPins } from "./default-pins.js";
11
+ import { useFittedColumns } from "./use-fitted-columns.js";
9
12
  import { GridHeader } from "./grid-header.js";
10
13
  import { DataRow, SkeletonRow } from "./grid-row.js";
11
14
  import { usePagedRows } from "./use-paged-rows.js";
@@ -17,10 +20,13 @@ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
17
20
  import { useTable } from "@tanstack/react-table";
18
21
  //#region src/data-grid/data-grid.tsx
19
22
  const headerHeight = 32;
20
- 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 DataGrid = ({ columns: given, source, naturalSort, initialScrollIndex = 0, onScrollIndex, latencyMs = 150, sort: controlledSort, onSortChange, onOpenRow, rowLink, rowActions, cellEditor, rowPatches, bulkActions, pinKey, defaultPinned, rowNumbers = true, compact = false, selectable: selecting = true, reorder, fit = false, scroll = "own", className }) => {
21
24
  const selectable = selecting && !reorder;
22
25
  const rowHeight = compact ? 28 : 36;
23
- const [pinned, setPinned] = usePinnedColumns(pinKey, defaultPinned);
26
+ const narrow = !useIsDesktop();
27
+ const pinsByDefault = (defaultPinned ?? (narrow ? [] : defaultPins(given))).join(" ");
28
+ const initialPins = useMemo(() => pinsByDefault ? pinsByDefault.split(" ") : [], [pinsByDefault]);
29
+ const [pinned, setPinned] = usePinnedColumns(pinKey, initialPins);
24
30
  const columns = useMemo(() => {
25
31
  return [...pinned.flatMap((id) => given.find((spec) => spec.id === id) ?? []), ...given.filter((spec) => !pinned.includes(spec.id))];
26
32
  }, [given, pinned]);
@@ -60,7 +66,6 @@ const DataGrid = ({ columns: given, source, naturalSort, initialScrollIndex = 0,
60
66
  initialIndex: startIndex,
61
67
  inset: headerHeight
62
68
  });
63
- const narrow = !useIsDesktop();
64
69
  const gutter = narrow || !rowNumbers ? 40 : gutterWidthFor(total);
65
70
  const topIndex = Math.floor(Math.max(0, top) / rowHeight);
66
71
  useEffect(() => onScrollIndex?.(topIndex), [topIndex, onScrollIndex]);
@@ -89,6 +94,7 @@ const DataGrid = ({ columns: given, source, naturalSort, initialScrollIndex = 0,
89
94
  firstIndex,
90
95
  lastIndex
91
96
  ]);
97
+ const sized = useFittedColumns(columns, data);
92
98
  const table = useTable({
93
99
  features,
94
100
  columns: tableColumns,
@@ -145,7 +151,8 @@ const DataGrid = ({ columns: given, source, naturalSort, initialScrollIndex = 0,
145
151
  onOpenRow,
146
152
  container: gridRef
147
153
  });
148
- const minWidth = gridMinWidth(columns, gutter, trailing, minScale);
154
+ const linkColumn = (columns.find((spec) => spec.title) ?? columns[0])?.id;
155
+ const minWidth = gridMinWidth(sized, gutter, trailing, minScale);
149
156
  const own = scroll === "own";
150
157
  const header = /* @__PURE__ */ jsx("div", {
151
158
  ref: headerRef,
@@ -154,7 +161,7 @@ const DataGrid = ({ columns: given, source, naturalSort, initialScrollIndex = 0,
154
161
  style: { minWidth },
155
162
  children: /* @__PURE__ */ jsx(GridHeader, {
156
163
  cells: headerCells,
157
- specs: columns,
164
+ specs: sized,
158
165
  gutter,
159
166
  trailing,
160
167
  minScale,
@@ -170,7 +177,7 @@ const DataGrid = ({ columns: given, source, naturalSort, initialScrollIndex = 0,
170
177
  });
171
178
  const rowsInView = loaded.map(({ index, offset, row }) => {
172
179
  const frame = {
173
- specs: columns,
180
+ specs: sized,
174
181
  gutter,
175
182
  index,
176
183
  offset,
@@ -196,9 +203,15 @@ const DataGrid = ({ columns: given, source, naturalSort, initialScrollIndex = 0,
196
203
  }) : void 0,
197
204
  cells: tableRow.getAllCells().map((cell) => {
198
205
  const content = /* @__PURE__ */ jsx(table.FlexRender, { cell });
206
+ const editable = Boolean(cellEditor?.editable(row, cell.column.id));
207
+ const linked = rowLink && cell.column.id === linkColumn && !editable ? /* @__PURE__ */ jsx(RouteLink, {
208
+ to: rowLink(row),
209
+ className: "flex min-w-0 items-center hover:underline",
210
+ children: content
211
+ }) : content;
199
212
  return {
200
213
  spec: columns.find((spec) => spec.id === cell.column.id),
201
- ...cellEditing ? cellEditing.cell(index, row, cell.column.id, content) : { content }
214
+ ...cellEditing ? cellEditing.cell(index, row, cell.column.id, linked) : { content: linked }
202
215
  };
203
216
  })
204
217
  };
@@ -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 };
@@ -40,10 +40,14 @@ const dragStyle = (frame, dragY, transition) => ({
40
40
  transform: dragY === 0 ? void 0 : `translateY(${dragY}px)`,
41
41
  transition
42
42
  });
43
+ const openUnlessLink = (onOpen) => (event) => {
44
+ if (event.target.closest("a")) return;
45
+ onOpen();
46
+ };
43
47
  const DataRow = ({ cells, selected, onToggle, onOpen, menu, handle, rowRef, dragY, transition, dragging, ...frame }) => /* @__PURE__ */ jsxs("div", {
44
48
  ref: rowRef,
45
49
  role: "row",
46
- onClick: onOpen,
50
+ onClick: onOpen && openUnlessLink(onOpen),
47
51
  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
52
  style: dragY === void 0 ? frameStyle(frame) : dragStyle(frame, dragY, transition),
49
53
  children: [
@@ -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
- const writePinned = (storage, key, pinned) => guarded(() => pinned.length === 0 ? storage.removeItem(key) : storage.setItem(key, JSON.stringify(pinned)), void 0);
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,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 [pinned, setPinned] = useState(() => (storageKey ? readPinned(localStorage, storageKey) : void 0) ?? initial);
10
- return [pinned, useCallback((next) => {
11
- setPinned(next);
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 };
@@ -1,6 +1,8 @@
1
+ import { recordTitleField } from "./model-helpers.js";
1
2
  import { humanize } from "./naming.js";
2
- import { isMarkdown } from "../markdown/markdown-hint.js";
3
+ import { badgeWidth, headerWidth } from "../data-grid/content-widths.js";
3
4
  import { markdownExcerpt } from "../markdown/markdown-excerpt.js";
5
+ import { isMarkdown } from "../markdown/markdown-hint.js";
4
6
  import { enumLabel } from "@protobase/schema";
5
7
  //#region src/live/columns-from-model.ts
6
8
  const tones = {
@@ -37,10 +39,6 @@ const widths = {
37
39
  user: 130,
38
40
  file: 180
39
41
  };
40
- /** Room for a header at 12px plus cell padding, the sort arrow and the column menu button. */
41
- const headerWidth = (header, sortable) => Math.ceil(header.length * 7.2) + 24 + (sortable ? 16 : 0) + 28;
42
- /** A status badge: dot, padding and the longest label. */
43
- const badgeWidth = (labels) => Math.ceil(Math.max(0, ...labels.map((label) => label.length)) * 7.2) + 56;
44
42
  const fixedKinds = /* @__PURE__ */ new Set([
45
43
  "datetime",
46
44
  "date",
@@ -67,6 +65,7 @@ const currencyCents = (value) => Math.round(Number.parseFloat(String(value)) * 1
67
65
  const listColumnNames = (resource, view) => view?.list?.columns ?? Object.keys(resource.fields).slice(0, 6);
68
66
  /** Grid columns for `names`, by default the view's list: kinds follow the field types and the view's format hints. */
69
67
  const columnsFromModel = (resource, view, highlight = [], names = listColumnNames(resource, view)) => {
68
+ const title = recordTitleField(resource, view);
70
69
  return names.flatMap((name, index) => {
71
70
  const field = resource.fields[name];
72
71
  if (!field) return [];
@@ -82,6 +81,7 @@ const columnsFromModel = (resource, view, highlight = [], names = listColumnName
82
81
  sortable: field.sortable,
83
82
  minWidth: Math.max(kind === "id" ? 150 : 0, headerWidth(viewField?.label ?? humanize(name), field.sortable), kind === "status" ? badgeWidth(Object.values(labels)) : 0, suffixWidth > 0 ? widths[kind] + suffixWidth : 0),
84
83
  ...fixedKinds.has(kind) && { fixed: true },
84
+ ...name === title && { title: true },
85
85
  ...highlight.includes(name) && { highlight: true },
86
86
  ...field.type === "enum" && {
87
87
  tones: Object.fromEntries((field.enumValues ?? []).map((value) => [value, toneFor(value)])),
@@ -102,4 +102,4 @@ const columnsFromModel = (resource, view, highlight = [], names = listColumnName
102
102
  });
103
103
  };
104
104
  //#endregion
105
- export { columnsFromModel, headerWidth, listColumnNames, toneFor };
105
+ export { columnsFromModel, listColumnNames, toneFor };
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.21.2";
2
+ var version = "0.21.4";
3
3
  var protobase = { "pluginApi": 1 };
4
4
  //#endregion
5
5
  export { protobase, version };