@rebasepro/admin 0.13.0 → 0.13.1-canary.g18cfeb7

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-CUFXevVg.js} +3 -3
  2. package/dist/{CollectionEditorDialog-CjXkWZ64.js.map → CollectionEditorDialog-CUFXevVg.js.map} +1 -1
  3. package/dist/{PropertyEditView-W8I3YV89.js → PropertyEditView-DDUC7giL.js} +2 -2
  4. package/dist/{PropertyEditView-W8I3YV89.js.map → PropertyEditView-DDUC7giL.js.map} +1 -1
  5. package/dist/{RouterCollectionsStudioView-BsLTNoqX.js → RouterCollectionsStudioView-vzS848Gf.js} +9 -13
  6. package/dist/RouterCollectionsStudioView-vzS848Gf.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-5bl2EoSs.js} +2 -2
  29. package/dist/{export-J79rMoNj.js.map → export-5bl2EoSs.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-BuKKAhoY.js} +4 -4
  36. package/dist/{history-D7L_JlLO.js.map → history-BuKKAhoY.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-BhdWs34H.js} +3 -3
  40. package/dist/{import-BvYl--U4.js.map → import-BhdWs34H.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-DNG7iK0w.js} +3756 -2300
  56. package/dist/util-DNG7iK0w.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 +723 -348
  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 +33 -2
  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 +93 -56
  79. package/src/components/DrawerNavigationGroup.tsx +16 -39
  80. package/src/components/DrawerNavigationItem.tsx +44 -16
  81. package/src/components/EditViewBinding.tsx +215 -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 +342 -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 +15 -4
  92. package/src/components/SideDialogs.tsx +15 -3
  93. package/src/components/SidePanelBinding.tsx +122 -66
  94. package/src/components/UserSelector.tsx +15 -4
  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 +64 -52
  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 +7 -1
  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,14 +1,17 @@
1
1
  import type { AdminCollection } from "@rebasepro/admin-types";
2
2
  import type { EntityStatus } from "@rebasepro/types";
3
3
  import React, { useState } from "react";
4
+ import { Link } from "react-router";
4
5
  import { useTranslation } from "@rebasepro/app";
5
6
  import {
6
7
  Button,
7
8
  CheckIcon,
9
+ ChevronDownIcon,
8
10
  cls,
9
11
  CodeIcon,
10
12
  CopyIcon,
11
13
  defaultBorderMixin,
14
+ ExternalLinkIcon,
12
15
  HistoryIcon,
13
16
  IconButton,
14
17
  iconSize,
@@ -19,12 +22,22 @@ import {
19
22
  MoreVerticalIcon,
20
23
  Separator,
21
24
  Tooltip,
22
- Typography
25
+ Typography,
26
+ XIcon
23
27
  } from "@rebasepro/ui";
24
28
 
25
29
  export interface EntityIdentityBarProps {
26
30
  /** Plural collection name, shown as the breadcrumb ahead of the title. */
27
31
  collection: AdminCollection;
32
+ /**
33
+ * Where the breadcrumb points. Without it the collection name is inert text
34
+ * that still reads as a breadcrumb — worse than no breadcrumb at all, and
35
+ * the only way back once a layout drops its back arrow.
36
+ *
37
+ * Left unset by the overlays (side panel, dialog), where the collection is
38
+ * already underneath and navigating would dismiss the record instead.
39
+ */
40
+ collectionUrl?: string;
28
41
  /** Resolved record title, already guarded against showing a field default. */
29
42
  title: string;
30
43
  entityId?: string | number;
@@ -44,11 +57,47 @@ export interface EntityIdentityBarProps {
44
57
  */
45
58
  onSaveAndClose?: () => void;
46
59
 
60
+ /**
61
+ * Where {@link onSaveAndClose} is offered.
62
+ *
63
+ * `"button"` — its own filled button, and Save demotes to text. For the
64
+ * overlays, where you opened the record to do one thing to it and closing is
65
+ * the dominant intent.
66
+ *
67
+ * `"menu"` — a `▾` welded to the Save button. For the split, where the list
68
+ * is right there and `j`/`k` walk from record to record: there, saving and
69
+ * *staying* is the common case, so it keeps the filled button and dismissing
70
+ * is one step further in. The gesture still exists, so muscle memory carried
71
+ * over from the side panel finds it.
72
+ */
73
+ saveAndClosePlacement?: "button" | "menu";
74
+
75
+ /**
76
+ * Dismiss this record, from the ✕ at the bar's trailing edge.
77
+ *
78
+ * Fenced off from Save by a separator: it is chrome, not the last item in
79
+ * the action row. The panel that supplies it decides what dismissing means —
80
+ * in the split it is a navigation, which the layout's unsaved-changes
81
+ * blocker already guards, so the ✕ cannot drop an edit without asking.
82
+ */
83
+ onClose?: () => void;
84
+
47
85
  onBack?: () => void;
48
86
  onInspect?: () => void;
49
87
  /** Opens the inspector straight on its history tab. */
50
88
  onViewHistory?: () => void;
51
89
 
90
+ /**
91
+ * Where this record appears on the live site, from `entityLinkBuilder`.
92
+ *
93
+ * Here rather than above the record, where it used to be a single unlabelled
94
+ * icon alone on its own right-aligned row — the only thing between the bar
95
+ * and the first field, and the only control in the view that did not say
96
+ * what it was. It is a thing you do *to* a record, so it belongs with the
97
+ * others.
98
+ */
99
+ externalLink?: string;
100
+
52
101
  /**
53
102
  * Actions performed *on* the record — copy, delete, whatever the collection
54
103
  * adds — as menu items under the overflow button. They live here rather
@@ -81,6 +130,7 @@ export interface EntityIdentityBarProps {
81
130
  */
82
131
  export function EntityIdentityBar({
83
132
  collection,
133
+ collectionUrl,
84
134
  title,
85
135
  entityId,
86
136
  status,
@@ -91,9 +141,12 @@ export function EntityIdentityBar({
91
141
  saveDisabled,
92
142
  hasErrors,
93
143
  onSaveAndClose,
144
+ saveAndClosePlacement = "button",
145
+ onClose,
94
146
  onBack,
95
147
  onInspect,
96
148
  onViewHistory,
149
+ externalLink,
97
150
  recordActions,
98
151
  pluginActions,
99
152
  trailing,
@@ -108,7 +161,17 @@ export function EntityIdentityBar({
108
161
  const closeLabel = status === "existing"
109
162
  ? t("save_and_close")
110
163
  : status === "copy" ? t("create_copy_and_close") : t("create_and_close");
111
- const hasMenu = Boolean(recordActions) || Boolean(onInspect) || Boolean(onViewHistory);
164
+ const hasMenu = Boolean(recordActions) || Boolean(onInspect) || Boolean(onViewHistory)
165
+ || Boolean(externalLink);
166
+
167
+ // The two shapes "save and close" takes, so the Save button next to it knows
168
+ // whether it is still the primary action.
169
+ const saveAndCloseButton = Boolean(onSaveAndClose) && saveAndClosePlacement === "button";
170
+ const saveAndCloseMenu = Boolean(onSaveAndClose) && saveAndClosePlacement === "menu";
171
+
172
+ const saveTooltip = hasErrors
173
+ ? (t("fix_errors_before_saving") ?? "Fix highlighted errors before saving")
174
+ : undefined;
112
175
 
113
176
  return (
114
177
  <div className={cls(
@@ -126,10 +189,21 @@ export function EntityIdentityBar({
126
189
  </Tooltip>
127
190
  )}
128
191
 
129
- <Typography variant={"caption"}
130
- className={"text-text-disabled dark:text-text-disabled-dark whitespace-nowrap hidden sm:block"}>
131
- {collection.name}&nbsp;/
132
- </Typography>
192
+ {collectionUrl
193
+ ? <Link to={collectionUrl}
194
+ className={"visited:text-inherit dark:visited:text-inherit hidden sm:block"}>
195
+ <Typography variant={"caption"}
196
+ className={cls(
197
+ "text-text-disabled dark:text-text-disabled-dark whitespace-nowrap",
198
+ "hover:text-text-primary dark:hover:text-text-primary-dark transition-colors"
199
+ )}>
200
+ {collection.name}&nbsp;/
201
+ </Typography>
202
+ </Link>
203
+ : <Typography variant={"caption"}
204
+ className={"text-text-disabled dark:text-text-disabled-dark whitespace-nowrap hidden sm:block"}>
205
+ {collection.name}&nbsp;/
206
+ </Typography>}
133
207
 
134
208
  <span className={"font-headers font-semibold text-[15px] tracking-tight truncate min-w-0"}
135
209
  title={title}>
@@ -154,24 +228,66 @@ export function EntityIdentityBar({
154
228
  )}
155
229
 
156
230
  {onSave && (
157
- <Tooltip title={hasErrors
158
- ? (t("fix_errors_before_saving") ?? "Fix highlighted errors before saving")
159
- : undefined}>
160
- <LoadingButton
161
- // Where there is something to close, closing is the
162
- // dominant intent and takes the filled treatment.
163
- variant={onSaveAndClose ? "text" : "filled"}
164
- color={"primary"}
165
- size={"small"}
166
- loading={saving && !onSaveAndClose}
167
- disabled={saveDisabled}
168
- onClick={onSave}>
169
- {saveLabel}
170
- </LoadingButton>
171
- </Tooltip>
231
+ // `rounded-lg overflow-hidden`: the two halves of the split
232
+ // button are square inside and the group carries the radius, so
233
+ // there is one control with a seam rather than two buttons that
234
+ // happen to touch. Their borders match their own fill, so the
235
+ // seam has to be drawn see the `▾` half for where and in what.
236
+ <div className={"flex items-stretch rounded-lg overflow-hidden"}>
237
+ <Tooltip title={saveTooltip}>
238
+ <LoadingButton
239
+ // Where a separate close button carries it, closing
240
+ // is the dominant intent and takes the filled
241
+ // treatment. Under the `▾` the record is staying
242
+ // open, so Save keeps it.
243
+ variant={saveAndCloseButton ? "text" : "filled"}
244
+ color={"primary"}
245
+ size={"small"}
246
+ loading={saving && !saveAndCloseButton}
247
+ disabled={saveDisabled}
248
+ onClick={onSave}
249
+ className={saveAndCloseMenu ? "rounded-none" : undefined}>
250
+ {saveLabel}
251
+ </LoadingButton>
252
+ </Tooltip>
253
+
254
+ {saveAndCloseMenu && <>
255
+ <Menu align={"end"}
256
+ trigger={
257
+ <Button variant={"filled"}
258
+ color={"primary"}
259
+ size={"small"}
260
+ disabled={saveDisabled}
261
+ aria-label={closeLabel}
262
+ // The seam is this half's own left border,
263
+ // tinted from `currentColor` — the ink the
264
+ // label is drawn in — so it holds on any
265
+ // button colour and dims with the control
266
+ // rather than needing a case per state. It
267
+ // was a `bg-white/25` span between the
268
+ // halves: a bright hairline scratched over a
269
+ // saturated blue, and, being a sibling of
270
+ // the buttons rather than part of one, it
271
+ // stayed at full strength while `disabled`
272
+ // dropped both halves to `opacity-40` — a
273
+ // rule brighter than the button it bisected.
274
+ // Inline because it has to beat the
275
+ // variant's `border-<color>` on one edge.
276
+ style={{ borderLeftColor: "color-mix(in oklab, currentColor 15%, transparent)" }}
277
+ className={"rounded-none px-1.5"}>
278
+ <ChevronDownIcon size={iconSize.smallest}/>
279
+ </Button>
280
+ }>
281
+ <MenuItem onClick={onSaveAndClose}>
282
+ <CheckIcon size={iconSize.smallest}/>
283
+ {closeLabel}
284
+ </MenuItem>
285
+ </Menu>
286
+ </>}
287
+ </div>
172
288
  )}
173
289
 
174
- {onSaveAndClose && (
290
+ {saveAndCloseButton && (
175
291
  <LoadingButton variant={"filled"}
176
292
  color={"primary"}
177
293
  size={"small"}
@@ -189,6 +305,14 @@ export function EntityIdentityBar({
189
305
  <MoreVerticalIcon size={iconSize.smallest}/>
190
306
  </IconButton>
191
307
  }>
308
+ {externalLink && (
309
+ <MenuItem onClick={() => window.open(externalLink, "_blank", "noopener,noreferrer")}>
310
+ <ExternalLinkIcon size={iconSize.smallest}/>
311
+ {t("open_in_live_site") ?? "Open in the live site"}
312
+ </MenuItem>
313
+ )}
314
+ {externalLink && (recordActions || onInspect || onViewHistory) &&
315
+ <Separator orientation={"horizontal"}/>}
192
316
  {recordActions}
193
317
  {recordActions && (onInspect || onViewHistory) && <Separator orientation={"horizontal"}/>}
194
318
  {onViewHistory && (
@@ -207,6 +331,18 @@ export function EntityIdentityBar({
207
331
  )}
208
332
 
209
333
  {trailing}
334
+
335
+ {/* Last, and behind a rule. A ✕ that sits flush against Save reads
336
+ as the next item in the action row, and the two adjacent controls
337
+ are then "commit this edit" and "abandon it". */}
338
+ {onClose && <>
339
+ <Separator orientation={"vertical"} className={"h-5 mx-1 shrink-0"}/>
340
+ <Tooltip title={`${t("close")} · Esc`}>
341
+ <IconButton size={"small"} onClick={onClose} aria-label={t("close")}>
342
+ <XIcon size={iconSize.smallest}/>
343
+ </IconButton>
344
+ </Tooltip>
345
+ </>}
210
346
  </div>
211
347
  );
212
348
  }
@@ -275,7 +411,11 @@ function IdChip({ value }: { value: string }) {
275
411
  onClick={copy}
276
412
  aria-label={`${t("copy_id")} ${value}`}
277
413
  className={cls(
278
- "hidden md:inline-flex items-center gap-1.5 shrink-0 px-2 py-0.5 rounded-md",
414
+ // `whitespace-nowrap`: the middle of the id is elided with a
415
+ // `…`, and a line break is allowed after one. In the dialog,
416
+ // where the bar is narrow and every button is on it, the
417
+ // chip broke across two lines inside a 52px row.
418
+ "hidden md:inline-flex items-center gap-1.5 shrink-0 whitespace-nowrap px-2 py-0.5 rounded-md",
279
419
  "font-mono text-[11px] text-text-secondary dark:text-text-secondary-dark",
280
420
  "bg-surface-accent-200/50 dark:bg-white/[0.055]",
281
421
  "hover:bg-surface-accent-200/75 dark:hover:bg-white/[0.09] transition-colors"
@@ -65,16 +65,34 @@ export function EntityInspector({
65
65
  const { t } = useTranslation();
66
66
  const open = tab !== null;
67
67
 
68
+ // Escape closes the inspector, and only the inspector.
69
+ //
70
+ // The split view holds its own Escape listener for as long as a record is
71
+ // selected, and closing the record panel is a navigation — so an Escape
72
+ // meant for this panel was taking the whole record with it. On `window` in
73
+ // the bubble phase, as this listener used to be, `stopPropagation` cannot
74
+ // prevent that: it governs an event's travel *between* elements and says
75
+ // nothing about the other listeners on the element it is called from. Both
76
+ // ran, and the split's ran first, because it registered first — this one
77
+ // only exists while the inspector is open, which is always later.
78
+ //
79
+ // Capture on `document` is the idiom that holds regardless of mount order:
80
+ // it runs on the way down, before anything bubbles back to `window`, and
81
+ // there `stopPropagation` is enough. It is what the relation and user
82
+ // selectors already use to own the key while their popovers are open.
83
+ // Pinned in escape_key_ownership.test.tsx; see docs/bug-classes.md.
68
84
  useEffect(() => {
69
85
  if (!open) return;
70
86
  const onKeyDown = (e: KeyboardEvent) => {
71
- if (e.key === "Escape") {
72
- e.stopPropagation();
73
- onClose();
74
- }
87
+ if (e.key !== "Escape") return;
88
+ // A Radix overlay opened from inside the inspector is above it and
89
+ // owns the key first — the same rule the split view applies.
90
+ if (document.querySelector('[role="dialog"][data-state="open"]')) return;
91
+ e.stopPropagation();
92
+ onClose();
75
93
  };
76
- window.addEventListener("keydown", onKeyDown);
77
- return () => window.removeEventListener("keydown", onKeyDown);
94
+ document.addEventListener("keydown", onKeyDown, true);
95
+ return () => document.removeEventListener("keydown", onKeyDown, true);
78
96
  }, [open, onClose]);
79
97
 
80
98
  if (!open) return null;
@@ -25,7 +25,8 @@ import {
25
25
  import { useAnalyticsController } from "@rebasepro/app";
26
26
  import { IconForView } from "@rebasepro/app";
27
27
  import { getPropertyInPath } from "../util/property_utils";
28
- import { getEntityPreviewKeys, getEntityTitlePropertyKeyForEntity } from "../util/previews";
28
+ import { resolveCollectionSlotKeys } from "./CollectionViewBinding/usePreviewSlots";
29
+ import { useEntityTitle } from "../hooks/useEntityDisplay";
29
30
  import { getValueInPath } from "@rebasepro/utils";
30
31
  import { useCollectionRegistryController } from "../hooks/navigation/contexts/CollectionRegistryContext";
31
32
  import { useSidePanel } from "../hooks/useSidePanel";
@@ -87,10 +88,33 @@ export function EntityPreviewBindingData({
87
88
 
88
89
  const collection = collectionProp ?? collectionRegistryController.getCollection(entity.path);
89
90
 
91
+ // Above the `!collection` return, because hooks are. `useEntityTitle`
92
+ // tolerates a missing collection for exactly this reason.
93
+ const title = useEntityTitle({
94
+ collection,
95
+ entity
96
+ });
97
+
98
+ // What the card says under the title.
99
+ //
100
+ // A stated list wins outright — `previewProperties` is the one place a
101
+ // developer says "show these", and nothing here is allowed to second-guess
102
+ // it. Failing that, the card fills the same slots as every other surface
103
+ // that renders one record in a row: a supporting line and a status. It used
104
+ // to take the first few properties in declaration order instead, which is
105
+ // how an author card ended up rendering an entire Markdown biography.
106
+ const statedKeys = previewKeys ?? (collection?.previewProperties as string[] | undefined);
90
107
  const listProperties = useMemo(() => {
91
108
  if (!collection) return [];
92
- return previewKeys ?? getEntityPreviewKeys(authController, collection, customizationController.propertyConfigs, previewKeys, size === "medium" || size === "large" ? 3 : 2);
93
- }, [previewKeys, collection, size, authController, customizationController.propertyConfigs]);
109
+ if (statedKeys && statedKeys.length > 0) {
110
+ return statedKeys.filter(key => getPropertyInPath(collection.properties, key));
111
+ }
112
+ const slotKeys = resolveCollectionSlotKeys(
113
+ collection as AdminCollection<Record<string, unknown>>,
114
+ authController,
115
+ customizationController.propertyConfigs);
116
+ return [slotKeys.subtitleKey, slotKeys.statusKey].filter(Boolean) as string[];
117
+ }, [statedKeys, collection, authController, customizationController.propertyConfigs]);
94
118
 
95
119
  if (!collection) {
96
120
  return (
@@ -100,7 +124,7 @@ export function EntityPreviewBindingData({
100
124
  );
101
125
  }
102
126
 
103
- const titleProperty = includeTitle ? getEntityTitlePropertyKeyForEntity(collection, entity.values, entity.id) : undefined;
127
+ const titleProperty = includeTitle ? title.source?.key : undefined;
104
128
  const imagePropertyKey = includeImage ? getEntityImagePreviewPropertyKey(collection) : undefined;
105
129
  const imageProperty = imagePropertyKey ? collection.properties[imagePropertyKey] : undefined;
106
130
  const ofProp = imageProperty && "of" in imageProperty ? imageProperty.of : undefined;
@@ -119,7 +143,10 @@ export function EntityPreviewBindingData({
119
143
  // relation among the preview properties collapses to one line instead
120
144
  // of nesting another bordered card with its own id and action button.
121
145
  <NestedEntityPreviewBoundary>
122
- <div className={cls("flex shrink-0", {
146
+ {/* `overflow-hidden`: the image slot is a fixed box, and a thumbnail
147
+ that fails to load renders a "file not found" message sized for a
148
+ full preview — which spilled across the title beside it. */}
149
+ <div className={cls("flex shrink-0 overflow-hidden", {
123
150
  "w-6 h-6 mx-1 my-0.5": size === "small" || size === "smallest",
124
151
  "w-8 h-8 ml-1 mr-2 m-2 self-start": size === "medium",
125
152
  "w-10 h-10 ml-2 mr-2 m-2 self-start": size === "large"
@@ -148,20 +175,24 @@ export function EntityPreviewBindingData({
148
175
  </div>
149
176
  : <Skeleton/>)}
150
177
 
151
- {titleProperty && (
178
+ {/* A title that came from a property keeps that property's own
179
+ rendering — an enum stays its coloured chip, a relation stays
180
+ a link. A computed one is text, because that is all a
181
+ resolver returns. */}
182
+ {includeTitle && (titleProperty || title.value) && (
152
183
  <div
153
184
  className={"truncate my-0.5 text-sm font-medium text-text-primary dark:text-text-primary-dark"}>
154
- {
155
- entity
185
+ {!entity && titleProperty
186
+ ? <SkeletonPropertyComponent
187
+ property={getPropertyInPath(collection.properties, titleProperty) as Property}
188
+ size={"medium"}/>
189
+ : titleProperty
156
190
  ? <PropertyPreview
157
- propertyKey={titleProperty as string}
158
- value={getValueInPath(entity.values, titleProperty) as never}
159
- property={collection.properties[titleProperty as string] as Property}
160
- size={"medium"}/>
161
- : <SkeletonPropertyComponent
162
- property={collection.properties[titleProperty as string] as Property}
191
+ propertyKey={titleProperty}
192
+ value={title.source?.value as never}
193
+ property={getPropertyInPath(collection.properties, titleProperty) as Property}
163
194
  size={"medium"}/>
164
- }
195
+ : title.value}
165
196
  </div>
166
197
  )}
167
198
 
@@ -171,8 +202,14 @@ export function EntityPreviewBindingData({
171
202
 
172
203
  const valueInPath = getValueInPath(entity.values, key);
173
204
  return (
205
+ // `truncate` clamps a line of text; it does nothing to a
206
+ // preview that renders blocks. Custom `admin.Preview`
207
+ // components can render anything at all, so the height
208
+ // is capped here as well — a card that loses the tail of
209
+ // an unusual value beats a card the size of a document.
174
210
  <div key={"ref_prev_" + key}
175
- className={cls("truncate", restProperties.length > 1 ? "my-0.5" : "my-0")}>
211
+ className={cls("truncate min-w-0 max-h-8 overflow-hidden",
212
+ restProperties.length > 1 ? "my-0.5" : "my-0")}>
176
213
  {
177
214
  entity
178
215
  ? <PropertyPreview
@@ -362,9 +399,12 @@ export const EntityPreviewContainer = React.forwardRef<HTMLDivElement, EntityPre
362
399
  style={style}
363
400
  className={cls(
364
401
  "bg-white dark:bg-surface-900",
365
- size === "small" ? "min-h-[32px]" : "min-h-[44px]",
402
+ // On the shared 28/32/40/48 control scale. `44px` was between two
403
+ // steps of it, so a reference in a form was 4px short of the field
404
+ // next to it and their boxes never quite agreed.
405
+ size === "small" ? "min-h-[32px]" : "min-h-[48px]",
366
406
  fullwidth ? "w-full" : "",
367
- "items-center",
407
+ "items-center overflow-hidden",
368
408
  hover ? "hover:bg-surface-accent-50 dark:hover:bg-surface-800 group-hover:bg-surface-accent-50 dark:group-hover:bg-surface-800" : "",
369
409
  size === "small" ? "p-1" : "px-2 py-1",
370
410
  "flex border rounded-lg",