diff --git a/apps/design-system/__registry__/index.tsx b/apps/design-system/__registry__/index.tsx index f8fbd00d211..e46c387c320 100644 --- a/apps/design-system/__registry__/index.tsx +++ b/apps/design-system/__registry__/index.tsx @@ -1831,6 +1831,28 @@ export const Index: Record = { subcategory: "undefined", chunks: [] }, + "form-patterns-pagelayout": { + name: "form-patterns-pagelayout", + type: "components:example", + registryDependencies: ["form","card","input","button","select","switch","checkbox","textarea","radio-group","calendar","popover","multi-select"], + component: React.lazy(() => import("@/registry/default/example/form-patterns-pagelayout")), + source: "", + files: ["registry/default/example/form-patterns-pagelayout.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, + "form-patterns-sidepanel": { + name: "form-patterns-sidepanel", + type: "components:example", + registryDependencies: ["form","sheet","input","button","select","switch","checkbox","textarea","radio-group","calendar","popover","multi-select"], + component: React.lazy(() => import("@/registry/default/example/form-patterns-sidepanel")), + source: "", + files: ["registry/default/example/form-patterns-sidepanel.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, "form-item-layout-with-select": { name: "form-item-layout-with-select", type: "components:example", diff --git a/apps/design-system/config/docs.ts b/apps/design-system/config/docs.ts index 098d67bb4cb..4c921fd1170 100644 --- a/apps/design-system/config/docs.ts +++ b/apps/design-system/config/docs.ts @@ -65,6 +65,11 @@ export const docsConfig: DocsConfig = { href: '/docs/ui-patterns/empty-states', items: [], }, + { + title: 'Forms', + href: '/docs/ui-patterns/forms', + items: [], + }, ], }, { diff --git a/apps/design-system/content/docs/components/table.mdx b/apps/design-system/content/docs/components/table.mdx index ab2cc6bf5dc..6dcafe2b601 100644 --- a/apps/design-system/content/docs/components/table.mdx +++ b/apps/design-system/content/docs/components/table.mdx @@ -48,6 +48,24 @@ import { ``` +## Sortable Columns + +Use `TableHeadSort` inside `TableHead` to enable column sorting. + +| Prop | Type | Description | +| -------------- | -------------------------- | ------------------------------------------------------------------ | +| `column` | `string` | Unique identifier for the column | +| `currentSort` | `string` | Current sort state in format `"column:order"` (e.g., `"name:asc"`) | +| `onSortChange` | `(column: string) => void` | Callback fired when column header is clicked | + +```tsx + + + Name + + +``` + ## Data Table You can use the `` component to build more complex data tables. Combine it with [@tanstack/react-table](https://tanstack.com/table/v8) to create tables with sorting, filtering and pagination. diff --git a/apps/design-system/content/docs/ui-patterns/forms.mdx b/apps/design-system/content/docs/ui-patterns/forms.mdx new file mode 100644 index 00000000000..eb71580c536 --- /dev/null +++ b/apps/design-system/content/docs/ui-patterns/forms.mdx @@ -0,0 +1,50 @@ +--- +title: Forms +description: Common form patterns used in Studio settings pages and side panels. +--- + +Forms in Supabase Studio should follow consistent patterns to ensure a cohesive user experience across settings pages and side panels. This guide covers the most common form patterns and field types. + +## Page Layout + +Forms in page layouts typically use `PageSection` components with `Card` containers. Fields use `FormItemLayout` with `layout="flex-row-reverse"` for horizontal alignment. + + + +## Side Panel + +Forms in side panels (Sheets) use `FormItemLayout` with `layout="horizontal"` on wider panels and `layout="vertical"` on panels with a size of `sm` or below. The form is typically wrapped in a `Sheet` component. + + + +## Best Practices + +1. **Always use FormItemLayout**: Use `FormItemLayout` instead of manually composing `FormItem`, `FormLabel`, `FormMessage`, and `FormDescription`. + +2. **Layout selection**: + + - Use `layout="flex-row-reverse"` for page layouts (horizontal alignment) + - Use `layout="horizontal"` for side panels with more width + - Use `layout="vertical"` for side panels with limited width + +3. **Wrap inputs in FormControl*Shadcn***: Always wrap form inputs with `FormControl_Shadcn_` to ensure proper form integration. + +4. **Use Cards for grouping**: Wrap form sections in `Card` components with `CardContent` and `CardFooter` for actions. + +5. **Handle dirty state**: Show cancel buttons and disable save buttons based on `form.formState.isDirty`. + +6. **Error handling**: Always use mutations with `onSuccess` and `onError` callbacks that show toast notifications. + +7. **Loading states**: Show loading states on submit buttons using the `loading` prop. + +8. **Form IDs**: When submit buttons are outside the form, use a form ID and reference it with the `form` prop on the button. diff --git a/apps/design-system/registry/default/example/dialog-demo.tsx b/apps/design-system/registry/default/example/dialog-demo.tsx index 68e81be28d5..3316817aea9 100644 --- a/apps/design-system/registry/default/example/dialog-demo.tsx +++ b/apps/design-system/registry/default/example/dialog-demo.tsx @@ -21,7 +21,7 @@ export default function DialogDemo() { Edit profile - Make changes to your profile here. Click save when you're done. + Make changes to your profile here. Click save when you're done. diff --git a/apps/design-system/registry/default/example/form-patterns-pagelayout.tsx b/apps/design-system/registry/default/example/form-patterns-pagelayout.tsx new file mode 100644 index 00000000000..71cf2850274 --- /dev/null +++ b/apps/design-system/registry/default/example/form-patterns-pagelayout.tsx @@ -0,0 +1,748 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { format } from 'date-fns' +import { CalendarIcon, ExternalLink, Plus, Trash2, Upload } from 'lucide-react' +import { useRef, useState } from 'react' +import { useFieldArray, useForm } from 'react-hook-form' +import * as z from 'zod' + +import { + Button, + Calendar, + Card, + CardContent, + CardFooter, + Checkbox_Shadcn_, + FormControl_Shadcn_, + FormField_Shadcn_, + Form_Shadcn_, + Input_Shadcn_, + PrePostTab, + Popover_Shadcn_, + PopoverContent_Shadcn_, + PopoverTrigger_Shadcn_, + RadioGroupStacked, + RadioGroupStackedItem, + Select_Shadcn_, + SelectContent_Shadcn_, + SelectItem_Shadcn_, + SelectTrigger_Shadcn_, + SelectValue_Shadcn_, + Separator, + Switch, + Textarea, +} from 'ui' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { Input } from 'ui-patterns/DataInputs/Input' +import { + MultiSelector, + MultiSelectorContent, + MultiSelectorItem, + MultiSelectorList, + MultiSelectorTrigger, +} from 'ui-patterns/multi-select' +import { + PageSection, + PageSectionContent, + PageSectionMeta, + PageSectionSummary, + PageSectionTitle, +} from 'ui-patterns/PageSection' + +const formSchema = z.object({ + name: z.string().min(1, 'Name is required'), + description: z.string().optional(), + maxConnections: z.number().min(1).max(1000), + enableFeature: z.boolean(), + enableRls: z.boolean(), + enableNotifications: z.boolean(), + enableAnalytics: z.boolean(), + region: z.string().min(1, 'Region is required'), + schemas: z.array(z.string()).min(1, 'At least one schema is required'), + queueType: z.enum(['basic', 'partitioned']), + expiryDate: z.date().optional(), + password: z.string().min(8, 'Password must be at least 8 characters'), + duration: z.number().min(5).max(30), + redirectUris: z.array(z.object({ value: z.string().url('Must be a valid URL') })), + apiKey: z.string().optional(), +}) + +const fakeApiKey = 'sk_live_51H3x4mpl3_4nd_53cur3_k3y_1234567890' + +export default function FormPatternsPageLayout() { + const uploadButtonRef = useRef(null) + const fileUploadRef = useRef(null) + const [logoFile, setLogoFile] = useState() + const [logoUrl, setLogoUrl] = useState() + const [uploadedFiles, setUploadedFiles] = useState([]) + const [isDragging, setIsDragging] = useState(false) + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + name: '', + description: '', + maxConnections: 10, + enableFeature: false, + enableRls: true, + enableNotifications: false, + enableAnalytics: true, + region: '', + schemas: ['public'], + queueType: 'basic', + expiryDate: undefined, + password: '', + duration: 10, + redirectUris: [{ value: '' }], + apiKey: fakeApiKey, + }, + }) + + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: 'redirectUris', + }) + + function onSubmit(values: z.infer) { + console.log(values) + } + + return ( +
+ + + + Form Settings + + + + +
+ + + {/* Text Input */} + ( + + + + + + )} + /> + + + + {/* Password Input */} + ( + + + + + + )} + /> + + + + {/* Copyable Input */} + ( + + + {}} + onCopy={() => console.log('Copied to clipboard')} + /> + + + )} + /> + + + + {/* Number Input */} + ( + + + field.onChange(Number(e.target.value))} + /> + + + )} + /> + + + + {/* Input with Units */} + ( + + + + + + + + )} + /> + + + + {/* Textarea */} + ( + + +