@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.
- package/dist/components/Typography.d.ts +4 -0
- package/dist/index.css +56 -4
- package/dist/index.es.js +162 -37
- 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/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/Typography.tsx +15 -3
- package/src/index.css +56 -4
- package/src/styles.ts +47 -1
- package/src/theme.css +8 -5
- package/src/util/chip_colors.ts +119 -5
- 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/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
|
|
|
@@ -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
|
+
};
|