@rebasepro/admin 0.13.1-canary.gef9608c → 0.14.0

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