Skip to content

Commit 44becae

Browse files
authored
Add ShadCN primitives and showcase the design system at /components (#869)
### Summary & Motivation Introduce a complete `/components` design system reference page that renders every primitive in `@repo/ui` with live, interactive previews. The custom `SideMenu`, `AppLayout`, and `SidePane` are removed and rebuilt on top of ShadCN's `Sidebar` (ported to BaseUI), giving the application shell, the Account, Main, and Back-Office side menus, and the showcase page itself a single shared foundation. The shared `@repo/ui` library grows by roughly 30 new primitives — including charts, drawers, sliders, resizable splits, drop zones, hover cards, and command palettes — with rich keyboard support across the date inputs. - **`/components` showcase page** — A dedicated route under `application/main/WebApp/routes/components/` with live previews for every primitive: `AccordionPreview`, `AlertDialogsPreview`, `AlertsBadgesPreview`, `AspectRatioPreview`, `AvatarPreview`, `ButtonGroupPreview`, `ButtonsPreview`, `CardsPreview`, `ChartsPreview` (six chart variants), `CommandPreview`, `ControlsPreview`, `DateFormatPreview`, `DialogsPreview`, `DrawerPreview`, `DropzonePreview`, `EmptyPreview`, `ExamplesPreview`, `HoverCardPreview`, `InlineCalendarPreview`, `ItemPreview`, `KbdPreview`, `LinkPreview`, `NavigationMenuPreview`, `NavigationPreview`, `OverlaysPreview`, `ProgressPreview`, `ResizablePreview`, `SheetPreview`, `SidePanePreview`, `SidebarPreview`, `SkeletonPreview`, `SpinnerPreview`, `TablePreview`, `TabsPreview`, and `TogglesPreview`. Includes a dedicated mobile menu, settings flyouts, side menu with nested sub-items, and end-to-end recipe / dish examples (`RecipeEditorDialog`, `ShareRecipeDialog`, `DishDetailsSidePane`, `DishMultiSelectSidePane`, multi-step wizards) that exercise the primitives in real flows. - **App shell rebuilt on ShadCN `Sidebar`** — The 1250-line custom `SideMenu` (plus `useResponsiveMenu`, `useSideMenuLayout`, and the federated `MobileMenuContent`) is deleted. A new 1100-line `Sidebar.tsx` ported from ShadCN to BaseUI replaces it, with icon-collapsed mode, drag-to-resize on the rail with localStorage persistence, viewport-aware overlay mode, mobile sheet with floating hamburger trigger, single-expand coordination across collapsible groups, hover flyouts in collapsed mode, inert sub-menu animations, banner-aware top offset, and the standard `Cmd/Ctrl+B` toggle shortcut. `AccountSideMenu`, `MainSideMenu`, `BackOfficeSideMenu`, and the `/components` `ComponentsSideMenu` are ported to the new primitives. `AppLayout` is rewritten on `Sidebar` + `SidebarInset`, and `SidePane` becomes a fixed-docked right panel with overlay backdrop and auto-close on cross-route navigation. - **New layout primitives** — `Drawer` (vaul-based mobile bottom sheet with snap points and swipe-to-dismiss), `Sheet` (BaseUI side-anchored modal), `Resizable` (`react-resizable-panels` with horizontal/vertical and nested splits), `AspectRatio`, `Collapsible`, `Accordion`, `NavigationMenu` (marketing-style dropdown nav), and `Item`/`ItemGroup` (composable list-row primitive for settings, members, notifications). - **New form controls** — `Combobox` + `ComboboxField`, `MultiSelect`, `Slider` + `SliderField`, `Switch` + `SwitchField`, `Checkbox` + `CheckboxField`, `RadioGroup` + `RadioGroupField`, `Select` + `SelectField`, `Toggle` + `ToggleGroup` + `ToggleGroupField`, `NumberField` (locale decimal separator, stepper buttons, long-press repeat), `TimeField`, `TimeZonePicker`, `InputOtpField`. A shared `useFieldError` hook unifies error clearing across `FormValidationContext` and inline `errorMessage` props. `Field` is updated to contain BaseUI hidden inputs and stop the phantom document scroll. - **DatePicker, DateRangePicker, and Calendar** — Full keyboard input is now supported: typing characters into the date input runs through a locale-aware mask (`useDateField` / `useDateRangeField` / `dateFieldInternals`) that auto-inserts separators, validates partial input as the user types, and commits on blur or Enter. The mask is shared with the standalone `DateInput` (no popover) so all three components behave identically. `Calendar` is rewired with an explicit public API and unified shell dimensions, 44px cells, weekStartsOn=Monday, locale from app context, and arrow-key navigation. `DateRangePicker` accepts two synchronized inputs with the same mask. `useSmartDate` is extended with relative formatting helpers. - **Chart primitive** — A new `Chart.tsx` wraps `recharts` and exports `ChartContainer`, `ChartTooltip`, `ChartTooltipContent`, `ChartLegend`, `ChartLegendContent`, `ChartStyle`, and the `ChartConfig` type. Six chart variants are showcased: `ChartAreaInteractive`, `ChartBarInteractive`, `ChartBarStacked`, `ChartPieDonutText`, `ChartRadialShape`, `ChartRadialStacked`. - **Other primitives** — `HoverCard` (BaseUI PreviewCard for user mentions and link previews), `Command` (cmdk-based palette), `Dropzone` (`react-dropzone` with drag-and-drop, `accept`, `maxSize`, `maxFiles`, `multiple`, custom preview children), `Spinner` (lucide Loader2), `Progress` (linear determinate bar), `Kbd` (keyboard hint chip with `KbdGroup`), `ButtonGroup` (visually-grouped independent action buttons). `TenantLogoPicker` and `UserAvatarPicker` gain drag-and-drop via `Dropzone`. - **Table** — `stickyHeader` prop, roving-tabindex keyboard navigation (`selectedIndex` / `onNavigate` on `Table`, `index` on `TableRow`), Enter/Space dispatches click on focused row, hover suppression during keyboard navigation, expanded checkbox click target across the full column, restored selected-row contrast in light mode. - **Dialog and form pattern refactor** — Every dialog is split into a wrapper (owns only `isOpen`) and a body (owns state, mutation, form). The body lives inside `<DialogContent>` and unmounts on close, so state and mutation errors reset automatically with no `handleCloseComplete`, `mutation.reset()`, or `setIsFormDirty(false)` plumbing. Body signals dirtiness via `useDialogSetDirty()` from `DirtyDialogContext`. `DialogClose` with `type="reset"` bypasses the unsaved-changes warning. All in-tree dialogs (invite user, change role, billing info, edit billing info, account fields, user profile, etc.) are migrated to the pattern. Frontend rules in `.claude/rules/frontend/` are updated to document the wrapper/body split, validation behavior, and `useDialogSetDirty()` usage. - **Tokens and theming** — `theme.css` and `tailwind.css` add new control-height tokens (`--control-height`, `--control-height-sm`, `--control-height-xs`, `--control-height-lg`). The custom focus ring (`outline-ring` / `outline-primary` / `outline-destructive` with `focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2`) replaces ShadCN's default `focus-visible:ring-*`. Logo-mark refreshed to a rounded-square shape. Component inventory and divergence patterns are tracked centrally in `application/shared-webapp/ui/components/README.md`. - **Backend** — `IdGenerator.cs` falls back to a random generator ID when no IPv4 address is available. ### Checklist - [x] I have added tests, or done manual regression tests - [x] I have updated the documentation, if necessary
2 parents 60481f1 + 295d00b commit 44becae

249 files changed

Lines changed: 20992 additions & 3538 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/rules/frontend/form-with-validation.md

Lines changed: 56 additions & 139 deletions
Original file line numberDiff line numberDiff line change
@@ -5,166 +5,83 @@ description: Rules for forms with validation using ShadCN 2.0 components
55

66
# Form With Validation
77

8-
Guidelines for implementing forms with validation in the frontend, covering UI components, mutation handling, and validation error display.
8+
## Form wiring
99

10-
## Implementation
10+
- `api.useMutation` (or TanStack `useMutation` for multi-call flows) + `mutationSubmitter` on `<Form>`.
11+
- Server validation via `<Form validationErrors={mutation.error?.errors} validationBehavior="aria">`. Fields read their own errors from `FormValidationContext` by `name`.
12+
- Submit button: `disabled={mutation.isPending}` only. No client-side `isValid` gate — server validates.
13+
- If the backend can't return field-level errors (e.g. non-nullable `DateOnly` fails JSON deserialization on `""`), fix the backend: make it nullable + `NotNull` FluentValidation rule.
1114

12-
1. Use ShadCN components from `@repo/ui/components` for form elements
13-
2. Use `api.useMutation` or TanStack's `useMutation` for form submissions
14-
3. Use the custom `mutationSubmitter` to handle form submission and data mapping
15-
4. Handle validation errors using the `validationErrors` prop from the mutation error
16-
5. Show loading state in submit buttons using `disabled={mutation.isPending}`
17-
6. For complex scenarios with multiple API calls, create a custom mutation with a `mutationFn`
15+
## Dialog wrapper/body split
1816

19-
## Anti-patterns
17+
Every form dialog has two components in the same file:
2018

21-
- **Do NOT use `<FormErrorMessage>`** - This component is deprecated. Instead:
22-
- Use `validationErrors` prop on the `<Form>` to show field-level validation errors
23-
- Use toast notifications to display server errors (non-validation errors)
19+
- **Wrapper** (`XxxDialog`): receives `isOpen` / `onOpenChange`. Contains only `DirtyDialog` + `DialogContent` + `DialogHeader`. No state, no mutation, no dirty tracking.
20+
- **Body** (`XxxDialogBody`): child of `<DialogContent>`. Owns all state, mutation, `Form`, `DialogBody`, `DialogFooter`. Signals dirtiness via `useDialogSetDirty()` in field `onChange` handlers.
2421

25-
Note: All .NET API endpoints are available as strongly typed API contracts in the frontend—when compiling the .NET backend, an OpenApi.json file is generated and the frontend build uses `openapi-typescript` to generate the API contracts.
22+
**Why this split:** `DialogContent` unmounts its children on close, so the body is recreated on every open. Form state, mutation errors, dirty flag — all reset automatically because the components holding them no longer exist. No `handleCloseComplete`, no `mutation.reset()`, no `setIsFormDirty(false)` anywhere.
2623

27-
## Examples
24+
## DirtyDialog API
2825

29-
### Example 1 - Basic Form With Validation
26+
- Props: `open`, `onOpenChange`, `trackingTitle`, optional label overrides. That's it.
27+
- Body calls `useDialogSetDirty()(true)` on any field change. The wrapper tracks the flag internally and clears it when `open` flips false.
28+
- Cancel button: `<DialogClose render={<Button type="reset" ... />}>``type="reset"` bypasses the unsaved warning.
29+
- Close on success: call `onClose` passed from the wrapper. Do not reset anything manually.
3030

31-
```typescript
32-
// ✅ DO: Use mutationSubmitter and proper error handling
33-
import { api } from "@/shared/lib/api/client";
34-
import { mutationSubmitter } from "@repo/ui/forms/mutationSubmitter";
35-
import { Form, TextField, Button } from "@repo/ui/components";
36-
import { Trans } from "@lingui/react/macro";
31+
## Anti-patterns
3732

38-
export function UserProfileForm({ user }) {
39-
const updateUserMutation = api.useMutation("put", "/api/account/users/me");
40-
41-
return (
42-
<Form
43-
onSubmit={mutationSubmitter(updateUserMutation)}
44-
validationBehavior="aria"
45-
validationErrors={updateUserMutation.error?.errors}
46-
>
47-
<TextField
48-
autoFocus={true}
49-
isRequired={true}
50-
name="firstName"
51-
label={t`First name`}
52-
defaultValue={user?.firstName}
53-
placeholder={t`E.g., Alex`}
54-
/>
55-
<TextField
56-
isRequired={true}
57-
name="lastName"
58-
label={t`Last name`}
59-
defaultValue={user?.lastName}
60-
placeholder={t`E.g., Taylor`}
61-
/>
62-
63-
<TextField
64-
name="title"
65-
label={t`Title`}
66-
defaultValue={user?.title}
67-
/>
68-
69-
<Button type="submit" disabled={updateUserMutation.isPending}>
70-
{updateUserMutation.isPending ? <Trans>Saving...</Trans> : <Trans>Save changes</Trans>}
71-
</Button>
72-
</Form>
73-
);
74-
}
33+
- State (`useState`, `useMutation`, refs) in the wrapper — persists across close/reopen. Always in the body.
34+
- `isValid`-gated submit button — hides server errors from the user.
35+
- `handleCloseComplete` / `mutation.reset()` / `setIsFormDirty(false)` — symptoms of state in the wrong place.
36+
- `<FormErrorMessage>` — deprecated. Use `validationErrors`.
37+
38+
Note: .NET endpoints generate an `*.Api.json` on backend build; `openapi-typescript` turns it into `api.generated.d.ts`. Backend contract changes need both builds.
7539

76-
// ❌ DON'T: Use direct form submission without mutationSubmitter
77-
function BadUserProfileForm({ user }) {
78-
const [isLoading, setIsLoading] = useState(false);
79-
const [error, setError] = useState(null);
80-
81-
const handleSubmit = async (event) => {
82-
event.preventDefault();
83-
setIsLoading(true);
84-
85-
try {
86-
const formData = new FormData(event.target);
87-
const data = Object.fromEntries(formData.entries());
88-
89-
await fetch("/api/account/users/me", {
90-
method: "PUT",
91-
headers: { "Content-Type": "application/json" },
92-
body: JSON.stringify(data)
93-
});
94-
} catch (err) {
95-
setError(err);
96-
} finally {
97-
setIsLoading(false);
98-
}
99-
};
100-
40+
## Example
41+
42+
```tsx
43+
export function InviteUserDialog({ isOpen, onOpenChange }: Readonly<Props>) {
44+
const handleClose = () => onOpenChange(false);
10145
return (
102-
<Form onSubmit={handleSubmit}>
103-
{/* Missing proper validation and error handling */}
104-
<TextField name="firstName" defaultValue={user?.firstName} isRequired />
105-
<TextField name="lastName" defaultValue={user?.lastName} isRequired />
106-
<TextField name="title" defaultValue={user?.title} />
107-
108-
<Button type="submit" disabled={isLoading}>
109-
{isLoading ? <Trans>Saving...</Trans> : <Trans>Save changes</Trans>}
110-
</Button>
111-
</Form>
46+
<DirtyDialog open={isOpen} onOpenChange={onOpenChange} trackingTitle="Invite user">
47+
<DialogContent className="sm:w-dialog-md">
48+
<DialogHeader>
49+
<DialogTitle><Trans>Invite user</Trans></DialogTitle>
50+
</DialogHeader>
51+
<InviteUserDialogBody onClose={handleClose} />
52+
</DialogContent>
53+
</DirtyDialog>
11254
);
11355
}
114-
```
11556

116-
### Example 2 - Complex Form With Multiple APIs Calls
117-
118-
```typescript
119-
// ✅ DO: Use custom mutation for complex scenarios
120-
export function UserProfileWithAvatarForm({ user, onSuccess, onClose }) {
121-
const [selectedAvatarFile, setSelectedAvatarFile] = useState(null);
122-
const [removeAvatar, setRemoveAvatar] = useState(false);
123-
124-
const updateUserMutation = api.useMutation("put", "/api/account/users/me");
125-
const updateAvatarMutation = api.useMutation("post", "/api/account/users/me/avatar");
126-
const removeAvatarMutation = api.useMutation("delete", "/api/account/users/me/avatar");
127-
128-
const queryClient = useQueryClient();
129-
130-
// Complex mutation with multiple API calls
131-
const saveMutation = useMutation({
132-
mutationFn: async (data) => {
133-
// First API call - upload avatar if selected
134-
if (selectedAvatarFile) {
135-
const formData = new FormData();
136-
formData.append("file", selectedAvatarFile);
137-
await updateAvatarMutation.mutateAsync({ body: formData });
138-
}
139-
140-
// Second API call - remove avatar if requested
141-
else if (removeAvatar) {
142-
await removeAvatarMutation.mutateAsync({});
143-
}
144-
145-
// Third API call - update user data
146-
return await updateUserMutation.mutateAsync(data);
147-
},
148-
onSuccess: () => {
149-
queryClient.invalidateQueries();
150-
onSuccess?.();
151-
onClose?.();
152-
}
57+
function InviteUserDialogBody({ onClose }: { onClose: () => void }) {
58+
const setDirty = useDialogSetDirty();
59+
const inviteMutation = api.useMutation("post", "/api/account/users/invite", {
60+
onSuccess: () => { toast.success(t`User invited`); onClose(); }
15361
});
154-
62+
15563
return (
15664
<Form
157-
onSubmit={mutationSubmitter(saveMutation)}
65+
onSubmit={mutationSubmitter(inviteMutation)}
66+
validationErrors={inviteMutation.error?.errors}
15867
validationBehavior="aria"
159-
validationErrors={saveMutation.error?.errors || updateUserMutation.error?.errors}
16068
>
161-
{/* Form fields */}
162-
163-
<Button type="submit" disabled={saveMutation.isPending}>
164-
{saveMutation.isPending ? <Trans>Saving...</Trans> : <Trans>Save changes</Trans>}
165-
</Button>
69+
<DialogBody>
70+
<TextField autoFocus name="email" label={t`Email`} onChange={() => setDirty(true)} />
71+
</DialogBody>
72+
<DialogFooter>
73+
<DialogClose render={<Button type="reset" variant="secondary" disabled={inviteMutation.isPending} />}>
74+
<Trans>Cancel</Trans>
75+
</DialogClose>
76+
<Button type="submit" disabled={inviteMutation.isPending}>
77+
{inviteMutation.isPending ? <Trans>Sending...</Trans> : <Trans>Send invite</Trans>}
78+
</Button>
79+
</DialogFooter>
16680
</Form>
16781
);
16882
}
16983
```
17084

85+
Multi-step dialogs: wizard state (`step`, intermediate values) also lives in the body — unmount resets the wizard to step 0 on reopen.
86+
87+
Multi-call submits: compose `api.useMutation` calls inside a TanStack `useMutation({ mutationFn: async (d) => { ... } })`, pass its `error?.errors` into `<Form validationErrors>`.

0 commit comments

Comments
 (0)