mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user