@protoboardai/views 0.0.0-stage → 0.2.1
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/LICENSE +202 -0
- package/NOTICE +13 -0
- package/README.md +21 -2
- package/package.json +57 -4
- package/src/applies.ts +54 -0
- package/src/builtins.tsx +89 -0
- package/src/drawing/BoardDrawingView.tsx +515 -0
- package/src/drawing/CircuitPcbView.tsx +494 -0
- package/src/drawing/DrawingControls.tsx +39 -0
- package/src/drawing/boards.ts +82 -0
- package/src/drawing/circuit.ts +413 -0
- package/src/drawing/kicadSchematic.ts +277 -0
- package/src/drawing/palettes.ts +150 -0
- package/src/drawing/svg.ts +445 -0
- package/src/drawing/usePanZoom.ts +400 -0
- package/src/icons.tsx +69 -0
- package/src/index.ts +33 -0
- package/src/inspector/DrcPanel.tsx +302 -0
- package/src/inspector/SelectionInspector.tsx +392 -0
- package/src/inspector/index.ts +9 -0
- package/src/inspector/model.ts +224 -0
- package/src/pair/PairView.tsx +314 -0
- package/src/pair/model.ts +206 -0
- package/src/parts/BomEditor.tsx +564 -0
- package/src/parts/CardPages.tsx +317 -0
- package/src/parts/CategoryList.tsx +72 -0
- package/src/parts/CloudPanel.tsx +160 -0
- package/src/parts/GlbPreview.tsx +116 -0
- package/src/parts/LibrariesManager.tsx +251 -0
- package/src/parts/LibraryBrowser.tsx +191 -0
- package/src/parts/LibraryPage.tsx +168 -0
- package/src/parts/PartCard.tsx +274 -0
- package/src/parts/PartDetail.tsx +613 -0
- package/src/parts/PartStatus.tsx +54 -0
- package/src/parts/Prices.tsx +241 -0
- package/src/parts/ProjectParts.tsx +174 -0
- package/src/parts/ProjectPartsPage.tsx +264 -0
- package/src/parts/SettingsPage.tsx +268 -0
- package/src/parts/Surface.tsx +51 -0
- package/src/parts/Windowed.tsx +70 -0
- package/src/parts/carousel.tsx +344 -0
- package/src/parts/index.ts +32 -0
- package/src/parts/library-data.ts +726 -0
- package/src/parts/matte.ts +69 -0
- package/src/parts/model.ts +590 -0
- package/src/parts/part-pages.ts +379 -0
- package/src/parts/part-status.ts +88 -0
- package/src/parts/revision.ts +11 -0
- package/src/parts/three.d.ts +5 -0
- package/src/parts/ui.tsx +335 -0
- package/src/parts/views.tsx +433 -0
- package/src/registry.ts +107 -0
- package/src/tabs.ts +118 -0
- package/src/theme.ts +225 -0
- package/src/types.ts +93 -0
- package/src/ui/Select.tsx +99 -0
package/src/theme.ts
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme adapters for the shells. The tokens and the neutral and VS Code
|
|
3
|
+
* adapters come from @deltarobotics/uhd-viewer; Protoboard's own theme lives
|
|
4
|
+
* here, so the open viewer never carries the brand. Protoboard orange exists
|
|
5
|
+
* only in these two themes: nothing else in the stack hard-codes it.
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
defaultDark,
|
|
9
|
+
defaultLight,
|
|
10
|
+
defaultThemeAdapter,
|
|
11
|
+
defineTheme,
|
|
12
|
+
staticTheme,
|
|
13
|
+
vscodeThemeAdapter,
|
|
14
|
+
type Theme,
|
|
15
|
+
type ThemeAdapter,
|
|
16
|
+
} from "@deltarobotics/uhd-viewer/components";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The Protoboard brand palette, light and dark. The themes below map it onto
|
|
20
|
+
* the viewer's tokens, so every Protoboard surface reads as one product.
|
|
21
|
+
* Design history: the values and their names were taken from the alpha app's stylesheet (next-alpha src/index.css, `:root` and `.dark`).
|
|
22
|
+
*/
|
|
23
|
+
export const BRAND_TOKENS = {
|
|
24
|
+
light: {
|
|
25
|
+
background: "oklch(0.9551 0 0)",
|
|
26
|
+
foreground: "oklch(0.3211 0 0)",
|
|
27
|
+
card: "oklch(0.97 0 0)",
|
|
28
|
+
popover: "oklch(0.9702 0 0)",
|
|
29
|
+
primary: "oklch(0.6837 0.212 40.5923)",
|
|
30
|
+
primaryForeground: "oklch(1 0 0)",
|
|
31
|
+
secondary: "oklch(0.9067 0 0)",
|
|
32
|
+
muted: "oklch(0.872 0 0)",
|
|
33
|
+
mutedForeground: "oklch(0.45 0 0)",
|
|
34
|
+
accent: "oklch(0.8078 0 0)",
|
|
35
|
+
border: "oklch(0.8576 0 0)",
|
|
36
|
+
input: "oklch(0.9067 0 0)",
|
|
37
|
+
ring: "oklch(0.4891 0 0)",
|
|
38
|
+
sidebar: "oklch(0.937 0 0)",
|
|
39
|
+
canvasBackground: "oklch(0.94 0 0)",
|
|
40
|
+
canvasGrid: "oklch(0.76 0 0)",
|
|
41
|
+
},
|
|
42
|
+
dark: {
|
|
43
|
+
background: "oklch(0.2178 0 0)",
|
|
44
|
+
foreground: "oklch(0.8853 0 0)",
|
|
45
|
+
card: "oklch(0.251 0 0)",
|
|
46
|
+
popover: "oklch(0.2435 0 0)",
|
|
47
|
+
primary: "oklch(0.6837 0.212 40.5923)",
|
|
48
|
+
primaryForeground: "oklch(0.922 0 0)",
|
|
49
|
+
secondary: "oklch(0.3092 0 0)",
|
|
50
|
+
muted: "oklch(0.285 0 0)",
|
|
51
|
+
mutedForeground: "oklch(0.68 0 0)",
|
|
52
|
+
accent: "oklch(0.3715 0 0)",
|
|
53
|
+
border: "oklch(0.329 0 0)",
|
|
54
|
+
input: "oklch(0.3092 0 0)",
|
|
55
|
+
ring: "oklch(0.7058 0 0)",
|
|
56
|
+
sidebar: "oklch(0.2393 0 0)",
|
|
57
|
+
canvasBackground: "oklch(0.2178 0 0)",
|
|
58
|
+
canvasGrid: "oklch(0.285 0 0)",
|
|
59
|
+
},
|
|
60
|
+
} as const;
|
|
61
|
+
|
|
62
|
+
/** The brand's shadows: a 2px y-offset step under a soft blur. */
|
|
63
|
+
const BRAND_SHADOWS = {
|
|
64
|
+
shadowSm: "0px 2px 0px 0px hsl(0 0% 20% / 0.15), 0px 1px 2px -1px hsl(0 0% 20% / 0.15)",
|
|
65
|
+
shadowMd: "0px 2px 0px 0px hsl(0 0% 20% / 0.15), 0px 2px 4px -1px hsl(0 0% 20% / 0.15)",
|
|
66
|
+
shadowLg: "0px 2px 0px 0px hsl(0 0% 20% / 0.15), 0px 4px 6px -1px hsl(0 0% 20% / 0.15)",
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** The brand palette on the viewer's surfaces, text, borders and accent. */
|
|
70
|
+
function brandTokens(a: (typeof BRAND_TOKENS)["light" | "dark"]) {
|
|
71
|
+
return {
|
|
72
|
+
background: a.background,
|
|
73
|
+
canvas: a.canvasBackground,
|
|
74
|
+
canvasDot: a.canvasGrid,
|
|
75
|
+
surface: a.card,
|
|
76
|
+
surfaceRaised: a.popover,
|
|
77
|
+
surfaceSunken: a.sidebar,
|
|
78
|
+
muted: a.muted,
|
|
79
|
+
active: a.accent,
|
|
80
|
+
text: a.foreground,
|
|
81
|
+
textMuted: a.mutedForeground,
|
|
82
|
+
textOnAccent: a.primaryForeground,
|
|
83
|
+
border: a.border,
|
|
84
|
+
borderSubtle: a.input,
|
|
85
|
+
borderStrong: a.ring,
|
|
86
|
+
nodeBorder: a.border,
|
|
87
|
+
accent: a.primary,
|
|
88
|
+
textLink: a.primary,
|
|
89
|
+
radius: "0.5rem",
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The dark Protoboard theme: the palette's dark tokens. */
|
|
94
|
+
export const protoboardDark: Theme = defineTheme(defaultDark, {
|
|
95
|
+
id: "protoboard-dark",
|
|
96
|
+
label: "Protoboard dark",
|
|
97
|
+
tokens: {
|
|
98
|
+
...brandTokens(BRAND_TOKENS.dark),
|
|
99
|
+
// the canvas's nodes and the 3D scene sit a step off the palette's background
|
|
100
|
+
surfaceSunken: "oklch(0.2 0 0)",
|
|
101
|
+
textFaint: "oklch(0.52 0 0)",
|
|
102
|
+
node: "oklch(0.2768 0 0)",
|
|
103
|
+
nodeSelected: "oklch(0.2386 0 0)",
|
|
104
|
+
nodeBorder: "oklch(0.4 0 0)",
|
|
105
|
+
nodeBorderHover: BRAND_TOKENS.dark.foreground,
|
|
106
|
+
selection: "rgba(168, 162, 158, 0.32)",
|
|
107
|
+
accentHover: "oklch(0.72 0.19 42)",
|
|
108
|
+
...BRAND_SHADOWS,
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
/** The light Protoboard theme: the palette's light tokens. */
|
|
113
|
+
export const protoboardLight: Theme = defineTheme(defaultLight, {
|
|
114
|
+
id: "protoboard-light",
|
|
115
|
+
label: "Protoboard light",
|
|
116
|
+
tokens: {
|
|
117
|
+
...brandTokens(BRAND_TOKENS.light),
|
|
118
|
+
textFaint: "oklch(0.6 0 0)",
|
|
119
|
+
node: BRAND_TOKENS.light.card,
|
|
120
|
+
nodeSelected: `color-mix(in oklab, var(--uhd-accent) 6%, ${BRAND_TOKENS.light.card})`,
|
|
121
|
+
nodeBorder: BRAND_TOKENS.light.border,
|
|
122
|
+
nodeBorderHover: BRAND_TOKENS.light.mutedForeground,
|
|
123
|
+
overlay: "color-mix(in oklab, var(--uhd-surface) 92%, transparent)",
|
|
124
|
+
scrim: "oklch(0.2 0 0 / 0.4)",
|
|
125
|
+
accentHover: "oklch(0.63 0.2 40.6)",
|
|
126
|
+
sceneBackground: BRAND_TOKENS.light.canvasBackground,
|
|
127
|
+
sceneGrid: "oklch(0.8 0 0)",
|
|
128
|
+
sceneGridMinor: "oklch(0.88 0 0)",
|
|
129
|
+
...BRAND_SHADOWS,
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
export const PROTOBOARD_THEMES: readonly Theme[] = [protoboardDark, protoboardLight];
|
|
134
|
+
|
|
135
|
+
/** Protoboard's theme: dark, light, or whichever the system prefers. */
|
|
136
|
+
export function protoboardThemeAdapter(scheme: "dark" | "light" | "system" = "dark", win: Window | undefined = typeof window === "undefined" ? undefined : window): ThemeAdapter {
|
|
137
|
+
if (scheme !== "system") return staticTheme(scheme === "light" ? protoboardLight : protoboardDark);
|
|
138
|
+
const query = win?.matchMedia?.("(prefers-color-scheme: light)");
|
|
139
|
+
const current = () => (query?.matches ? protoboardLight : protoboardDark);
|
|
140
|
+
return {
|
|
141
|
+
id: "protoboard",
|
|
142
|
+
current,
|
|
143
|
+
subscribe: query
|
|
144
|
+
? (onChange) => {
|
|
145
|
+
const h = () => onChange(current());
|
|
146
|
+
query.addEventListener("change", h);
|
|
147
|
+
return () => query.removeEventListener("change", h);
|
|
148
|
+
}
|
|
149
|
+
: undefined,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The colour theme VS Code applies is a Protoboard one (the extension's Protoboard Dark or Light). */
|
|
154
|
+
export const isProtoboardColourTheme = (doc: Document | undefined = typeof document === "undefined" ? undefined : document): boolean =>
|
|
155
|
+
/^protoboard\b/i.test(doc?.body?.dataset.vscodeThemeId ?? doc?.body?.dataset.vscodeThemeName ?? "");
|
|
156
|
+
|
|
157
|
+
/** VS Code's faces, or the Protoboard ones under a Protoboard colour theme (the only place they apply). */
|
|
158
|
+
const VSCODE_FACES = { fontUi: "var(--vscode-font-family)", fontLabel: "var(--vscode-font-family)", fontMono: "var(--vscode-editor-font-family, ui-monospace, monospace)" };
|
|
159
|
+
const PROTOBOARD_FACES = { fontUi: '"Kode Mono", var(--vscode-font-family)', fontLabel: '"Kode Mono Bold", "Kode Mono", var(--vscode-font-family)', fontMono: "var(--vscode-editor-font-family, ui-monospace, monospace)" };
|
|
160
|
+
|
|
161
|
+
/** Faint text in a native view: readable as text on the side bar's and the panel's background in a light and a dark theme. */
|
|
162
|
+
const VIEW_FAINT = "color-mix(in oklab, var(--vscode-foreground) 72%, var(--vscode-sideBar-background, var(--vscode-editor-background)))";
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* VS Code's theme for a native view (the Inspector's selection, the DRC
|
|
166
|
+
* panel): the viewer's VS Code adapter, every token on `--vscode-*`, with VS
|
|
167
|
+
* Code's own faces, and Kode Mono (bold for labels) only under a Protoboard
|
|
168
|
+
* colour theme. The viewer tab's shell has the same rule
|
|
169
|
+
* (`vscodeShellThemeAdapter` in @protoboardai/web-viewer). A change of theme
|
|
170
|
+
* (the body's class or its theme id) re-themes.
|
|
171
|
+
*/
|
|
172
|
+
export function vscodeViewThemeAdapter(doc: Document | undefined = typeof document === "undefined" ? undefined : document): ThemeAdapter {
|
|
173
|
+
const base = vscodeThemeAdapter(doc);
|
|
174
|
+
const current = () => {
|
|
175
|
+
const t = base.current();
|
|
176
|
+
// faint text (the keys of the details' facts) is much of what a native view shows, and VS Code's disabledForeground is
|
|
177
|
+
// too pale to read as text (2.2:1 in Light Modern): the foreground stepped towards the view's background keeps 4.5:1;
|
|
178
|
+
// high contrast has one text colour, the foreground
|
|
179
|
+
const textFaint = t.scheme.startsWith("high-contrast") ? "var(--vscode-foreground)" : VIEW_FAINT;
|
|
180
|
+
return defineTheme(t, { id: t.id, label: t.label, tokens: { ...(isProtoboardColourTheme(doc) ? PROTOBOARD_FACES : VSCODE_FACES), textFaint } });
|
|
181
|
+
};
|
|
182
|
+
return {
|
|
183
|
+
id: "vscode",
|
|
184
|
+
current,
|
|
185
|
+
subscribe: doc?.body
|
|
186
|
+
? (onChange) => {
|
|
187
|
+
let key = `${doc.body.className}|${isProtoboardColourTheme(doc)}`;
|
|
188
|
+
const observer = new MutationObserver(() => {
|
|
189
|
+
const next = `${doc.body.className}|${isProtoboardColourTheme(doc)}`;
|
|
190
|
+
if (next !== key) {
|
|
191
|
+
key = next;
|
|
192
|
+
onChange(current());
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
observer.observe(doc.body, { attributes: true, attributeFilter: ["class", "data-vscode-theme-id", "data-vscode-theme-name"] });
|
|
196
|
+
return () => observer.disconnect();
|
|
197
|
+
}
|
|
198
|
+
: undefined,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** The theme choices a shell offers by id (`?theme=` in the web viewer). */
|
|
203
|
+
export type ThemeChoice = "protoboard-dark" | "protoboard-light" | "protoboard-system" | "vscode" | "default-dark" | "default-light";
|
|
204
|
+
|
|
205
|
+
export const THEME_CHOICES: readonly ThemeChoice[] = ["protoboard-dark", "protoboard-light", "protoboard-system", "vscode", "default-dark", "default-light"];
|
|
206
|
+
|
|
207
|
+
/** The adapter for a choice (unknown: Protoboard dark). */
|
|
208
|
+
export function themeAdapterFor(choice: string | null | undefined): ThemeAdapter {
|
|
209
|
+
switch (choice) {
|
|
210
|
+
case "protoboard-light":
|
|
211
|
+
return protoboardThemeAdapter("light");
|
|
212
|
+
case "protoboard-system":
|
|
213
|
+
return protoboardThemeAdapter("system");
|
|
214
|
+
case "vscode":
|
|
215
|
+
return vscodeThemeAdapter();
|
|
216
|
+
case "default-dark":
|
|
217
|
+
return defaultThemeAdapter("dark");
|
|
218
|
+
case "default-light":
|
|
219
|
+
return defaultThemeAdapter("light");
|
|
220
|
+
default:
|
|
221
|
+
return protoboardThemeAdapter("dark");
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export { defaultThemeAdapter, staticTheme, vscodeThemeAdapter, type Theme, type ThemeAdapter };
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The view contract: what a view is, what it is given, and the host and theme
|
|
3
|
+
* adapters a shell (web, VS Code, desktop) implements so the same views run
|
|
4
|
+
* in each without rebuilding (docs/specs/viewer-stack.md).
|
|
5
|
+
*/
|
|
6
|
+
import type { ComponentType } from "react";
|
|
7
|
+
import type { ModuleDef, UhdBundle } from "@deltarobotics/uhd";
|
|
8
|
+
import type { Theme, ThemeAdapter, ViewerSelection } from "@deltarobotics/uhd-viewer/components";
|
|
9
|
+
|
|
10
|
+
export type { Theme, ThemeAdapter };
|
|
11
|
+
|
|
12
|
+
/** Where a view is opened: the focused module, by instance path, in the loaded bundle. */
|
|
13
|
+
export interface ViewTarget {
|
|
14
|
+
/** The focused module's definition. */
|
|
15
|
+
module: ModuleDef;
|
|
16
|
+
/** Its instance path from the root system ("" is the root). */
|
|
17
|
+
path: string;
|
|
18
|
+
bundle: UhdBundle;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** What a view's component receives. */
|
|
22
|
+
export interface ViewProps extends ViewTarget {
|
|
23
|
+
host: HostAdapter;
|
|
24
|
+
/** The view's own registration (its id, title, icon). */
|
|
25
|
+
view: ViewDefinition;
|
|
26
|
+
/**
|
|
27
|
+
* What the view was opened with (`openView`'s `params`): the pair view's
|
|
28
|
+
* two modules (`a`, `b`) or link (`link`), a board (`board`). Each view
|
|
29
|
+
* documents its own; absent keys mean its default.
|
|
30
|
+
*/
|
|
31
|
+
params?: Readonly<Record<string, string>>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** `ready`: a working view. `planned`: registered so the tab exists; the shells show what is coming. */
|
|
35
|
+
export type ViewStatus = "ready" | "planned";
|
|
36
|
+
|
|
37
|
+
export interface ViewDefinition {
|
|
38
|
+
/** Unique: `diagram`, `3d`, `schematic`, or `<extension>.<view>` for an extension's own. */
|
|
39
|
+
id: string;
|
|
40
|
+
title: string;
|
|
41
|
+
/** A Lucide icon name (kebab-case). */
|
|
42
|
+
icon: string;
|
|
43
|
+
/** Whether the view has anything to show for a module (its tab is offered only then). */
|
|
44
|
+
applies(module: ModuleDef, bundle: UhdBundle, at?: { path: string }): boolean;
|
|
45
|
+
/** The view. Absent: a planned view (the shell shows its placeholder). */
|
|
46
|
+
component?: ComponentType<ViewProps>;
|
|
47
|
+
status?: ViewStatus;
|
|
48
|
+
/** Sort key among a module's tabs (lower first: diagram 0, 3d 10, schematic 20, pcb 30, bom 40, pair 50, library 60). */
|
|
49
|
+
order?: number;
|
|
50
|
+
/** One sentence for the tab's tooltip and the placeholder. */
|
|
51
|
+
description?: string;
|
|
52
|
+
/** Who registered it: `builtin`, or the contributing extension's id. */
|
|
53
|
+
source?: string;
|
|
54
|
+
/**
|
|
55
|
+
* The view draws the shared UHD viewer (diagram, 3D): shells keep one
|
|
56
|
+
* viewer state for all of these, so selection and drill level carry across.
|
|
57
|
+
*/
|
|
58
|
+
uhd?: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* What a view may ask of the shell around it. Every method is optional: a
|
|
63
|
+
* shell offers what it can (VS Code opens a source file in an editor, the
|
|
64
|
+
* web shell in a new tab, a static page not at all).
|
|
65
|
+
*/
|
|
66
|
+
export interface HostAdapter {
|
|
67
|
+
/** `web`, `vscode`, `desktop`, … (for views that word things per host). */
|
|
68
|
+
kind: string;
|
|
69
|
+
/** Show a module's definition (or its instance's line) in source. */
|
|
70
|
+
openSource?(request: { defId: string; instancePath?: string }): void;
|
|
71
|
+
/** Open one of a module's artifact files (VS Code: STEP and GLB in its CAD viewer, the rest in an editor). */
|
|
72
|
+
openArtifact?(request: { defId: string; artifactId: string }): void;
|
|
73
|
+
/**
|
|
74
|
+
* Open a view for a module: a new editor tab in VS Code, the tab in the web
|
|
75
|
+
* shell. `params` reach the view as its `params` (see ViewProps).
|
|
76
|
+
*/
|
|
77
|
+
openView?(viewId: string, path: string, params?: Record<string, string>): void;
|
|
78
|
+
/**
|
|
79
|
+
* A URL the view can fetch a module's artifact file from (a drawing, a
|
|
80
|
+
* pick-and-place file): the dev server's, a webview resource. Absent: the
|
|
81
|
+
* view uses the artifact's own `url` when it has one.
|
|
82
|
+
*/
|
|
83
|
+
artifactUrl?(defId: string, filePath: string): string | undefined;
|
|
84
|
+
/** The user's selection changed (VS Code's native Inspector follows it). */
|
|
85
|
+
selectionChanged?(selection: ViewerSelection | null): void;
|
|
86
|
+
/** A short status message ("layout saved"). */
|
|
87
|
+
notify?(message: string, severity?: "info" | "warning" | "error"): void;
|
|
88
|
+
/** Add the library parts the project names and does not hold (VS Code: Fetch Parts, B6). */
|
|
89
|
+
fetchParts?(): void;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A host that offers nothing (tests, static pages). */
|
|
93
|
+
export const NULL_HOST: HostAdapter = { kind: "none" };
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A themed dropdown (Radix Select) for the views: the trigger and the list
|
|
3
|
+
* draw with the theme's tokens (surface, line, text, focus, the contrast
|
|
4
|
+
* outline in high contrast), and the list portals into the themed root, so it
|
|
5
|
+
* follows VS Code's theme like everything else. Keyboard and screen readers
|
|
6
|
+
* get Radix's listbox. A native <select> draws its list with the operating
|
|
7
|
+
* system's colours, whatever the theme.
|
|
8
|
+
*/
|
|
9
|
+
import * as RadixSelect from "@radix-ui/react-select";
|
|
10
|
+
import type { ReactNode } from "react";
|
|
11
|
+
import { Icon, cx, useTheme } from "@deltarobotics/uhd-viewer/components";
|
|
12
|
+
|
|
13
|
+
export interface SelectOption {
|
|
14
|
+
value: string;
|
|
15
|
+
label: ReactNode;
|
|
16
|
+
/** What the trigger shows when this option is picked (defaults to the label). */
|
|
17
|
+
text?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface SelectProps {
|
|
21
|
+
value: string;
|
|
22
|
+
options: readonly SelectOption[];
|
|
23
|
+
onChange(value: string): void;
|
|
24
|
+
/** The accessible name. */
|
|
25
|
+
label: string;
|
|
26
|
+
/** A tooltip on the trigger. */
|
|
27
|
+
tooltip?: string;
|
|
28
|
+
className?: string;
|
|
29
|
+
/** Draw the trigger yourself (its content; `className` then styles the whole trigger). */
|
|
30
|
+
trigger?: ReactNode;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function Select({ value, options, onChange, label, tooltip, className, trigger }: SelectProps) {
|
|
34
|
+
const { root } = useTheme();
|
|
35
|
+
const picked = options.find((o) => o.value === value);
|
|
36
|
+
return (
|
|
37
|
+
<RadixSelect.Root value={value} onValueChange={onChange}>
|
|
38
|
+
<RadixSelect.Trigger
|
|
39
|
+
aria-label={label}
|
|
40
|
+
data-tooltip={tooltip}
|
|
41
|
+
className={
|
|
42
|
+
trigger
|
|
43
|
+
? cx("cursor-pointer outline-none focus-visible:outline focus-visible:outline-1 focus-visible:outline-focus", className)
|
|
44
|
+
: cx(
|
|
45
|
+
"inline-flex h-8 max-w-[420px] min-w-0 cursor-pointer items-center gap-2 rounded-md border border-line bg-sunken px-2 font-ui text-[12px] text-fg outline-1 -outline-offset-1 outline-contrast",
|
|
46
|
+
"hover:border-line-strong focus-visible:outline focus-visible:outline-1 focus-visible:outline-focus data-[state=open]:border-line-strong",
|
|
47
|
+
className,
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
>
|
|
51
|
+
{trigger ?? (
|
|
52
|
+
<>
|
|
53
|
+
<span className="min-w-0 truncate">
|
|
54
|
+
<RadixSelect.Value>{picked?.text ?? picked?.label}</RadixSelect.Value>
|
|
55
|
+
</span>
|
|
56
|
+
<RadixSelect.Icon asChild>
|
|
57
|
+
<Icon name="chevron-down" size={13} className="shrink-0 text-fg-muted" aria-hidden />
|
|
58
|
+
</RadixSelect.Icon>
|
|
59
|
+
</>
|
|
60
|
+
)}
|
|
61
|
+
</RadixSelect.Trigger>
|
|
62
|
+
<RadixSelect.Portal container={root ?? undefined}>
|
|
63
|
+
<RadixSelect.Content
|
|
64
|
+
position="popper"
|
|
65
|
+
sideOffset={4}
|
|
66
|
+
align="start"
|
|
67
|
+
collisionPadding={8}
|
|
68
|
+
className="z-[1000] flex max-h-[min(360px,var(--radix-select-content-available-height))] min-w-[var(--radix-select-trigger-width)] overflow-hidden rounded-md border border-line bg-surface text-fg flex-col shadow-panel outline outline-1 -outline-offset-1 outline-contrast"
|
|
69
|
+
>
|
|
70
|
+
{/* the list scrolls without a scrollbar (Radix hides it), with arrows at the ends when it overflows */}
|
|
71
|
+
<RadixSelect.ScrollUpButton className="flex h-5 cursor-default items-center justify-center text-fg-muted">
|
|
72
|
+
<Icon name="chevron-up" size={13} aria-hidden />
|
|
73
|
+
</RadixSelect.ScrollUpButton>
|
|
74
|
+
<RadixSelect.Viewport className="p-1">
|
|
75
|
+
{options.map((o) => (
|
|
76
|
+
<RadixSelect.Item
|
|
77
|
+
key={o.value}
|
|
78
|
+
value={o.value}
|
|
79
|
+
className={cx(
|
|
80
|
+
"relative flex h-7 cursor-pointer items-center gap-2 rounded-[4px] pr-2 pl-7 font-ui text-[12px] whitespace-nowrap text-fg outline-none select-none",
|
|
81
|
+
"data-[highlighted]:bg-hover data-[highlighted]:outline data-[highlighted]:outline-1 data-[highlighted]:-outline-offset-1 data-[highlighted]:outline-contrast-active",
|
|
82
|
+
"data-[state=checked]:font-semibold",
|
|
83
|
+
)}
|
|
84
|
+
>
|
|
85
|
+
<RadixSelect.ItemIndicator className="absolute left-2 inline-flex">
|
|
86
|
+
<Icon name="check" size={13} className="text-accent" aria-hidden />
|
|
87
|
+
</RadixSelect.ItemIndicator>
|
|
88
|
+
<RadixSelect.ItemText>{o.label}</RadixSelect.ItemText>
|
|
89
|
+
</RadixSelect.Item>
|
|
90
|
+
))}
|
|
91
|
+
</RadixSelect.Viewport>
|
|
92
|
+
<RadixSelect.ScrollDownButton className="flex h-5 cursor-default items-center justify-center text-fg-muted">
|
|
93
|
+
<Icon name="chevron-down" size={13} aria-hidden />
|
|
94
|
+
</RadixSelect.ScrollDownButton>
|
|
95
|
+
</RadixSelect.Content>
|
|
96
|
+
</RadixSelect.Portal>
|
|
97
|
+
</RadixSelect.Root>
|
|
98
|
+
);
|
|
99
|
+
}
|