From be97268a7ccb24691cdf8e042a7589d8c68d5d25 Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:29:55 +0100 Subject: [PATCH] SUI - setting up mantine backed SUI components (#6890) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 `` 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 `` 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 onRegion(e.target.value)} + onChange={(value) => onRegion(value ?? "")} options={regionOptions} /> @@ -750,8 +750,8 @@ function AuthenticationPanel({ > setRetention(e.target.value as RetentionWindow)} + onChange={(value) => + setRetention((value ?? "") as RetentionWindow) + } /> diff --git a/frontend/editor/src/portal/components/pipelines/PipelineComposer.tsx b/frontend/editor/src/portal/components/pipelines/PipelineComposer.tsx index 1d35e8d0f7..5040a97ac0 100644 --- a/frontend/editor/src/portal/components/pipelines/PipelineComposer.tsx +++ b/frontend/editor/src/portal/components/pipelines/PipelineComposer.tsx @@ -362,8 +362,8 @@ export function PipelineComposer({ - setRunOn(e.target.value as "upload" | "export") + onChange={(value) => + setRunOn((value ?? "upload") as "upload" | "export") } options={[ { @@ -474,8 +474,10 @@ function PolicySetupWizardBody({ + onChange={(value) => setOutputNamePosition( - e.target.value as "prefix" | "suffix" | "auto-number", + (value ?? "suffix") as + | "prefix" + | "suffix" + | "auto-number", ) } options={[ diff --git a/frontend/editor/src/portal/components/sources/ConnectWizard.test.tsx b/frontend/editor/src/portal/components/sources/ConnectWizard.test.tsx index c6874effcd..d354da1d40 100644 --- a/frontend/editor/src/portal/components/sources/ConnectWizard.test.tsx +++ b/frontend/editor/src/portal/components/sources/ConnectWizard.test.tsx @@ -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({ui}); +} + // 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(); + renderWithMantine( + , + ); stepToReview(); @@ -66,7 +73,7 @@ describe("ConnectWizard", () => { createSource.mockResolvedValue({ id: "s1" }); const onCreated = vi.fn(); - render( + renderWithMantine( { }), ); - render(); + renderWithMantine( + , + ); stepToReview(); fireEvent.click(screen.getByText("portal.sources.actions.connectSource")); diff --git a/frontend/editor/src/portal/components/sources/ConnectWizard.tsx b/frontend/editor/src/portal/components/sources/ConnectWizard.tsx index 8683873316..2c6e9263b7 100644 --- a/frontend/editor/src/portal/components/sources/ConnectWizard.tsx +++ b/frontend/editor/src/portal/components/sources/ConnectWizard.tsx @@ -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 ?? "" })) } /> ) : ( diff --git a/frontend/editor/src/portal/components/users/InviteMemberModal.tsx b/frontend/editor/src/portal/components/users/InviteMemberModal.tsx index 5031b6427c..ad98077ba4 100644 --- a/frontend/editor/src/portal/components/users/InviteMemberModal.tsx +++ b/frontend/editor/src/portal/components/users/InviteMemberModal.tsx @@ -91,7 +91,7 @@ export function InviteMemberModal({ open, onClose }: InviteMemberModalProps) { - 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({ + onChange={(value) => setOutputNamePosition( - e.target.value as "prefix" | "suffix" | "auto-number", + (value ?? "suffix") as + | "prefix" + | "suffix" + | "auto-number", ) } aria-label={t( diff --git a/frontend/editor/src/proprietary/ui/ColorInput.tsx b/frontend/editor/src/proprietary/ui/ColorInput.tsx new file mode 100644 index 0000000000..6063ecf688 --- /dev/null +++ b/frontend/editor/src/proprietary/ui/ColorInput.tsx @@ -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; + onBlur?: React.FocusEventHandler; + + // 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 + * 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 ( + + ); +} diff --git a/frontend/editor/src/proprietary/ui/Forms.stories.tsx b/frontend/editor/src/proprietary/ui/Forms.stories.tsx index 10f4e0b1c3..0d14cf3392 100644 --- a/frontend/editor/src/proprietary/ui/Forms.stories.tsx +++ b/frontend/editor/src/proprietary/ui/Forms.stories.tsx @@ -73,23 +73,6 @@ export const Input_Error: Story = { ), }; -export const Select_Default: Story = { - render: () => ( - - setRetention(e.target.value)} + onChange={(value) => setRetention(value ?? "90")} options={[ { value: "30", label: "30 days" }, { value: "90", label: "90 days" }, diff --git a/frontend/editor/src/proprietary/ui/IconBadge.css b/frontend/editor/src/proprietary/ui/IconBadge.css index 10e2d28915..43d4eb0af9 100644 --- a/frontend/editor/src/proprietary/ui/IconBadge.css +++ b/frontend/editor/src/proprietary/ui/IconBadge.css @@ -34,3 +34,7 @@ .sui-iconbadge--red { --ib-base: var(--color-red); } +.sui-iconbadge--neutral { + --ib-base: var(--color-text-1); + background: none; +} diff --git a/frontend/editor/src/proprietary/ui/IconBadge.tsx b/frontend/editor/src/proprietary/ui/IconBadge.tsx index 9b421d47c5..ae6c5536d1 100644 --- a/frontend/editor/src/proprietary/ui/IconBadge.tsx +++ b/frontend/editor/src/proprietary/ui/IconBadge.tsx @@ -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; diff --git a/frontend/editor/src/proprietary/ui/MantineForms.css b/frontend/editor/src/proprietary/ui/MantineForms.css new file mode 100644 index 0000000000..66551aedf5 --- /dev/null +++ b/frontend/editor/src/proprietary/ui/MantineForms.css @@ -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); +} diff --git a/frontend/editor/src/proprietary/ui/MantineForms.stories.tsx b/frontend/editor/src/proprietary/ui/MantineForms.stories.tsx new file mode 100644 index 0000000000..dfa40644dd --- /dev/null +++ b/frontend/editor/src/proprietary/ui/MantineForms.stories.tsx @@ -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) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +// ─── MultiSelect ───────────────────────────────────────────────────────────── + +export const MultiSelect_Default: Story = { + render: () => { + function Bound() { + const [value, setValue] = useState([]); + return ( + + + + ); + } + return ; + }, +}; + +export const MultiSelect_WithValues: Story = { + render: () => { + function Bound() { + const [value, setValue] = useState(["ssn", "dob", "email"]); + return ( + + + + ); + } + return ; + }, +}; + +export const MultiSelect_SmSize: Story = { + render: () => { + function Bound() { + const [value, setValue] = useState(["ssn", "email"]); + return ( + + + + ); + } + return ; + }, +}; + +export const MultiSelect_Error: Story = { + render: () => ( + + {}} + placeholder="Choose types…" + invalid + /> + + ), +}; + +export const MultiSelect_Disabled: Story = { + render: () => ( + + {}} + disabled + /> + + ), +}; + +// ─── NumberInput ───────────────────────────────────────────────────────────── + +export const NumberInput_Default: Story = { + render: () => { + function Bound() { + const [value, setValue] = useState(100); + return ( + + + + ); + } + return ; + }, +}; + +export const NumberInput_Decimal: Story = { + render: () => { + function Bound() { + const [value, setValue] = useState(0.85); + return ( + + + + ); + } + return ; + }, +}; + +export const NumberInput_WithUnit: Story = { + render: () => { + function Bound() { + const [opacity, setOpacity] = useState(80); + const [fontSize, setFontSize] = useState(24); + return ( + + + + + + + + + ); + } + return ; + }, +}; + +export const NumberInput_SmSize: Story = { + render: () => { + function Bound() { + const [v, setV] = useState(12); + return ( + + + + ); + } + return ; + }, +}; + +export const NumberInput_Error: Story = { + render: () => ( + + {}} invalid /> + + ), +}; + +export const NumberInput_Disabled: Story = { + render: () => ( + + {}} disabled /> + + ), +}; + +// ─── ColorInput ────────────────────────────────────────────────────────────── + +export const ColorInput_Default: Story = { + render: () => { + function Bound() { + const [color, setColor] = useState(""); + return ( + + + + ); + } + return ; + }, +}; + +export const ColorInput_Preselected: Story = { + render: () => { + function Bound() { + const [color, setColor] = useState("#3B82F6"); + return ( + + + + ); + } + return ; + }, +}; + +export const ColorInput_SmSize: Story = { + render: () => { + function Bound() { + const [color, setColor] = useState("#EF4444"); + return ( + + + + ); + } + return ; + }, +}; + +export const ColorInput_Error: Story = { + render: () => ( + + {}} invalid /> + + ), +}; + +export const ColorInput_Disabled: Story = { + render: () => ( + + {}} disabled /> + + ), +}; + +// ─── 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("90"); + return ( + + + + ); + } + return ; + }, +}; + +export const Select_SmSize: Story = { + render: () => { + function Bound() { + const [value, setValue] = useState("upload"); + return ( + + {}} + placeholder="Choose…" + invalid + /> + + ), +}; + +export const Select_Disabled: Story = { + render: () => ( + + - {placeholder && ( - - )} - {options.map((opt) => ( - - ))} - - - - - - - - ); - }, -); +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";