@rebasepro/ui 0.14.1 → 0.14.2-canary.g27a129e

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/styles.ts CHANGED
@@ -53,7 +53,53 @@ export const fieldBackgroundInvisibleMixin = "bg-surface-accent-200/0 dark:bg-wh
53
53
  export const fieldBackgroundDisabledMixin = "bg-surface-accent-200/50 dark:bg-white/[0.03]";
54
54
  export const fieldBackgroundHoverMixin = "hover:bg-surface-accent-200/70 hover:dark:bg-white/[0.09]";
55
55
  export const defaultBorderMixin = "border-surface-200 dark:border-surface-700/60 ";
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Surfaces: two kinds, and the difference is what the border is for.
59
+ //
60
+ // A **floating** surface — a menu, a dialog, a popover — sits OVER the page. It
61
+ // has to be legible against whatever happens to be underneath it, so its edge
62
+ // is definite: solid `surface-700`. That is `paperMixin`.
63
+ //
64
+ // A **page** surface — a card in the document flow — sits ON the page, and the
65
+ // page is already `surface-950`/`surface-900`. A solid edge there reads as a
66
+ // box drawn around content rather than as the content having a surface, which
67
+ // is why every serious caller was overriding it: 53 `<Card>` sites in the SaaS
68
+ // console alone re-declared this border at `/60`, the same value
69
+ // `defaultBorderMixin` above has always used. The component was wrong and the
70
+ // callers were right, so the component now says what they meant.
71
+ // ---------------------------------------------------------------------------
56
72
  export const paperMixin = "bg-white rounded-lg dark:bg-surface-900 border border-surface-200 dark:border-surface-700";
57
- export const cardMixin = "bg-white dark:bg-surface-900 rounded-lg border border-surface-200 dark:border-surface-700";
73
+ export const cardMixin = "bg-white dark:bg-surface-900 rounded-xl border border-surface-200 dark:border-surface-700/60";
74
+
75
+ /**
76
+ * An inset well: code, a query, a log tail, a connection string.
77
+ *
78
+ * It must read as *recessed into* the surface holding it, which means darker
79
+ * than that surface in dark mode — `surface-950` under a `surface-900` card.
80
+ * The obvious-looking `surface-800` is a trap: `#111111` is LIGHTER than the
81
+ * `#0a0a0a` card around it, so the well appears to float above the thing it is
82
+ * set into. Pair with `font-mono`; this mixin carries the surface only.
83
+ */
84
+ export const codeSurfaceMixin = "bg-surface-100 dark:bg-surface-950 rounded-md";
85
+
86
+ /**
87
+ * The accent, used as TEXT.
88
+ *
89
+ * `--color-primary` (#0070F4) is tuned to be a fill — white text on it, it on
90
+ * white. Read as text on our dark surfaces it lands at **4.36:1 on a
91
+ * `surface-900` card** (measured), which is below AA for body-sized text, and
92
+ * every accent link in the product sits on exactly that surface. On the page's
93
+ * `surface-950` it only reaches 4.62:1, so the margin was never real.
94
+ *
95
+ * `--color-primary-light` is the same hue lifted 0.15 in OKLCH lightness and
96
+ * measures **7.34:1** on the same card. It is indistinguishable as "the blue"
97
+ * and comfortably legible, so dark mode swaps to it for text.
98
+ *
99
+ * Use this for links, accent labels and any accent-coloured type. It is NOT for
100
+ * fills — a filled button keeps `bg-primary`, where the contrast question runs
101
+ * the other way and #0070F4 is already correct.
102
+ */
103
+ export const accentTextMixin = "text-primary dark:text-primary-light";
58
104
  export const cardClickableMixin = "hover:bg-primary/5 dark:hover:bg-primary/5 cursor-pointer transition-colors duration-150";
59
105
  export const cardSelectedMixin = "bg-primary-bg/30 dark:bg-primary-bg/10 ring-1 ring-primary/75";
package/src/theme.css CHANGED
@@ -30,11 +30,14 @@
30
30
  --tracking-title: -0.018em; /* 20-24px — section titles */
31
31
  --tracking-heading: -0.01em; /* <= 18px — labels; still a heading, not a headline */
32
32
 
33
- /* Sub-`xs` type. MARKETING ONLY — product UI must not go below `text-xs`
34
- (12px). These exist because the marketing site had ~450 hand-written
35
- `text-[10px]` / `text-[11px]` classes: the tier was real, it just wasn't
36
- in the system, so every page improvised it. Naming it stops the drift
37
- without pretending 10px is acceptable for an admin panel. */
33
+ /* Sub-`xs` type. These exist because the marketing site had ~450
34
+ hand-written `text-[10px]` / `text-[11px]` classes: the tier was real, it
35
+ just wasn't in the system, so every page improvised it.
36
+
37
+ In product UI there is exactly ONE sanctioned use — `typography-micro`,
38
+ the uppercase field label above a value — and it earns the exception by
39
+ never carrying a sentence. Everything a person actually *reads* stays at
40
+ `text-xs` (12px) or above. `--text-3xs` remains marketing-only. */
38
41
  --text-2xs: 11px;
39
42
  --text-2xs--line-height: 1.45;
40
43
  --text-3xs: 10px;
@@ -7,6 +7,98 @@ export type ChipColorScheme = {
7
7
  darkColor?: string;
8
8
  /** Text color override for dark mode */
9
9
  darkText?: string;
10
+ /**
11
+ * Ink for the `outlined` variant, which has no fill of its own.
12
+ *
13
+ * `text`/`darkText` are the ink ON the chip's own background. An outlined
14
+ * chip drops that background and sits on the PAGE, so the same value is
15
+ * being asked to be legible against two different surfaces at once — and
16
+ * for most hues it cannot be. Splitting the roles is what lets the filled
17
+ * ink follow its fill (often dark ink on a bright chip) without dragging
18
+ * the outlined variant down with it.
19
+ */
20
+ outlineText?: string;
21
+ /** Outlined-variant ink in dark mode. */
22
+ darkOutlineText?: string;
23
+ }
24
+
25
+ /* ────────────────────────────────────────────────────────────────────────────
26
+ Contrast
27
+ ────────────────────────────────────────────────────────────────────────────
28
+
29
+ Chip ink is DERIVED, not hand-picked. It used to be written down per tone —
30
+ `"#fff"` on every `solid` background, the hue's `pale` on every `deep` one —
31
+ and an audit of all 120 hue/tone/mode pairs found **63 of them below WCAG AA**.
32
+ The worst was white on `teal.solid` at **1.76:1**, which is not a near miss;
33
+ it is unreadable. The palette is an Airtable-style one whose mid stops are
34
+ bright enough to need dark ink, and hardcoding light ink ignored that.
35
+
36
+ So the ink is measured against the background it will actually sit on, and
37
+ pushed toward black or white only as far as it must go to clear the floor.
38
+ Starting from the hue's own dark/light tints rather than from flat `#000`
39
+ and `#fff` keeps the family looking like a family: `blue.solid` gets a very
40
+ dark navy, not black.
41
+
42
+ Deriving it also means a new hue cannot be added below AA — there is no
43
+ per-tone ink to forget to check. */
44
+
45
+ /** WCAG floor, plus a little margin so rounding cannot drop a pair below 4.5. */
46
+ const CONTRAST_TARGET = 4.6;
47
+
48
+ /** Page backgrounds the outlined variant sits on: `bg-white` and `surface-950`. */
49
+ const PAGE_LIGHT = "#ffffff";
50
+ const PAGE_DARK = "#0a0a0a";
51
+
52
+ function toRgb(hex: string): [number, number, number] {
53
+ let h = hex.replace("#", "");
54
+ if (h.length === 3) h = h.split("").map(c => c + c).join("");
55
+ return [0, 2, 4].map(i => parseInt(h.slice(i, i + 2), 16)) as [number, number, number];
56
+ }
57
+
58
+ function toHex(rgb: number[]): string {
59
+ return "#" + rgb.map(v => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, "0")).join("");
60
+ }
61
+
62
+ function relativeLuminance(hex: string): number {
63
+ const [r, g, b] = toRgb(hex).map(v => {
64
+ const c = v / 255;
65
+ return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
66
+ });
67
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
68
+ }
69
+
70
+ export function contrastRatio(a: string, b: string): number {
71
+ const x = relativeLuminance(a);
72
+ const y = relativeLuminance(b);
73
+ const [hi, lo] = x > y ? [x, y] : [y, x];
74
+ return (hi + 0.05) / (lo + 0.05);
75
+ }
76
+
77
+ function mixHex(from: string, to: string, t: number): string {
78
+ const A = toRgb(from);
79
+ const B = toRgb(to);
80
+ return toHex([0, 1, 2].map(i => A[i] + (B[i] - A[i]) * t));
81
+ }
82
+
83
+ /**
84
+ * Walk a tinted ink toward black or white until it clears the floor on `bg`.
85
+ *
86
+ * Stops at the first passing step rather than going all the way, so the ink
87
+ * keeps as much of its hue as legibility allows.
88
+ */
89
+ function push(base: string, toward: string, bg: string): string {
90
+ for (let t = 0; t <= 1.0001; t += 0.02) {
91
+ const candidate = mixHex(base, toward, t);
92
+ if (contrastRatio(candidate, bg) >= CONTRAST_TARGET) return candidate;
93
+ }
94
+ return toward;
95
+ }
96
+
97
+ /** The ink for a chip filled with `bg`: whichever direction has more headroom. */
98
+ function inkOn(stops: HueStops, bg: string): string {
99
+ const dark = push(stops.text, "#000000", bg);
100
+ const light = push(stops.onDeep ?? stops.pale, "#ffffff", bg);
101
+ return contrastRatio(dark, bg) >= contrastRatio(light, bg) ? dark : light;
10
102
  }
11
103
 
12
104
  /**
@@ -61,18 +153,40 @@ const HUE_STOPS: Record<ChipHue, HueStops> = {
61
153
  emerald: { pale: "#d1fae5", mid: "#6ee7b7", solid: "#10b981", deep: "#059669", text: "#064e3b" }
62
154
  };
63
155
 
156
+ /**
157
+ * One tone of one hue.
158
+ *
159
+ * Backgrounds are the palette's, unchanged — the stops were not the problem and
160
+ * moving them would have restyled every chip in the product. Only the ink moved,
161
+ * and it moved to wherever the measurement says it has to be.
162
+ */
64
163
  function tone(stops: HueStops, chipTone: ChipTone): ChipColorScheme {
65
- const onDeep = stops.onDeep ?? stops.pale;
164
+ // The outlined variant is a property of the HUE, not of the tone: it has no
165
+ // fill, so every tone of a hue sits on the same page background and wants
166
+ // the same ink.
167
+ const outline = {
168
+ outlineText: push(stops.text, "#000000", PAGE_LIGHT),
169
+ darkOutlineText: push(stops.onDeep ?? stops.pale, "#ffffff", PAGE_DARK)
170
+ };
171
+
172
+ const filled = (light: string, dark: string): ChipColorScheme => ({
173
+ color: light,
174
+ text: inkOn(stops, light),
175
+ darkColor: dark,
176
+ darkText: inkOn(stops, dark),
177
+ ...outline
178
+ });
179
+
66
180
  switch (chipTone) {
67
181
  case "Light":
68
- return { color: stops.mid, text: stops.text, darkColor: stops.solid, darkText: "#fff" };
182
+ return filled(stops.mid, stops.solid);
69
183
  case "Dark":
70
- return { color: stops.solid, text: "#fff", darkColor: stops.solid, darkText: "#fff" };
184
+ return filled(stops.solid, stops.solid);
71
185
  case "Darker":
72
- return { color: stops.deep, text: onDeep, darkColor: stops.deep, darkText: onDeep };
186
+ return filled(stops.deep, stops.deep);
73
187
  case "Lighter":
74
188
  default:
75
- return { color: stops.pale, text: stops.text, darkColor: stops.deep, darkText: onDeep };
189
+ return filled(stops.pale, stops.deep);
76
190
  }
77
191
  }
78
192
 
@@ -0,0 +1,101 @@
1
+ import React from "react";
2
+
3
+ /**
4
+ * Dynamic imports that survive a redeploy.
5
+ *
6
+ * A built SPA names its chunks by content hash and a deploy replaces the whole
7
+ * `assets/` directory, so a tab opened before the deploy still holds the *old*
8
+ * entry chunk. The first lazy route that tab opens asks for a hash that no
9
+ * longer exists on the server, the import rejects, and the view is dead until
10
+ * the user thinks to reload — which nothing on screen tells them to do. The
11
+ * browser's own wording ("Failed to fetch dynamically imported module: …") reads
12
+ * like a build bug, so this is usually reported as one.
13
+ *
14
+ * `loadChunk` retries once — that covers a transient network failure — and then
15
+ * gives up with an error tagged as a chunk load failure, so `ErrorBoundary` can
16
+ * offer a reload instead of printing the browser's message.
17
+ */
18
+
19
+ /**
20
+ * Marker property set on the error thrown after a failed retry. Read by
21
+ * `isChunkLoadError`, which is what the UI matches on.
22
+ */
23
+ const CHUNK_LOAD_ERROR_FLAG = "rebaseChunkLoadError";
24
+
25
+ /**
26
+ * Each engine words this differently, and the message is the only signal — none
27
+ * of them use a distinguishable error type. The MIME variants matter as much as
28
+ * the fetch ones: a server whose SPA fallback answers a missing `/assets/x.js`
29
+ * with `index.html` returns 200 HTML, and the browser rejects it as a module.
30
+ */
31
+ const CHUNK_ERROR_PATTERNS = [
32
+ "failed to fetch dynamically imported module",
33
+ "error loading dynamically imported module",
34
+ "importing a module script failed",
35
+ "failed to load module script",
36
+ "expected a javascript",
37
+ "unable to preload css"
38
+ ];
39
+
40
+ /**
41
+ * Does this error mean a chunk could not be loaded — rather than the chunk's
42
+ * own code throwing once it did load?
43
+ *
44
+ * Errors raised anywhere are matched, not just ones `loadChunk` produced: React
45
+ * `lazy()` calls that still import directly, and Vite's own CSS preloads, fail
46
+ * the same way and deserve the same recovery.
47
+ */
48
+ export function isChunkLoadError(error: unknown): boolean {
49
+ if (!error || typeof error !== "object") return false;
50
+ if ((error as Record<string, unknown>)[CHUNK_LOAD_ERROR_FLAG] === true) return true;
51
+ const message = (error as { message?: unknown }).message;
52
+ if (typeof message !== "string") return false;
53
+ const lower = message.toLowerCase();
54
+ return CHUNK_ERROR_PATTERNS.some(pattern => lower.includes(pattern));
55
+ }
56
+
57
+ function chunkLoadError(cause: unknown): Error {
58
+ const error = new Error(
59
+ "This app was updated while the tab was open, so part of it could not be loaded. Reload to continue."
60
+ );
61
+ Object.assign(error, { [CHUNK_LOAD_ERROR_FLAG]: true,
62
+ cause });
63
+ return error;
64
+ }
65
+
66
+ /**
67
+ * Run a dynamic import, retrying once before declaring the chunk unreachable.
68
+ *
69
+ * Errors that are not chunk load failures — the imported module throwing while
70
+ * it evaluates, say — are re-thrown untouched and never retried: running a
71
+ * module's side effects twice is worse than the original failure.
72
+ */
73
+ export async function loadChunk<T>(loader: () => Promise<T>): Promise<T> {
74
+ try {
75
+ return await loader();
76
+ } catch (error) {
77
+ if (!isChunkLoadError(error)) throw error;
78
+ // A blip resolves inside a few hundred ms; a stale hash never will.
79
+ await new Promise(resolve => setTimeout(resolve, 250));
80
+ try {
81
+ return await loader();
82
+ } catch (retryError) {
83
+ throw chunkLoadError(retryError);
84
+ }
85
+ }
86
+ }
87
+
88
+ /**
89
+ * `React.lazy` with the retry above. A drop-in replacement — use it for every
90
+ * lazy route or dialog, since any one of them can be the first chunk a stale
91
+ * tab reaches for.
92
+ *
93
+ * Note that React caches the rejection: once a lazy component has failed, later
94
+ * renders re-throw without calling the loader again. Resetting an error boundary
95
+ * therefore cannot recover it, which is why the boundary offers a reload.
96
+ */
97
+ export function lazyChunk<T extends React.ComponentType<any>>(
98
+ loader: () => Promise<{ default: T }>
99
+ ): React.LazyExoticComponent<T> {
100
+ return React.lazy(() => loadChunk(loader));
101
+ }
@@ -12,6 +12,7 @@ import type {
12
12
  CollectionPropertyConfig,
13
13
  CollectionDataController,
14
14
  CellRendererOverride,
15
+ CollectionCustomViewEntry,
15
16
  CollectionSelectionController,
16
17
  KanbanPropertyOption
17
18
  } from "./CollectionViewTypes";
@@ -134,6 +135,27 @@ export interface CollectionViewProps<T = Record<string, unknown>> {
134
135
  /** Custom empty state component */
135
136
  emptyComponent?: React.ReactNode;
136
137
 
138
+ /**
139
+ * Additional ways to render these rows, keyed by view mode. A key here can
140
+ * be named by `viewMode`, `defaultViewMode` and `enabledViews`, and gets
141
+ * its own entry in the view switcher.
142
+ *
143
+ * A custom view is another rendering of the *same* `dataController`, so it
144
+ * inherits the toolbar's search, the loading state and selection. A
145
+ * component that fetches its own data does not want to be a view mode —
146
+ * the toolbar above it would be describing a query it does not render.
147
+ *
148
+ * @example
149
+ * ```tsx
150
+ * <CollectionView
151
+ * customViews={{ map: { name: "Map", Component: MapView } }}
152
+ * enabledViews={["table", "map"]}
153
+ * defaultViewMode="map"
154
+ * />
155
+ * ```
156
+ */
157
+ customViews?: Record<string, CollectionCustomViewEntry<T>>;
158
+
137
159
  // ── Toolbar ───────────────────────────────────────────────────────
138
160
 
139
161
  /** Actions rendered at the start (left) of the toolbar */
@@ -215,6 +237,7 @@ export function CollectionView<T extends Record<string, unknown> = Record<string
215
237
  onMultipleDelete,
216
238
  cellRenderer,
217
239
  emptyComponent,
240
+ customViews,
218
241
  toolbarActionsStart,
219
242
  toolbarActionsEnd,
220
243
  hideToolbar,
@@ -298,7 +321,39 @@ export function CollectionView<T extends Record<string, unknown> = Record<string
298
321
  emptyComponent,
299
322
  };
300
323
 
324
+ // Resolved once so both the switcher and the render path agree. A custom
325
+ // view keyed as a built-in is ignored rather than allowed to shadow it —
326
+ // the switch below checks the built-ins first, so honouring such a key
327
+ // here would put the switcher and the canvas out of step.
328
+ const customViewEntries = useMemo(() => {
329
+ if (!customViews) return [];
330
+ return Object.entries(customViews)
331
+ .filter(([key]) => !DEFAULT_ENABLED_VIEWS.includes(key))
332
+ .map(([key, entry]) => {
333
+ const isBare = typeof entry === "function";
334
+ return {
335
+ key,
336
+ name: isBare ? key : (entry.name ?? key),
337
+ icon: isBare ? undefined : entry.icon,
338
+ Component: isBare ? entry : entry.Component
339
+ };
340
+ });
341
+ }, [customViews]);
342
+
301
343
  function renderView() {
344
+ const custom = customViewEntries.find((entry) => entry.key === viewMode);
345
+ if (custom) {
346
+ const { Component } = custom;
347
+ return (
348
+ <Component
349
+ {...sharedProps}
350
+ onRowCreate={canCreate ? onRowCreate : undefined}
351
+ canCreate={canCreate}
352
+ size={size}
353
+ />
354
+ );
355
+ }
356
+
302
357
  switch (viewMode) {
303
358
  case "table":
304
359
  return (
@@ -372,6 +427,7 @@ export function CollectionView<T extends Record<string, unknown> = Record<string
372
427
  viewMode={viewMode}
373
428
  onViewModeChange={enabledViews.length > 1 ? onViewModeChange : undefined}
374
429
  enabledViews={enabledViews}
430
+ customViews={customViewEntries}
375
431
  size={size}
376
432
  onSizeChange={onSizeChange}
377
433
  searchString={dataController.searchString}
@@ -20,6 +20,12 @@ export type CollectionViewToolbarProps = {
20
20
  viewMode: CollectionViewMode;
21
21
  onViewModeChange?: (mode: CollectionViewMode) => void;
22
22
  enabledViews?: CollectionViewMode[];
23
+ /**
24
+ * Custom view modes, supplying the label and icon for their switcher
25
+ * entries. Without these an `enabledViews` key outside the four built-ins
26
+ * has no label and no icon to draw.
27
+ */
28
+ customViews?: { key: string, name: string, icon?: React.ReactNode }[];
23
29
  size?: CollectionViewSize;
24
30
  onSizeChange?: (size: CollectionViewSize) => void;
25
31
  searchString?: string;
@@ -33,14 +39,14 @@ export type CollectionViewToolbarProps = {
33
39
  onKanbanPropertyChange?: (property: string) => void;
34
40
  };
35
41
 
36
- const VIEW_MODE_ICONS: Record<CollectionViewMode, React.ReactNode> = {
42
+ const VIEW_MODE_ICONS: Record<string, React.ReactNode> = {
37
43
  list: <LayoutList size={iconSize.small} />,
38
44
  table: <Table2 size={iconSize.small} />,
39
45
  cards: <LayoutGrid size={iconSize.small} />,
40
46
  kanban: <Kanban size={iconSize.small} />,
41
47
  };
42
48
 
43
- const VIEW_MODE_LABELS: Record<CollectionViewMode, string> = {
49
+ const VIEW_MODE_LABELS: Record<string, string> = {
44
50
  list: "List",
45
51
  table: "Table",
46
52
  cards: "Cards",
@@ -67,6 +73,7 @@ export function CollectionViewToolbar({
67
73
  viewMode,
68
74
  onViewModeChange,
69
75
  enabledViews = ["list", "table", "cards", "kanban"],
76
+ customViews,
70
77
  size = "m",
71
78
  onSizeChange,
72
79
  searchString,
@@ -81,11 +88,17 @@ export function CollectionViewToolbar({
81
88
  }: CollectionViewToolbarProps) {
82
89
  const [popoverOpen, setPopoverOpen] = useState(false);
83
90
 
84
- const viewOptions: ToggleButtonOption<CollectionViewMode>[] = enabledViews.map((mode) => ({
85
- value: mode,
86
- label: VIEW_MODE_LABELS[mode],
87
- icon: VIEW_MODE_ICONS[mode],
88
- }));
91
+ // A key outside the four built-ins takes its label and icon from the
92
+ // custom view that declared it; without one it falls back to the key
93
+ // itself and the list glyph, so an entry is never blank.
94
+ const viewOptions: ToggleButtonOption<CollectionViewMode>[] = enabledViews.map((mode) => {
95
+ const custom = customViews?.find((entry) => entry.key === mode);
96
+ return {
97
+ value: mode,
98
+ label: VIEW_MODE_LABELS[mode] ?? custom?.name ?? mode,
99
+ icon: VIEW_MODE_ICONS[mode] ?? custom?.icon ?? <LayoutList size={iconSize.small} />,
100
+ };
101
+ });
89
102
 
90
103
  const handleViewModeChange = useCallback(
91
104
  (mode: CollectionViewMode) => {
@@ -1,8 +1,13 @@
1
1
  import React from "react";
2
2
  import type { VirtualTableSortKey } from "../../components/VirtualTable/VirtualTableProps";
3
3
 
4
- /** Supported view modes */
5
- export type CollectionViewMode = "table" | "cards" | "kanban" | "list";
4
+ /**
5
+ * Supported view modes.
6
+ *
7
+ * Any other string is a key into the `customViews` prop. The `(string & {})`
8
+ * arm keeps the four built-ins in autocomplete while admitting those keys.
9
+ */
10
+ export type CollectionViewMode = "table" | "cards" | "kanban" | "list" | (string & {});
6
11
 
7
12
  /** Supported collection sizes */
8
13
  export type CollectionViewSize = "xs" | "s" | "m" | "l" | "xl";
@@ -137,3 +142,40 @@ export interface KanbanPropertyOption {
137
142
  key: string;
138
143
  label: string;
139
144
  }
145
+
146
+ /**
147
+ * What a custom view's component receives — the same set the built-in table,
148
+ * card, list and kanban views are given, so a custom view starts at parity
149
+ * with them and inherits the toolbar's search, the empty state and selection.
150
+ */
151
+ export interface CollectionCustomViewProps<T = Record<string, unknown>> {
152
+ dataController: CollectionDataController<T>;
153
+ properties: Record<string, CollectionPropertyConfig>;
154
+ propertiesOrder?: string[];
155
+ idProperty?: string;
156
+ titleProperty?: string;
157
+ onRowClick?: (row: T) => void;
158
+ onRowCreate?: (defaults?: Record<string, unknown>) => void;
159
+ canCreate?: boolean;
160
+ cellRenderer?: CellRendererOverride<T>;
161
+ selectionEnabled?: boolean;
162
+ selectionController?: CollectionSelectionController<T>;
163
+ highlightedItems?: T[];
164
+ emptyComponent?: React.ReactNode;
165
+ size?: CollectionViewSize;
166
+ }
167
+
168
+ /**
169
+ * A custom view registration. Either the component on its own, or the
170
+ * component with the label and icon its switcher entry should carry.
171
+ *
172
+ * Without a `name` the switcher falls back to the key, which is legible but
173
+ * rarely what you want on screen.
174
+ */
175
+ export type CollectionCustomViewEntry<T = Record<string, unknown>> =
176
+ | React.ComponentType<CollectionCustomViewProps<T>>
177
+ | {
178
+ name?: string;
179
+ icon?: React.ReactNode;
180
+ Component: React.ComponentType<CollectionCustomViewProps<T>>;
181
+ };