@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.
- package/dist/index.d.ts +1195 -0
- package/dist/index.js +3628 -0
- package/package.json +66 -0
- package/src/catalog.ts +654 -0
- package/src/components/accordion.tsx +91 -0
- package/src/components/alert.tsx +58 -0
- package/src/components/autocomplete.tsx +174 -0
- package/src/components/avatar.tsx +60 -0
- package/src/components/badge.tsx +37 -0
- package/src/components/breadcrumb.tsx +62 -0
- package/src/components/button-group.tsx +23 -0
- package/src/components/button.tsx +53 -0
- package/src/components/calendar.tsx +61 -0
- package/src/components/card.tsx +72 -0
- package/src/components/chart.tsx +130 -0
- package/src/components/checkbox.tsx +27 -0
- package/src/components/chip.tsx +75 -0
- package/src/components/code-block.tsx +126 -0
- package/src/components/command.tsx +139 -0
- package/src/components/data-table.tsx +194 -0
- package/src/components/date-picker.tsx +77 -0
- package/src/components/dialog.tsx +57 -0
- package/src/components/dropdown-menu.tsx +186 -0
- package/src/components/form-field.tsx +97 -0
- package/src/components/input.tsx +29 -0
- package/src/components/label.tsx +18 -0
- package/src/components/layout.tsx +179 -0
- package/src/components/link.tsx +37 -0
- package/src/components/modal.tsx +67 -0
- package/src/components/multi-select.tsx +175 -0
- package/src/components/pagination.tsx +72 -0
- package/src/components/popover.tsx +25 -0
- package/src/components/progress.tsx +31 -0
- package/src/components/radio-group.tsx +34 -0
- package/src/components/select.tsx +134 -0
- package/src/components/separator.tsx +21 -0
- package/src/components/sheet.tsx +80 -0
- package/src/components/skeleton.tsx +11 -0
- package/src/components/slider.tsx +28 -0
- package/src/components/stepper.tsx +69 -0
- package/src/components/switch.tsx +33 -0
- package/src/components/table.tsx +121 -0
- package/src/components/tabs.tsx +90 -0
- package/src/components/text.tsx +109 -0
- package/src/components/textarea.tsx +27 -0
- package/src/components/toast.tsx +107 -0
- package/src/components/toggle-button.tsx +103 -0
- package/src/components/tooltip.tsx +26 -0
- package/src/icons/forge-icon.tsx +55 -0
- package/src/icons/icon-set.ts +60 -0
- package/src/icons/svg-icon.tsx +43 -0
- package/src/index.ts +80 -0
- package/src/layouts/app-bar.tsx +95 -0
- package/src/layouts/app-shell.tsx +80 -0
- package/src/layouts/side-nav.tsx +196 -0
- package/src/layouts/theme-provider.tsx +128 -0
- package/src/lib/recipes.ts +50 -0
- package/src/lib/types.ts +3 -0
- package/src/lib/use-media-query.ts +18 -0
- package/src/lib/utils.ts +10 -0
- 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
|