Skip to content

Commit be97268

Browse files
authored
SUI - setting up mantine backed SUI components (Stirling-Tools#6890)
## Summary Converts SUI's existing Select and Slider to Mantine-backed implementations, and adds three new Mantine-backed SUI components: MultiSelect, NumberInput, ColorInput. All five components follow the same contract as the rest of the SUI catalogue: - Imported from `@app/ui` — Mantine is an implementation detail - Explicit prop allowlists: appearance props (color, variant, radius, classNames, styles) are locked internally to SUI tokens; only behavioural props are exposed - Labels and error messages stripped from the interface — callers use `<FormField>` for both. The components take an `invalid` flag that applies error styling only; Mantine never renders its own message element, so the text can't appear twice - `aria-label` / `aria-invalid` / `aria-describedby` and `FormField`'s injected `required` are forwarded, so the injected accessibility wiring reaches the underlying input. Mantine drops some of this wiring internally (`aria-describedby` on inputs, all aria props on Slider's thumb, `required` on MultiSelect's field), so `ariaForwarding.ts` re-applies it to the DOM node and `ariaForwarding.test.tsx` locks the contract in - Typed escape hatches (`comboboxProps`, `popoverProps`, `rightSection`) documented for the z-index-in-modal use case **Select** — rebuilt from native `<select>` to Mantine combobox. Gains searchable/clearable. `onChange` now receives the value string directly, not a DOM event — callers updated. **Slider** — rebuilt from native `<input type="range">` to Mantine Slider. Gains accessible keyboard navigation and `marks` support. **MultiSelect, NumberInput, ColorInput** — new components. The behaviour (multi-select combobox, number stepper, colour picker) is too complex to hand-build correctly; Mantine provides it for free behind a locked SUI interface. Also wires `suiCssVariablesResolver` into the Storybook `MantineProvider` so Mantine combobox/popover dropdowns follow the SUI palette in dark mode, and adds `"neutral"` accent variant to `IconBadge`. ## Usage ```tsx import { Select, Slider, MultiSelect, NumberInput, ColorInput } from "@app/ui"; import { FormField } from "@app/ui/FormField"; // Select — onChange receives string | null, not a DOM event <FormField label="Retention"> <Select options={options} value={value} onChange={setValue} searchable clearable /> </FormField> // Slider — same external API as before, now with marks support <FormField label="Confidence"> <Slider value={v} onChange={setV} min={0} max={1} marks={[{ value: 0.5, label: "0.5" }]} /> </FormField> // New components <FormField label="PII types"> <MultiSelect data={options} value={value} onChange={setValue} searchable clearable /> </FormField> <FormField label="Opacity"> <NumberInput value={opacity} onChange={setOpacity} min={0} max={100} suffix="%" /> </FormField> <FormField label="Watermark colour"> <ColorInput value={color} onChange={setColor} /> </FormField> ``` ## Notes - **Select `onChange` is a breaking change** — receives `string | null` instead of a DOM event. All existing callers in this repo are updated. - The policy PR (`main` WIP) depends on this merging first. - Stories for all five components are under **Primitives / Forms** in Storybook.
1 parent 7bd3826 commit be97268

28 files changed

Lines changed: 1812 additions & 202 deletions

frontend/.storybook/preview.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import type { Decorator, Preview } from "@storybook/react-vite";
77
import { initialize, mswLoader } from "msw-storybook-addon";
88
import { MemoryRouter } from "react-router-dom";
99
import { withThemeByDataAttribute } from "@storybook/addon-themes";
10-
import { MantineProvider } from "@mantine/core";
1110

1211
// Reference React so the import isn't dropped as unused by the bundler — the
1312
// classic runtime needs it present even though it's not named in the JSX.
@@ -17,7 +16,7 @@ import { TierProvider, type Tier } from "@portal/contexts/TierContext";
1716
import { LinkProvider, type LinkState } from "@portal/contexts/LinkContext";
1817
import { ThemeProvider } from "@portal/contexts/ThemeContext";
1918
import { UIProvider } from "@portal/contexts/UIContext";
20-
import { mantineTheme } from "@portal/theme/mantineTheme";
19+
import { SuiProvider } from "@portal/theme/SuiProvider";
2120
import { handlers } from "@portal/mocks/handlers";
2221
import { configureSupabase } from "@proprietary/auth/supabase/supabaseClient";
2322

@@ -102,7 +101,7 @@ const withProviders: Decorator = (Story, context) => {
102101
return (
103102
<MemoryRouter initialEntries={["/"]}>
104103
<ThemeProvider>
105-
<MantineProvider theme={mantineTheme} forceColorScheme={colorScheme}>
104+
<SuiProvider colorScheme={colorScheme}>
106105
{/* LinkProvider must wrap TierProvider: TierContext derives its tier
107106
from useLink() (matches App.tsx's nesting). */}
108107
<LinkProvider key={linkState} initialState={linkState}>
@@ -115,7 +114,7 @@ const withProviders: Decorator = (Story, context) => {
115114
</UIProvider>
116115
</TierKey>
117116
</LinkProvider>
118-
</MantineProvider>
117+
</SuiProvider>
119118
</ThemeProvider>
120119
</MemoryRouter>
121120
);

frontend/editor/src/portal/PortalApp.tsx

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
import { useEffect, type ReactNode } from "react";
22
import { useLocation } from "react-router-dom";
3-
import { MantineProvider } from "@mantine/core";
43
import { AuthProvider } from "@app/auth";
54
import { ErrorBoundary } from "@portal/components/ErrorBoundary";
65
import { ThemeProvider, useTheme } from "@portal/contexts/ThemeContext";
76
import { TierProvider } from "@portal/contexts/TierContext";
87
import { LinkProvider, useLink } from "@portal/contexts/LinkContext";
98
import type { SupabaseLoginSession } from "@app/auth/ui/useSupabaseLogin";
109
import { UIProvider, useUI } from "@portal/contexts/UIContext";
11-
import { mantineTheme } from "@portal/theme/mantineTheme";
10+
import { SuiProvider } from "@portal/theme/SuiProvider";
1211
import { AppShell } from "@portal/components/AppShell";
1312
import { AuthGate } from "@portal/components/AuthGate";
1413
import { AssistantButton } from "@portal/components/AssistantButton";
@@ -25,17 +24,13 @@ import { ViewRouter } from "@portal/ViewRouter";
2524
import "@portal/theme/base.css";
2625

2726
/**
28-
* Binds Mantine's colour scheme to the portal's own ThemeProvider so Mantine
29-
* components follow the same light/dark switch as the SUI primitives. Must sit
27+
* Binds the SUI design system to the portal's own ThemeProvider so the SUI
28+
* components follow the same light/dark switch as the CSS tokens. Must sit
3029
* inside <ThemeProvider> to read useTheme().
3130
*/
32-
function PortalMantineProvider({ children }: { children: ReactNode }) {
31+
function ThemedSuiProvider({ children }: { children: ReactNode }) {
3332
const { theme } = useTheme();
34-
return (
35-
<MantineProvider theme={mantineTheme} forceColorScheme={theme}>
36-
{children}
37-
</MantineProvider>
38-
);
33+
return <SuiProvider colorScheme={theme}>{children}</SuiProvider>;
3934
}
4035

4136
/**
@@ -129,7 +124,7 @@ function RoutedContent() {
129124
export function PortalApp() {
130125
return (
131126
<ThemeProvider>
132-
<PortalMantineProvider>
127+
<ThemedSuiProvider>
133128
{/* Scopes base.css to the portal so it doesn't restyle the host editor. */}
134129
<div className="portal-scope">
135130
<AuthProvider mode="spring">
@@ -156,7 +151,7 @@ export function PortalApp() {
156151
</LinkProvider>
157152
</AuthProvider>
158153
</div>
159-
</PortalMantineProvider>
154+
</ThemedSuiProvider>
160155
</ThemeProvider>
161156
);
162157
}
Lines changed: 63 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,23 @@
11
# Portal UI conventions — SUI vs Mantine
22

3-
The portal has two component sources. The rule:
3+
The portal has one component source from the caller's point of view: **SUI
4+
(`@app/ui`)**. Under the hood there are two kinds of SUI component. The rule:
45

5-
> **Simple, presentational, brand-defining UI → our SUI design system
6-
> (`@app/ui`). Complex, stateful, or accessibility-hard widgets →
7-
> Mantine.** Don't reinvent what Mantine already does well; do own the look of
8-
> the simple, high-frequency pieces.
6+
> **Simple, presentational, brand-defining UI → hand-rolled SUI components.
7+
> Complex, stateful, or accessibility-hard widgets → Mantine, wrapped behind a
8+
> locked SUI interface.** Either way, callers import from `@app/ui`. Mantine is
9+
> an implementation detail of the design system — feature code never imports
10+
> `@mantine/core` directly.
911
10-
Both are theme-bound: `MantineProvider` in `App.tsx` is wired to the portal's
11-
`ThemeProvider` (`mantineTheme.ts` maps the brand palette), so Mantine widgets
12-
follow the same light/dark switch and brand colours as SUI. **The provider is
13-
intentional** — it exists precisely so we can drop Mantine widgets in where they
14-
earn their keep.
12+
Theme wiring lives in one place: `SuiProvider` (`@portal/theme/SuiProvider`)
13+
applies the SUI-token Mantine theme, remaps Mantine's neutral palette
14+
(dropdown/popover surfaces, borders, text) onto SUI tokens via
15+
`suiCssVariablesResolver`, and takes the resolved light/dark scheme so Mantine
16+
chrome and the SUI CSS variables switch together. The app and Storybook both
17+
render through it.
18+
19+
## Hand-rolled SUI — our own style
1520

16-
## Use SUI (`@app/ui`) — our own style
1721
Layout and presentational primitives we want full brand control over and that
1822
are cheap to own:
1923

@@ -23,40 +27,64 @@ are cheap to own:
2327
`Stack` / `Inline` · `Table` (static/presentational) · `CodeBlock` ·
2428
`FormField` (label/help/error layout) · simple `Tabs`.
2529

26-
## Use Mantine — don't reinvent
30+
## Mantine-backed SUI — don't reinvent, but do own the interface
31+
2732
Anything that needs portals, focus traps, ARIA keyboard patterns, or is just a
28-
solved hard problem:
33+
solved hard problem gets a Mantine implementation behind a SUI wrapper.
34+
Shipped today: `Select` · `MultiSelect` · `NumberInput` · `ColorInput` ·
35+
`Slider`.
36+
37+
Every wrapper follows the same contract (use the existing ones as the
38+
template):
2939

30-
- **Overlays**: `Modal`, `Drawer`, `Popover` (focus trap, scroll lock, escape, focus restore)
31-
- **Menus**: `Menu` (roving arrow-key navigation)
32-
- **Selects**: `Select` / `MultiSelect` / `Combobox` / `Autocomplete` (keyboard + filtering)
33-
- **Dates**: `@mantine/dates` `DatePicker` / `DatePickerInput` (e.g. billing period range)
34-
- **Files**: `@mantine/dropzone` `Dropzone` (connect-source upload, op-runner sample drop)
35-
- **Progress UX**: `Stepper` (multi-step wizards), `Notifications`, `Tooltip`
36-
- Hooks: prefer `@mantine/hooks` (`useDisclosure`, `useClickOutside`, `useHotkeys`, …) over hand-rolling.
40+
- **Explicit prop allowlist.** Only behavioural props are exposed; appearance
41+
props (`color`, `variant`, `radius`, `classNames`, `styles`) are locked
42+
internally to SUI tokens.
43+
- **No labels or error text.** Callers use `<FormField>` for both. Wrappers
44+
take an `invalid` flag that applies error styling only; Mantine never
45+
renders its own message element.
46+
- **Accessibility props forwarded.** `id`, `aria-label`, `aria-invalid`, and
47+
`aria-describedby` pass through so `FormField`'s injected wiring reaches the
48+
underlying input.
49+
- **Typed escape hatches** (`comboboxProps`, `popoverProps`, `rightSection`)
50+
for the z-index-in-modal case, documented on the component.
51+
52+
When a feature needs a Mantine widget that has no wrapper yet (`Modal`,
53+
`Drawer`, `Menu`, `Stepper`, `Tooltip`, `@mantine/dates`,
54+
`@mantine/dropzone`, …), add the wrapper to `@app/ui` following this contract
55+
rather than importing Mantine in feature code. Hooks are the exception:
56+
prefer `@mantine/hooks` (`useDisclosure`, `useClickOutside`, `useHotkeys`, …)
57+
over hand-rolling, imported directly.
3758

3859
## Why
60+
3961
Mantine is mature and battle-tested for accessibility. A review of the
4062
hand-rolled SUI overlays found real gaps — `Dropdown` has no arrow-key
4163
navigation, `Modal`/`Drawer` mishandle focus when there are no focusable
4264
children, `Toast` uses `role="alert"` for every tone — exactly the things
4365
Mantine gets right. Owning those is wasted effort and an a11y liability.
4466

45-
## Known migrations (hand-rolled today → should be Mantine)
46-
These shipped as SUI primitives during the initial build and should move to
47-
Mantine equivalents (fixes the a11y findings above):
48-
49-
| Today (SUI) | → Mantine |
50-
|---|---|
51-
| `Dropdown` (menus: tier switcher, app switcher, notifications) | `Menu` |
52-
| `Modal` (composer, wizards, settings, create-key) | `Modal` |
53-
| `Drawer` (pipeline detail) | `Drawer` |
54-
| `Toast` | `notifications` |
55-
| _new need:_ billing date range | `@mantine/dates` |
56-
| _new need:_ file upload | `@mantine/dropzone` |
57-
58-
Keep `Tabs` SUI for the simple in-page switchers; only reach for more if a true
59-
tabpanel/roving-focus contract is needed.
67+
The wrapper (rather than direct Mantine use) is what keeps the door open to
68+
swapping the implementation later: callers depend on the SUI contract, not on
69+
Mantine's API surface.
70+
71+
## Known migrations (hand-rolled today → Mantine-backed SUI)
72+
73+
These shipped as hand-rolled primitives during the initial build and should
74+
move to Mantine-backed wrappers (fixes the a11y findings above). `Select` and
75+
`Slider` have already made this move.
76+
77+
| Today (hand-rolled) | → Mantine-backed SUI wrapper |
78+
| -------------------------------------------------------------- | ---------------------------- |
79+
| `Dropdown` (menus: tier switcher, app switcher, notifications) | wraps `Menu` |
80+
| `Modal` (composer, wizards, settings, create-key) | wraps `Modal` |
81+
| `Drawer` (pipeline detail) | wraps `Drawer` |
82+
| `Toast` | wraps `notifications` |
83+
| _new need:_ billing date range | wraps `@mantine/dates` |
84+
| _new need:_ file upload | wraps `@mantine/dropzone` |
85+
86+
Keep `Tabs` hand-rolled for the simple in-page switchers; only reach for more
87+
if a true tabpanel/roving-focus contract is needed.
6088

6189
> Migrating overlays touches visible chrome and behaviour, so do it deliberately
6290
> (with eyes on the result), not as a blind sweep.

frontend/editor/src/portal/components/SettingsModal.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -612,7 +612,7 @@ function WorkspacePanel({
612612
>
613613
<Select
614614
value={region}
615-
onChange={(e) => onRegion(e.target.value)}
615+
onChange={(value) => onRegion(value ?? "")}
616616
options={regionOptions}
617617
/>
618618
</FormField>
@@ -750,8 +750,8 @@ function AuthenticationPanel({
750750
>
751751
<Select
752752
value={String(security.sessionTimeoutMins)}
753-
onChange={(e) =>
754-
onSecurity({ sessionTimeoutMins: Number(e.target.value) })
753+
onChange={(value) =>
754+
onSecurity({ sessionTimeoutMins: Number(value ?? "0") })
755755
}
756756
options={SESSION_TIMEOUT_VALUES.map((value) => ({
757757
value,

frontend/editor/src/portal/components/infrastructure/StorageTab.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,9 @@ export function StorageTab() {
186186
<Select
187187
options={RETENTION_OPTS}
188188
value={retentionValue}
189-
onChange={(e) => setRetention(e.target.value as RetentionWindow)}
189+
onChange={(value) =>
190+
setRetention((value ?? "") as RetentionWindow)
191+
}
190192
/>
191193
</FormField>
192194

frontend/editor/src/portal/components/pipelines/PipelineComposer.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -362,8 +362,8 @@ export function PipelineComposer({
362362
<Select
363363
inputSize="sm"
364364
value={scheduleUnit}
365-
onChange={(e) =>
366-
setScheduleUnit(e.target.value as ScheduleUnit)
365+
onChange={(value) =>
366+
setScheduleUnit((value ?? "") as ScheduleUnit)
367367
}
368368
options={SCHEDULE_UNITS.map((unit) => ({
369369
value: unit,

frontend/editor/src/portal/components/policies/PolicyFieldRow.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ export function PolicyFieldRow({
6464
inputSize="sm"
6565
value={typeof value === "string" ? value : ""}
6666
options={(field.options ?? []).map((o) => ({ value: o, label: o }))}
67-
onChange={(e) => onChange(e.target.value)}
67+
onChange={(value) => onChange(value ?? "")}
6868
/>
6969
</FormField>
7070
);

frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -453,8 +453,8 @@ function PolicySetupWizardBody({
453453
<Select
454454
inputSize="sm"
455455
value={runOn}
456-
onChange={(e) =>
457-
setRunOn(e.target.value as "upload" | "export")
456+
onChange={(value) =>
457+
setRunOn((value ?? "upload") as "upload" | "export")
458458
}
459459
options={[
460460
{
@@ -474,8 +474,10 @@ function PolicySetupWizardBody({
474474
<Select
475475
inputSize="sm"
476476
value={outputMode}
477-
onChange={(e) => {
478-
const mode = e.target.value as "new_file" | "new_version";
477+
onChange={(value) => {
478+
const mode = (value ?? "new_file") as
479+
| "new_file"
480+
| "new_version";
479481
setOutputMode(mode);
480482
// Auto-number only applies to separate new files.
481483
if (
@@ -508,9 +510,12 @@ function PolicySetupWizardBody({
508510
<Select
509511
inputSize="sm"
510512
value={outputNamePosition}
511-
onChange={(e) =>
513+
onChange={(value) =>
512514
setOutputNamePosition(
513-
e.target.value as "prefix" | "suffix" | "auto-number",
515+
(value ?? "suffix") as
516+
| "prefix"
517+
| "suffix"
518+
| "auto-number",
514519
)
515520
}
516521
options={[

frontend/editor/src/portal/components/sources/ConnectWizard.test.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
import { beforeEach, describe, expect, it, vi } from "vitest";
22
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
3+
import { MantineProvider } from "@mantine/core";
34
import { HttpError } from "@portal/api/http";
45
import { ConnectWizard } from "@portal/components/sources/ConnectWizard";
56

7+
function renderWithMantine(ui: React.ReactElement) {
8+
return render(<MantineProvider>{ui}</MantineProvider>);
9+
}
10+
611
// Deterministic i18n: keys come back verbatim so the test never waits on the
712
// async TOML backend.
813
vi.mock("react-i18next", () => ({
@@ -41,7 +46,9 @@ describe("ConnectWizard", () => {
4146
const onCreated = vi.fn();
4247
const onClose = vi.fn();
4348

44-
render(<ConnectWizard open onClose={onClose} onCreated={onCreated} />);
49+
renderWithMantine(
50+
<ConnectWizard open onClose={onClose} onCreated={onCreated} />,
51+
);
4552

4653
stepToReview();
4754

@@ -66,7 +73,7 @@ describe("ConnectWizard", () => {
6673
createSource.mockResolvedValue({ id: "s1" });
6774
const onCreated = vi.fn();
6875

69-
render(
76+
renderWithMantine(
7077
<ConnectWizard
7178
open
7279
source={{
@@ -113,7 +120,9 @@ describe("ConnectWizard", () => {
113120
}),
114121
);
115122

116-
render(<ConnectWizard open onClose={vi.fn()} onCreated={vi.fn()} />);
123+
renderWithMantine(
124+
<ConnectWizard open onClose={vi.fn()} onCreated={vi.fn()} />,
125+
);
117126

118127
stepToReview();
119128
fireEvent.click(screen.getByText("portal.sources.actions.connectSource"));

frontend/editor/src/portal/components/sources/ConnectWizard.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -245,8 +245,8 @@ export function ConnectWizard({
245245
value: o.value,
246246
label: t(o.labelKey),
247247
}))}
248-
onChange={(e) =>
249-
setOptions((o) => ({ ...o, [field.key]: e.target.value }))
248+
onChange={(value) =>
249+
setOptions((o) => ({ ...o, [field.key]: value ?? "" }))
250250
}
251251
/>
252252
) : (

0 commit comments

Comments
 (0)