@rebasepro/admin 0.13.0 → 0.13.1-canary.g27a562a

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 (143) hide show
  1. package/dist/{CollectionEditorDialog-CjXkWZ64.js → CollectionEditorDialog-Bcu6QdZ9.js} +3 -3
  2. package/dist/{CollectionEditorDialog-CjXkWZ64.js.map → CollectionEditorDialog-Bcu6QdZ9.js.map} +1 -1
  3. package/dist/{PropertyEditView-W8I3YV89.js → PropertyEditView-eefnNKmT.js} +2 -2
  4. package/dist/{PropertyEditView-W8I3YV89.js.map → PropertyEditView-eefnNKmT.js.map} +1 -1
  5. package/dist/{RouterCollectionsStudioView-BsLTNoqX.js → RouterCollectionsStudioView-plWURw8l.js} +9 -13
  6. package/dist/RouterCollectionsStudioView-plWURw8l.js.map +1 -0
  7. package/dist/collection_editor/serializable_types.d.ts +1 -0
  8. package/dist/collection_editor_ui.js +4 -4
  9. package/dist/components/AdditionalFieldValue.d.ts +25 -0
  10. package/dist/components/CollectionViewBinding/CollectionViewBinding.d.ts +16 -0
  11. package/dist/components/CollectionViewBinding/SlotValue.d.ts +63 -0
  12. package/dist/components/CollectionViewBinding/SplitListCloseButton.d.ts +17 -4
  13. package/dist/components/CollectionViewBinding/SplitListShowButton.d.ts +12 -0
  14. package/dist/components/CollectionViewBinding/usePreviewSlots.d.ts +55 -5
  15. package/dist/components/DetailViewBinding.d.ts +15 -0
  16. package/dist/components/DrawerNavigationGroup.d.ts +9 -6
  17. package/dist/components/DrawerNavigationItem.d.ts +16 -6
  18. package/dist/components/EditViewBinding.d.ts +30 -1
  19. package/dist/components/EntityDisplayHeader.d.ts +53 -0
  20. package/dist/components/EntityIdentityBar.d.ts +43 -1
  21. package/dist/components/EntityViewBinding.d.ts +60 -6
  22. package/dist/components/RebaseLayout.d.ts +1 -1
  23. package/dist/components/RebaseShell.d.ts +1 -1
  24. package/dist/components/RelationSelector.d.ts +8 -1
  25. package/dist/components/UserSelector.d.ts +8 -1
  26. package/dist/components/app/Scaffold.d.ts +9 -3
  27. package/dist/components/index.d.ts +2 -0
  28. package/dist/{export-J79rMoNj.js → export-BDnH21ch.js} +2 -2
  29. package/dist/{export-J79rMoNj.js.map → export-BDnH21ch.js.map} +1 -1
  30. package/dist/form/components/FieldBlock.d.ts +58 -4
  31. package/dist/form/components/FormRail.d.ts +13 -0
  32. package/dist/form/components/FormSections.d.ts +15 -1
  33. package/dist/form/components/useRailVisible.d.ts +5 -4
  34. package/dist/form/form_utils.d.ts +38 -1
  35. package/dist/{history-D7L_JlLO.js → history-CnHOzMCM.js} +4 -4
  36. package/dist/{history-D7L_JlLO.js.map → history-CnHOzMCM.js.map} +1 -1
  37. package/dist/hooks/useEntityDisplay.d.ts +102 -0
  38. package/dist/hooks/useEntityDisplayTitle.d.ts +18 -9
  39. package/dist/{import-BvYl--U4.js → import-Dv28kDBV.js} +3 -3
  40. package/dist/{import-BvYl--U4.js.map → import-Dv28kDBV.js.map} +1 -1
  41. package/dist/index.d.ts +2 -2
  42. package/dist/index.js +152 -57
  43. package/dist/index.js.map +1 -1
  44. package/dist/preview/compact_text.d.ts +23 -0
  45. package/dist/preview/components/BooleanPreview.d.ts +3 -1
  46. package/dist/preview/property_previews/ArrayOfStringsPreview.d.ts +1 -1
  47. package/dist/preview/property_previews/ArrayOneOfPreview.d.ts +1 -1
  48. package/dist/preview/property_previews/ArrayPropertyPreview.d.ts +1 -1
  49. package/dist/preview/property_previews/MapPropertyPreview.d.ts +1 -1
  50. package/dist/preview/property_previews/StringPropertyPreview.d.ts +1 -1
  51. package/dist/routes/RebaseRoute.d.ts +1 -1
  52. package/dist/types/components/PropertyPreviewProps.d.ts +19 -0
  53. package/dist/util/number_format.d.ts +14 -0
  54. package/dist/util/previews.d.ts +9 -2
  55. package/dist/{util-GiwPgxnL.js → util-CvofPA4B.js} +3791 -2295
  56. package/dist/util-CvofPA4B.js.map +1 -0
  57. package/package.json +9 -9
  58. package/src/collection_editor/serializable_types.ts +1 -0
  59. package/src/collection_editor/ui/collection_editor/CollectionsStudioView.tsx +9 -11
  60. package/src/components/AdditionalFieldValue.tsx +68 -0
  61. package/src/components/CollectionTableBinding/CollectionTableBinding.tsx +7 -8
  62. package/src/components/CollectionTableBinding/PropertyTableCell.tsx +2 -2
  63. package/src/components/CollectionTableBinding/column_utils.tsx +15 -6
  64. package/src/components/CollectionTableBinding/internal/popup_field/PopupFormField.tsx +2 -2
  65. package/src/components/CollectionViewBinding/BoardCardBinding.tsx +26 -27
  66. package/src/components/CollectionViewBinding/CollectionListViewBinding.tsx +790 -351
  67. package/src/components/CollectionViewBinding/CollectionViewActions.tsx +1 -1
  68. package/src/components/CollectionViewBinding/CollectionViewBinding.tsx +38 -8
  69. package/src/components/CollectionViewBinding/EntityCardBinding.tsx +23 -36
  70. package/src/components/CollectionViewBinding/SlotValue.tsx +156 -0
  71. package/src/components/CollectionViewBinding/SplitListCloseButton.tsx +23 -7
  72. package/src/components/CollectionViewBinding/SplitListShowButton.tsx +28 -0
  73. package/src/components/CollectionViewBinding/SplitListView.tsx +47 -7
  74. package/src/components/CollectionViewBinding/useBoardDataController.tsx +12 -3
  75. package/src/components/CollectionViewBinding/usePreviewSlots.ts +183 -64
  76. package/src/components/CollectionViewBinding/utils.ts +7 -4
  77. package/src/components/DefaultDrawer.tsx +19 -23
  78. package/src/components/DetailViewBinding.tsx +94 -56
  79. package/src/components/DrawerNavigationGroup.tsx +16 -39
  80. package/src/components/DrawerNavigationItem.tsx +44 -16
  81. package/src/components/EditViewBinding.tsx +216 -42
  82. package/src/components/EntityDisplayHeader.tsx +170 -0
  83. package/src/components/EntityIdentityBar.tsx +163 -23
  84. package/src/components/EntityInspector.tsx +24 -6
  85. package/src/components/EntityPreviewBinding.tsx +58 -18
  86. package/src/components/EntityViewBinding.tsx +344 -41
  87. package/src/components/InlineEntityPreview.tsx +10 -10
  88. package/src/components/RebaseLayout.tsx +2 -2
  89. package/src/components/RebaseShell.tsx +2 -2
  90. package/src/components/ReferenceTable/SelectionTableBinding.tsx +4 -2
  91. package/src/components/RelationSelector.tsx +23 -9
  92. package/src/components/SideDialogs.tsx +15 -3
  93. package/src/components/SidePanelBinding.tsx +122 -66
  94. package/src/components/UserSelector.tsx +20 -8
  95. package/src/components/app/Scaffold.tsx +112 -10
  96. package/src/components/history/LastEditedByIndicator.tsx +7 -12
  97. package/src/components/index.ts +3 -0
  98. package/src/data_import/utils/data.ts +9 -1
  99. package/src/form/EntityForm.tsx +74 -53
  100. package/src/form/EntityFormBinding.tsx +24 -11
  101. package/src/form/PropertyFieldBinding.tsx +2 -2
  102. package/src/form/components/FieldBlock.tsx +80 -10
  103. package/src/form/components/FormRail.tsx +19 -2
  104. package/src/form/components/FormSections.tsx +32 -5
  105. package/src/form/components/StorageUploadProgress.tsx +9 -3
  106. package/src/form/components/useRailVisible.tsx +5 -4
  107. package/src/form/field_bindings/ArrayCustomShapedFieldBinding.tsx +2 -2
  108. package/src/form/field_bindings/BinaryFieldBinding.tsx +10 -5
  109. package/src/form/field_bindings/BlockFieldBinding.tsx +3 -3
  110. package/src/form/field_bindings/GeopointFieldBinding.tsx +23 -16
  111. package/src/form/field_bindings/MapFieldBinding.tsx +2 -2
  112. package/src/form/field_bindings/MarkdownEditorFieldBinding.tsx +13 -7
  113. package/src/form/field_bindings/MultiSelectFieldBinding.tsx +7 -1
  114. package/src/form/field_bindings/ReadOnlyFieldBinding.tsx +21 -2
  115. package/src/form/field_bindings/ReferenceFieldBinding.tsx +1 -1
  116. package/src/form/field_bindings/RelationFieldBinding.tsx +5 -2
  117. package/src/form/field_bindings/RepeatFieldBinding.tsx +3 -3
  118. package/src/form/field_bindings/StorageUploadFieldBinding.tsx +2 -2
  119. package/src/form/field_bindings/UserSelectFieldBinding.tsx +4 -1
  120. package/src/form/field_bindings/VectorFieldBinding.tsx +6 -4
  121. package/src/form/form_utils.ts +78 -0
  122. package/src/hooks/navigation/useBuildCollectionRegistryController.tsx +29 -5
  123. package/src/hooks/useEntityDisplay.tsx +262 -0
  124. package/src/hooks/useEntityDisplayTitle.tsx +27 -38
  125. package/src/index.ts +9 -1
  126. package/src/preview/PropertyPreview.tsx +25 -5
  127. package/src/preview/compact_text.ts +68 -0
  128. package/src/preview/components/BooleanPreview.tsx +5 -2
  129. package/src/preview/components/StorageThumbnail.tsx +5 -2
  130. package/src/preview/property_previews/ArrayOfRelationsPreview.tsx +6 -3
  131. package/src/preview/property_previews/ArrayOfStringsPreview.tsx +26 -1
  132. package/src/preview/property_previews/ArrayOneOfPreview.tsx +10 -1
  133. package/src/preview/property_previews/ArrayPropertyPreview.tsx +10 -1
  134. package/src/preview/property_previews/MapPropertyPreview.tsx +52 -1
  135. package/src/preview/property_previews/NumberPropertyPreview.tsx +9 -1
  136. package/src/preview/property_previews/StringPropertyPreview.tsx +8 -5
  137. package/src/routes/RebaseRoute.tsx +62 -4
  138. package/src/types/components/PropertyPreviewProps.tsx +21 -0
  139. package/src/util/number_format.ts +46 -0
  140. package/src/util/previews.ts +8 -55
  141. package/src/util/property_utils.tsx +6 -1
  142. package/dist/RouterCollectionsStudioView-BsLTNoqX.js.map +0 -1
  143. package/dist/util-GiwPgxnL.js.map +0 -1
@@ -1,12 +1,13 @@
1
1
 
2
- import type { Property } from "@rebasepro/types";
2
+ import type { Properties, Property } from "@rebasepro/types";
3
3
  import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
4
  import { Entity } from "@rebasepro/types";
5
5
  import { CollectionSize, EntityAction, EntityTableController, SelectionController, AdminCollection } from "@rebasepro/admin-types";
6
6
  import {
7
+ ArrowDownIcon,
8
+ ArrowUpIcon,
7
9
  Checkbox,
8
10
  Chip,
9
- CircularProgress,
10
11
  cls,
11
12
  defaultBorderMixin,
12
13
  IconButton,
@@ -20,13 +21,16 @@ import {
20
21
  useCustomizationController
21
22
  } from "@rebasepro/app";
22
23
  import { useAnalyticsController } from "@rebasepro/app";
23
- import { getEntityPreviewKeys } from "../../util/previews";
24
24
  import { IconForView } from "@rebasepro/app";
25
25
  import { getIcon } from "@rebasepro/app";
26
- import { getValueInPath } from "@rebasepro/utils";
27
- import { useCollectionSlotKeys, resolveEntitySlots, type CollectionSlotKeys } from "./usePreviewSlots";
26
+ import { hasDeclaredDisplay } from "@rebasepro/app";
27
+ import { formatRelativeTime, getValueInPath } from "@rebasepro/utils";
28
+ import { useCollectionSlotKeys, useEntitySlots, type CollectionSlotKeys, type EntityPreviewSlots } from "./usePreviewSlots";
29
+ import { SlotValue, TagChips } from "./SlotValue";
28
30
  import { useAdminContext } from "../../hooks/useAdminContext";
29
31
  import { resolveEntityAction } from "../../util/resolutions";
32
+ import { getSortablePropertyOptions } from "../CollectionTableBinding/column_utils";
33
+ import { getResolvedPropertyInPath } from "../../util/property_utils";
30
34
 
31
35
  export type CollectionListViewBindingProps<M extends Record<string, unknown> = Record<string, unknown>> = {
32
36
  collection: AdminCollection<M>;
@@ -69,17 +73,79 @@ export type CollectionListViewBindingProps<M extends Record<string, unknown> = R
69
73
  openEntityMode?: "side_panel" | "full_screen" | "split" | "dialog";
70
74
  };
71
75
 
72
- type ListColumnDef = {
76
+ /**
77
+ * Which display slot fills a column, when a slot does rather than a property.
78
+ * A status is an enum chip and a date a relative timestamp wherever they come
79
+ * from, so the cell renders the slot; the column exists either way, because a
80
+ * header needs something to sit over.
81
+ */
82
+ type ListColumnSlot = "tags" | "status" | "date";
83
+
84
+ /**
85
+ * One column of the list, in the header and in every row.
86
+ *
87
+ * The header and the cells read the same definition — the same width, the same
88
+ * alignment, the same decision about being shown at this container width — so a
89
+ * label cannot end up over a column that is not there, or over the wrong one.
90
+ */
91
+ type ListColumn = {
73
92
  key: string;
74
93
  label: string;
75
- property: Property;
76
- isTitle?: boolean;
77
- isStatus?: boolean;
78
- isDate?: boolean;
94
+ /** Set when a display slot fills the cell; absent when the record does. */
95
+ slot?: ListColumnSlot;
96
+ property?: Property;
79
97
  align: "left" | "center" | "right";
80
98
  width: string;
99
+ /**
100
+ * What {@link width} costs in pixels, so the fit calculation can budget in
101
+ * the same units the row lays out in. A flat per-column estimate treated a
102
+ * relation (w-64) as costing the same as a date (w-28), which is where a row
103
+ * ran out of width for the title while still claiming everything fit.
104
+ */
105
+ widthPx: number;
106
+ /** Whether clicking this header can order the collection by it. */
107
+ sortable: boolean;
108
+ /**
109
+ * What survives when the row runs out of width: the lowest goes first.
110
+ */
111
+ priority: number;
81
112
  };
82
113
 
114
+ // ── Column priorities ─────────────────────────────────────────────────
115
+ // Read as "what a narrowing row gives up first".
116
+ //
117
+ // Three bands. The sort or filter *in force* comes first: it is the reason the
118
+ // rows are in the order they are in, and hiding the column it names leaves that
119
+ // order unexplained. The collection's own columns come next — its `listProperties`,
120
+ // or the status and date it was read as having. A column left over from a
121
+ // request since cleared comes last: it is worth keeping while there is room for
122
+ // it, and it is not worth the collection's own status.
123
+
124
+ /** A column left over from a sort or filter that is no longer applied. */
125
+ const PRIORITY_STALE_REQUEST = 1000;
126
+ /** The collection's own columns. Slots sit just above it, in give-way order. */
127
+ const PRIORITY_DECLARED = 2000;
128
+ const PRIORITY_DATE = PRIORITY_DECLARED;
129
+ const PRIORITY_STATUS = PRIORITY_DECLARED + 1;
130
+ const PRIORITY_TAGS = PRIORITY_DECLARED + 2;
131
+ /** The sort or filter currently in force. */
132
+ const PRIORITY_ACTIVE_REQUEST = 3000;
133
+
134
+ /**
135
+ * How wide a column of this property wants to be — the Tailwind class the cell
136
+ * gets, and the pixel cost of that class. The two are returned together because
137
+ * they must not drift: the budget is only meaningful if it describes the widths
138
+ * actually rendered. `lg:` variants key off the viewport, not this panel, so a
139
+ * narrow split on a wide screen gets the larger of the two — the cost we budget.
140
+ */
141
+ function getIdealColumnWidth(prop: Property): { width: string, widthPx: number } {
142
+ if (prop.type === "string" && "enum" in prop) return { width: "flex-shrink-0 w-32", widthPx: 128 };
143
+ if (prop.type === "date" || prop.type === "number" || prop.type === "boolean") return { width: "flex-shrink-0 w-28", widthPx: 112 };
144
+ if (prop.type === "reference" || prop.type === "relation") return { width: "flex-shrink-0 w-56 lg:w-64", widthPx: 256 };
145
+ if (prop.type === "string") return { width: "flex-shrink-0 w-40 lg:w-48", widthPx: 192 };
146
+ return { width: "flex-shrink-0 w-40", widthPx: 160 };
147
+ }
148
+
83
149
  /**
84
150
  * Get row padding/spacing classes based on size
85
151
  */
@@ -94,47 +160,47 @@ function getRowClasses(size: CollectionSize): string {
94
160
  }
95
161
  }
96
162
 
163
+ // ── Row layout budget ─────────────────────────────────────────────────
164
+ // What the row spends before a single column is placed. These mirror the
165
+ // classes on the row itself (`px-5`, `gap-4`, `w-8` checkbox, `w-10` image);
166
+ // they are the same layout described twice, so a change to one is a change to
167
+ // the other.
168
+
169
+ /** `px-5` on both sides. */
170
+ const ROW_PADDING_WIDTH = 40;
171
+ /** The checkbox cell (`w-8`). */
172
+ const CHECKBOX_WIDTH = 32;
173
+ /** The thumbnail / icon square (`w-10`). */
174
+ const IMAGE_WIDTH = 40;
175
+ /** `gap-4` between every cell of the row. */
176
+ const COLUMN_GAP = 16;
177
+
97
178
  /**
98
- * Estimated row height in pixels for virtualization, based on size.
179
+ * The title's share of a row, taken before any column bids for width.
180
+ *
181
+ * A title is a name, and a name truncated to "Lider Sr. en…" has stopped being
182
+ * one. Columns are a scanner's convenience; the title is the row's identity, so
183
+ * a narrow list spends its width on the title and simply shows fewer columns.
99
184
  */
100
- function getEstimatedRowHeight(size: CollectionSize): number {
101
- switch (size) {
102
- case "xs": return 44;
103
- case "s": return 52;
104
- case "m": return 64;
105
- case "l": return 76;
106
- case "xl": return 88;
107
- default: return 64;
108
- }
109
- }
185
+ const TITLE_COMFORTABLE_WIDTH = 320;
110
186
 
111
- /** Number of extra rows rendered above/below the viewport. */
112
- const OVERSCAN_COUNT = 8;
113
-
114
- /** Threshold in pixels from the bottom of the scroll area to trigger loading more. */
115
- const LOAD_MORE_THRESHOLD = 400;
187
+ /** An `IconButton size="small"` (`w-8`), which is what a row action renders as. */
188
+ const ACTION_BUTTON_WIDTH = 32;
189
+ /** `gap-0.5` between row actions. */
190
+ const ACTION_BUTTON_GAP = 2;
191
+ /**
192
+ * How many rows are read to size the actions cell.
193
+ *
194
+ * Actions are resolved per record, so the width they need is only knowable by
195
+ * asking — and asking every loaded row would mean re-resolving thousands of
196
+ * them on every page. The first rows are representative: a collection whose
197
+ * actions vary at all varies within its first screenful.
198
+ */
199
+ const ACTIONS_SAMPLE_SIZE = 20;
116
200
 
117
201
  /** Stable empty array for when no list-view actions are available. */
118
202
  const EMPTY_LIST_VIEW_ACTIONS: EntityAction[] = [];
119
203
 
120
- /**
121
- * Walk up the DOM from `element` to find the nearest scrollable ancestor.
122
- */
123
- function getScrollParent(element: HTMLElement | null): HTMLElement | null {
124
- let parent = element?.parentElement ?? null;
125
- while (parent) {
126
- const style = getComputedStyle(parent);
127
- if (
128
- style.overflowY === "auto" || style.overflowY === "scroll" ||
129
- style.overflow === "auto" || style.overflow === "scroll"
130
- ) {
131
- return parent;
132
- }
133
- parent = parent.parentElement;
134
- }
135
- return document.documentElement;
136
- }
137
-
138
204
  /**
139
205
  * Returns true if a property type should NOT be rendered via
140
206
  * PropertyPreview in list row columns (because it would blow up height).
@@ -235,9 +301,15 @@ function compactValueSummary(value: unknown, property: Property): string | null
235
301
  }
236
302
 
237
303
  /**
238
- * Format a date value for display in the list
304
+ * Format a date value for display in the list.
305
+ *
306
+ * A date column holds whatever the developer put in it, so the value is as
307
+ * likely to be ahead of now as behind it. The relative phrase therefore comes
308
+ * from {@link formatRelativeTime}, which reads the sign: a row due this
309
+ * afternoon says "in 2h" rather than the "-1d ago" that flooring a negative
310
+ * difference used to produce.
239
311
  */
240
- function formatDateValue(value: unknown): string | null {
312
+ function formatDateValue(value: unknown, now: Date = new Date()): string | null {
241
313
  if (!value) return null;
242
314
  let date: Date | null = null;
243
315
  if (value instanceof Date) {
@@ -247,22 +319,25 @@ function formatDateValue(value: unknown): string | null {
247
319
  }
248
320
  if (!date || isNaN(date.getTime())) return null;
249
321
 
250
- const now = new Date();
251
322
  const diffMs = now.getTime() - date.getTime();
252
323
  const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
253
324
 
325
+ // Today and yesterday get their own wording; both are strictly in the past,
326
+ // because a negative difference floors to -1 and falls through to below.
254
327
  if (diffDays === 0) {
255
328
  return date.toLocaleTimeString(undefined, { hour: "2-digit",
256
329
  minute: "2-digit" });
257
- } else if (diffDays === 1) {
258
- return "Yesterday";
259
- } else if (diffDays < 7) {
260
- return `${diffDays}d ago`;
261
- } else {
262
- return date.toLocaleDateString(undefined, { month: "short",
263
- day: "numeric",
264
- year: diffDays > 365 ? "numeric" : undefined });
265
330
  }
331
+ if (diffDays === 1) return "Yesterday";
332
+
333
+ const relative = formatRelativeTime(date, { now });
334
+ if (relative) return relative;
335
+
336
+ // Comparing years rather than counting days: a date six weeks back across a
337
+ // new year needs the year, and one 400 days ahead needs it just as much.
338
+ return date.toLocaleDateString(undefined, { month: "short",
339
+ day: "numeric",
340
+ year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined });
266
341
  }
267
342
 
268
343
  /**
@@ -293,14 +368,21 @@ export function CollectionListViewBinding<M extends Record<string, unknown> = Re
293
368
  const context = useAdminContext();
294
369
 
295
370
  const containerRef = useRef<HTMLDivElement>(null);
296
- const [containerWidth, setContainerWidth] = useState(1200);
371
+ // `undefined` is "not measured yet", and it renders the title alone. The
372
+ // seed used to be a 1200px guess, which is a claim about a list that may be
373
+ // a 300px column of a split — and, because the ref below was never attached
374
+ // to anything, a guess nothing ever corrected.
375
+ const [containerWidth, setContainerWidth] = useState<number | undefined>(undefined);
297
376
 
298
377
  // Track container width for responsive column visibility
299
378
  useEffect(() => {
300
379
  const el = containerRef.current;
301
380
  if (!el) return;
381
+ const measure = (width: number) => setContainerWidth(width > 0 ? width : undefined);
382
+ measure(el.getBoundingClientRect().width);
383
+ if (typeof ResizeObserver === "undefined") return;
302
384
  const ro = new ResizeObserver(([entry]) => {
303
- if (entry) setContainerWidth(entry.contentRect.width);
385
+ if (entry) measure(entry.contentRect.width);
304
386
  });
305
387
  ro.observe(el);
306
388
  return () => ro.disconnect();
@@ -314,16 +396,13 @@ export function CollectionListViewBinding<M extends Record<string, unknown> = Re
314
396
  itemCount,
315
397
  setItemCount,
316
398
  pageSize = 50,
317
- paginationEnabled
399
+ paginationEnabled,
400
+ sortBy,
401
+ setSortBy,
402
+ filterValues,
403
+ checkFilterCombination
318
404
  } = tableController;
319
405
 
320
- const isLoadingMore = useRef(false);
321
-
322
- // Reset the loading-more gate when new data arrives
323
- useEffect(() => {
324
- if (!dataLoading) isLoadingMore.current = false;
325
- }, [dataLoading]);
326
-
327
406
  const resolvedCollection = collection;
328
407
 
329
408
  // ── Shared slot resolution (replaces 4 individual useMemo calls) ──
@@ -334,158 +413,292 @@ export function CollectionListViewBinding<M extends Record<string, unknown> = Re
334
413
  );
335
414
  const { titleKey: titlePropertyKey, imageKey: imagePropertyKey, subtitleKey, statusKey: statusPropertyKey, dateKey: datePropertyKey } = slotKeys;
336
415
 
337
- const previewKeys = useMemo(
338
- () => getEntityPreviewKeys(authController, resolvedCollection, customizationController.propertyConfigs, undefined, 10)
339
- .filter(key => key !== titlePropertyKey && key !== imagePropertyKey && key !== statusPropertyKey && key !== datePropertyKey),
340
- [authController, resolvedCollection, customizationController.propertyConfigs, titlePropertyKey, imagePropertyKey, statusPropertyKey, datePropertyKey]
341
- );
416
+ const columnMode = !!resolvedCollection.listProperties && resolvedCollection.listProperties.length > 0;
342
417
 
343
- const columns = useMemo(() => {
344
- const cols: ListColumnDef[] = [];
418
+ /**
419
+ * The properties an `ORDER BY` can be written for, under the same authority
420
+ * the table headers and the sort menu ask — so a header that offers a sort
421
+ * here offers it there, and none of the three invents one the driver would
422
+ * silently drop.
423
+ */
424
+ const sortableKeys = useMemo(
425
+ () => new Set(getSortablePropertyOptions(resolvedCollection.properties as Properties).map(option => option.key)),
426
+ [resolvedCollection.properties]
427
+ );
345
428
 
346
- const getIdealColumnWidth = (prop: Property) => {
347
- if (prop.type === "string" && "enum" in prop) return "flex-shrink-0 w-32";
348
- if (prop.type === "date" || prop.type === "number" || prop.type === "boolean") return "flex-shrink-0 w-28";
349
- if (prop.type === "reference" || prop.type === "relation") return "flex-shrink-0 w-56 lg:w-64";
350
- if (prop.type === "string") return "flex-shrink-0 w-40 lg:w-48";
351
- return "flex-shrink-0 w-40";
429
+ /** The row's identity cell: the leading column, or the title slot. */
430
+ const titleColumn = useMemo<ListColumn | undefined>(() => {
431
+ const key = columnMode ? resolvedCollection.listProperties?.[0] : titlePropertyKey;
432
+ if (!key) return undefined;
433
+ const property = resolvedCollection.properties[key] as Property | undefined;
434
+ return {
435
+ key,
436
+ label: property?.name || (columnMode ? key : "Name"),
437
+ property,
438
+ align: "left",
439
+ width: "flex-1 min-w-0",
440
+ widthPx: TITLE_COMFORTABLE_WIDTH,
441
+ sortable: sortableKeys.has(key),
442
+ priority: Number.MAX_SAFE_INTEGER
352
443
  };
444
+ }, [columnMode, resolvedCollection, titlePropertyKey, sortableKeys]);
353
445
 
354
- if (resolvedCollection.listProperties && resolvedCollection.listProperties.length > 0) {
355
- resolvedCollection.listProperties.forEach((key, index) => {
356
- const prop = resolvedCollection.properties[key] as Property;
357
- if (!prop) return;
358
-
359
- cols.push({
446
+ /**
447
+ * The trailing columns the collection itself implies: the developer's
448
+ * `listProperties` when there are any, otherwise the display slots the row
449
+ * has always shown to the right of the title.
450
+ */
451
+ const declaredColumns = useMemo<ListColumn[]>(() => {
452
+ if (columnMode) {
453
+ const keys = resolvedCollection.listProperties!.slice(1);
454
+ return keys.flatMap((key, index) => {
455
+ const property = resolvedCollection.properties[key] as Property | undefined;
456
+ if (!property) return [];
457
+ return [{
360
458
  key,
361
- label: prop.name || key,
362
- property: prop,
363
- isTitle: index === 0,
364
- align: prop.type === "number" || prop.type === "date" ? "right" : "left",
365
- width: index === 0 ? "flex-1 min-w-[200px]" : getIdealColumnWidth(prop)
366
- });
459
+ label: property.name || key,
460
+ property,
461
+ align: (property.type === "number" || property.type === "date" ? "right" : "left") as ListColumn["align"],
462
+ ...getIdealColumnWidth(property),
463
+ sortable: sortableKeys.has(key),
464
+ // Declared order is a statement of importance: the first
465
+ // column after the title outlives the last one.
466
+ priority: PRIORITY_DECLARED + (keys.length - index)
467
+ }];
367
468
  });
368
- return cols;
369
469
  }
370
470
 
371
- // Smart default
372
- if (titlePropertyKey) {
373
- const prop = resolvedCollection.properties[titlePropertyKey] as Property;
374
- if (prop) {
375
- cols.push({
376
- key: titlePropertyKey,
377
- label: prop.name || "Name",
378
- property: prop,
379
- isTitle: true,
380
- align: "left",
381
- width: "flex-1 min-w-[200px]"
382
- });
383
- }
384
- }
385
-
386
- // Collect all candidate columns — visible count determined later by container width
387
- const showDate = datePropertyKey && size !== "xs";
388
- const showStatus = statusPropertyKey && size !== "xs" && size !== "s";
389
-
390
- // The first previewKey makes a great subtitle (strict rules: no relations, no large blocks).
391
- const subtitleKey = previewKeys.length > 0 ? previewKeys[0] : undefined;
471
+ const cols: ListColumn[] = [];
472
+
473
+ // A slot column exists when the collection has something to put in it —
474
+ // a property to read, or a resolver that computes one. Whether a given
475
+ // record fills it is a per-row question, and the wrong one to ask here:
476
+ // a column that came and went with the values under it would leave its
477
+ // header over a different cell on every row.
478
+ const slotColumn = (
479
+ slot: ListColumnSlot,
480
+ key: string | undefined,
481
+ fallbackLabel: string,
482
+ layout: { align: ListColumn["align"], width: string, widthPx: number },
483
+ priority: number
484
+ ): ListColumn | undefined => {
485
+ const declared = hasDeclaredDisplay(resolvedCollection, slot);
486
+ if (!key && !declared) return undefined;
487
+ const property = key ? resolvedCollection.properties[key] as Property | undefined : undefined;
488
+ return {
489
+ key: key ?? `display:${slot}`,
490
+ label: property?.name || fallbackLabel,
491
+ slot,
492
+ property,
493
+ ...layout,
494
+ // A computed slot has no column to order by, whatever it renders.
495
+ sortable: !!key && sortableKeys.has(key),
496
+ priority
497
+ };
498
+ };
392
499
 
393
- // For the additional columns, we can use any unused property (including relations!).
394
- const allKeys = resolvedCollection.propertiesOrder || Object.keys(resolvedCollection.properties);
395
- const usedKeys = new Set([
396
- titlePropertyKey,
397
- imagePropertyKey,
500
+ const tags = slotColumn(
501
+ "tags",
502
+ slotKeys.tagsKey,
503
+ "Tags",
504
+ { align: "left", width: "flex-shrink-0 w-40", widthPx: 160 },
505
+ PRIORITY_TAGS
506
+ );
507
+ if (tags) cols.push(tags);
508
+
509
+ const status = slotColumn(
510
+ "status",
398
511
  statusPropertyKey,
512
+ "Status",
513
+ { align: "left", width: "flex-shrink-0 w-32", widthPx: 128 },
514
+ PRIORITY_STATUS
515
+ );
516
+ if (status) cols.push(status);
517
+
518
+ const date = slotColumn(
519
+ "date",
399
520
  datePropertyKey,
400
- subtitleKey,
401
- "id"
402
- ]);
403
-
404
- const availableExtraKeys = allKeys.filter(k => {
405
- if (usedKeys.has(k)) return false;
406
- const prop = resolvedCollection.properties[k] as Property | undefined;
407
- if (!prop) return false;
408
- // Exclude storage/image properties — they are already rendered in the image slot
409
- if (prop.type === "string" && (prop.storage || prop.admin?.urlPreview === "image")) return false;
410
- if (prop.type === "array" && prop.of && !Array.isArray(prop.of)) {
411
- const inner = prop.of;
412
- if (inner.type === "string" && (inner.storage || inner.admin?.urlPreview === "image")) return false;
413
- }
414
- return true;
415
- });
521
+ "Modified",
522
+ { align: "right", width: "flex-shrink-0 w-20", widthPx: 80 },
523
+ PRIORITY_DATE
524
+ );
525
+ if (date) cols.push(date);
416
526
 
417
- availableExtraKeys.forEach(key => {
418
- const prop = resolvedCollection.properties[key] as Property;
419
- cols.push({
420
- key,
421
- label: prop.name || key,
422
- property: prop,
423
- align: prop.type === "number" || prop.type === "date" ? "right" : "left",
424
- width: getIdealColumnWidth(prop)
425
- });
426
- });
527
+ return cols;
528
+ }, [columnMode, resolvedCollection, slotKeys.tagsKey, statusPropertyKey, datePropertyKey, sortableKeys]);
529
+
530
+ /** What the row already shows without a column of its own. */
531
+ const shownKeys = useMemo(() => new Set<string | undefined>([
532
+ titleColumn?.key,
533
+ imagePropertyKey,
534
+ subtitleKey,
535
+ ...declaredColumns.map(col => col.key),
536
+ // The relation chips under the title are as visible as any column.
537
+ ...(columnMode ? [] : slotKeys.relationKeys)
538
+ ]), [titleColumn, imagePropertyKey, subtitleKey, declaredColumns, columnMode, slotKeys.relationKeys]);
539
+
540
+ /** The property the sort names, when no cell already shows it. */
541
+ const sortedKey = useMemo(() => {
542
+ const key = sortBy?.[0];
543
+ return key && !shownKeys.has(key) ? key : undefined;
544
+ }, [sortBy, shownKeys]);
545
+
546
+ /** The properties the filters name, minus the ones a cell already shows. */
547
+ const filteredKeys = useMemo(
548
+ () => Object.keys(filterValues ?? {}).filter(key => !shownKeys.has(key)),
549
+ [filterValues, shownKeys]
550
+ );
427
551
 
428
- if (showStatus && statusPropertyKey) {
429
- const prop = resolvedCollection.properties[statusPropertyKey] as Property;
430
- if (prop) {
431
- cols.push({
432
- key: statusPropertyKey,
433
- label: prop.name || "Status",
434
- property: prop,
435
- isStatus: true,
436
- align: "center",
437
- width: "flex-shrink-0 w-32"
438
- });
439
- }
440
- }
552
+ /**
553
+ * The last property the sort named, and the last set the filters named.
554
+ *
555
+ * A column earns its place when a sort or a filter names it, and keeps it
556
+ * once that sort or filter is cleared: taking it back would move every
557
+ * column right of it at the moment the user is comparing values across rows,
558
+ * and clearing a sort says nothing about wanting to stop seeing the property
559
+ * it sorted on.
560
+ *
561
+ * What it does *not* do is accumulate. There is only ever one sort, so
562
+ * ordering by four properties in turn is changing one's mind four times, not
563
+ * asking for four columns — and asking for four is how the row lost its
564
+ * status and its date to a wall of numbers. A new sort therefore replaces
565
+ * the property the last one left behind, and a new set of filters replaces
566
+ * the set before it. At most one sort column and one set of filter columns
567
+ * outlive their request.
568
+ *
569
+ * The list remounts per collection (it is keyed by path), so this is the
570
+ * memory of one visit to one collection, not a preference.
571
+ */
572
+ const [lastSortedKey, setLastSortedKey] = useState<string | undefined>(undefined);
573
+ const [lastFilteredKeys, setLastFilteredKeys] = useState<string[]>([]);
441
574
 
442
- if (showDate && datePropertyKey) {
443
- const prop = resolvedCollection.properties[datePropertyKey] as Property;
444
- if (prop) {
445
- cols.push({
446
- key: datePropertyKey,
447
- label: prop.name || "Modified",
448
- property: prop,
449
- isDate: true,
450
- align: "right",
451
- width: "flex-shrink-0 w-28"
452
- });
453
- }
454
- }
575
+ useEffect(() => {
576
+ if (sortedKey) setLastSortedKey(sortedKey);
577
+ }, [sortedKey]);
455
578
 
456
- return cols;
457
- }, [resolvedCollection, titlePropertyKey, statusPropertyKey, datePropertyKey, imagePropertyKey, previewKeys, size]);
579
+ useEffect(() => {
580
+ if (filteredKeys.length === 0) return;
581
+ // Same members, same array: `filteredKeys` is rebuilt every render, and
582
+ // storing an equal copy would re-render every consumer of the columns.
583
+ setLastFilteredKeys(previous =>
584
+ previous.length === filteredKeys.length && previous.every((key, i) => key === filteredKeys[i])
585
+ ? previous
586
+ : filteredKeys);
587
+ }, [filteredKeys]);
458
588
 
459
- const showImage = size !== "xs";
589
+ /**
590
+ * Columns for what the user asked to sort or filter by and cannot see.
591
+ *
592
+ * Ordering a list by a property no cell shows produces a shuffle with no
593
+ * explanation in it — the rows move and nothing on screen says why. So the
594
+ * property joins the row as its own column, last: the filters' first, in
595
+ * their own order, then the sort's.
596
+ */
597
+ const requestedColumns = useMemo<ListColumn[]>(() => {
598
+ // The live request where there is one, the remembered one otherwise —
599
+ // read in that order rather than from state alone, because the effects
600
+ // above have not run yet on the render that first sees a request, and
601
+ // waiting a frame would flash the row without its new column.
602
+ const active = new Set([...filteredKeys, ...(sortedKey ? [sortedKey] : [])]);
603
+ const keys: string[] = [];
604
+ const request = (key: string | undefined) => {
605
+ if (!key || keys.includes(key) || shownKeys.has(key)) return;
606
+ keys.push(key);
607
+ };
608
+ (filteredKeys.length > 0 ? filteredKeys : lastFilteredKeys).forEach(request);
609
+ request(sortedKey ?? lastSortedKey);
460
610
 
461
- // Responsive: determine visible columns based on container width.
462
- // The first extra column requires significantly more available space (600px)
463
- // to ensure the title area has generous room before columns appear.
464
- // Each subsequent column needs 160px.
465
- const visibleColumns = useMemo(() => {
466
- if (columns.length <= 1) return columns;
611
+ return keys.flatMap((key, index) => {
612
+ const property = getResolvedPropertyInPath(resolvedCollection.properties, key) as Property | undefined;
613
+ if (!property) return [];
614
+ return [{
615
+ key,
616
+ label: property.name || key,
617
+ property,
618
+ align: (property.type === "number" || property.type === "date" ? "right" : "left") as ListColumn["align"],
619
+ ...getIdealColumnWidth(property),
620
+ sortable: sortableKeys.has(key),
621
+ priority: (active.has(key) ? PRIORITY_ACTIVE_REQUEST : PRIORITY_STALE_REQUEST) - index
622
+ }];
623
+ });
624
+ }, [filteredKeys, sortedKey, lastFilteredKeys, lastSortedKey, shownKeys, resolvedCollection, sortableKeys]);
467
625
 
468
- const titleCol = columns.find(c => c.isTitle);
469
- const extraCols = columns.filter(c => !c.isTitle);
626
+ // ── Compute list-view-visible actions per entity ──
627
+ const getListViewActions = useCallback((entity: Entity<M>): EntityAction[] => {
628
+ if (!getActionsForEntity) return EMPTY_LIST_VIEW_ACTIONS;
629
+ const customEntityActions = (collection.entityActions ?? [])
630
+ .map(action => resolveEntityAction(action, customizationController.entityActions))
631
+ .filter(Boolean) as EntityAction<M>[];
632
+ const allActions = getActionsForEntity({ entity,
633
+ customEntityActions });
634
+ return allActions.filter(a => a.showActionsInListView);
635
+ }, [getActionsForEntity, collection.entityActions, customizationController.entityActions]);
470
636
 
471
- // Base width consumed by fixed-width elements (padding + checkbox + image + min title)
472
- const baseWidth = 40 + (selectionEnabled ? 40 : 0) + (showImage ? 56 : 0) + 200;
473
- const availableForExtra = Math.max(0, containerWidth - baseWidth);
637
+ const showImage = size !== "xs";
474
638
 
475
- // First extra column needs 500px; each additional needs 160px
476
- const FIRST_COL_THRESHOLD = 500;
477
- const EXTRA_COL_WIDTH = 160;
639
+ /**
640
+ * Does the checkbox ride on the thumbnail rather than sit beside it?
641
+ *
642
+ * Only in the split's compact list — the one layout where the list is a
643
+ * column beside a record rather than the page itself, and where the 48px of
644
+ * checkbox cell is 48px the title does not get. Everywhere else the list has
645
+ * the room, and a checkbox that is always there is easier to find than one
646
+ * that appears under the pointer.
647
+ *
648
+ * Needs a thumbnail to ride on: at `xs` there is none, so the checkbox keeps
649
+ * its cell.
650
+ */
651
+ const combineSelection = openEntityMode === "split" && selectedEntityId !== undefined && showImage;
478
652
 
479
- let maxExtraCols = 0;
480
- if (availableForExtra >= FIRST_COL_THRESHOLD) {
481
- maxExtraCols = 1 + Math.floor((availableForExtra - FIRST_COL_THRESHOLD) / EXTRA_COL_WIDTH);
653
+ /**
654
+ * How much room the action buttons take, reserved identically in the header
655
+ * and in every row so the columns line up above one another whether or not a
656
+ * given record has actions.
657
+ */
658
+ const actionsWidth = useMemo(() => {
659
+ if (!getActionsForEntity) return 0;
660
+ let most = 0;
661
+ for (const entity of data.slice(0, ACTIONS_SAMPLE_SIZE)) {
662
+ most = Math.max(most, getListViewActions(entity).length);
482
663
  }
483
-
484
- return [
485
- ...(titleCol ? [titleCol] : []),
486
- ...extraCols.slice(0, maxExtraCols)
487
- ];
488
- }, [columns, containerWidth, selectionEnabled, showImage]);
664
+ return most === 0 ? 0 : most * ACTION_BUTTON_WIDTH + (most - 1) * ACTION_BUTTON_GAP;
665
+ }, [data, getActionsForEntity, getListViewActions]);
666
+
667
+ // Responsive: keep only the columns the row can actually pay for.
668
+ //
669
+ // The title is what a row is *for* — it is the one cell that identifies the
670
+ // record — so it is not one competitor among the columns: it takes its
671
+ // comfortable share off the top, and the columns divide what is left. What
672
+ // gives way when there is not enough is decided by `priority` rather than by
673
+ // position, so the column the user just sorted by outlives the inferred
674
+ // ones; the survivors keep their declared order, because a row that
675
+ // reordered itself as it resized would be unreadable.
676
+ const visibleColumns = useMemo(() => {
677
+ const columns = [...declaredColumns, ...requestedColumns];
678
+ if (columns.length === 0) return columns;
679
+
680
+ // Unmeasured: the title alone, which is the answer we would rather flash
681
+ // than a row of columns squeezing it down to an ellipsis.
682
+ if (containerWidth === undefined) return [];
683
+
684
+ const chrome = ROW_PADDING_WIDTH
685
+ + (selectionEnabled && !combineSelection ? CHECKBOX_WIDTH + COLUMN_GAP : 0)
686
+ + (showImage ? IMAGE_WIDTH + COLUMN_GAP : 0)
687
+ + (actionsWidth > 0 ? actionsWidth + COLUMN_GAP : 0);
688
+ const available = containerWidth - chrome - TITLE_COMFORTABLE_WIDTH;
689
+
690
+ const cost = (col: ListColumn) => col.widthPx + COLUMN_GAP;
691
+ let total = columns.reduce((acc, col) => acc + cost(col), 0);
692
+ if (total <= available) return columns;
693
+
694
+ const dropped = new Set<string>();
695
+ for (const col of [...columns].sort((a, b) => a.priority - b.priority)) {
696
+ if (total <= available) break;
697
+ dropped.add(col.key);
698
+ total -= cost(col);
699
+ }
700
+ return columns.filter(col => !dropped.has(col.key));
701
+ }, [declaredColumns, requestedColumns, containerWidth, selectionEnabled, combineSelection, showImage, actionsWidth]);
489
702
 
490
703
  const handleEntityClick = useCallback((entity: Entity<M>) => {
491
704
  analyticsController.onAnalyticsEvent?.("entity_click", {
@@ -507,16 +720,6 @@ export function CollectionListViewBinding<M extends Record<string, unknown> = Re
507
720
  return highlightedEntities?.some(e => e.id === entity.id && e.path === entity.path) ?? false;
508
721
  }, [highlightedEntities]);
509
722
 
510
- // ── Compute list-view-visible actions per entity ──
511
- const getListViewActions = useCallback((entity: Entity<M>): EntityAction[] => {
512
- if (!getActionsForEntity) return EMPTY_LIST_VIEW_ACTIONS;
513
- const customEntityActions = (collection.entityActions ?? [])
514
- .map(action => resolveEntityAction(action, customizationController.entityActions))
515
- .filter(Boolean) as EntityAction<M>[];
516
- const allActions = getActionsForEntity({ entity,
517
- customEntityActions });
518
- return allActions.filter(a => a.showActionsInListView);
519
- }, [getActionsForEntity, collection.entityActions, customizationController.entityActions]);
520
723
 
521
724
  const rowClasses = getRowClasses(size);
522
725
 
@@ -527,56 +730,110 @@ customEntityActions });
527
730
  handleSelectionChange(entity, selected);
528
731
  }, [handleSelectionChange]);
529
732
 
530
- return (
531
- <ListView<Entity<M>>
532
- data={data}
533
- dataLoading={dataLoading}
534
- noMoreToLoad={noMoreToLoad}
535
- dataLoadingError={dataLoadingError}
536
- itemCount={itemCount}
537
- setItemCount={setItemCount}
538
- pageSize={pageSize}
539
- paginationEnabled={paginationEnabled}
540
- onItemClick={handleEntityClick}
541
- selectedIds={selectedIds}
542
- highlightedIds={highlightedIds}
543
- selectionEnabled={selectionEnabled}
544
- emptyComponent={emptyComponent}
545
- size={size}
546
- selectedEntityId={selectedEntityId}
547
- renderRow={useCallback(({ item: entity, style, className, selected, highlighted, isLast, onClick, onSelectionChange }) => {
548
- return (
549
- <div
550
- key={entity.id}
551
- style={style}
552
- className={className}
553
- >
554
- <ListRow
555
- entity={entity}
556
- collection={resolvedCollection}
557
- onClick={handleEntityClick}
558
- selected={selected}
559
- highlighted={highlighted}
560
- onSelectionChange={handleRowSelectionChange}
561
- selectionEnabled={selectionEnabled}
562
- columns={visibleColumns}
563
- slotKeys={slotKeys}
564
- rowClasses={rowClasses}
565
- showImage={showImage}
566
- size={size}
567
- isLast={isLast}
568
- isActive={selectedEntityId !== undefined && entity.id === selectedEntityId}
569
- listViewActions={getListViewActions(entity)}
570
- context={context}
571
- path={path}
572
- selectionController={selectionController}
573
- openEntityMode={openEntityMode}
574
- />
575
- </div>
576
- );
577
- }, [resolvedCollection, selectionEnabled, visibleColumns, slotKeys, rowClasses, showImage, size, selectedEntityId, getListViewActions, context, path, selectionController, openEntityMode, handleRowSelectionChange, handleEntityClick])}
733
+ /**
734
+ * Order by a column, on the same cycle a table header runs: unordered →
735
+ * ascending → descending → unordered. Clicking a header here and clicking it
736
+ * in the table view have to mean the same thing, because they are the same
737
+ * collection under the same controller.
738
+ */
739
+ const onColumnSort = useCallback((key: string) => {
740
+ if (!setSortBy) return;
741
+ const active = sortBy?.[0] === key ? sortBy[1] : undefined;
742
+ const next: [string, "asc" | "desc"] | undefined = active === "asc"
743
+ ? [key, "desc"]
744
+ : active === "desc"
745
+ ? undefined
746
+ : [key, "asc"];
747
+ setSortBy(next);
748
+ // Re-ordering a partially loaded collection invalidates the pages
749
+ // already fetched — they are no longer the rows the query answers with —
750
+ // so pagination starts over, as it does for the table and the sort menu.
751
+ setItemCount?.(pageSize);
752
+ }, [setSortBy, sortBy, setItemCount, pageSize]);
753
+
754
+ /**
755
+ * Whether a sort can be offered at all next to the filter already applied.
756
+ * A driver may refuse the combination; the table clears the filter to make
757
+ * room, which is a heavier answer than a header click asks for, so the
758
+ * header simply stops offering it — the same choice the sort menu makes.
759
+ */
760
+ const sortIsAvailable = useCallback((key: string) => {
761
+ if (!setSortBy) return false;
762
+ if (!checkFilterCombination) return true;
763
+ const active = sortBy?.[0] === key ? sortBy[1] : undefined;
764
+ // Clearing the sort is always available: it asks nothing of the driver.
765
+ if (active === "desc") return true;
766
+ return checkFilterCombination(filterValues ?? {}, [key, active === "asc" ? "desc" : "asc"]);
767
+ }, [setSortBy, checkFilterCombination, filterValues, sortBy]);
768
+
769
+ const header = (titleColumn || visibleColumns.length > 0) && (
770
+ <ListHeader
771
+ titleColumn={titleColumn}
772
+ columns={visibleColumns}
773
+ selectionEnabled={selectionEnabled && !combineSelection}
774
+ showImage={showImage}
775
+ actionsWidth={actionsWidth}
776
+ sortBy={sortBy as [string, "asc" | "desc"] | undefined}
777
+ onColumnSort={onColumnSort}
778
+ sortIsAvailable={sortIsAvailable}
578
779
  />
579
780
  );
781
+
782
+ return (
783
+ <div ref={containerRef} className="w-full">
784
+ <ListView<Entity<M>>
785
+ data={data}
786
+ dataLoading={dataLoading}
787
+ noMoreToLoad={noMoreToLoad}
788
+ dataLoadingError={dataLoadingError}
789
+ itemCount={itemCount}
790
+ setItemCount={setItemCount}
791
+ pageSize={pageSize}
792
+ paginationEnabled={paginationEnabled}
793
+ onItemClick={handleEntityClick}
794
+ selectedIds={selectedIds}
795
+ highlightedIds={highlightedIds}
796
+ selectionEnabled={selectionEnabled}
797
+ emptyComponent={emptyComponent}
798
+ size={size}
799
+ selectedEntityId={selectedEntityId}
800
+ header={header}
801
+ renderRow={useCallback(({ item: entity, style, className, selected, highlighted, isLast, onClick, onSelectionChange }) => {
802
+ return (
803
+ <div
804
+ key={entity.id}
805
+ style={style}
806
+ className={className}
807
+ >
808
+ <ListRow
809
+ entity={entity}
810
+ collection={resolvedCollection}
811
+ onClick={handleEntityClick}
812
+ selected={selected}
813
+ highlighted={highlighted}
814
+ onSelectionChange={handleRowSelectionChange}
815
+ selectionEnabled={selectionEnabled}
816
+ combineSelection={combineSelection}
817
+ columns={visibleColumns}
818
+ slotKeys={slotKeys}
819
+ rowClasses={rowClasses}
820
+ showImage={showImage}
821
+ size={size}
822
+ isLast={isLast}
823
+ isActive={selectedEntityId !== undefined && entity.id === selectedEntityId}
824
+ listViewActions={getListViewActions(entity)}
825
+ actionsWidth={actionsWidth}
826
+ context={context}
827
+ path={path}
828
+ selectionController={selectionController}
829
+ openEntityMode={openEntityMode}
830
+ />
831
+ </div>
832
+ );
833
+ }, [resolvedCollection, selectionEnabled, combineSelection, visibleColumns, slotKeys, rowClasses, showImage, size, selectedEntityId, getListViewActions, actionsWidth, context, path, selectionController, openEntityMode, handleRowSelectionChange, handleEntityClick])}
834
+ />
835
+ </div>
836
+ );
580
837
  }
581
838
 
582
839
  /**
@@ -595,6 +852,7 @@ const ListRow = React.memo(function ListRow<M extends Record<string, unknown>>({
595
852
  highlighted,
596
853
  onSelectionChange,
597
854
  selectionEnabled,
855
+ combineSelection = false,
598
856
  columns,
599
857
  slotKeys,
600
858
  rowClasses,
@@ -603,6 +861,7 @@ const ListRow = React.memo(function ListRow<M extends Record<string, unknown>>({
603
861
  isLast,
604
862
  isActive = false,
605
863
  listViewActions = [],
864
+ actionsWidth = 0,
606
865
  context,
607
866
  path,
608
867
  selectionController,
@@ -615,7 +874,14 @@ const ListRow = React.memo(function ListRow<M extends Record<string, unknown>>({
615
874
  highlighted?: boolean;
616
875
  onSelectionChange?: (entity: Entity<M>, selected: boolean) => void;
617
876
  selectionEnabled?: boolean;
618
- columns: ListColumnDef[];
877
+ /**
878
+ * Fold the selection checkbox onto the thumbnail instead of giving it a cell
879
+ * of its own, buying the row back 48px for its title. Set where the row is
880
+ * paying for width it does not have — see the split's compact list in
881
+ * {@link CollectionListViewBinding}.
882
+ */
883
+ combineSelection?: boolean;
884
+ columns: ListColumn[];
619
885
  slotKeys: CollectionSlotKeys;
620
886
  rowClasses: string;
621
887
  showImage: boolean;
@@ -623,13 +889,18 @@ const ListRow = React.memo(function ListRow<M extends Record<string, unknown>>({
623
889
  isLast: boolean;
624
890
  isActive?: boolean;
625
891
  listViewActions?: EntityAction[];
892
+ /** Reserved width of the actions cell, shared with the header. */
893
+ actionsWidth?: number;
626
894
  context?: ReturnType<typeof useAdminContext>;
627
895
  path?: string;
628
896
  selectionController?: SelectionController<M>;
629
897
  openEntityMode?: "side_panel" | "full_screen" | "split" | "dialog";
630
898
  }) {
631
- // ── Resolve slots (pure function, no hooks) ──
632
- const slots = resolveEntitySlots(
899
+ // ── Resolve slots ──
900
+ // A hook now, not a pure call: a `display` resolver may have to fetch what
901
+ // fills a slot, so the row subscribes to the result and re-renders when it
902
+ // lands. `ListRow` is memoized, which is what keeps that to the one row.
903
+ const slots = useEntitySlots(
633
904
  entity as Entity<Record<string, unknown>>,
634
905
  collection as AdminCollection<Record<string, unknown>>,
635
906
  slotKeys
@@ -653,9 +924,14 @@ const ListRow = React.memo(function ListRow<M extends Record<string, unknown>>({
653
924
  onSelectionChange?.(entity, checked);
654
925
  }, [entity, onSelectionChange]);
655
926
 
927
+ /** The thumbnail-as-checkbox cell: the square toggles, and never opens the record. */
928
+ const handleSelectionCellClick = useCallback((e: React.MouseEvent) => {
929
+ e.stopPropagation();
930
+ onSelectionChange?.(entity, !selected);
931
+ }, [entity, onSelectionChange, selected]);
932
+
656
933
  // Developer-defined column mode (listProperties is explicitly set)
657
934
  const useColumnMode = !!collection.listProperties && collection.listProperties.length > 0;
658
- const extraColumns = useColumnMode ? columns.filter(c => !c.isTitle) : [];
659
935
 
660
936
  return (
661
937
  <div
@@ -677,8 +953,9 @@ const ListRow = React.memo(function ListRow<M extends Record<string, unknown>>({
677
953
  <div className="absolute left-0 top-0 bottom-0 w-0.5 bg-primary-500 rounded-r-full"/>
678
954
  )}
679
955
 
680
- {/* Selection Checkbox */}
681
- {selectionEnabled && (
956
+ {/* Selection Checkbox — its own cell only when it is not riding on
957
+ the thumbnail. See {@link ListRowProps.combineSelection}. */}
958
+ {selectionEnabled && !combineSelection && (
682
959
  <div
683
960
  className="flex-shrink-0 w-8"
684
961
  onClick={handleCheckboxClick}
@@ -693,10 +970,10 @@ const ListRow = React.memo(function ListRow<M extends Record<string, unknown>>({
693
970
 
694
971
  {/* MEDIA slot → Image / Icon */}
695
972
  {showImage && (
696
- <div className="flex-shrink-0">
973
+ <div className="flex-shrink-0 relative w-10 h-10">
697
974
  {slots.image ? (
698
975
  <div className={cls("w-10 h-10 rounded-lg border relative overflow-hidden bg-surface-100 dark:bg-surface-900", defaultBorderMixin)}>
699
- <PropertyPreview propertyKey={slots.image.propertyKey} value={slots.image.value} property={slots.image.property} size="small" fill={true}/>
976
+ <SlotValue slot={slots.image} size="small" fill={true}/>
700
977
  </div>
701
978
  ) : (
702
979
  <div className={cls("w-10 h-10 rounded-lg bg-surface-100 dark:bg-surface-900 flex items-center justify-center border", defaultBorderMixin)}>
@@ -707,21 +984,48 @@ const ListRow = React.memo(function ListRow<M extends Record<string, unknown>>({
707
984
  />
708
985
  </div>
709
986
  )}
987
+
988
+ {/* The checkbox, over the thumbnail rather than beside it.
989
+ Hidden until the row is hovered or the record is already
990
+ selected: at rest the cell is the picture it was, and the
991
+ row spends the reclaimed width on its title instead. The
992
+ scrim is what makes an unchecked box legible over a photo. */}
993
+ {selectionEnabled && combineSelection && (
994
+ <div
995
+ className={cls(
996
+ "absolute inset-0 flex items-center justify-center rounded-lg transition-opacity duration-150",
997
+ "bg-surface-950/55",
998
+ selected
999
+ ? "opacity-100"
1000
+ : "opacity-0 group-hover:opacity-100 focus-within:opacity-100"
1001
+ )}
1002
+ // The whole square toggles, not just the box drawn in
1003
+ // it: at 40px the cell is the target the pointer is
1004
+ // already over, and a click that lands beside a 16px
1005
+ // checkbox and does nothing reads as a broken row.
1006
+ // The box itself takes no pointer events, so one
1007
+ // click is one toggle — it stays focusable, and space
1008
+ // still works, for the keyboard.
1009
+ onClick={handleSelectionCellClick}
1010
+ >
1011
+ <div className="pointer-events-none">
1012
+ <Checkbox
1013
+ checked={selected ?? false}
1014
+ onCheckedChange={handleCheckboxChange}
1015
+ size="smallest"
1016
+ />
1017
+ </div>
1018
+ </div>
1019
+ )}
710
1020
  </div>
711
1021
  )}
712
1022
 
713
1023
  {/* PRIMARY slot → Title + subtitle + byline */}
714
- <div className={cls("min-w-0 overflow-hidden", useColumnMode ? "flex-1" : "flex-1")}>
1024
+ <div className="flex-1 min-w-0 overflow-hidden">
715
1025
  <div className="truncate">
716
1026
  {slots.title?.value !== undefined ? (
717
1027
  <Typography component="div" variant="body2" className="font-semibold text-surface-900 dark:text-surface-50 truncate transition-colors group-hover:text-primary-600 dark:group-hover:text-primary-400">
718
- <PropertyPreview
719
- propertyKey={slots.title.propertyKey}
720
- value={slots.title.value}
721
- property={slots.title.property}
722
- size="small"
723
- textOnly={true}
724
- />
1028
+ <SlotValue slot={slots.title} size="small"/>
725
1029
  </Typography>
726
1030
  ) : (
727
1031
  <Typography component="div" variant="body2" className="font-semibold text-surface-500 dark:text-surface-400 font-mono text-xs transition-colors group-hover:text-primary-600 dark:group-hover:text-primary-400">
@@ -734,13 +1038,7 @@ const ListRow = React.memo(function ListRow<M extends Record<string, unknown>>({
734
1038
  {slots.subtitle && (
735
1039
  <div className="truncate mt-0.5">
736
1040
  <Typography variant="caption" component="div" className="text-surface-500 dark:text-surface-400 truncate">
737
- <PropertyPreview
738
- propertyKey={slots.subtitle.propertyKey}
739
- value={slots.subtitle.value}
740
- property={slots.subtitle.property}
741
- size="small"
742
- textOnly={true}
743
- />
1041
+ <SlotValue slot={slots.subtitle} size="small"/>
744
1042
  </Typography>
745
1043
  </div>
746
1044
  )}
@@ -769,85 +1067,34 @@ const ListRow = React.memo(function ListRow<M extends Record<string, unknown>>({
769
1067
  )}
770
1068
  </div>
771
1069
 
772
- {useColumnMode ? (
773
- /* ── COLUMN MODE (developer-defined listProperties) ── */
774
- <>
775
- {extraColumns.map((col) => {
776
- const value = getValueInPath(entity.values, col.key);
777
-
778
- if (col.isStatus || (col.property.type === "string" && "enum" in col.property)) {
779
- return (
780
- <div key={col.key} className={cls(col.width, "flex", col.align === "center" ? "justify-center" : col.align === "right" ? "justify-end" : "justify-start")}>
781
- {value ? (
782
- <PropertyPreview propertyKey={col.key} value={value} property={col.property} size="small"/>
783
- ) : <span className="text-surface-400">—</span>}
784
- </div>
785
- );
786
- }
787
-
788
- if (col.isDate || col.property.type === "date") {
789
- return (
790
- <div key={col.key} className={cls(col.width, col.align === "center" ? "text-center" : col.align === "right" ? "text-right" : "text-left")}>
791
- <Typography variant="caption" className="whitespace-nowrap text-surface-400 dark:text-surface-500 font-medium">
792
- {formatDateValue(value) ?? "—"}
793
- </Typography>
794
- </div>
795
- );
796
- }
797
-
798
- // Complex types → compact single-line summary
799
- if (isComplexPropertyType(col.property)) {
800
- const summary = compactValueSummary(value, col.property);
801
- return (
802
- <div key={col.key} className={cls(col.width, "truncate", col.align === "center" ? "text-center" : col.align === "right" ? "text-right" : "text-left")}>
803
- <Typography variant="caption" className="text-surface-500 dark:text-surface-400 truncate">
804
- {summary ?? "—"}
805
- </Typography>
806
- </div>
807
- );
808
- }
809
-
810
- // Simple scalar → PropertyPreview (single-line)
811
- return (
812
- <div key={col.key} className={cls(col.width, "truncate", col.align === "center" ? "text-center" : col.align === "right" ? "text-right" : "text-left")}>
813
- <Typography component="div" variant="body2" className="text-surface-600 dark:text-surface-300 truncate">
814
- {value !== undefined ? (
815
- <PropertyPreview propertyKey={col.key} value={value} property={col.property} size="small"/>
816
- ) : "—"}
817
- </Typography>
818
- </div>
819
- );
820
- })}
821
- </>
822
- ) : (
823
- /* ── SLOT MODE (editorial scanner layout) ── */
1070
+ {/* TRAILING COLUMNS — the same definitions the header labels, in the
1071
+ same order and at the same widths, so a label and the values
1072
+ under it cannot come apart. */}
1073
+ {columns.length > 0 && (
824
1074
  <div className="flex items-center gap-4 flex-shrink-0 ml-auto">
825
- {/* STATUS slot — hidden when row is narrow */}
826
- {slots.status && (
827
- <div className="flex-shrink-0 @max-[400px]:hidden">
828
- <PropertyPreview
829
- propertyKey={slots.status.propertyKey}
830
- value={slots.status.value}
831
- property={slots.status.property}
832
- size="small"
833
- />
1075
+ {columns.map((col) => (
1076
+ <div
1077
+ key={col.key}
1078
+ className={cls(
1079
+ col.width,
1080
+ "flex items-center overflow-hidden",
1081
+ col.align === "center" ? "justify-center" : col.align === "right" ? "justify-end" : "justify-start"
1082
+ )}
1083
+ >
1084
+ <ListCell column={col} entity={entity} slots={slots}/>
834
1085
  </div>
835
- )}
836
-
837
- {/* DATE slot */}
838
- {slots.date && (
839
- <div className="flex-shrink-0 text-right w-[80px] @max-[500px]:hidden">
840
- <Typography variant="caption" className="whitespace-nowrap text-surface-400 dark:text-surface-500 font-medium">
841
- {slots.date.formatted ?? "—"}
842
- </Typography>
843
- </div>
844
- )}
1086
+ ))}
845
1087
  </div>
846
1088
  )}
847
1089
 
848
- {/* LIST VIEW ACTIONS — always visible on each row */}
849
- {listViewActions.length > 0 && (
850
- <div className="flex items-center gap-0.5 flex-shrink-0 ml-auto" onClick={(e) => e.stopPropagation()}>
1090
+ {/* LIST VIEW ACTIONS — always visible on each row.
1091
+ Width is reserved from the widest row rather than taken from
1092
+ this one, so a record with fewer actions does not slide its
1093
+ columns out from under the header. */}
1094
+ {actionsWidth > 0 && (
1095
+ <div className="flex items-center justify-end gap-0.5 flex-shrink-0 ml-auto"
1096
+ style={{ minWidth: actionsWidth }}
1097
+ onClick={(e) => e.stopPropagation()}>
851
1098
  {listViewActions.map((action, index) => (
852
1099
  <Tooltip key={action.key ?? index} title={action.name} asChild>
853
1100
  <IconButton
@@ -881,7 +1128,8 @@ const ListRow = React.memo(function ListRow<M extends Record<string, unknown>>({
881
1128
  highlighted?: boolean;
882
1129
  onSelectionChange?: (entity: Entity<M>, selected: boolean) => void;
883
1130
  selectionEnabled?: boolean;
884
- columns: ListColumnDef[];
1131
+ combineSelection?: boolean;
1132
+ columns: ListColumn[];
885
1133
  slotKeys: CollectionSlotKeys;
886
1134
  rowClasses: string;
887
1135
  showImage: boolean;
@@ -889,9 +1137,200 @@ const ListRow = React.memo(function ListRow<M extends Record<string, unknown>>({
889
1137
  isLast: boolean;
890
1138
  isActive?: boolean;
891
1139
  listViewActions?: EntityAction[];
1140
+ /** Reserved width of the actions cell, shared with the header. */
1141
+ actionsWidth?: number;
892
1142
  context?: ReturnType<typeof useAdminContext>;
893
1143
  path?: string;
894
1144
  selectionController?: SelectionController<M>;
895
1145
  openEntityMode?: "side_panel" | "full_screen" | "split" | "dialog";
896
1146
  }) => React.ReactElement;
897
1147
 
1148
+ /**
1149
+ * One cell of one row: whatever the column says fills it.
1150
+ *
1151
+ * A slot column renders the slot — a status keeps its coloured chip and a date
1152
+ * its relative wording wherever the value came from — and a property column
1153
+ * reads the record. Both go through here so the header above them is labelling
1154
+ * one thing, not two that happen to line up.
1155
+ */
1156
+ function ListCell<M extends Record<string, unknown>>({
1157
+ column,
1158
+ entity,
1159
+ slots
1160
+ }: {
1161
+ column: ListColumn;
1162
+ entity: Entity<M>;
1163
+ slots: EntityPreviewSlots;
1164
+ }) {
1165
+ if (column.slot === "tags") {
1166
+ if (!slots.tags) return null;
1167
+ return (
1168
+ <div className="flex items-center gap-1 overflow-hidden">
1169
+ <TagChips slot={slots.tags} max={3}/>
1170
+ </div>
1171
+ );
1172
+ }
1173
+
1174
+ if (column.slot === "status") {
1175
+ if (!slots.status) return <EmptyCell/>;
1176
+ return <SlotValue slot={slots.status} size="small"/>;
1177
+ }
1178
+
1179
+ if (column.slot === "date") {
1180
+ return (
1181
+ <Typography variant="caption" className="whitespace-nowrap text-surface-400 dark:text-surface-500 font-medium">
1182
+ {slots.date?.formatted ?? "—"}
1183
+ </Typography>
1184
+ );
1185
+ }
1186
+
1187
+ const property = column.property;
1188
+ if (!property) return null;
1189
+
1190
+ const value = getValueInPath(entity.values, column.key);
1191
+
1192
+ if (property.type === "date") {
1193
+ return (
1194
+ <Typography variant="caption" className="whitespace-nowrap text-surface-400 dark:text-surface-500 font-medium">
1195
+ {formatDateValue(value) ?? "—"}
1196
+ </Typography>
1197
+ );
1198
+ }
1199
+
1200
+ // Complex types → compact single-line summary, so a row keeps its height.
1201
+ if (isComplexPropertyType(property)) {
1202
+ const summary = compactValueSummary(value, property);
1203
+ return (
1204
+ <Typography variant="caption" className="text-surface-500 dark:text-surface-400 truncate">
1205
+ {summary ?? "—"}
1206
+ </Typography>
1207
+ );
1208
+ }
1209
+
1210
+ if (value === undefined || value === null) return <EmptyCell/>;
1211
+
1212
+ return (
1213
+ <Typography component="div" variant="body2" className="text-surface-600 dark:text-surface-300 truncate">
1214
+ <PropertyPreview propertyKey={column.key} value={value} property={property} size="small"/>
1215
+ </Typography>
1216
+ );
1217
+ }
1218
+
1219
+ /** What a column shows for a record that has nothing in it. */
1220
+ function EmptyCell() {
1221
+ return <span className="text-surface-400 dark:text-surface-600">—</span>;
1222
+ }
1223
+
1224
+ /**
1225
+ * The list's column header.
1226
+ *
1227
+ * Quiet by design: a list is read row by row, and the header is there to name
1228
+ * the columns and to be clicked, not to compete with the rows. It lays out
1229
+ * against the same widths the rows do, and skips the checkbox and the image —
1230
+ * neither is a column, and neither has anything to be called.
1231
+ */
1232
+ function ListHeader({
1233
+ titleColumn,
1234
+ columns,
1235
+ selectionEnabled,
1236
+ showImage,
1237
+ actionsWidth,
1238
+ sortBy,
1239
+ onColumnSort,
1240
+ sortIsAvailable
1241
+ }: {
1242
+ titleColumn?: ListColumn;
1243
+ columns: ListColumn[];
1244
+ selectionEnabled?: boolean;
1245
+ showImage: boolean;
1246
+ actionsWidth: number;
1247
+ sortBy?: [string, "asc" | "desc"];
1248
+ onColumnSort: (key: string) => void;
1249
+ sortIsAvailable: (key: string) => boolean;
1250
+ }) {
1251
+ const headerCell = (column: ListColumn) => (
1252
+ <ListHeaderLabel
1253
+ column={column}
1254
+ direction={sortBy?.[0] === column.key ? sortBy[1] : undefined}
1255
+ onSort={column.sortable && sortIsAvailable(column.key) ? () => onColumnSort(column.key) : undefined}
1256
+ />
1257
+ );
1258
+
1259
+ return (
1260
+ <div className={cls(
1261
+ "flex items-center gap-4 px-5 py-1.5 select-none border-b bg-surface-50 dark:bg-surface-900",
1262
+ defaultBorderMixin
1263
+ )}>
1264
+ {selectionEnabled && <div className="flex-shrink-0 w-8"/>}
1265
+ {showImage && <div className="flex-shrink-0 w-10"/>}
1266
+
1267
+ <div className="flex-1 min-w-0 flex items-center">
1268
+ {titleColumn && headerCell(titleColumn)}
1269
+ </div>
1270
+
1271
+ {columns.length > 0 && (
1272
+ <div className="flex items-center gap-4 flex-shrink-0 ml-auto">
1273
+ {columns.map(column => (
1274
+ <div
1275
+ key={column.key}
1276
+ className={cls(
1277
+ column.width,
1278
+ "flex items-center overflow-hidden",
1279
+ column.align === "center" ? "justify-center" : column.align === "right" ? "justify-end" : "justify-start"
1280
+ )}
1281
+ >
1282
+ {headerCell(column)}
1283
+ </div>
1284
+ ))}
1285
+ </div>
1286
+ )}
1287
+
1288
+ {actionsWidth > 0 && <div className="flex-shrink-0 ml-auto" style={{ minWidth: actionsWidth }}/>}
1289
+ </div>
1290
+ );
1291
+ }
1292
+
1293
+ /**
1294
+ * One header label, clickable when the collection can be ordered by it.
1295
+ *
1296
+ * The arrow shows only on the column currently ordering the list: three arrows
1297
+ * in a header row are three invitations, and only one of them is ever the
1298
+ * answer to "how is this sorted".
1299
+ */
1300
+ function ListHeaderLabel({
1301
+ column,
1302
+ direction,
1303
+ onSort
1304
+ }: {
1305
+ column: ListColumn;
1306
+ direction?: "asc" | "desc";
1307
+ onSort?: () => void;
1308
+ }) {
1309
+ const content = (
1310
+ <>
1311
+ <span className="truncate">{column.label}</span>
1312
+ {direction === "asc" && <ArrowUpIcon size={12} className="flex-shrink-0"/>}
1313
+ {direction === "desc" && <ArrowDownIcon size={12} className="flex-shrink-0"/>}
1314
+ </>
1315
+ );
1316
+
1317
+ const base = "inline-flex items-center gap-1 max-w-full text-[11px] leading-none uppercase tracking-wider font-medium";
1318
+ const tone = direction
1319
+ ? "text-surface-600 dark:text-surface-300"
1320
+ : "text-surface-400 dark:text-surface-500";
1321
+
1322
+ if (!onSort) {
1323
+ return <span className={cls(base, tone)}>{content}</span>;
1324
+ }
1325
+
1326
+ return (
1327
+ <button
1328
+ type="button"
1329
+ title={`Sort by ${column.label}`}
1330
+ onClick={onSort}
1331
+ className={cls(base, tone, "cursor-pointer hover:text-surface-700 dark:hover:text-surface-200 transition-colors")}
1332
+ >
1333
+ {content}
1334
+ </button>
1335
+ );
1336
+ }