diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx index dd8e379a8a..cd601cf525 100644 --- a/frontend/.storybook/preview.tsx +++ b/frontend/.storybook/preview.tsx @@ -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 ( - + {/* LinkProvider must wrap TierProvider: TierContext derives its tier from useLink() (matches App.tsx's nesting). */} @@ -115,7 +114,7 @@ const withProviders: Decorator = (Story, context) => { - + ); diff --git a/frontend/editor/src/portal/PortalApp.tsx b/frontend/editor/src/portal/PortalApp.tsx index 1494e23cdf..629a8ac57a 100644 --- a/frontend/editor/src/portal/PortalApp.tsx +++ b/frontend/editor/src/portal/PortalApp.tsx @@ -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 to read useTheme(). */ -function PortalMantineProvider({ children }: { children: ReactNode }) { +function ThemedSuiProvider({ children }: { children: ReactNode }) { const { theme } = useTheme(); - return ( - - {children} - - ); + return {children}; } /** @@ -129,7 +124,7 @@ function RoutedContent() { export function PortalApp() { return ( - + {/* Scopes base.css to the portal so it doesn't restyle the host editor. */}
@@ -156,7 +151,7 @@ export function PortalApp() {
-
+
); } diff --git a/frontend/editor/src/portal/UI_CONVENTIONS.md b/frontend/editor/src/portal/UI_CONVENTIONS.md index 40879e2199..5487672b7b 100644 --- a/frontend/editor/src/portal/UI_CONVENTIONS.md +++ b/frontend/editor/src/portal/UI_CONVENTIONS.md @@ -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 `` 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. diff --git a/frontend/editor/src/portal/components/SettingsModal.tsx b/frontend/editor/src/portal/components/SettingsModal.tsx index aa01c2ba0e..c6a40f3389 100644 --- a/frontend/editor/src/portal/components/SettingsModal.tsx +++ b/frontend/editor/src/portal/components/SettingsModal.tsx @@ -612,7 +612,7 @@ function WorkspacePanel({ > - onSecurity({ sessionTimeoutMins: Number(e.target.value) }) + onChange={(value) => + onSecurity({ sessionTimeoutMins: Number(value ?? "0") }) } options={SESSION_TIMEOUT_VALUES.map((value) => ({ value, diff --git a/frontend/editor/src/portal/components/infrastructure/StorageTab.tsx b/frontend/editor/src/portal/components/infrastructure/StorageTab.tsx index 5f944465d8..7b2ecaa441 100644 --- a/frontend/editor/src/portal/components/infrastructure/StorageTab.tsx +++ b/frontend/editor/src/portal/components/infrastructure/StorageTab.tsx @@ -186,7 +186,9 @@ export function StorageTab() { - setScheduleUnit(e.target.value as ScheduleUnit) + onChange={(value) => + setScheduleUnit((value ?? "") as ScheduleUnit) } options={SCHEDULE_UNITS.map((unit) => ({ value: unit, diff --git a/frontend/editor/src/portal/components/policies/PolicyFieldRow.tsx b/frontend/editor/src/portal/components/policies/PolicyFieldRow.tsx index cb1893cc66..4024cce5e3 100644 --- a/frontend/editor/src/portal/components/policies/PolicyFieldRow.tsx +++ b/frontend/editor/src/portal/components/policies/PolicyFieldRow.tsx @@ -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 ?? "")} /> ); diff --git a/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx b/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx index 30b1d05403..96a9d377f2 100644 --- a/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx +++ b/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx @@ -453,8 +453,8 @@ function PolicySetupWizardBody({ { - 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({ setRole(e.target.value as RoleId)} + onChange={(value) => setRole((value ?? "") as RoleId)} /> diff --git a/frontend/editor/src/portal/theme/SuiProvider.tsx b/frontend/editor/src/portal/theme/SuiProvider.tsx new file mode 100644 index 0000000000..3af449d0e1 --- /dev/null +++ b/frontend/editor/src/portal/theme/SuiProvider.tsx @@ -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 ( + + {children} + + ); +} diff --git a/frontend/editor/src/portal/theme/mantineTheme.ts b/frontend/editor/src/portal/theme/mantineTheme.ts index fa8e6d5297..01953245f9 100644 --- a/frontend/editor/src/portal/theme/mantineTheme.ts +++ b/frontend/editor/src/portal/theme/mantineTheme.ts @@ -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 diff --git a/frontend/editor/src/proprietary/components/policies/PolicyFieldRow.tsx b/frontend/editor/src/proprietary/components/policies/PolicyFieldRow.tsx index 2559a0e522..4049533290 100644 --- a/frontend/editor/src/proprietary/components/policies/PolicyFieldRow.tsx +++ b/frontend/editor/src/proprietary/components/policies/PolicyFieldRow.tsx @@ -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} /> ) : ( diff --git a/frontend/editor/src/proprietary/components/policies/PolicySetupWizard.tsx b/frontend/editor/src/proprietary/components/policies/PolicySetupWizard.tsx index e63ee4e61f..abd1f6d8df 100644 --- a/frontend/editor/src/proprietary/components/policies/PolicySetupWizard.tsx +++ b/frontend/editor/src/proprietary/components/policies/PolicySetupWizard.tsx @@ -409,8 +409,8 @@ export function PolicySetupWizard({ { - 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({ - - ), -}; - export const Checkbox_Single: Story = { render: () => ( @@ -251,7 +234,7 @@ export const FullForm: Story = { + + ); + } + return ; + }, +}; + +export const Select_Searchable: Story = { + render: () => { + function Bound() { + const [value, setValue] = useState(null); + return ( + + + + ); + } + return ; + }, +}; + +export const Select_Error: Story = { + render: () => ( + + {}} + disabled + /> + + ), +}; + +// ─── Slider ─────────────────────────────────────────────────────────────────── + +export const Slider_Default: Story = { + render: () => { + function Bound() { + const [v, setV] = useState(0.85); + return ( + + x.toFixed(2)} + /> + + ); + } + return ; + }, +}; + +export const Slider_WithMarks: Story = { + render: () => { + function Bound() { + const [days, setDays] = useState(90); + return ( + + `${d}d`} + marks={[ + { value: 30, label: "30d" }, + { value: 90, label: "90d" }, + { value: 180, label: "180d" }, + { value: 365, label: "1y" }, + ]} + /> + + ); + } + return ; + }, +}; + +export const Slider_NoLabel: Story = { + render: () => { + function Bound() { + const [v, setV] = useState(50); + return ( + + + + ); + } + return ; + }, +}; + +export const Slider_Disabled: Story = { + render: () => ( + + x.toFixed(2)} + disabled + /> + + ), +}; + +// ─── Combined ──────────────────────────────────────────────────────────────── + +export const WatermarkForm: Story = { + render: () => { + function Form() { + const [color, setColor] = useState("#000000"); + const [opacity, setOpacity] = useState(50); + const [fontSize, setFontSize] = useState(24); + const [piiFields, setPiiFields] = useState([]); + return ( + + + + + + + + + + + + + + + ); + } + return
; + }, +}; diff --git a/frontend/editor/src/proprietary/ui/MultiSelect.tsx b/frontend/editor/src/proprietary/ui/MultiSelect.tsx new file mode 100644 index 0000000000..31ac9544f8 --- /dev/null +++ b/frontend/editor/src/proprietary/ui/MultiSelect.tsx @@ -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; + onBlur?: React.FocusEventHandler; + 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 + * 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 ( + + ); +} diff --git a/frontend/editor/src/proprietary/ui/NumberInput.tsx b/frontend/editor/src/proprietary/ui/NumberInput.tsx new file mode 100644 index 0000000000..751c902636 --- /dev/null +++ b/frontend/editor/src/proprietary/ui/NumberInput.tsx @@ -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; + onBlur?: React.FocusEventHandler; + onKeyDown?: React.KeyboardEventHandler; + + // 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 + * 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 ( + + ); +} diff --git a/frontend/editor/src/proprietary/ui/Select.tsx b/frontend/editor/src/proprietary/ui/Select.tsx index b970cc1815..fa2d5346b7 100644 --- a/frontend/editor/src/proprietary/ui/Select.tsx +++ b/frontend/editor/src/proprietary/ui/Select.tsx @@ -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, - "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; + onBlur?: React.FocusEventHandler; + + // SUI — invalid applies error styling; FormField renders the message itself. + inputSize?: SelectSize; invalid?: boolean; } -export const Select = forwardRef( - function Select( - { inputSize = "md", options, placeholder, invalid, className, ...rest }, - ref, - ) { - return ( - - - - - - - - - ); - }, -); +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 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 ( + + ); +} diff --git a/frontend/editor/src/proprietary/ui/Slider.tsx b/frontend/editor/src/proprietary/ui/Slider.tsx index 3b540def06..ebeb782f9e 100644 --- a/frontend/editor/src/proprietary/ui/Slider.tsx +++ b/frontend/editor/src/proprietary/ui/Slider.tsx @@ -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, - "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(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 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 ( - - onChange(Number(e.target.value))} - className="sui-slider__input" - {...rest} - /> - {showValue && ( - - {formatValue ? formatValue(value) : value.toString()} - - )} - + ); -}); +} diff --git a/frontend/editor/src/proprietary/ui/ariaForwarding.test.tsx b/frontend/editor/src/proprietary/ui/ariaForwarding.test.tsx new file mode 100644 index 0000000000..608baff21d --- /dev/null +++ b/frontend/editor/src/proprietary/ui/ariaForwarding.test.tsx @@ -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({ui}); +} + +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( + {}} invalid />, + ); + expect(container.querySelector("input")?.getAttribute("aria-invalid")).toBe( + "true", + ); + }); + + it("MultiSelect forwards aria-required and aria-describedby to the field", () => { + const { container } = renderInProvider( + {}} + 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( + {}} + 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( + {}} + 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( + {}} + 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"); + }); +}); diff --git a/frontend/editor/src/proprietary/ui/ariaForwarding.ts b/frontend/editor/src/proprietary/ui/ariaForwarding.ts new file mode 100644 index 0000000000..344016a50c --- /dev/null +++ b/frontend/editor/src/proprietary/ui/ariaForwarding.ts @@ -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(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(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); + } +} diff --git a/frontend/editor/src/proprietary/ui/index.ts b/frontend/editor/src/proprietary/ui/index.ts index 6c2132f894..e6c4864c52 100644 --- a/frontend/editor/src/proprietary/ui/index.ts +++ b/frontend/editor/src/proprietary/ui/index.ts @@ -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";