@windforge/ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/index.d.ts +1195 -0
  2. package/dist/index.js +3628 -0
  3. package/package.json +66 -0
  4. package/src/catalog.ts +654 -0
  5. package/src/components/accordion.tsx +91 -0
  6. package/src/components/alert.tsx +58 -0
  7. package/src/components/autocomplete.tsx +174 -0
  8. package/src/components/avatar.tsx +60 -0
  9. package/src/components/badge.tsx +37 -0
  10. package/src/components/breadcrumb.tsx +62 -0
  11. package/src/components/button-group.tsx +23 -0
  12. package/src/components/button.tsx +53 -0
  13. package/src/components/calendar.tsx +61 -0
  14. package/src/components/card.tsx +72 -0
  15. package/src/components/chart.tsx +130 -0
  16. package/src/components/checkbox.tsx +27 -0
  17. package/src/components/chip.tsx +75 -0
  18. package/src/components/code-block.tsx +126 -0
  19. package/src/components/command.tsx +139 -0
  20. package/src/components/data-table.tsx +194 -0
  21. package/src/components/date-picker.tsx +77 -0
  22. package/src/components/dialog.tsx +57 -0
  23. package/src/components/dropdown-menu.tsx +186 -0
  24. package/src/components/form-field.tsx +97 -0
  25. package/src/components/input.tsx +29 -0
  26. package/src/components/label.tsx +18 -0
  27. package/src/components/layout.tsx +179 -0
  28. package/src/components/link.tsx +37 -0
  29. package/src/components/modal.tsx +67 -0
  30. package/src/components/multi-select.tsx +175 -0
  31. package/src/components/pagination.tsx +72 -0
  32. package/src/components/popover.tsx +25 -0
  33. package/src/components/progress.tsx +31 -0
  34. package/src/components/radio-group.tsx +34 -0
  35. package/src/components/select.tsx +134 -0
  36. package/src/components/separator.tsx +21 -0
  37. package/src/components/sheet.tsx +80 -0
  38. package/src/components/skeleton.tsx +11 -0
  39. package/src/components/slider.tsx +28 -0
  40. package/src/components/stepper.tsx +69 -0
  41. package/src/components/switch.tsx +33 -0
  42. package/src/components/table.tsx +121 -0
  43. package/src/components/tabs.tsx +90 -0
  44. package/src/components/text.tsx +109 -0
  45. package/src/components/textarea.tsx +27 -0
  46. package/src/components/toast.tsx +107 -0
  47. package/src/components/toggle-button.tsx +103 -0
  48. package/src/components/tooltip.tsx +26 -0
  49. package/src/icons/forge-icon.tsx +55 -0
  50. package/src/icons/icon-set.ts +60 -0
  51. package/src/icons/svg-icon.tsx +43 -0
  52. package/src/index.ts +80 -0
  53. package/src/layouts/app-bar.tsx +95 -0
  54. package/src/layouts/app-shell.tsx +80 -0
  55. package/src/layouts/side-nav.tsx +196 -0
  56. package/src/layouts/theme-provider.tsx +128 -0
  57. package/src/lib/recipes.ts +50 -0
  58. package/src/lib/types.ts +3 -0
  59. package/src/lib/use-media-query.ts +18 -0
  60. package/src/lib/utils.ts +10 -0
  61. package/tailwind-preset.cjs +77 -0
@@ -0,0 +1,194 @@
1
+ import * as React from 'react'
2
+ import { ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react'
3
+ import { cn } from '../lib/utils'
4
+ import {
5
+ Table, TableHeader, TableBody, TableRow, TableHead, TableCell,
6
+ } from './table'
7
+ import { Checkbox } from './checkbox'
8
+ import { Pagination } from './pagination'
9
+
10
+ /**
11
+ * DataTable — the batteries-included table: column sorting, row selection, and
12
+ * optional client-side pagination, built on the Table primitives so it stays
13
+ * on-system. Pass `columns` + `data` + a `rowKey`; opt into `selectable` and
14
+ * `pageSize`. For a purely presentational table, use Table directly.
15
+ *
16
+ * <DataTable rowKey={(r) => r.id} selectable pageSize={10}
17
+ * columns={[
18
+ * { header: 'Name', accessor: 'name', sortable: true },
19
+ * { header: 'Amount', accessor: 'amount', align: 'right', sortable: true },
20
+ * ]}
21
+ * data={rows}
22
+ * />
23
+ */
24
+ type Align = 'left' | 'center' | 'right'
25
+
26
+ export interface DataTableColumn<Row> {
27
+ header: React.ReactNode
28
+ /** A row key, or a render function returning the cell content. */
29
+ accessor: keyof Row | ((row: Row) => React.ReactNode)
30
+ align?: Align
31
+ sortable?: boolean
32
+ /** Sort value when `accessor` is a render function (or to sort by something else). */
33
+ sortAccessor?: (row: Row) => string | number
34
+ }
35
+
36
+ export interface DataTableProps<Row> {
37
+ columns: DataTableColumn<Row>[]
38
+ data: readonly Row[]
39
+ /** Stable id per row — required for selection and React keys. */
40
+ rowKey: (row: Row) => string
41
+ caption?: React.ReactNode
42
+ selectable?: boolean
43
+ /** Controlled selection (ids). Omit for uncontrolled. */
44
+ selected?: string[]
45
+ onSelectedChange?: (ids: string[]) => void
46
+ /** Enable client-side pagination at this page size. */
47
+ pageSize?: number
48
+ /** Empty-state content when there are no rows. */
49
+ emptyState?: React.ReactNode
50
+ }
51
+
52
+ type SortState = { index: number; dir: 'asc' | 'desc' } | null
53
+
54
+ function sortValue<Row>(col: DataTableColumn<Row>, row: Row): string | number {
55
+ if (col.sortAccessor) return col.sortAccessor(row)
56
+ if (typeof col.accessor !== 'function') {
57
+ const v = row[col.accessor]
58
+ return typeof v === 'number' ? v : String(v ?? '')
59
+ }
60
+ return ''
61
+ }
62
+
63
+ export function DataTable<Row>({
64
+ columns, data, rowKey, caption, selectable, selected, onSelectedChange, pageSize, emptyState,
65
+ }: DataTableProps<Row>) {
66
+ const [sort, setSort] = React.useState<SortState>(null)
67
+ const [page, setPage] = React.useState(1)
68
+ const [internalSel, setInternalSel] = React.useState<string[]>([])
69
+ const sel = selected ?? internalSel
70
+ const setSel = (ids: string[]) => {
71
+ onSelectedChange?.(ids)
72
+ if (selected === undefined) setInternalSel(ids)
73
+ }
74
+
75
+ const sorted = React.useMemo(() => {
76
+ if (!sort) return [...data]
77
+ const col = columns[sort.index]
78
+ const dir = sort.dir === 'asc' ? 1 : -1
79
+ return [...data].sort((a, b) => {
80
+ const av = sortValue(col, a)
81
+ const bv = sortValue(col, b)
82
+ if (av < bv) return -1 * dir
83
+ if (av > bv) return 1 * dir
84
+ return 0
85
+ })
86
+ }, [data, sort, columns])
87
+
88
+ const pageCount = pageSize ? Math.max(1, Math.ceil(sorted.length / pageSize)) : 1
89
+ const current = Math.min(page, pageCount)
90
+ const rows = pageSize ? sorted.slice((current - 1) * pageSize, current * pageSize) : sorted
91
+
92
+ const pageIds = rows.map(rowKey)
93
+ const allOnPageSelected = pageIds.length > 0 && pageIds.every((id) => sel.includes(id))
94
+ const toggleAll = () =>
95
+ setSel(allOnPageSelected ? sel.filter((id) => !pageIds.includes(id)) : [...new Set([...sel, ...pageIds])])
96
+ const toggleRow = (id: string) =>
97
+ setSel(sel.includes(id) ? sel.filter((x) => x !== id) : [...sel, id])
98
+
99
+ const toggleSort = (index: number) =>
100
+ setSort((s) =>
101
+ s?.index !== index ? { index, dir: 'asc' } : s.dir === 'asc' ? { index, dir: 'desc' } : null,
102
+ )
103
+
104
+ const colCount = columns.length + (selectable ? 1 : 0)
105
+
106
+ return (
107
+ <div className="flex flex-col gap-3">
108
+ <Table caption={caption}>
109
+ <TableHeader>
110
+ <TableRow>
111
+ {selectable && (
112
+ <TableHead align="center">
113
+ <Checkbox
114
+ checked={allOnPageSelected}
115
+ onCheckedChange={toggleAll}
116
+ aria-label="Select all rows on this page"
117
+ />
118
+ </TableHead>
119
+ )}
120
+ {columns.map((col, i) => {
121
+ const active = sort?.index === i
122
+ const SortIcon = !active ? ChevronsUpDown : sort!.dir === 'asc' ? ChevronUp : ChevronDown
123
+ return (
124
+ <TableHead key={i} align={col.align}>
125
+ {col.sortable ? (
126
+ <button
127
+ type="button"
128
+ onClick={() => toggleSort(i)}
129
+ aria-label={`Sort by ${typeof col.header === 'string' ? col.header : 'column'}`}
130
+ className={cn(
131
+ 'inline-flex items-center gap-1 rounded-sm transition-colors hover:text-primary',
132
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
133
+ active ? 'text-primary' : 'text-tertiary',
134
+ )}
135
+ >
136
+ {col.header}
137
+ <SortIcon className="size-3.5 shrink-0" aria-hidden="true" />
138
+ </button>
139
+ ) : (
140
+ col.header
141
+ )}
142
+ </TableHead>
143
+ )
144
+ })}
145
+ </TableRow>
146
+ </TableHeader>
147
+ <TableBody>
148
+ {rows.length === 0 ? (
149
+ <TableRow>
150
+ <TableCell align="center" colSpan={colCount}>
151
+ <div className="py-6 text-sm text-secondary">{emptyState ?? 'No results.'}</div>
152
+ </TableCell>
153
+ </TableRow>
154
+ ) : (
155
+ rows.map((row) => {
156
+ const id = rowKey(row)
157
+ const isSelected = sel.includes(id)
158
+ return (
159
+ <TableRow key={id} data-state={isSelected ? 'selected' : undefined}>
160
+ {selectable && (
161
+ <TableCell align="center">
162
+ <Checkbox
163
+ checked={isSelected}
164
+ onCheckedChange={() => toggleRow(id)}
165
+ aria-label="Select row"
166
+ />
167
+ </TableCell>
168
+ )}
169
+ {columns.map((col, c) => (
170
+ <TableCell key={c} align={col.align}>
171
+ {typeof col.accessor === 'function'
172
+ ? col.accessor(row)
173
+ : (row[col.accessor] as React.ReactNode)}
174
+ </TableCell>
175
+ ))}
176
+ </TableRow>
177
+ )
178
+ })
179
+ )}
180
+ </TableBody>
181
+ </Table>
182
+
183
+ {pageSize && pageCount > 1 && (
184
+ <div className="flex items-center justify-between">
185
+ <span className="text-sm text-secondary">
186
+ {selectable && sel.length > 0 ? `${sel.length} selected · ` : ''}
187
+ {sorted.length} {sorted.length === 1 ? 'row' : 'rows'}
188
+ </span>
189
+ <Pagination page={current} count={pageCount} onPageChange={setPage} />
190
+ </div>
191
+ )}
192
+ </div>
193
+ )
194
+ }
@@ -0,0 +1,77 @@
1
+ import * as React from 'react'
2
+ import * as PopoverPrimitive from '@radix-ui/react-popover'
3
+ import { Calendar as CalendarIcon } from 'lucide-react'
4
+ import { Popover, PopoverTrigger } from './popover'
5
+ import { Calendar } from './calendar'
6
+ import { cn } from '../lib/utils'
7
+ import { focusRingField, floatingPanel } from '../lib/recipes'
8
+
9
+ /**
10
+ * DatePicker — a single-date field: a token-styled trigger that opens the Calendar
11
+ * in a Popover. Controlled via `value`/`onValueChange`. For ranges or multi-select,
12
+ * use the Calendar directly inside a Popover.
13
+ *
14
+ * <DatePicker value={date} onValueChange={setDate} />
15
+ */
16
+ export interface DatePickerProps {
17
+ value?: Date
18
+ onValueChange?: (date?: Date) => void
19
+ placeholder?: string
20
+ disabled?: boolean
21
+ invalid?: boolean
22
+ id?: string
23
+ 'aria-describedby'?: string
24
+ /** Intl format for the displayed value. Default { dateStyle: 'medium' }. */
25
+ formatOptions?: Intl.DateTimeFormatOptions
26
+ }
27
+
28
+ export function DatePicker({
29
+ value, onValueChange, placeholder = 'Pick a date', disabled, invalid, id,
30
+ 'aria-describedby': ariaDescribedBy, formatOptions = { dateStyle: 'medium' },
31
+ }: DatePickerProps) {
32
+ const [open, setOpen] = React.useState(false)
33
+ const label = value ? new Intl.DateTimeFormat(undefined, formatOptions).format(value) : null
34
+
35
+ return (
36
+ <Popover open={open} onOpenChange={setOpen}>
37
+ <PopoverTrigger asChild>
38
+ <button
39
+ type="button"
40
+ id={id}
41
+ disabled={disabled}
42
+ aria-invalid={invalid || undefined}
43
+ aria-describedby={ariaDescribedBy}
44
+ className={cn(
45
+ 'flex h-10 w-full items-center gap-2 rounded-lg border border-strong bg-surface px-3 text-left text-sm',
46
+ 'transition-colors hover:bg-surface-subtle',
47
+ focusRingField,
48
+ 'disabled:cursor-not-allowed disabled:opacity-50',
49
+ invalid && 'border-error focus-visible:border-error',
50
+ )}
51
+ >
52
+ <CalendarIcon className="size-4 shrink-0 text-tertiary" />
53
+ <span className={cn('flex-1', label ? 'text-primary' : 'text-tertiary')}>
54
+ {label ?? placeholder}
55
+ </span>
56
+ </button>
57
+ </PopoverTrigger>
58
+ <PopoverPrimitive.Portal>
59
+ <PopoverPrimitive.Content
60
+ align="start"
61
+ sideOffset={6}
62
+ className={cn(floatingPanel, 'w-auto rounded-xl shadow-lg animate-scale-in')}
63
+ >
64
+ <Calendar
65
+ mode="single"
66
+ selected={value}
67
+ onSelect={(date) => {
68
+ onValueChange?.(date)
69
+ setOpen(false)
70
+ }}
71
+ autoFocus
72
+ />
73
+ </PopoverPrimitive.Content>
74
+ </PopoverPrimitive.Portal>
75
+ </Popover>
76
+ )
77
+ }
@@ -0,0 +1,57 @@
1
+ import * as React from 'react'
2
+ import * as DialogPrimitive from '@radix-ui/react-dialog'
3
+ import { Modal, ModalTrigger, ModalContent, type ModalContentProps } from './modal'
4
+ import { cn } from '../lib/utils'
5
+
6
+ /**
7
+ * Dialog — the opinionated, props-based convenience built on `Modal`. Pass
8
+ * `title`, `description`, and `actions` (buttons) as nodes; body goes in
9
+ * `children`. There are no header/title/footer sub-components to assemble — for
10
+ * full control over structure, drop down to the `Modal` primitives directly.
11
+ *
12
+ * <Dialog
13
+ * trigger={<Button>Delete</Button>}
14
+ * title="Delete project?"
15
+ * description="This can't be undone."
16
+ * actions={<><Button variant="secondary">Cancel</Button><Button variant="destructive">Delete</Button></>}
17
+ * />
18
+ */
19
+ export interface DialogProps {
20
+ title: React.ReactNode
21
+ description?: React.ReactNode
22
+ /** Footer content — typically buttons. */
23
+ actions?: React.ReactNode
24
+ /** The element that opens the dialog. Omit for fully controlled use. */
25
+ trigger?: React.ReactNode
26
+ children?: React.ReactNode
27
+ size?: ModalContentProps['size']
28
+ open?: boolean
29
+ defaultOpen?: boolean
30
+ onOpenChange?: (open: boolean) => void
31
+ }
32
+
33
+ export function Dialog({
34
+ title, description, actions, trigger, children, size, open, defaultOpen, onOpenChange,
35
+ }: DialogProps) {
36
+ return (
37
+ <Modal open={open} defaultOpen={defaultOpen} onOpenChange={onOpenChange}>
38
+ {trigger && <ModalTrigger asChild>{trigger}</ModalTrigger>}
39
+ <ModalContent size={size}>
40
+ <div className={cn('flex flex-col gap-1.5 text-left')}>
41
+ <DialogPrimitive.Title className={cn('text-lg font-semibold leading-snug tracking-tight')}>
42
+ {title}
43
+ </DialogPrimitive.Title>
44
+ {description && (
45
+ <DialogPrimitive.Description className={cn('text-sm text-primary')}>
46
+ {description}
47
+ </DialogPrimitive.Description>
48
+ )}
49
+ </div>
50
+ {children}
51
+ {actions && (
52
+ <div className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end')}>{actions}</div>
53
+ )}
54
+ </ModalContent>
55
+ </Modal>
56
+ )
57
+ }
@@ -0,0 +1,186 @@
1
+ import * as React from 'react'
2
+ import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
3
+ import { Check, ChevronRight, Circle } from 'lucide-react'
4
+ import { cn } from '../lib/utils'
5
+ import type { NoStyle } from '../lib/types'
6
+ import { floatingPanel, menuItem } from '../lib/recipes'
7
+
8
+ export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
9
+ export const DropdownMenuGroup = DropdownMenuPrimitive.Group
10
+ export const DropdownMenuPortal = DropdownMenuPrimitive.Portal
11
+ export const DropdownMenuSub = DropdownMenuPrimitive.Sub
12
+ export const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
13
+
14
+ export const DropdownMenuContent = React.forwardRef<
15
+ React.ElementRef<typeof DropdownMenuPrimitive.Content>,
16
+ NoStyle<React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>>
17
+ >(({ sideOffset = 6, ...props }, ref) => (
18
+ <DropdownMenuPrimitive.Portal>
19
+ <DropdownMenuPrimitive.Content
20
+ ref={ref}
21
+ sideOffset={sideOffset}
22
+ className={cn(floatingPanel, 'min-w-48 overflow-hidden p-1 animate-scale-in')}
23
+ {...props}
24
+ />
25
+ </DropdownMenuPrimitive.Portal>
26
+ ))
27
+ DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
28
+
29
+ export const DropdownMenuItem = React.forwardRef<
30
+ React.ElementRef<typeof DropdownMenuPrimitive.Item>,
31
+ NoStyle<React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>> & { inset?: boolean }
32
+ >(({ inset, ...props }, ref) => (
33
+ <DropdownMenuPrimitive.Item
34
+ ref={ref}
35
+ className={cn(
36
+ menuItem,
37
+ 'gap-2 px-2 py-1.5 focus:bg-surface-inset focus:text-primary [&_svg]:size-4',
38
+ inset && 'pl-8',
39
+ )}
40
+ {...props}
41
+ />
42
+ ))
43
+ DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
44
+
45
+ export const DropdownMenuCheckboxItem = React.forwardRef<
46
+ React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
47
+ NoStyle<React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>>
48
+ >(({ children, checked, ...props }, ref) => (
49
+ <DropdownMenuPrimitive.CheckboxItem
50
+ ref={ref}
51
+ checked={checked}
52
+ className={cn(menuItem, 'py-1.5 pl-8 pr-2 focus:bg-surface-inset focus:text-primary')}
53
+ {...props}
54
+ >
55
+ <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
56
+ <DropdownMenuPrimitive.ItemIndicator>
57
+ <Check className="h-4 w-4" />
58
+ </DropdownMenuPrimitive.ItemIndicator>
59
+ </span>
60
+ {children}
61
+ </DropdownMenuPrimitive.CheckboxItem>
62
+ ))
63
+ DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName
64
+
65
+ export const DropdownMenuRadioItem = React.forwardRef<
66
+ React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
67
+ NoStyle<React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>>
68
+ >(({ children, ...props }, ref) => (
69
+ <DropdownMenuPrimitive.RadioItem
70
+ ref={ref}
71
+ className={cn(menuItem, 'py-1.5 pl-8 pr-2 focus:bg-surface-inset focus:text-primary')}
72
+ {...props}
73
+ >
74
+ <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
75
+ <DropdownMenuPrimitive.ItemIndicator>
76
+ <Circle className="h-2 w-2 fill-current" />
77
+ </DropdownMenuPrimitive.ItemIndicator>
78
+ </span>
79
+ {children}
80
+ </DropdownMenuPrimitive.RadioItem>
81
+ ))
82
+ DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
83
+
84
+ export const DropdownMenuLabel = React.forwardRef<
85
+ React.ElementRef<typeof DropdownMenuPrimitive.Label>,
86
+ NoStyle<React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label>> & { inset?: boolean }
87
+ >(({ inset, ...props }, ref) => (
88
+ <DropdownMenuPrimitive.Label
89
+ ref={ref}
90
+ className={cn('px-2 py-1.5 text-sm font-semibold text-tertiary', inset && 'pl-8')}
91
+ {...props}
92
+ />
93
+ ))
94
+ DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
95
+
96
+ export const DropdownMenuSeparator = React.forwardRef<
97
+ React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
98
+ NoStyle<React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>>
99
+ >(({ ...props }, ref) => (
100
+ <DropdownMenuPrimitive.Separator ref={ref} className={cn('-mx-1 my-1 h-px bg-border')} {...props} />
101
+ ))
102
+ DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
103
+
104
+ export function DropdownMenuShortcut({ ...props }: NoStyle<React.HTMLAttributes<HTMLSpanElement>>) {
105
+ return <span className={cn('ml-auto text-sm tracking-widest text-tertiary')} {...props} />
106
+ }
107
+
108
+ export const DropdownMenuSubTrigger = React.forwardRef<
109
+ React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
110
+ NoStyle<React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger>> & { inset?: boolean }
111
+ >(({ inset, children, ...props }, ref) => (
112
+ <DropdownMenuPrimitive.SubTrigger
113
+ ref={ref}
114
+ className={cn(menuItem, 'px-2 py-1.5 focus:bg-surface-inset focus:text-primary', inset && 'pl-8')}
115
+ {...props}
116
+ >
117
+ {children}
118
+ <ChevronRight className="ml-auto h-4 w-4" />
119
+ </DropdownMenuPrimitive.SubTrigger>
120
+ ))
121
+ DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
122
+
123
+ export const DropdownMenuSubContent = React.forwardRef<
124
+ React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
125
+ NoStyle<React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>>
126
+ >(({ ...props }, ref) => (
127
+ <DropdownMenuPrimitive.SubContent
128
+ ref={ref}
129
+ className={cn(floatingPanel, 'min-w-32 overflow-hidden p-1 animate-scale-in')}
130
+ {...props}
131
+ />
132
+ ))
133
+ DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName
134
+
135
+ /**
136
+ * DropdownMenu — pass a `trigger` and an `items` array for the common case (the
137
+ * component renders the content, items, labels, and separators), or compose the
138
+ * primitives (`DropdownMenuTrigger`/`DropdownMenuContent`/…) by hand for
139
+ * submenus, checkbox/radio groups, and other advanced layouts.
140
+ *
141
+ * <DropdownMenu trigger={<Button>Open</Button>} items={[
142
+ * { label: 'Profile', icon: <User />, shortcut: '⇧⌘P', onSelect: goProfile },
143
+ * { type: 'separator' },
144
+ * { label: 'Log out', onSelect: logout },
145
+ * ]} />
146
+ */
147
+ export type DropdownMenuItemData =
148
+ | {
149
+ type?: 'item'
150
+ label: React.ReactNode
151
+ icon?: React.ReactNode
152
+ shortcut?: string
153
+ onSelect?: () => void
154
+ disabled?: boolean
155
+ }
156
+ | { type: 'separator' }
157
+ | { type: 'label'; label: React.ReactNode }
158
+
159
+ export interface DropdownMenuProps extends React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Root> {
160
+ /** The element that opens the menu (rendered via asChild). */
161
+ trigger?: React.ReactNode
162
+ /** Declarative items. Omit to compose the primitives as children instead. */
163
+ items?: DropdownMenuItemData[]
164
+ }
165
+
166
+ export function DropdownMenu({ trigger, items, children, ...props }: DropdownMenuProps) {
167
+ if (!items) return <DropdownMenuPrimitive.Root {...props}>{children}</DropdownMenuPrimitive.Root>
168
+ return (
169
+ <DropdownMenuPrimitive.Root {...props}>
170
+ {trigger && <DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>}
171
+ <DropdownMenuContent>
172
+ {items.map((item, i) => {
173
+ if (item.type === 'separator') return <DropdownMenuSeparator key={i} />
174
+ if (item.type === 'label') return <DropdownMenuLabel key={i}>{item.label}</DropdownMenuLabel>
175
+ return (
176
+ <DropdownMenuItem key={i} onSelect={item.onSelect} disabled={item.disabled}>
177
+ {item.icon}
178
+ {item.label}
179
+ {item.shortcut && <DropdownMenuShortcut>{item.shortcut}</DropdownMenuShortcut>}
180
+ </DropdownMenuItem>
181
+ )
182
+ })}
183
+ </DropdownMenuContent>
184
+ </DropdownMenuPrimitive.Root>
185
+ )
186
+ }
@@ -0,0 +1,97 @@
1
+ import * as React from 'react'
2
+ import { CircleAlert } from 'lucide-react'
3
+ import { Label } from './label'
4
+ import { Text } from './text'
5
+ import { Input } from './input'
6
+ import { cn } from '../lib/utils'
7
+ import type { NoStyle } from '../lib/types'
8
+
9
+ /**
10
+ * FormField — the on-system way to assemble a labelled control. For the common
11
+ * case it renders the field for you: pass `label`, `description`, `placeholder`,
12
+ * and `type` and it owns an `Input`. To wrap a different control (Textarea,
13
+ * Select, Autocomplete) pass it as the single child instead. Either way FormField
14
+ * generates the ids and wires `htmlFor`, `aria-describedby`, `aria-invalid`, and
15
+ * the error/required state — so accessible, validated forms stay a one-liner and
16
+ * never drift.
17
+ *
18
+ * <FormField label="Email" description="We'll only use this to sign you in."
19
+ * type="email" placeholder="you@example.com" error={errors.email} required />
20
+ *
21
+ * <FormField label="Notes" description="Optional.">
22
+ * <Textarea rows={3} />
23
+ * </FormField>
24
+ *
25
+ * Per the system's hue policy, the error MESSAGE text stays the normal foreground;
26
+ * only the leading icon (and the control's outline) carry the status hue — matching
27
+ * Alert and Toast. The state is never conveyed by color alone (icon + border + text).
28
+ */
29
+ export interface FormFieldProps extends NoStyle<Omit<React.HTMLAttributes<HTMLDivElement>, 'children'>> {
30
+ label?: React.ReactNode
31
+ /** Helper text below the label. Kept as normal foreground — meant to be read. */
32
+ description?: React.ReactNode
33
+ /** Placeholder forwarded onto the control. A prop set directly on a child wins. */
34
+ placeholder?: string
35
+ /** Input type for the default control (text, email, password, …). Forwarded onto the control. */
36
+ type?: React.HTMLInputTypeAttribute
37
+ /** Error message. Its presence puts the control into the invalid state. */
38
+ error?: React.ReactNode
39
+ required?: boolean
40
+ /** Escape hatch: a non-Input control to wrap. Omit to let FormField render an Input. */
41
+ children?: React.ReactElement
42
+ }
43
+
44
+ type FieldChildProps = {
45
+ id?: string
46
+ invalid?: boolean
47
+ placeholder?: string
48
+ type?: string
49
+ 'aria-describedby'?: string
50
+ }
51
+
52
+ export const FormField = React.forwardRef<HTMLDivElement, FormFieldProps>(
53
+ ({ label, description, placeholder, type, error, required, children, ...props }, ref) => {
54
+ const uid = React.useId()
55
+ const field = children ?? <Input />
56
+ const childProps = field.props as FieldChildProps
57
+ const fieldId = childProps.id ?? `${uid}-field`
58
+ const descId = description ? `${uid}-desc` : undefined
59
+ const errId = error ? `${uid}-err` : undefined
60
+ const describedBy =
61
+ [childProps['aria-describedby'], descId, errId].filter(Boolean).join(' ') || undefined
62
+ const invalid = error != null || childProps.invalid
63
+
64
+ const control = React.cloneElement(field, {
65
+ id: fieldId,
66
+ invalid: invalid || undefined,
67
+ placeholder: childProps.placeholder ?? placeholder,
68
+ type: childProps.type ?? type,
69
+ 'aria-describedby': describedBy,
70
+ 'aria-required': required || undefined,
71
+ } as Partial<FieldChildProps> & Record<string, unknown>)
72
+
73
+ return (
74
+ <div ref={ref} className={cn('flex flex-col gap-1.5')} {...props}>
75
+ {label != null && (
76
+ <Label htmlFor={fieldId}>
77
+ {label}
78
+ {required && <span aria-hidden="true" className="text-error"> *</span>}
79
+ </Label>
80
+ )}
81
+ {control}
82
+ {description != null && error == null && (
83
+ <Text span size="sm" id={descId}>
84
+ {description}
85
+ </Text>
86
+ )}
87
+ {error != null && (
88
+ <div id={errId} className="flex items-start gap-1.5 text-sm text-primary">
89
+ <CircleAlert className="mt-0.5 size-3.5 shrink-0 text-error" aria-hidden="true" />
90
+ <span>{error}</span>
91
+ </div>
92
+ )}
93
+ </div>
94
+ )
95
+ },
96
+ )
97
+ FormField.displayName = 'FormField'
@@ -0,0 +1,29 @@
1
+ import * as React from 'react'
2
+ import { cn } from '../lib/utils'
3
+ import type { NoStyle } from '../lib/types'
4
+ import { focusRingField } from '../lib/recipes'
5
+
6
+ export interface InputProps extends NoStyle<React.InputHTMLAttributes<HTMLInputElement>> {
7
+ /** Error state — red control outline + aria-invalid. Usually set for you by FormField. */
8
+ invalid?: boolean
9
+ }
10
+
11
+ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
12
+ ({ type, invalid, ...props }, ref) => (
13
+ <input
14
+ type={type}
15
+ ref={ref}
16
+ aria-invalid={invalid || undefined}
17
+ className={cn(
18
+ 'flex h-10 w-full rounded-lg border border-strong bg-surface px-3 py-2 text-sm text-primary',
19
+ 'transition-colors placeholder:text-tertiary',
20
+ focusRingField,
21
+ 'disabled:cursor-not-allowed disabled:opacity-50',
22
+ 'file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-primary',
23
+ invalid && 'border-error focus-visible:border-error',
24
+ )}
25
+ {...props}
26
+ />
27
+ ),
28
+ )
29
+ Input.displayName = 'Input'
@@ -0,0 +1,18 @@
1
+ import * as React from 'react'
2
+ import * as LabelPrimitive from '@radix-ui/react-label'
3
+ import { cn } from '../lib/utils'
4
+ import type { NoStyle } from '../lib/types'
5
+
6
+ export const Label = React.forwardRef<
7
+ React.ElementRef<typeof LabelPrimitive.Root>,
8
+ NoStyle<React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>>
9
+ >(({ ...props }, ref) => (
10
+ <LabelPrimitive.Root
11
+ ref={ref}
12
+ className={cn(
13
+ 'text-sm font-medium leading-none text-primary peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
14
+ )}
15
+ {...props}
16
+ />
17
+ ))
18
+ Label.displayName = LabelPrimitive.Root.displayName