mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
## 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.
101 lines
2.9 KiB
TypeScript
101 lines
2.9 KiB
TypeScript
import { useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Button, FormField, Input, Modal, Select } from "@app/ui";
|
|
import { type RoleId, ROLES } from "@portal/api/users";
|
|
import "@portal/views/Users.css";
|
|
|
|
interface InviteMemberModalProps {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
}
|
|
|
|
const ROLE_SELECT_OPTIONS = ROLES.map((r) => ({
|
|
value: r.id,
|
|
label: r.label,
|
|
}));
|
|
|
|
/** Org Owner is reserved for transfer flows — invites default to Developer. */
|
|
const DEFAULT_ROLE: RoleId = "developer";
|
|
|
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
|
|
/**
|
|
* Invite-by-email shell. Submitting validates locally then closes without
|
|
* sending — wiring the submit to the backend dispatches the invitation.
|
|
*/
|
|
export function InviteMemberModal({ open, onClose }: InviteMemberModalProps) {
|
|
const { t } = useTranslation();
|
|
const [email, setEmail] = useState("");
|
|
const [role, setRole] = useState<RoleId>(DEFAULT_ROLE);
|
|
const [touched, setTouched] = useState(false);
|
|
|
|
const emailValid = EMAIL_RE.test(email.trim());
|
|
const error =
|
|
touched && !emailValid ? t("portal.users.invite.emailError") : undefined;
|
|
|
|
function close() {
|
|
onClose();
|
|
// Reset for the next open, after the close transition has finished.
|
|
setTimeout(() => {
|
|
setEmail("");
|
|
setRole(DEFAULT_ROLE);
|
|
setTouched(false);
|
|
}, 200);
|
|
}
|
|
|
|
function submit() {
|
|
setTouched(true);
|
|
if (!emailValid) return;
|
|
// TODO(backend): POST /v1/users/invitations { email, role } — send the
|
|
// invite, then close on success and refetch the members list.
|
|
close();
|
|
}
|
|
|
|
return (
|
|
<Modal
|
|
open={open}
|
|
onClose={close}
|
|
width="sm"
|
|
title={t("portal.common.inviteMember")}
|
|
subtitle={t("portal.users.invite.subtitle")}
|
|
footer={
|
|
<div className="portal-users__modal-footer">
|
|
<Button variant="ghost" size="sm" onClick={close}>
|
|
{t("portal.users.invite.cancel")}
|
|
</Button>
|
|
<Button size="sm" onClick={submit} disabled={touched && !emailValid}>
|
|
{t("portal.users.invite.send")}
|
|
</Button>
|
|
</div>
|
|
}
|
|
>
|
|
<div className="portal-users__invite-body">
|
|
<FormField
|
|
label={t("portal.users.invite.email")}
|
|
error={error}
|
|
required
|
|
>
|
|
<Input
|
|
type="email"
|
|
placeholder={t("portal.users.invite.emailPlaceholder")}
|
|
value={email}
|
|
invalid={!!error}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
onBlur={() => setTouched(true)}
|
|
/>
|
|
</FormField>
|
|
<FormField
|
|
label={t("portal.users.invite.role")}
|
|
helperText={t("portal.users.invite.roleHelper")}
|
|
>
|
|
<Select
|
|
options={ROLE_SELECT_OPTIONS}
|
|
value={role}
|
|
onChange={(value) => setRole((value ?? "") as RoleId)}
|
|
/>
|
|
</FormField>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|