@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/dist/components/Typography.d.ts +4 -0
- package/dist/index.css +56 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.es.js +298 -58
- package/dist/index.es.js.map +1 -1
- package/dist/src/index.css +56 -4
- package/dist/src/theme.css +8 -5
- package/dist/styles.d.ts +29 -1
- package/dist/theme.css +8 -5
- package/dist/util/chip_colors.d.ts +14 -0
- package/dist/util/lazy_chunk.d.ts +30 -0
- package/dist/views/CollectionView/CollectionView.d.ts +22 -2
- package/dist/views/CollectionView/CollectionViewToolbar.d.ts +11 -1
- package/dist/views/CollectionView/CollectionViewTypes.d.ts +40 -2
- package/package.json +1 -1
- package/src/components/Chip.tsx +10 -0
- package/src/components/ErrorBoundary.tsx +51 -12
- package/src/components/Typography.tsx +15 -3
- package/src/index.css +56 -4
- package/src/index.ts +1 -0
- package/src/styles.ts +47 -1
- package/src/theme.css +8 -5
- package/src/util/chip_colors.ts +119 -5
- package/src/util/lazy_chunk.ts +101 -0
- package/src/views/CollectionView/CollectionView.tsx +56 -0
- package/src/views/CollectionView/CollectionViewToolbar.tsx +20 -7
- package/src/views/CollectionView/CollectionViewTypes.ts +44 -2
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-
|
|
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.
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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;
|
package/src/util/chip_colors.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
182
|
+
return filled(stops.mid, stops.solid);
|
|
69
183
|
case "Dark":
|
|
70
|
-
return
|
|
184
|
+
return filled(stops.solid, stops.solid);
|
|
71
185
|
case "Darker":
|
|
72
|
-
return
|
|
186
|
+
return filled(stops.deep, stops.deep);
|
|
73
187
|
case "Lighter":
|
|
74
188
|
default:
|
|
75
|
-
return
|
|
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<
|
|
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<
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
/**
|
|
5
|
-
|
|
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
|
+
};
|