SUI - setting up mantine backed SUI components (#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.
This commit is contained in:
Reece Browne
2026-07-07 14:29:55 +00:00
committed by GitHub
parent 7bd3826178
commit be97268a7c
28 changed files with 1811 additions and 201 deletions
+3 -4
View File
@@ -7,7 +7,6 @@ import type { Decorator, Preview } from "@storybook/react-vite";
import { initialize, mswLoader } from "msw-storybook-addon";
import { MemoryRouter } from "react-router-dom";
import { withThemeByDataAttribute } from "@storybook/addon-themes";
import { MantineProvider } from "@mantine/core";
// Reference React so the import isn't dropped as unused by the bundler — the
// 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";
import { LinkProvider, type LinkState } from "@portal/contexts/LinkContext";
import { ThemeProvider } from "@portal/contexts/ThemeContext";
import { UIProvider } from "@portal/contexts/UIContext";
import { mantineTheme } from "@portal/theme/mantineTheme";
import { SuiProvider } from "@portal/theme/SuiProvider";
import { handlers } from "@portal/mocks/handlers";
import { configureSupabase } from "@proprietary/auth/supabase/supabaseClient";
@@ -102,7 +101,7 @@ const withProviders: Decorator = (Story, context) => {
return (
<MemoryRouter initialEntries={["/"]}>
<ThemeProvider>
<MantineProvider theme={mantineTheme} forceColorScheme={colorScheme}>
<SuiProvider colorScheme={colorScheme}>
{/* LinkProvider must wrap TierProvider: TierContext derives its tier
from useLink() (matches App.tsx's nesting). */}
<LinkProvider key={linkState} initialState={linkState}>
@@ -115,7 +114,7 @@ const withProviders: Decorator = (Story, context) => {
</UIProvider>
</TierKey>
</LinkProvider>
</MantineProvider>
</SuiProvider>
</ThemeProvider>
</MemoryRouter>
);
+7 -12
View File
@@ -1,6 +1,5 @@
import { useEffect, type ReactNode } from "react";
import { useLocation } from "react-router-dom";
import { MantineProvider } from "@mantine/core";
import { AuthProvider } from "@app/auth";
import { ErrorBoundary } from "@portal/components/ErrorBoundary";
import { ThemeProvider, useTheme } from "@portal/contexts/ThemeContext";
@@ -8,7 +7,7 @@ import { TierProvider } from "@portal/contexts/TierContext";
import { LinkProvider, useLink } from "@portal/contexts/LinkContext";
import type { SupabaseLoginSession } from "@app/auth/ui/useSupabaseLogin";
import { UIProvider, useUI } from "@portal/contexts/UIContext";
import { mantineTheme } from "@portal/theme/mantineTheme";
import { SuiProvider } from "@portal/theme/SuiProvider";
import { AppShell } from "@portal/components/AppShell";
import { AuthGate } from "@portal/components/AuthGate";
import { AssistantButton } from "@portal/components/AssistantButton";
@@ -25,17 +24,13 @@ import { ViewRouter } from "@portal/ViewRouter";
import "@portal/theme/base.css";
/**
* Binds Mantine's colour scheme to the portal's own ThemeProvider so Mantine
* components follow the same light/dark switch as the SUI primitives. Must sit
* Binds the SUI design system to the portal's own ThemeProvider so the SUI
* components follow the same light/dark switch as the CSS tokens. Must sit
* inside <ThemeProvider> to read useTheme().
*/
function PortalMantineProvider({ children }: { children: ReactNode }) {
function ThemedSuiProvider({ children }: { children: ReactNode }) {
const { theme } = useTheme();
return (
<MantineProvider theme={mantineTheme} forceColorScheme={theme}>
{children}
</MantineProvider>
);
return <SuiProvider colorScheme={theme}>{children}</SuiProvider>;
}
/**
@@ -129,7 +124,7 @@ function RoutedContent() {
export function PortalApp() {
return (
<ThemeProvider>
<PortalMantineProvider>
<ThemedSuiProvider>
{/* Scopes base.css to the portal so it doesn't restyle the host editor. */}
<div className="portal-scope">
<AuthProvider mode="spring">
@@ -156,7 +151,7 @@ export function PortalApp() {
</LinkProvider>
</AuthProvider>
</div>
</PortalMantineProvider>
</ThemedSuiProvider>
</ThemeProvider>
);
}
+62 -34
View File
@@ -1,19 +1,23 @@
# Portal UI conventions — SUI vs Mantine
The portal has two component sources. The rule:
The portal has one component source from the caller's point of view: **SUI
(`@app/ui`)**. Under the hood there are two kinds of SUI component. The rule:
> **Simple, presentational, brand-defining UI → our SUI design system
> (`@app/ui`). Complex, stateful, or accessibility-hard widgets →
> Mantine.** Don't reinvent what Mantine already does well; do own the look of
> the simple, high-frequency pieces.
> **Simple, presentational, brand-defining UI → hand-rolled SUI components.
> Complex, stateful, or accessibility-hard widgets → Mantine, wrapped behind a
> locked SUI interface.** Either way, callers import from `@app/ui`. Mantine is
> an implementation detail of the design system — feature code never imports
> `@mantine/core` directly.
Both are theme-bound: `MantineProvider` in `App.tsx` is wired to the portal's
`ThemeProvider` (`mantineTheme.ts` maps the brand palette), so Mantine widgets
follow the same light/dark switch and brand colours as SUI. **The provider is
intentional** — it exists precisely so we can drop Mantine widgets in where they
earn their keep.
Theme wiring lives in one place: `SuiProvider` (`@portal/theme/SuiProvider`)
applies the SUI-token Mantine theme, remaps Mantine's neutral palette
(dropdown/popover surfaces, borders, text) onto SUI tokens via
`suiCssVariablesResolver`, and takes the resolved light/dark scheme so Mantine
chrome and the SUI CSS variables switch together. The app and Storybook both
render through it.
## Hand-rolled SUI — our own style
## Use SUI (`@app/ui`) — our own style
Layout and presentational primitives we want full brand control over and that
are cheap to own:
@@ -23,40 +27,64 @@ are cheap to own:
`Stack` / `Inline` · `Table` (static/presentational) · `CodeBlock` ·
`FormField` (label/help/error layout) · simple `Tabs`.
## Use Mantine — don't reinvent
Anything that needs portals, focus traps, ARIA keyboard patterns, or is just a
solved hard problem:
## Mantine-backed SUI — don't reinvent, but do own the interface
- **Overlays**: `Modal`, `Drawer`, `Popover` (focus trap, scroll lock, escape, focus restore)
- **Menus**: `Menu` (roving arrow-key navigation)
- **Selects**: `Select` / `MultiSelect` / `Combobox` / `Autocomplete` (keyboard + filtering)
- **Dates**: `@mantine/dates` `DatePicker` / `DatePickerInput` (e.g. billing period range)
- **Files**: `@mantine/dropzone` `Dropzone` (connect-source upload, op-runner sample drop)
- **Progress UX**: `Stepper` (multi-step wizards), `Notifications`, `Tooltip`
- Hooks: prefer `@mantine/hooks` (`useDisclosure`, `useClickOutside`, `useHotkeys`, …) over hand-rolling.
Anything that needs portals, focus traps, ARIA keyboard patterns, or is just a
solved hard problem gets a Mantine implementation behind a SUI wrapper.
Shipped today: `Select` · `MultiSelect` · `NumberInput` · `ColorInput` ·
`Slider`.
Every wrapper follows the same contract (use the existing ones as the
template):
- **Explicit prop allowlist.** Only behavioural props are exposed; appearance
props (`color`, `variant`, `radius`, `classNames`, `styles`) are locked
internally to SUI tokens.
- **No labels or error text.** Callers use `<FormField>` for both. Wrappers
take an `invalid` flag that applies error styling only; Mantine never
renders its own message element.
- **Accessibility props forwarded.** `id`, `aria-label`, `aria-invalid`, and
`aria-describedby` pass through so `FormField`'s injected wiring reaches the
underlying input.
- **Typed escape hatches** (`comboboxProps`, `popoverProps`, `rightSection`)
for the z-index-in-modal case, documented on the component.
When a feature needs a Mantine widget that has no wrapper yet (`Modal`,
`Drawer`, `Menu`, `Stepper`, `Tooltip`, `@mantine/dates`,
`@mantine/dropzone`, …), add the wrapper to `@app/ui` following this contract
rather than importing Mantine in feature code. Hooks are the exception:
prefer `@mantine/hooks` (`useDisclosure`, `useClickOutside`, `useHotkeys`, …)
over hand-rolling, imported directly.
## Why
Mantine is mature and battle-tested for accessibility. A review of the
hand-rolled SUI overlays found real gaps — `Dropdown` has no arrow-key
navigation, `Modal`/`Drawer` mishandle focus when there are no focusable
children, `Toast` uses `role="alert"` for every tone — exactly the things
Mantine gets right. Owning those is wasted effort and an a11y liability.
## Known migrations (hand-rolled today → should be Mantine)
These shipped as SUI primitives during the initial build and should move to
Mantine equivalents (fixes the a11y findings above):
The wrapper (rather than direct Mantine use) is what keeps the door open to
swapping the implementation later: callers depend on the SUI contract, not on
Mantine's API surface.
| Today (SUI) | → Mantine |
|---|---|
| `Dropdown` (menus: tier switcher, app switcher, notifications) | `Menu` |
| `Modal` (composer, wizards, settings, create-key) | `Modal` |
| `Drawer` (pipeline detail) | `Drawer` |
| `Toast` | `notifications` |
| _new need:_ billing date range | `@mantine/dates` |
| _new need:_ file upload | `@mantine/dropzone` |
## Known migrations (hand-rolled today → Mantine-backed SUI)
Keep `Tabs` SUI for the simple in-page switchers; only reach for more if a true
tabpanel/roving-focus contract is needed.
These shipped as hand-rolled primitives during the initial build and should
move to Mantine-backed wrappers (fixes the a11y findings above). `Select` and
`Slider` have already made this move.
| Today (hand-rolled) | → Mantine-backed SUI wrapper |
| -------------------------------------------------------------- | ---------------------------- |
| `Dropdown` (menus: tier switcher, app switcher, notifications) | wraps `Menu` |
| `Modal` (composer, wizards, settings, create-key) | wraps `Modal` |
| `Drawer` (pipeline detail) | wraps `Drawer` |
| `Toast` | wraps `notifications` |
| _new need:_ billing date range | wraps `@mantine/dates` |
| _new need:_ file upload | wraps `@mantine/dropzone` |
Keep `Tabs` hand-rolled for the simple in-page switchers; only reach for more
if a true tabpanel/roving-focus contract is needed.
> Migrating overlays touches visible chrome and behaviour, so do it deliberately
> (with eyes on the result), not as a blind sweep.
@@ -612,7 +612,7 @@ function WorkspacePanel({
>
<Select
value={region}
onChange={(e) => onRegion(e.target.value)}
onChange={(value) => onRegion(value ?? "")}
options={regionOptions}
/>
</FormField>
@@ -750,8 +750,8 @@ function AuthenticationPanel({
>
<Select
value={String(security.sessionTimeoutMins)}
onChange={(e) =>
onSecurity({ sessionTimeoutMins: Number(e.target.value) })
onChange={(value) =>
onSecurity({ sessionTimeoutMins: Number(value ?? "0") })
}
options={SESSION_TIMEOUT_VALUES.map((value) => ({
value,
@@ -186,7 +186,9 @@ export function StorageTab() {
<Select
options={RETENTION_OPTS}
value={retentionValue}
onChange={(e) => setRetention(e.target.value as RetentionWindow)}
onChange={(value) =>
setRetention((value ?? "") as RetentionWindow)
}
/>
</FormField>
@@ -362,8 +362,8 @@ export function PipelineComposer({
<Select
inputSize="sm"
value={scheduleUnit}
onChange={(e) =>
setScheduleUnit(e.target.value as ScheduleUnit)
onChange={(value) =>
setScheduleUnit((value ?? "") as ScheduleUnit)
}
options={SCHEDULE_UNITS.map((unit) => ({
value: unit,
@@ -64,7 +64,7 @@ export function PolicyFieldRow({
inputSize="sm"
value={typeof value === "string" ? value : ""}
options={(field.options ?? []).map((o) => ({ value: o, label: o }))}
onChange={(e) => onChange(e.target.value)}
onChange={(value) => onChange(value ?? "")}
/>
</FormField>
);
@@ -453,8 +453,8 @@ function PolicySetupWizardBody({
<Select
inputSize="sm"
value={runOn}
onChange={(e) =>
setRunOn(e.target.value as "upload" | "export")
onChange={(value) =>
setRunOn((value ?? "upload") as "upload" | "export")
}
options={[
{
@@ -474,8 +474,10 @@ function PolicySetupWizardBody({
<Select
inputSize="sm"
value={outputMode}
onChange={(e) => {
const mode = e.target.value as "new_file" | "new_version";
onChange={(value) => {
const mode = (value ?? "new_file") as
| "new_file"
| "new_version";
setOutputMode(mode);
// Auto-number only applies to separate new files.
if (
@@ -508,9 +510,12 @@ function PolicySetupWizardBody({
<Select
inputSize="sm"
value={outputNamePosition}
onChange={(e) =>
onChange={(value) =>
setOutputNamePosition(
e.target.value as "prefix" | "suffix" | "auto-number",
(value ?? "suffix") as
| "prefix"
| "suffix"
| "auto-number",
)
}
options={[
@@ -1,8 +1,13 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { HttpError } from "@portal/api/http";
import { ConnectWizard } from "@portal/components/sources/ConnectWizard";
function renderWithMantine(ui: React.ReactElement) {
return render(<MantineProvider>{ui}</MantineProvider>);
}
// Deterministic i18n: keys come back verbatim so the test never waits on the
// async TOML backend.
vi.mock("react-i18next", () => ({
@@ -41,7 +46,9 @@ describe("ConnectWizard", () => {
const onCreated = vi.fn();
const onClose = vi.fn();
render(<ConnectWizard open onClose={onClose} onCreated={onCreated} />);
renderWithMantine(
<ConnectWizard open onClose={onClose} onCreated={onCreated} />,
);
stepToReview();
@@ -66,7 +73,7 @@ describe("ConnectWizard", () => {
createSource.mockResolvedValue({ id: "s1" });
const onCreated = vi.fn();
render(
renderWithMantine(
<ConnectWizard
open
source={{
@@ -113,7 +120,9 @@ describe("ConnectWizard", () => {
}),
);
render(<ConnectWizard open onClose={vi.fn()} onCreated={vi.fn()} />);
renderWithMantine(
<ConnectWizard open onClose={vi.fn()} onCreated={vi.fn()} />,
);
stepToReview();
fireEvent.click(screen.getByText("portal.sources.actions.connectSource"));
@@ -245,8 +245,8 @@ export function ConnectWizard({
value: o.value,
label: t(o.labelKey),
}))}
onChange={(e) =>
setOptions((o) => ({ ...o, [field.key]: e.target.value }))
onChange={(value) =>
setOptions((o) => ({ ...o, [field.key]: value ?? "" }))
}
/>
) : (
@@ -91,7 +91,7 @@ export function InviteMemberModal({ open, onClose }: InviteMemberModalProps) {
<Select
options={ROLE_SELECT_OPTIONS}
value={role}
onChange={(e) => setRole(e.target.value as RoleId)}
onChange={(value) => setRole((value ?? "") as RoleId)}
/>
</FormField>
</div>
@@ -0,0 +1,34 @@
import type { ReactNode } from "react";
import { MantineProvider } from "@mantine/core";
import {
mantineTheme,
suiCssVariablesResolver,
} from "@portal/theme/mantineTheme";
export interface SuiProviderProps {
/**
* Resolved colour scheme. Must match whatever drives [data-theme] so the
* Mantine chrome and the SUI CSS tokens switch together.
*/
colorScheme: "light" | "dark";
children: ReactNode;
}
/**
* Sets up the SUI design system for a subtree. Mantine is an implementation
* detail of the SUI components (@app/ui); this provider applies the SUI-token
* theme and remaps Mantine's neutral palette (dropdown/popover surfaces,
* borders, text) onto SUI tokens so floating elements follow the SUI palette
* in both colour schemes.
*/
export function SuiProvider({ colorScheme, children }: SuiProviderProps) {
return (
<MantineProvider
theme={mantineTheme}
cssVariablesResolver={suiCssVariablesResolver}
forceColorScheme={colorScheme}
>
{children}
</MantineProvider>
);
}
@@ -1,4 +1,8 @@
import { createTheme, type MantineColorsTuple } from "@mantine/core";
import {
createTheme,
type CSSVariablesResolver,
type MantineColorsTuple,
} from "@mantine/core";
/**
* Mantine theme for the portal, bound to the SUI design tokens in
@@ -69,6 +73,39 @@ const purple = tuple(
"--color-purple-dark",
);
/**
* Maps Mantine's neutral CSS variables to SUI tokens so dropdowns, popovers,
* and other floating elements follow the SUI surface/border/text palette rather
* than Mantine's default white/gray-* / dark-* scale.
*
* SuiProvider syncs Mantine's color scheme to SUI's light/dark toggle
* (forceColorScheme), so the light/dark buckets here align with
* [data-theme="light/dark"] and the SUI token values are correct.
*/
export const suiCssVariablesResolver: CSSVariablesResolver = () => ({
variables: {
"--mantine-color-text": "var(--color-text-1)",
"--mantine-color-placeholder": "var(--color-text-placeholder)",
"--mantine-color-body": "var(--color-bg)",
},
light: {
// Popover/dropdown background + combobox search input
"--mantine-color-white": "var(--color-surface)",
// Option hover background
"--mantine-color-gray-0": "var(--color-bg-hover)",
// Dropdown border
"--mantine-color-gray-2": "var(--color-border)",
},
dark: {
// Popover/dropdown background (dark-6 is the floating surface in dark mode)
"--mantine-color-dark-6": "var(--color-surface)",
// Deeper background used for option hover + combobox search input
"--mantine-color-dark-7": "var(--color-bg)",
// Border in dark mode
"--mantine-color-dark-4": "var(--color-border)",
},
});
export const mantineTheme = createTheme({
primaryColor: "blue",
// Mantine uses index 6 of the tuple for filled components by default, which
@@ -81,7 +81,7 @@ export function PolicyFieldRow({
label: t(`policies.fieldOption.${field.key}.${o}`, o),
}))}
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value)}
onChange={(value) => onChange(value ?? "")}
aria-label={fieldLabel}
/>
) : (
@@ -409,8 +409,8 @@ export function PolicySetupWizard({
<Select
inputSize="sm"
value={runOn}
onChange={(e) =>
setRunOn(e.target.value as "upload" | "export")
onChange={(value) =>
setRunOn((value ?? "upload") as "upload" | "export")
}
aria-label={t("policies.wizard.runOnLabel", "Run on")}
options={[
@@ -437,8 +437,8 @@ export function PolicySetupWizard({
<Select
inputSize="sm"
value={outputMode}
onChange={(e) => {
const mode = e.target.value as
onChange={(value) => {
const mode = (value ?? "new_file") as
| "new_file"
| "new_version";
setOutputMode(mode);
@@ -481,9 +481,12 @@ export function PolicySetupWizard({
<Select
inputSize="sm"
value={outputNamePosition}
onChange={(e) =>
onChange={(value) =>
setOutputNamePosition(
e.target.value as "prefix" | "suffix" | "auto-number",
(value ?? "suffix") as
| "prefix"
| "suffix"
| "auto-number",
)
}
aria-label={t(
@@ -0,0 +1,146 @@
import type React from "react";
import {
ColorInput as MantineColorInput,
type ColorInputProps as MantineColorInputProps,
} from "@mantine/core";
import { useInputAria } from "@app/ui/ariaForwarding";
import "@app/ui/MantineForms.css";
const SUI_INPUT_VARS = {
"--input-bg": "var(--color-surface)",
"--input-bd": "var(--color-border-input)",
"--input-bd-focus": "var(--color-blue)",
"--input-radius": "var(--radius-md)",
"--input-color": "var(--color-text-1)",
"--input-placeholder-color": "var(--color-text-placeholder)",
"--input-height-sm": "1.75rem",
"--input-height-md": "2.25rem",
} as React.CSSProperties;
export type ColorInputSize = "sm" | "md";
export interface ColorInputProps {
// Value
value?: string;
onChange?: (value: string) => void;
defaultValue?: string;
// Behaviour
format?: "hex" | "hexa" | "rgb" | "rgba" | "hsl" | "hsla";
swatches?: string[];
swatchesPerRow?: number;
withPicker?: boolean;
// Popover escape hatch — only for zIndex / offset overrides in modals
popoverProps?: MantineColorInputProps["popoverProps"];
// Form
placeholder?: string;
id?: string;
name?: string;
"aria-label"?: string;
"aria-invalid"?: boolean;
"aria-describedby"?: string;
required?: boolean;
disabled?: boolean;
readOnly?: boolean;
onFocus?: React.FocusEventHandler<HTMLInputElement>;
onBlur?: React.FocusEventHandler<HTMLInputElement>;
// SUI — invalid applies error styling; FormField renders the message itself.
inputSize?: ColorInputSize;
invalid?: boolean;
}
type PassthroughProps = Omit<
Pick<
MantineColorInputProps,
| "value"
| "onChange"
| "defaultValue"
| "format"
| "swatches"
| "swatchesPerRow"
| "withPicker"
| "popoverProps"
| "placeholder"
| "id"
| "name"
| "aria-label"
| "aria-describedby"
| "required"
| "disabled"
| "readOnly"
| "onFocus"
| "onBlur"
>,
never
>;
/**
* SUI colour picker input with swatch preview and popover picker. Use with
* <FormField> for labels and error display. Appearance is locked to SUI tokens.
*
* Defaults to hex format. Pass `popoverProps={{ withinPortal: true, zIndex: Z }}` when
* rendering inside a modal.
*/
export function ColorInput({
inputSize = "md",
invalid,
format = "hex",
value,
onChange,
defaultValue,
swatches,
swatchesPerRow,
withPicker,
popoverProps,
placeholder,
id,
name,
"aria-label": ariaLabel,
"aria-invalid": ariaInvalid,
"aria-describedby": ariaDescribedBy,
required,
disabled,
readOnly,
onFocus,
onBlur,
}: ColorInputProps) {
const inputRef = useInputAria({ describedBy: ariaDescribedBy });
const passthroughProps: PassthroughProps = {
value,
onChange,
defaultValue,
format,
swatches,
swatchesPerRow,
withPicker,
popoverProps,
placeholder,
id,
name,
"aria-label": ariaLabel,
"aria-describedby": ariaDescribedBy,
required,
disabled,
readOnly,
onFocus,
onBlur,
};
return (
<MantineColorInput
size={inputSize}
// Boolean error applies invalid styling without rendering Mantine's own
// message element — FormField owns the visible error text.
error={invalid || ariaInvalid || undefined}
// required sets the input attribute only; FormField renders the asterisk.
withAsterisk={false}
ref={inputRef}
classNames={{ wrapper: "sui-mantine-wrapper" }}
styles={{ wrapper: SUI_INPUT_VARS }}
{...passthroughProps}
/>
);
}
@@ -73,23 +73,6 @@ export const Input_Error: Story = {
),
};
export const Select_Default: Story = {
render: () => (
<FormField label="Retention period">
<Select
defaultValue="90"
options={[
{ value: "30", label: "30 days" },
{ value: "60", label: "60 days" },
{ value: "90", label: "90 days (default)" },
{ value: "180", label: "180 days" },
{ value: "never", label: "Never expire" },
]}
/>
</FormField>
),
};
export const Checkbox_Single: Story = {
render: () => (
<Stack gap="2">
@@ -251,7 +234,7 @@ export const FullForm: Story = {
<FormField label="Retention">
<Select
value={retention}
onChange={(e) => setRetention(e.target.value)}
onChange={(value) => setRetention(value ?? "90")}
options={[
{ value: "30", label: "30 days" },
{ value: "90", label: "90 days" },
@@ -34,3 +34,7 @@
.sui-iconbadge--red {
--ib-base: var(--color-red);
}
.sui-iconbadge--neutral {
--ib-base: var(--color-text-1);
background: none;
}
@@ -1,7 +1,13 @@
import type { ReactNode } from "react";
import "@app/ui/IconBadge.css";
export type IconBadgeAccent = "blue" | "purple" | "green" | "amber" | "red";
export type IconBadgeAccent =
| "blue"
| "purple"
| "green"
| "amber"
| "red"
| "neutral";
export interface IconBadgeProps {
children: ReactNode;
@@ -0,0 +1,114 @@
/* ===================================================================
* MantineForms.css — SUI design tokens injected into Mantine inputs
*
* Applied via classNames.wrapper="sui-mantine-wrapper" on each
* Mantine-backed SUI component (MultiSelect, NumberInput, ColorInput).
* The inline `styles.wrapper` in each component sets the base CSS
* variables; this file adds focus ring + pill styling that require
* CSS pseudo-selectors or attribute selectors.
* =================================================================== */
/* CSS custom properties for Mantine's input system, mapped to SUI tokens.
* These are also set as inline styles (higher specificity); the declarations
* here act as a typed reference and as a fallback for any slot Mantine reads
* before the inline vars are applied. */
.sui-mantine-wrapper {
--input-bg: var(--color-surface);
--input-bd: var(--color-border-input);
--input-bd-focus: var(--color-blue);
--input-radius: var(--radius-md);
--input-color: var(--color-text-1);
--input-placeholder-color: var(--color-text-placeholder);
--input-height-sm: 1.75rem;
--input-height-md: 2.25rem;
font-size: 0.875rem;
}
/* SUI focus ring — matches .sui-input:focus-within */
.sui-mantine-wrapper[data-focused],
.sui-mantine-wrapper:focus-within {
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-blue) 16%, transparent);
}
/* Error state */
.sui-mantine-wrapper[data-invalid] {
--input-bd: var(--color-red);
--input-bd-focus: var(--color-red);
}
.sui-mantine-wrapper[data-invalid][data-focused],
.sui-mantine-wrapper[data-invalid]:focus-within {
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-red) 16%, transparent);
}
/* Disabled state */
.sui-mantine-wrapper[data-disabled] {
opacity: 0.5;
cursor: not-allowed;
}
/* ---- MultiSelect pills ---- */
/* Pills match SUI's Chip component: small rounded tags. */
.sui-mantine-pill {
background: var(--color-blue-light) !important;
color: var(--color-blue-dark) !important;
border: 1px solid var(--color-blue-border) !important;
border-radius: var(--radius-sm) !important;
font-size: 0.75rem !important;
font-weight: 500 !important;
}
/* Pills list needs a min-height to not collapse when empty */
.sui-mantine-pills-list {
min-height: var(--input-height-sm, 1.75rem);
}
/* ---- NumberInput controls (increment/decrement buttons) ---- */
.sui-mantine-control {
border-color: var(--color-border-input) !important;
color: var(--color-text-3) !important;
}
.sui-mantine-control:hover {
background: var(--color-bg-hover) !important;
color: var(--color-text-1) !important;
}
/* ---- Select: hide Mantine's right-section clear button border ---- */
.sui-mantine-wrapper .mantine-Select-section {
color: var(--color-text-3);
}
/* ---- Slider ---- */
.sui-mantine-slider {
--slider-color: var(--color-blue);
--slider-track-bg: var(--color-border);
--slider-thumb-color: var(--color-surface);
--slider-thumb-bd: var(--color-blue);
}
.sui-mantine-slider:focus-within {
outline: none;
}
/* Thumb focus ring matches SUI */
.sui-mantine-slider .mantine-Slider-thumb:focus-visible {
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-blue) 16%, transparent);
outline: none;
}
/* Mark labels use SUI text tokens */
.sui-mantine-slider .mantine-Slider-markLabel {
color: var(--color-text-3);
font-size: 0.75rem;
}
/* ---- Dark mode: Mantine's own dark-mode vars beat ours when Mantine's
* color-scheme is "dark". Reassert SUI tokens so the two systems stay in sync.
* SuiProvider syncs forceColorScheme to SUI theme, so
* [data-mantine-color-scheme="dark"] === [data-theme="dark"] in practice. ---- */
[data-mantine-color-scheme="dark"] .sui-mantine-wrapper {
--input-bg: var(--color-surface);
--input-bd: var(--color-border-input);
--input-color: var(--color-text-1);
}
@@ -0,0 +1,568 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { FormField } from "@app/ui/FormField";
import { Stack } from "@app/ui/Stack";
import { MultiSelect } from "@app/ui/MultiSelect";
import { NumberInput } from "@app/ui/NumberInput";
import { ColorInput } from "@app/ui/ColorInput";
import { Select } from "@app/ui/Select";
import { Slider } from "@app/ui/Slider";
const PII_OPTIONS = [
{ value: "ssn", label: "Social Security Number" },
{ value: "dob", label: "Date of Birth" },
{ value: "account", label: "Account Number" },
{ value: "email", label: "Email Address" },
{ value: "phone", label: "Phone Number" },
{ value: "address", label: "Postal Address" },
{ value: "passport", label: "Passport Number" },
{ value: "license", label: "Driver's License" },
];
const meta: Meta = {
title: "Primitives/Forms",
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ width: "28rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj;
// ─── MultiSelect ─────────────────────────────────────────────────────────────
export const MultiSelect_Default: Story = {
render: () => {
function Bound() {
const [value, setValue] = useState<string[]>([]);
return (
<FormField
label="PII field types"
helperText="Select all entity types to detect."
>
<MultiSelect
data={PII_OPTIONS}
value={value}
onChange={setValue}
placeholder="Choose types…"
clearable
searchable
/>
</FormField>
);
}
return <Bound />;
},
};
export const MultiSelect_WithValues: Story = {
render: () => {
function Bound() {
const [value, setValue] = useState(["ssn", "dob", "email"]);
return (
<FormField label="PII field types">
<MultiSelect
data={PII_OPTIONS}
value={value}
onChange={setValue}
clearable
searchable
/>
</FormField>
);
}
return <Bound />;
},
};
export const MultiSelect_SmSize: Story = {
render: () => {
function Bound() {
const [value, setValue] = useState(["ssn", "email"]);
return (
<FormField label="Fields">
<MultiSelect
data={PII_OPTIONS}
value={value}
onChange={setValue}
inputSize="sm"
clearable
/>
</FormField>
);
}
return <Bound />;
},
};
export const MultiSelect_Error: Story = {
render: () => (
<FormField
label="PII field types"
error="At least one field type is required."
required
>
<MultiSelect
data={PII_OPTIONS}
value={[]}
onChange={() => {}}
placeholder="Choose types…"
invalid
/>
</FormField>
),
};
export const MultiSelect_Disabled: Story = {
render: () => (
<FormField label="PII field types">
<MultiSelect
data={PII_OPTIONS}
value={["ssn", "dob"]}
onChange={() => {}}
disabled
/>
</FormField>
),
};
// ─── NumberInput ─────────────────────────────────────────────────────────────
export const NumberInput_Default: Story = {
render: () => {
function Bound() {
const [value, setValue] = useState<number | string>(100);
return (
<FormField label="Max pages" helperText="Maximum pages per run.">
<NumberInput
value={value}
onChange={setValue}
min={1}
max={10000}
step={1}
/>
</FormField>
);
}
return <Bound />;
},
};
export const NumberInput_Decimal: Story = {
render: () => {
function Bound() {
const [value, setValue] = useState<number | string>(0.85);
return (
<FormField
label="Confidence threshold"
helperText="Documents below this score route to the review queue."
>
<NumberInput
value={value}
onChange={setValue}
min={0}
max={1}
step={0.01}
decimalScale={2}
fixedDecimalScale
/>
</FormField>
);
}
return <Bound />;
},
};
export const NumberInput_WithUnit: Story = {
render: () => {
function Bound() {
const [opacity, setOpacity] = useState<number | string>(80);
const [fontSize, setFontSize] = useState<number | string>(24);
return (
<Stack gap="4">
<FormField label="Watermark opacity">
<NumberInput
value={opacity}
onChange={setOpacity}
min={0}
max={100}
step={5}
suffix="%"
/>
</FormField>
<FormField label="Font size">
<NumberInput
value={fontSize}
onChange={setFontSize}
min={6}
max={200}
step={1}
suffix=" pt"
/>
</FormField>
</Stack>
);
}
return <Bound />;
},
};
export const NumberInput_SmSize: Story = {
render: () => {
function Bound() {
const [v, setV] = useState<number | string>(12);
return (
<FormField label="Rotation">
<NumberInput
value={v}
onChange={setV}
min={-360}
max={360}
step={1}
suffix="°"
inputSize="sm"
/>
</FormField>
);
}
return <Bound />;
},
};
export const NumberInput_Error: Story = {
render: () => (
<FormField label="Max pages" error="Must be between 1 and 10 000." required>
<NumberInput value={-5} onChange={() => {}} invalid />
</FormField>
),
};
export const NumberInput_Disabled: Story = {
render: () => (
<FormField label="Max pages">
<NumberInput value={100} onChange={() => {}} disabled />
</FormField>
),
};
// ─── ColorInput ──────────────────────────────────────────────────────────────
export const ColorInput_Default: Story = {
render: () => {
function Bound() {
const [color, setColor] = useState("");
return (
<FormField
label="Watermark color"
helperText="Pick a hex colour for the overlay text."
>
<ColorInput value={color} onChange={setColor} placeholder="#000000" />
</FormField>
);
}
return <Bound />;
},
};
export const ColorInput_Preselected: Story = {
render: () => {
function Bound() {
const [color, setColor] = useState("#3B82F6");
return (
<FormField label="Accent color">
<ColorInput value={color} onChange={setColor} />
</FormField>
);
}
return <Bound />;
},
};
export const ColorInput_SmSize: Story = {
render: () => {
function Bound() {
const [color, setColor] = useState("#EF4444");
return (
<FormField label="Badge color">
<ColorInput value={color} onChange={setColor} inputSize="sm" />
</FormField>
);
}
return <Bound />;
},
};
export const ColorInput_Error: Story = {
render: () => (
<FormField
label="Watermark color"
error="Enter a valid hex colour."
required
>
<ColorInput value="not-a-color" onChange={() => {}} invalid />
</FormField>
),
};
export const ColorInput_Disabled: Story = {
render: () => (
<FormField label="Watermark color">
<ColorInput value="#3B82F6" onChange={() => {}} disabled />
</FormField>
),
};
// ─── Select ──────────────────────────────────────────────────────────────────
const RETENTION_OPTIONS = [
{ value: "30", label: "30 days" },
{ value: "60", label: "60 days" },
{ value: "90", label: "90 days (default)" },
{ value: "180", label: "180 days" },
{ value: "never", label: "Never expire" },
];
export const Select_Default: Story = {
render: () => {
function Bound() {
const [value, setValue] = useState<string | null>("90");
return (
<FormField label="Retention period">
<Select
options={RETENTION_OPTIONS}
value={value}
onChange={setValue}
/>
</FormField>
);
}
return <Bound />;
},
};
export const Select_Searchable: Story = {
render: () => {
function Bound() {
const [value, setValue] = useState<string | null>(null);
return (
<FormField label="Output format" helperText="Start typing to filter.">
<Select
options={[
{ value: "pdf", label: "PDF" },
{ value: "pdfa", label: "PDF/A (archival)" },
{ value: "pdfa2", label: "PDF/A-2 (archival)" },
{ value: "pdfua", label: "PDF/UA (accessible)" },
{ value: "docx", label: "Word document (.docx)" },
{ value: "xlsx", label: "Spreadsheet (.xlsx)" },
{ value: "txt", label: "Plain text (.txt)" },
]}
value={value}
onChange={setValue}
placeholder="Choose format…"
searchable
clearable
/>
</FormField>
);
}
return <Bound />;
},
};
export const Select_SmSize: Story = {
render: () => {
function Bound() {
const [value, setValue] = useState<string | null>("upload");
return (
<FormField label="Run on">
<Select
options={[
{ value: "upload", label: "Upload" },
{ value: "export", label: "Export" },
]}
value={value}
onChange={setValue}
inputSize="sm"
/>
</FormField>
);
}
return <Bound />;
},
};
export const Select_Error: Story = {
render: () => (
<FormField
label="Retention period"
error="A retention period is required."
required
>
<Select
options={RETENTION_OPTIONS}
value={null}
onChange={() => {}}
placeholder="Choose…"
invalid
/>
</FormField>
),
};
export const Select_Disabled: Story = {
render: () => (
<FormField label="Retention period">
<Select
options={RETENTION_OPTIONS}
value="90"
onChange={() => {}}
disabled
/>
</FormField>
),
};
// ─── Slider ───────────────────────────────────────────────────────────────────
export const Slider_Default: Story = {
render: () => {
function Bound() {
const [v, setV] = useState(0.85);
return (
<FormField
label="Confidence threshold"
helperText="Documents below this route to the review queue."
>
<Slider
value={v}
onChange={setV}
min={0}
max={1}
step={0.01}
formatValue={(x) => x.toFixed(2)}
/>
</FormField>
);
}
return <Bound />;
},
};
export const Slider_WithMarks: Story = {
render: () => {
function Bound() {
const [days, setDays] = useState(90);
return (
<FormField label="Retain artifacts for">
<Slider
value={days}
onChange={setDays}
min={7}
max={365}
step={1}
formatValue={(d) => `${d}d`}
marks={[
{ value: 30, label: "30d" },
{ value: 90, label: "90d" },
{ value: 180, label: "180d" },
{ value: 365, label: "1y" },
]}
/>
</FormField>
);
}
return <Bound />;
},
};
export const Slider_NoLabel: Story = {
render: () => {
function Bound() {
const [v, setV] = useState(50);
return (
<FormField label="Opacity" helperText={`${v}%`}>
<Slider
value={v}
onChange={setV}
min={0}
max={100}
step={1}
showValue={false}
/>
</FormField>
);
}
return <Bound />;
},
};
export const Slider_Disabled: Story = {
render: () => (
<FormField label="Confidence threshold">
<Slider
value={0.85}
min={0}
max={1}
step={0.01}
formatValue={(x) => x.toFixed(2)}
disabled
/>
</FormField>
),
};
// ─── Combined ────────────────────────────────────────────────────────────────
export const WatermarkForm: Story = {
render: () => {
function Form() {
const [color, setColor] = useState("#000000");
const [opacity, setOpacity] = useState<number | string>(50);
const [fontSize, setFontSize] = useState<number | string>(24);
const [piiFields, setPiiFields] = useState<string[]>([]);
return (
<Stack gap="4">
<FormField label="Watermark color">
<ColorInput value={color} onChange={setColor} />
</FormField>
<FormField label="Opacity">
<NumberInput
value={opacity}
onChange={setOpacity}
min={0}
max={100}
suffix="%"
/>
</FormField>
<FormField label="Font size">
<NumberInput
value={fontSize}
onChange={setFontSize}
min={6}
max={200}
suffix=" pt"
/>
</FormField>
<FormField
label="Redact PII types"
helperText="Fields to strip before watermarking."
>
<MultiSelect
data={PII_OPTIONS}
value={piiFields}
onChange={setPiiFields}
placeholder="None"
clearable
searchable
/>
</FormField>
</Stack>
);
}
return <Form />;
},
};
@@ -0,0 +1,184 @@
import type React from "react";
import {
MultiSelect as MantineMultiSelect,
type MultiSelectProps as MantineMultiSelectProps,
type ComboboxData,
} from "@mantine/core";
import { useInputAria } from "@app/ui/ariaForwarding";
import "@app/ui/MantineForms.css";
const SUI_INPUT_VARS = {
"--input-bg": "var(--color-surface)",
"--input-bd": "var(--color-border-input)",
"--input-bd-focus": "var(--color-blue)",
"--input-radius": "var(--radius-md)",
"--input-color": "var(--color-text-1)",
"--input-placeholder-color": "var(--color-text-placeholder)",
"--input-height-sm": "1.75rem",
"--input-height-md": "2.25rem",
} as React.CSSProperties;
export type MultiSelectSize = "sm" | "md";
export interface MultiSelectProps {
// Data
data: ComboboxData;
value?: string[];
onChange?: (value: string[]) => void;
defaultValue?: string[];
// Behaviour
searchable?: boolean;
clearable?: boolean;
limit?: number;
maxValues?: number;
searchValue?: string;
onSearchChange?: (value: string) => void;
nothingFoundMessage?: React.ReactNode;
maxDropdownHeight?: number | string;
filter?: MantineMultiSelectProps["filter"];
// Dropdown escape hatch — only for zIndex / offset overrides in modals
comboboxProps?: MantineMultiSelectProps["comboboxProps"];
// Form
placeholder?: string;
id?: string;
name?: string;
"aria-label"?: string;
"aria-invalid"?: boolean;
"aria-describedby"?: string;
required?: boolean;
disabled?: boolean;
readOnly?: boolean;
onFocus?: React.FocusEventHandler<HTMLInputElement>;
onBlur?: React.FocusEventHandler<HTMLInputElement>;
onDropdownOpen?: () => void;
onDropdownClose?: () => void;
// SUI — invalid applies error styling; FormField renders the message itself.
inputSize?: MultiSelectSize;
invalid?: boolean;
}
type PassthroughProps = Omit<
Pick<
MantineMultiSelectProps,
| "data"
| "value"
| "onChange"
| "defaultValue"
| "searchable"
| "clearable"
| "limit"
| "maxValues"
| "searchValue"
| "onSearchChange"
| "nothingFoundMessage"
| "maxDropdownHeight"
| "filter"
| "comboboxProps"
| "placeholder"
| "id"
| "name"
| "aria-label"
| "aria-describedby"
| "required"
| "disabled"
| "readOnly"
| "onFocus"
| "onBlur"
| "onDropdownOpen"
| "onDropdownClose"
>,
never
>;
/**
* SUI multi-select with pill display and optional search. Use with <FormField>
* for labels and error display. Appearance is locked to SUI tokens.
*
* Pass `comboboxProps={{ zIndex: Z_INDEX_MODAL }}` when rendering inside a modal.
*/
export function MultiSelect({
inputSize = "md",
invalid,
data,
value,
onChange,
defaultValue,
searchable,
clearable,
limit,
maxValues,
searchValue,
onSearchChange,
nothingFoundMessage,
maxDropdownHeight,
filter,
comboboxProps,
placeholder,
id,
name,
"aria-label": ariaLabel,
"aria-invalid": ariaInvalid,
"aria-describedby": ariaDescribedBy,
required,
disabled,
readOnly,
onFocus,
onBlur,
onDropdownOpen,
onDropdownClose,
}: MultiSelectProps) {
const inputRef = useInputAria({ describedBy: ariaDescribedBy, required });
const passthroughProps: PassthroughProps = {
data,
value,
onChange,
defaultValue,
searchable,
clearable,
limit,
maxValues,
searchValue,
onSearchChange,
nothingFoundMessage,
maxDropdownHeight,
filter,
comboboxProps,
placeholder,
id,
name,
"aria-label": ariaLabel,
"aria-describedby": ariaDescribedBy,
required,
disabled,
readOnly,
onFocus,
onBlur,
onDropdownOpen,
onDropdownClose,
};
return (
<MantineMultiSelect
size={inputSize}
// Boolean error applies invalid styling without rendering Mantine's own
// message element — FormField owns the visible error text.
error={invalid || ariaInvalid || undefined}
// FormField renders the asterisk; the field is announced as required
// via aria-required from useInputAria (Mantine keeps `required` on the
// pills wrapper, not the focusable field).
withAsterisk={false}
ref={inputRef}
classNames={{
wrapper: "sui-mantine-wrapper",
pill: "sui-mantine-pill",
pillsList: "sui-mantine-pills-list",
}}
styles={{ wrapper: SUI_INPUT_VARS }}
{...passthroughProps}
/>
);
}
@@ -0,0 +1,190 @@
import type React from "react";
import {
NumberInput as MantineNumberInput,
type NumberInputProps as MantineNumberInputProps,
} from "@mantine/core";
import { useInputAria } from "@app/ui/ariaForwarding";
import "@app/ui/MantineForms.css";
const SUI_INPUT_VARS = {
"--input-bg": "var(--color-surface)",
"--input-bd": "var(--color-border-input)",
"--input-bd-focus": "var(--color-blue)",
"--input-radius": "var(--radius-md)",
"--input-color": "var(--color-text-1)",
"--input-placeholder-color": "var(--color-text-placeholder)",
"--input-height-sm": "1.75rem",
"--input-height-md": "2.25rem",
} as React.CSSProperties;
export type NumberInputSize = "sm" | "md";
export interface NumberInputProps {
// Value
value?: number | string;
onChange?: (value: number | string) => void;
defaultValue?: number | string;
// Constraints
min?: number;
max?: number;
step?: number;
decimalScale?: number;
fixedDecimalScale?: boolean;
allowNegative?: boolean;
allowDecimal?: boolean;
clampBehavior?: "strict" | "blur" | "none";
// Display
placeholder?: string;
suffix?: string;
prefix?: string;
hideControls?: boolean;
// Right section — escape hatch for inline unit labels
rightSection?: React.ReactNode;
rightSectionWidth?: React.CSSProperties["width"];
// Form
id?: string;
name?: string;
"aria-label"?: string;
"aria-invalid"?: boolean;
"aria-describedby"?: string;
required?: boolean;
disabled?: boolean;
readOnly?: boolean;
autoFocus?: boolean;
onFocus?: React.FocusEventHandler<HTMLInputElement>;
onBlur?: React.FocusEventHandler<HTMLInputElement>;
onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>;
// SUI — invalid applies error styling; FormField renders the message itself.
inputSize?: NumberInputSize;
invalid?: boolean;
}
// Narrows MantineNumberInputProps to only what our interface exposes so the
// spread below stays type-safe without manually listing every prop.
type PassthroughProps = Omit<
Pick<
MantineNumberInputProps,
| "value"
| "onChange"
| "defaultValue"
| "min"
| "max"
| "step"
| "decimalScale"
| "fixedDecimalScale"
| "allowNegative"
| "allowDecimal"
| "clampBehavior"
| "placeholder"
| "suffix"
| "prefix"
| "hideControls"
| "rightSection"
| "rightSectionWidth"
| "id"
| "name"
| "aria-label"
| "aria-describedby"
| "required"
| "disabled"
| "readOnly"
| "autoFocus"
| "onFocus"
| "onBlur"
| "onKeyDown"
>,
never
>;
/**
* SUI number input with increment/decrement controls. Use with <FormField>
* for labels and error display. Appearance is locked to SUI tokens.
*/
export function NumberInput({
inputSize = "md",
invalid,
value,
onChange,
defaultValue,
min,
max,
step,
decimalScale,
fixedDecimalScale,
allowNegative,
allowDecimal,
clampBehavior,
placeholder,
suffix,
prefix,
hideControls,
rightSection,
rightSectionWidth,
id,
name,
"aria-label": ariaLabel,
"aria-invalid": ariaInvalid,
"aria-describedby": ariaDescribedBy,
required,
disabled,
readOnly,
autoFocus,
onFocus,
onBlur,
onKeyDown,
}: NumberInputProps) {
const inputRef = useInputAria({ describedBy: ariaDescribedBy });
const passthroughProps: PassthroughProps = {
value,
onChange,
defaultValue,
min,
max,
step,
decimalScale,
fixedDecimalScale,
allowNegative,
allowDecimal,
clampBehavior,
placeholder,
suffix,
prefix,
hideControls,
rightSection,
rightSectionWidth,
id,
name,
"aria-label": ariaLabel,
"aria-describedby": ariaDescribedBy,
required,
disabled,
readOnly,
autoFocus,
onFocus,
onBlur,
onKeyDown,
};
return (
<MantineNumberInput
size={inputSize}
// Boolean error applies invalid styling without rendering Mantine's own
// message element — FormField owns the visible error text.
error={invalid || ariaInvalid || undefined}
// required sets the input attribute only; FormField renders the asterisk.
withAsterisk={false}
ref={inputRef}
classNames={{
wrapper: "sui-mantine-wrapper",
control: "sui-mantine-control",
}}
styles={{ wrapper: SUI_INPUT_VARS }}
{...passthroughProps}
/>
);
}
+140 -55
View File
@@ -1,5 +1,21 @@
import { forwardRef, type SelectHTMLAttributes } from "react";
import "@app/ui/Select.css";
import type React from "react";
import {
Select as MantineSelect,
type SelectProps as MantineSelectProps,
} from "@mantine/core";
import { useInputAria } from "@app/ui/ariaForwarding";
import "@app/ui/MantineForms.css";
const SUI_INPUT_VARS = {
"--input-bg": "var(--color-surface)",
"--input-bd": "var(--color-border-input)",
"--input-bd-focus": "var(--color-blue)",
"--input-radius": "var(--radius-md)",
"--input-color": "var(--color-text-1)",
"--input-placeholder-color": "var(--color-text-placeholder)",
"--input-height-sm": "1.75rem",
"--input-height-md": "2.25rem",
} as React.CSSProperties;
export interface SelectOption {
value: string;
@@ -9,61 +25,130 @@ export interface SelectOption {
export type SelectSize = "sm" | "md";
export interface SelectProps extends Omit<
SelectHTMLAttributes<HTMLSelectElement>,
"size"
> {
inputSize?: SelectSize;
export interface SelectProps {
// Data
options: SelectOption[];
/** Optional placeholder rendered as a disabled first option. */
value?: string | null;
onChange?: (value: string | null) => void;
defaultValue?: string;
// Behaviour
searchable?: boolean;
clearable?: boolean;
placeholder?: string;
nothingFoundMessage?: React.ReactNode;
maxDropdownHeight?: number | string;
// Dropdown escape hatch — for zIndex / offset overrides in modals
comboboxProps?: MantineSelectProps["comboboxProps"];
// Form
id?: string;
name?: string;
"aria-label"?: string;
"aria-invalid"?: boolean;
"aria-describedby"?: string;
required?: boolean;
disabled?: boolean;
readOnly?: boolean;
onFocus?: React.FocusEventHandler<HTMLInputElement>;
onBlur?: React.FocusEventHandler<HTMLInputElement>;
// SUI — invalid applies error styling; FormField renders the message itself.
inputSize?: SelectSize;
invalid?: boolean;
}
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
function Select(
{ inputSize = "md", options, placeholder, invalid, className, ...rest },
ref,
) {
return (
<span
className={[
"sui-select",
`sui-select--${inputSize}`,
invalid ? "sui-select--invalid" : "",
rest.disabled ? "sui-select--disabled" : "",
className ?? "",
]
.filter(Boolean)
.join(" ")}
>
<select ref={ref} className="sui-select__el" {...rest}>
{placeholder && (
<option value="" disabled hidden>
{placeholder}
</option>
)}
{options.map((opt) => (
<option key={opt.value} value={opt.value} disabled={opt.disabled}>
{opt.label}
</option>
))}
</select>
<span className="sui-select__caret" aria-hidden>
<svg
viewBox="0 0 24 24"
width="12"
height="12"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</span>
</span>
);
},
);
type PassthroughProps = Omit<
Pick<
MantineSelectProps,
| "value"
| "onChange"
| "defaultValue"
| "searchable"
| "clearable"
| "placeholder"
| "nothingFoundMessage"
| "maxDropdownHeight"
| "comboboxProps"
| "id"
| "name"
| "aria-label"
| "aria-describedby"
| "required"
| "disabled"
| "readOnly"
| "onFocus"
| "onBlur"
>,
never
>;
/**
* SUI select / combobox backed by Mantine. Supports optional search and clear.
* Use with <FormField> for labels and error display. Appearance is locked to SUI tokens.
*
* onChange receives the selected string value (or null when cleared), not a DOM event.
*/
export function Select({
inputSize = "md",
invalid,
options,
value,
onChange,
defaultValue,
searchable,
clearable,
placeholder,
nothingFoundMessage,
maxDropdownHeight,
comboboxProps,
id,
name,
"aria-label": ariaLabel,
"aria-invalid": ariaInvalid,
"aria-describedby": ariaDescribedBy,
required,
disabled,
readOnly,
onFocus,
onBlur,
}: SelectProps) {
const inputRef = useInputAria({ describedBy: ariaDescribedBy });
const passthroughProps: PassthroughProps = {
value,
onChange,
defaultValue,
searchable,
clearable,
placeholder,
nothingFoundMessage,
maxDropdownHeight,
comboboxProps,
id,
name,
"aria-label": ariaLabel,
"aria-describedby": ariaDescribedBy,
required,
disabled,
readOnly,
onFocus,
onBlur,
};
return (
<MantineSelect
data={options}
size={inputSize}
// Boolean error applies invalid styling without rendering Mantine's own
// message element — FormField owns the visible error text.
error={invalid || ariaInvalid || undefined}
// required sets the input attribute only; FormField renders the asterisk.
withAsterisk={false}
ref={inputRef}
classNames={{ wrapper: "sui-mantine-wrapper" }}
styles={{ wrapper: SUI_INPUT_VARS }}
{...passthroughProps}
/>
);
}
+100 -50
View File
@@ -1,63 +1,113 @@
import { forwardRef, type InputHTMLAttributes } from "react";
import "@app/ui/Slider.css";
import {
Slider as MantineSlider,
type SliderProps as MantineSliderProps,
} from "@mantine/core";
import { useThumbAria } from "@app/ui/ariaForwarding";
import "@app/ui/MantineForms.css";
export interface SliderProps extends Omit<
InputHTMLAttributes<HTMLInputElement>,
"type" | "value" | "onChange"
> {
export interface SliderMark {
value: number;
label?: React.ReactNode;
}
export interface SliderProps {
value: number;
onChange?: (value: number) => void;
min?: number;
max?: number;
step?: number;
onChange: (value: number) => void;
/** Optional formatter for the value pill (e.g. "0.85", "30 days"). */
/** Tick marks along the track. */
marks?: SliderMark[];
/** Format the tooltip shown while dragging. Defaults to the raw number. */
formatValue?: (value: number) => string;
/** Show the right-aligned value badge. Defaults to true. */
/**
* Show the value tooltip on hover/drag. Defaults to true.
* Pass false to hide the label entirely (useful when the value is shown elsewhere).
*/
showValue?: boolean;
// Form
id?: string;
/** Accessible name for the slider thumb — the visible FormField label can't
* associate with Mantine's non-input thumb element, so set this too. */
"aria-label"?: string;
"aria-invalid"?: boolean;
"aria-describedby"?: string;
disabled?: boolean;
// SUI
inputSize?: "sm" | "md";
}
export const Slider = forwardRef<HTMLInputElement, SliderProps>(function Slider(
{
type PassthroughProps = Omit<
Pick<
MantineSliderProps,
| "value"
| "onChange"
| "min"
| "max"
| "step"
| "marks"
| "label"
| "thumbLabel"
| "id"
| "disabled"
| "size"
>,
never
>;
/**
* SUI range slider backed by Mantine. Provides accessible keyboard navigation,
* optional tick marks, and a drag tooltip. Use with <FormField> for labels.
* Appearance is locked to SUI tokens.
*/
export function Slider({
value,
onChange,
min = 0,
max = 1,
step = 0.01,
marks,
formatValue,
showValue = true,
id,
"aria-label": ariaLabel,
"aria-invalid": ariaInvalid,
"aria-describedby": ariaDescribedBy,
disabled,
inputSize = "md",
}: SliderProps) {
const label = showValue
? (v: number) => (formatValue ? formatValue(v) : String(v))
: null;
// The role="slider" element is the thumb, not an input, so FormField's
// injected aria wiring has to land there for AT to announce it.
const rootRef = useThumbAria(ariaDescribedBy, ariaInvalid);
const passthroughProps: PassthroughProps = {
value,
min = 0,
max = 1,
step = 0.01,
onChange,
formatValue,
showValue = true,
className,
...rest
},
ref,
) {
const pct = ((value - min) / (max - min)) * 100;
min,
max,
step,
marks,
label,
thumbLabel: ariaLabel,
id,
disabled,
size: inputSize,
};
return (
<span
className={[
"sui-slider",
rest.disabled ? "sui-slider--disabled" : "",
className ?? "",
]
.filter(Boolean)
.join(" ")}
style={{ "--slider-pct": `${pct}%` } as React.CSSProperties}
>
<input
ref={ref}
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="sui-slider__input"
{...rest}
/>
{showValue && (
<span className="sui-slider__value" aria-hidden>
{formatValue ? formatValue(value) : value.toString()}
</span>
)}
</span>
<MantineSlider
ref={rootRef}
classNames={{ root: "sui-mantine-slider" }}
{...passthroughProps}
/>
);
});
}
@@ -0,0 +1,106 @@
import { describe, expect, it } from "vitest";
import { render } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { Select } from "@app/ui/Select";
import { MultiSelect } from "@app/ui/MultiSelect";
import { NumberInput } from "@app/ui/NumberInput";
import { ColorInput } from "@app/ui/ColorInput";
import { Slider } from "@app/ui/Slider";
// Guards the FormField contract on the Mantine-backed components: the
// injected required / aria-describedby / aria-invalid wiring must reach the
// focusable element. Mantine internals clobber some of these (see
// ariaForwarding.ts), so this exercises the real DOM output.
function renderInProvider(ui: React.ReactElement) {
return render(<MantineProvider>{ui}</MantineProvider>);
}
const OPTIONS = [{ value: "a", label: "A" }];
describe("Mantine-backed SUI aria forwarding", () => {
it("Select forwards required and aria-describedby to the input", () => {
const { container } = renderInProvider(
<Select
options={OPTIONS}
value="a"
onChange={() => {}}
required
aria-describedby="help-1"
/>,
);
const input = container.querySelector("input");
expect(input?.hasAttribute("required")).toBe(true);
expect(input?.getAttribute("aria-describedby")).toBe("help-1");
});
it("Select sets aria-invalid from the invalid flag", () => {
const { container } = renderInProvider(
<Select options={OPTIONS} value="a" onChange={() => {}} invalid />,
);
expect(container.querySelector("input")?.getAttribute("aria-invalid")).toBe(
"true",
);
});
it("MultiSelect forwards aria-required and aria-describedby to the field", () => {
const { container } = renderInProvider(
<MultiSelect
data={OPTIONS}
value={["a"]}
onChange={() => {}}
required
aria-describedby="help-2"
/>,
);
// The focusable pills field; a native `required` would misfire form
// validation there, so the requirement is announced via aria-required.
const input = container.querySelector("input");
expect(input?.getAttribute("aria-required")).toBe("true");
expect(input?.getAttribute("aria-describedby")).toBe("help-2");
});
it("NumberInput forwards required and aria-describedby to the input", () => {
const { container } = renderInProvider(
<NumberInput
value={1}
onChange={() => {}}
required
aria-describedby="help-3"
/>,
);
const input = container.querySelector("input");
expect(input?.hasAttribute("required")).toBe(true);
expect(input?.getAttribute("aria-describedby")).toBe("help-3");
});
it("ColorInput forwards required and aria-describedby to the input", () => {
const { container } = renderInProvider(
<ColorInput
value="#000000"
onChange={() => {}}
required
aria-describedby="help-4"
/>,
);
const input = container.querySelector("input");
expect(input?.hasAttribute("required")).toBe(true);
expect(input?.getAttribute("aria-describedby")).toBe("help-4");
});
it("Slider forwards aria wiring to the role=slider thumb", () => {
const { container } = renderInProvider(
<Slider
value={0.5}
onChange={() => {}}
aria-label="Confidence"
aria-invalid
aria-describedby="help-5"
/>,
);
const thumb = container.querySelector('[role="slider"]');
expect(thumb?.getAttribute("aria-label")).toBe("Confidence");
expect(thumb?.getAttribute("aria-invalid")).toBe("true");
expect(thumb?.getAttribute("aria-describedby")).toBe("help-5");
});
});
@@ -0,0 +1,57 @@
import { useEffect, useRef } from "react";
/**
* Mantine drops caller-supplied accessibility wiring in a few places:
* Input-based components overwrite `aria-describedby` with their own
* Input.Wrapper context (unset here — FormField owns the help text),
* MultiSelect consumes `required` for its label asterisk without marking the
* focusable field, and Slider's thumb ignores unknown `thumbProps` keys
* entirely. These hooks re-apply the attributes to the rendered DOM node
* after every render so FormField's injected wiring survives.
*/
/**
* Ref for a Mantine input component; keeps `aria-describedby` applied.
* Pass `required` only when Mantine doesn't put the attribute on the field
* itself (MultiSelect) — it is announced as `aria-required`, since a native
* `required` on a combobox search field would misfire form validation.
*/
export function useInputAria(options: {
describedBy?: string;
required?: boolean;
}) {
const { describedBy, required } = options;
const ref = useRef<HTMLInputElement>(null);
useEffect(() => {
applyAria(ref.current, "aria-describedby", describedBy);
applyAria(ref.current, "aria-required", required ? "true" : undefined);
});
return ref;
}
/** Ref for Mantine Slider's root; keeps aria wiring applied to the thumb. */
export function useThumbAria(
describedBy: string | undefined,
invalid: boolean | undefined,
) {
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const thumb = rootRef.current?.querySelector('[role="slider"]');
applyAria(thumb, "aria-describedby", describedBy);
applyAria(thumb, "aria-invalid", invalid ? "true" : undefined);
});
return rootRef;
}
function applyAria(
el: Element | null | undefined,
attribute: string,
value: string | undefined,
) {
if (!el) return;
if (value !== undefined) {
el.setAttribute(attribute, value);
} else {
el.removeAttribute(attribute);
}
}
@@ -41,3 +41,8 @@ export * from "@app/ui/Select";
export * from "@app/ui/Checkbox";
export * from "@app/ui/Radio";
export * from "@app/ui/Slider";
// Mantine-backed form elements (SUI-styled)
export * from "@app/ui/MultiSelect";
export * from "@app/ui/NumberInput";
export * from "@app/ui/ColorInput";