@rebasepro/ui 0.14.2-canary.gf913b76 → 0.15.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.
@@ -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
 
@@ -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
+ };