@glyphui/ui 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +137 -2
- package/dist/data-table.d.ts +110 -0
- package/dist/data-table.js +225 -0
- package/dist/field-DXZD0l8w.js +107 -0
- package/dist/field-context-B8cYheXz.js +321 -0
- package/dist/form.d.ts +79 -0
- package/dist/form.js +84 -0
- package/dist/index-BWK4czIG.d.ts +233 -0
- package/dist/index.d.ts +2155 -0
- package/dist/index.js +1529 -0
- package/dist/registry-BGSJoiLq.d.ts +9 -0
- package/dist/states-BxPjCUxz.js +287 -0
- package/dist/tv-DuMh7UH2.d.ts +6 -0
- package/package.json +113 -3
- package/src/components/avatar/avatar.styles.ts +22 -0
- package/src/components/avatar/avatar.tsx +62 -0
- package/src/components/avatar/index.ts +2 -0
- package/src/components/badge/badge.styles.ts +69 -0
- package/src/components/badge/badge.tsx +30 -0
- package/src/components/badge/index.ts +2 -0
- package/src/components/button/button.styles.ts +43 -0
- package/src/components/button/button.tsx +77 -0
- package/src/components/button/index.ts +2 -0
- package/src/components/card/card.styles.ts +32 -0
- package/src/components/card/card.tsx +91 -0
- package/src/components/card/index.ts +11 -0
- package/src/components/checkbox/checkbox.tsx +39 -0
- package/src/components/checkbox/index.ts +1 -0
- package/src/components/container/container.tsx +35 -0
- package/src/components/container/index.ts +1 -0
- package/src/components/eyebrow/eyebrow.styles.ts +14 -0
- package/src/components/eyebrow/eyebrow.tsx +19 -0
- package/src/components/eyebrow/index.ts +2 -0
- package/src/components/field/field-context.ts +40 -0
- package/src/components/field/field.styles.ts +23 -0
- package/src/components/field/field.tsx +109 -0
- package/src/components/field/index.ts +3 -0
- package/src/components/heading/heading.styles.ts +16 -0
- package/src/components/heading/heading.tsx +26 -0
- package/src/components/heading/index.ts +2 -0
- package/src/components/input/index.ts +2 -0
- package/src/components/input/input.styles.ts +36 -0
- package/src/components/input/input.tsx +33 -0
- package/src/components/kbd/index.ts +1 -0
- package/src/components/kbd/kbd.tsx +21 -0
- package/src/components/label/index.ts +1 -0
- package/src/components/label/label.tsx +21 -0
- package/src/components/section/index.ts +1 -0
- package/src/components/section/section.tsx +39 -0
- package/src/components/segmented-control/index.ts +6 -0
- package/src/components/segmented-control/segmented-control.tsx +79 -0
- package/src/components/select/index.ts +1 -0
- package/src/components/select/select.tsx +133 -0
- package/src/components/separator/index.ts +1 -0
- package/src/components/separator/separator.tsx +29 -0
- package/src/components/skeleton/index.ts +1 -0
- package/src/components/skeleton/skeleton.tsx +20 -0
- package/src/components/spinner/index.ts +2 -0
- package/src/components/spinner/spinner.styles.ts +19 -0
- package/src/components/spinner/spinner.tsx +42 -0
- package/src/components/stack/index.ts +1 -0
- package/src/components/stack/stack.tsx +62 -0
- package/src/components/switch/index.ts +1 -0
- package/src/components/switch/switch.tsx +48 -0
- package/src/components/table/index.ts +10 -0
- package/src/components/table/table.tsx +90 -0
- package/src/components/tabs/index.ts +1 -0
- package/src/components/tabs/tabs.tsx +92 -0
- package/src/components/text/index.ts +2 -0
- package/src/components/text/text.styles.ts +32 -0
- package/src/components/text/text.tsx +17 -0
- package/src/components/textarea/index.ts +1 -0
- package/src/components/textarea/textarea.tsx +27 -0
- package/src/components/tooltip/index.ts +1 -0
- package/src/components/tooltip/tooltip.tsx +42 -0
- package/src/hooks/index.ts +4 -0
- package/src/hooks/use-component-config.ts +42 -0
- package/src/hooks/use-controllable-state.ts +27 -0
- package/src/hooks/use-media-query.ts +14 -0
- package/src/hooks/use-scrolled.ts +14 -0
- package/src/index.ts +52 -0
- package/src/internal/icons.tsx +99 -0
- package/src/patterns/app-shell/app-shell-context.ts +27 -0
- package/src/patterns/app-shell/app-shell.styles.ts +127 -0
- package/src/patterns/app-shell/app-shell.tsx +81 -0
- package/src/patterns/app-shell/index.ts +16 -0
- package/src/patterns/app-shell/navbar.tsx +119 -0
- package/src/patterns/app-shell/page-header.tsx +30 -0
- package/src/patterns/app-shell/sidebar.tsx +163 -0
- package/src/patterns/async/async.tsx +83 -0
- package/src/patterns/async/index.ts +10 -0
- package/src/patterns/async/states.tsx +128 -0
- package/src/patterns/data-table/data-table.tsx +367 -0
- package/src/patterns/data-table/index.ts +2 -0
- package/src/patterns/form/form.tsx +150 -0
- package/src/patterns/form/index.ts +13 -0
- package/src/provider/color-mode.tsx +133 -0
- package/src/provider/glyph-context.ts +42 -0
- package/src/provider/glyph-provider.tsx +41 -0
- package/src/provider/index.ts +18 -0
- package/src/provider/theme-scope.tsx +36 -0
- package/src/provider/theme-style.tsx +19 -0
- package/src/register.ts +69 -0
- package/src/themes.ts +56 -0
- package/src/utils/cn.ts +12 -0
- package/src/utils/compose-refs.ts +11 -0
- package/src/utils/registry.ts +9 -0
- package/src/utils/styles.ts +13 -0
- package/src/utils/tv.ts +7 -0
- package/src/utils/tw-merge-config.ts +14 -0
- package/styles.css +178 -0
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ColumnDef,
|
|
3
|
+
flexRender,
|
|
4
|
+
getCoreRowModel,
|
|
5
|
+
getFilteredRowModel,
|
|
6
|
+
getPaginationRowModel,
|
|
7
|
+
getSortedRowModel,
|
|
8
|
+
type RowData,
|
|
9
|
+
type RowSelectionState,
|
|
10
|
+
type SortingState,
|
|
11
|
+
useReactTable,
|
|
12
|
+
} from '@tanstack/react-table'
|
|
13
|
+
import { type ReactNode, useEffect, useMemo, useState } from 'react'
|
|
14
|
+
import { Button, IconButton } from '../../components/button'
|
|
15
|
+
import { Checkbox } from '../../components/checkbox'
|
|
16
|
+
import { Input } from '../../components/input'
|
|
17
|
+
import { Skeleton } from '../../components/skeleton'
|
|
18
|
+
import {
|
|
19
|
+
Table,
|
|
20
|
+
TableBody,
|
|
21
|
+
TableCell,
|
|
22
|
+
TableHead,
|
|
23
|
+
TableHeader,
|
|
24
|
+
TableRow,
|
|
25
|
+
} from '../../components/table'
|
|
26
|
+
import { useComponentConfig } from '../../hooks/use-component-config'
|
|
27
|
+
import {
|
|
28
|
+
ArrowDownIcon,
|
|
29
|
+
ArrowUpIcon,
|
|
30
|
+
ChevronLeftIcon,
|
|
31
|
+
ChevronRightIcon,
|
|
32
|
+
ChevronsUpDownIcon,
|
|
33
|
+
SearchIcon,
|
|
34
|
+
} from '../../internal/icons'
|
|
35
|
+
import type { StylesEntry } from '../../utils/registry'
|
|
36
|
+
import { focusRing, themedBorder } from '../../utils/styles'
|
|
37
|
+
import { tv, type VariantProps } from '../../utils/tv'
|
|
38
|
+
import { EmptyState, ErrorState } from '../async/states'
|
|
39
|
+
|
|
40
|
+
declare module '@tanstack/react-table' {
|
|
41
|
+
interface ColumnMeta<TData extends RowData, TValue> {
|
|
42
|
+
/** Cell and header alignment. Use `right` for numbers. */
|
|
43
|
+
align?: 'left' | 'center' | 'right'
|
|
44
|
+
/** Extra classes for this column's cells. */
|
|
45
|
+
className?: string
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const dataTableStyles = tv({
|
|
50
|
+
slots: {
|
|
51
|
+
root: 'flex min-w-0 flex-col',
|
|
52
|
+
toolbar: 'flex flex-wrap items-center gap-2',
|
|
53
|
+
search: 'w-full sm:max-w-64',
|
|
54
|
+
actions: 'ml-auto flex items-center gap-2',
|
|
55
|
+
sortButton: [
|
|
56
|
+
'-ml-1.5 inline-flex cursor-pointer items-center gap-1 rounded-xs px-1.5 py-1 hover:text-foreground [&>svg]:size-3.5',
|
|
57
|
+
focusRing,
|
|
58
|
+
],
|
|
59
|
+
footer:
|
|
60
|
+
'flex flex-wrap items-center justify-between gap-3 text-small text-muted-foreground tabular-nums',
|
|
61
|
+
pager: 'flex items-center gap-1',
|
|
62
|
+
},
|
|
63
|
+
variants: {
|
|
64
|
+
variant: {
|
|
65
|
+
card: {
|
|
66
|
+
root: ['overflow-hidden rounded-lg border-border bg-surface shadow-sm', themedBorder],
|
|
67
|
+
toolbar: 'border-b border-border p-3',
|
|
68
|
+
footer: 'border-t border-border px-4 py-2.5',
|
|
69
|
+
},
|
|
70
|
+
plain: { root: 'gap-3' },
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
defaultVariants: { variant: 'card' },
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
declare module '@glyphui/tokens' {
|
|
77
|
+
interface GlyphComponentRegistry {
|
|
78
|
+
DataTable: StylesEntry<typeof dataTableStyles>
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const alignClass = { left: 'text-left', center: 'text-center', right: 'text-right' } as const
|
|
83
|
+
const NO_ROWS: never[] = []
|
|
84
|
+
|
|
85
|
+
export interface DataTableProps<T> extends VariantProps<typeof dataTableStyles> {
|
|
86
|
+
data: T[] | undefined
|
|
87
|
+
/** Build with `createColumnHelper<T>()` for typed accessors. */
|
|
88
|
+
// biome-ignore lint/suspicious/noExplicitAny: TanStack's own convention for mixed column value types
|
|
89
|
+
columns: ColumnDef<T, any>[]
|
|
90
|
+
/** Shows skeleton rows. */
|
|
91
|
+
loading?: boolean
|
|
92
|
+
/** Shows an error state with a retry button when `onRetry` is set. */
|
|
93
|
+
error?: unknown
|
|
94
|
+
onRetry?: () => unknown
|
|
95
|
+
getRowId?: (row: T, index: number) => string
|
|
96
|
+
/** Rows per page, or `false` to show every row. Default 10. */
|
|
97
|
+
pageSize?: number | false
|
|
98
|
+
/** Adds a checkbox column. */
|
|
99
|
+
selectable?: boolean
|
|
100
|
+
onSelectionChange?: (rows: T[]) => void
|
|
101
|
+
/** Adds a search box that filters across all columns. */
|
|
102
|
+
search?: boolean | { placeholder?: string }
|
|
103
|
+
/** Extra controls at the end of the toolbar (filters, buttons). */
|
|
104
|
+
toolbar?: ReactNode
|
|
105
|
+
/** Shown when there are no rows at all (not when a search matches nothing). */
|
|
106
|
+
empty?: ReactNode
|
|
107
|
+
initialSorting?: SortingState
|
|
108
|
+
'aria-label'?: string
|
|
109
|
+
className?: string
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function selectColumn<T>(): ColumnDef<T> {
|
|
113
|
+
return {
|
|
114
|
+
id: '__select',
|
|
115
|
+
enableSorting: false,
|
|
116
|
+
enableGlobalFilter: false,
|
|
117
|
+
header: ({ table }) => (
|
|
118
|
+
<Checkbox
|
|
119
|
+
className="mt-0"
|
|
120
|
+
aria-label="Select all rows on this page"
|
|
121
|
+
checked={
|
|
122
|
+
table.getIsAllPageRowsSelected()
|
|
123
|
+
? true
|
|
124
|
+
: table.getIsSomePageRowsSelected()
|
|
125
|
+
? 'indeterminate'
|
|
126
|
+
: false
|
|
127
|
+
}
|
|
128
|
+
onCheckedChange={(value) => table.toggleAllPageRowsSelected(value === true)}
|
|
129
|
+
/>
|
|
130
|
+
),
|
|
131
|
+
cell: ({ row }) => (
|
|
132
|
+
<Checkbox
|
|
133
|
+
className="mt-0"
|
|
134
|
+
aria-label="Select row"
|
|
135
|
+
checked={row.getIsSelected()}
|
|
136
|
+
disabled={!row.getCanSelect()}
|
|
137
|
+
onCheckedChange={(value) => row.toggleSelected(value === true)}
|
|
138
|
+
/>
|
|
139
|
+
),
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* A table with sorting, search, pagination and row selection, plus built-in loading,
|
|
145
|
+
* empty, no-results and error states. Columns are typed from your row type.
|
|
146
|
+
*/
|
|
147
|
+
export function DataTable<T>(inProps: DataTableProps<T>) {
|
|
148
|
+
const [props, slots] = useComponentConfig('DataTable', inProps)
|
|
149
|
+
const {
|
|
150
|
+
data,
|
|
151
|
+
columns,
|
|
152
|
+
loading = false,
|
|
153
|
+
error,
|
|
154
|
+
onRetry,
|
|
155
|
+
getRowId,
|
|
156
|
+
pageSize = 10,
|
|
157
|
+
selectable = false,
|
|
158
|
+
onSelectionChange,
|
|
159
|
+
search = false,
|
|
160
|
+
toolbar,
|
|
161
|
+
empty,
|
|
162
|
+
initialSorting = [],
|
|
163
|
+
variant,
|
|
164
|
+
className,
|
|
165
|
+
'aria-label': ariaLabel,
|
|
166
|
+
} = props
|
|
167
|
+
|
|
168
|
+
const [sorting, setSorting] = useState<SortingState>(initialSorting)
|
|
169
|
+
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
|
170
|
+
const [globalFilter, setGlobalFilter] = useState('')
|
|
171
|
+
const allColumns = useMemo(
|
|
172
|
+
() => (selectable ? [selectColumn<T>(), ...columns] : columns),
|
|
173
|
+
[selectable, columns],
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
const table = useReactTable<T>({
|
|
177
|
+
data: data ?? NO_ROWS,
|
|
178
|
+
columns: allColumns,
|
|
179
|
+
state: { sorting, rowSelection, globalFilter },
|
|
180
|
+
onSortingChange: setSorting,
|
|
181
|
+
onRowSelectionChange: setRowSelection,
|
|
182
|
+
onGlobalFilterChange: setGlobalFilter,
|
|
183
|
+
enableRowSelection: selectable,
|
|
184
|
+
sortDescFirst: false,
|
|
185
|
+
getCoreRowModel: getCoreRowModel(),
|
|
186
|
+
getSortedRowModel: getSortedRowModel(),
|
|
187
|
+
getFilteredRowModel: getFilteredRowModel(),
|
|
188
|
+
...(getRowId && { getRowId }),
|
|
189
|
+
...(pageSize !== false && {
|
|
190
|
+
getPaginationRowModel: getPaginationRowModel(),
|
|
191
|
+
initialState: { pagination: { pageIndex: 0, pageSize } },
|
|
192
|
+
}),
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: report only when the selection changes
|
|
196
|
+
useEffect(() => {
|
|
197
|
+
onSelectionChange?.(table.getSelectedRowModel().rows.map((row) => row.original))
|
|
198
|
+
}, [rowSelection])
|
|
199
|
+
|
|
200
|
+
const s = dataTableStyles({ variant })
|
|
201
|
+
const columnCount = table.getVisibleLeafColumns().length
|
|
202
|
+
const rows = table.getRowModel().rows
|
|
203
|
+
const filteredTotal = table.getFilteredRowModel().rows.length
|
|
204
|
+
const selectedCount = Object.keys(rowSelection).length
|
|
205
|
+
const { pageIndex, pageSize: size } = table.getState().pagination
|
|
206
|
+
const showFooter = pageSize !== false && !loading && !error && filteredTotal > 0
|
|
207
|
+
const searchPlaceholder = typeof search === 'object' ? search.placeholder : undefined
|
|
208
|
+
|
|
209
|
+
const fullWidthRow = (content: ReactNode) => (
|
|
210
|
+
<TableRow className="hover:bg-transparent">
|
|
211
|
+
<TableCell colSpan={columnCount} className="p-0">
|
|
212
|
+
{content}
|
|
213
|
+
</TableCell>
|
|
214
|
+
</TableRow>
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
let body: ReactNode
|
|
218
|
+
if (loading) {
|
|
219
|
+
body = Array.from({ length: pageSize === false ? 5 : Math.min(pageSize, 8) }, (_, i) => (
|
|
220
|
+
<TableRow key={i} className="hover:bg-transparent">
|
|
221
|
+
{table.getVisibleLeafColumns().map((column) => (
|
|
222
|
+
<TableCell key={column.id}>
|
|
223
|
+
<Skeleton className={column.id === '__select' ? 'size-4' : 'h-4 w-[70%]'} />
|
|
224
|
+
</TableCell>
|
|
225
|
+
))}
|
|
226
|
+
</TableRow>
|
|
227
|
+
))
|
|
228
|
+
} else if (error) {
|
|
229
|
+
body = fullWidthRow(<ErrorState error={error} onRetry={onRetry} />)
|
|
230
|
+
} else if (rows.length === 0) {
|
|
231
|
+
body = fullWidthRow(
|
|
232
|
+
globalFilter ? (
|
|
233
|
+
<EmptyState
|
|
234
|
+
icon={<SearchIcon />}
|
|
235
|
+
title={`No results for “${globalFilter}”`}
|
|
236
|
+
description="Check the spelling or try a shorter search."
|
|
237
|
+
action={
|
|
238
|
+
<Button variant="outline" size="sm" onClick={() => setGlobalFilter('')}>
|
|
239
|
+
Clear search
|
|
240
|
+
</Button>
|
|
241
|
+
}
|
|
242
|
+
/>
|
|
243
|
+
) : (
|
|
244
|
+
(empty ?? <EmptyState title="No rows yet" />)
|
|
245
|
+
),
|
|
246
|
+
)
|
|
247
|
+
} else {
|
|
248
|
+
body = rows.map((row) => (
|
|
249
|
+
<TableRow key={row.id} data-state={row.getIsSelected() ? 'selected' : undefined}>
|
|
250
|
+
{row.getVisibleCells().map((cell) => {
|
|
251
|
+
const meta = cell.column.columnDef.meta
|
|
252
|
+
return (
|
|
253
|
+
<TableCell
|
|
254
|
+
key={cell.id}
|
|
255
|
+
className={[alignClass[meta?.align ?? 'left'], meta?.className].join(' ')}
|
|
256
|
+
>
|
|
257
|
+
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
258
|
+
</TableCell>
|
|
259
|
+
)
|
|
260
|
+
})}
|
|
261
|
+
</TableRow>
|
|
262
|
+
))
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return (
|
|
266
|
+
<div data-slot="data-table" className={s.root({ class: [slots.root, className] })}>
|
|
267
|
+
{(search || toolbar) && (
|
|
268
|
+
<div className={s.toolbar({ class: slots.toolbar })}>
|
|
269
|
+
{search && (
|
|
270
|
+
<Input
|
|
271
|
+
size="sm"
|
|
272
|
+
type="search"
|
|
273
|
+
aria-label="Search rows"
|
|
274
|
+
placeholder={searchPlaceholder ?? 'Search…'}
|
|
275
|
+
prefix={<SearchIcon />}
|
|
276
|
+
value={globalFilter}
|
|
277
|
+
onChange={(event) => {
|
|
278
|
+
setGlobalFilter(event.target.value)
|
|
279
|
+
table.setPageIndex(0)
|
|
280
|
+
}}
|
|
281
|
+
className={s.search({ class: slots.search })}
|
|
282
|
+
/>
|
|
283
|
+
)}
|
|
284
|
+
{toolbar && <div className={s.actions({ class: slots.actions })}>{toolbar}</div>}
|
|
285
|
+
</div>
|
|
286
|
+
)}
|
|
287
|
+
|
|
288
|
+
<Table aria-label={ariaLabel} aria-busy={loading || undefined}>
|
|
289
|
+
<TableHeader>
|
|
290
|
+
{table.getHeaderGroups().map((group) => (
|
|
291
|
+
<TableRow key={group.id} className="hover:bg-transparent">
|
|
292
|
+
{group.headers.map((header) => {
|
|
293
|
+
const column = header.column
|
|
294
|
+
const sorted = column.getIsSorted()
|
|
295
|
+
const align = column.columnDef.meta?.align ?? 'left'
|
|
296
|
+
const label = header.isPlaceholder
|
|
297
|
+
? null
|
|
298
|
+
: flexRender(column.columnDef.header, header.getContext())
|
|
299
|
+
return (
|
|
300
|
+
<TableHead
|
|
301
|
+
key={header.id}
|
|
302
|
+
className={alignClass[align]}
|
|
303
|
+
aria-sort={
|
|
304
|
+
sorted === 'asc' ? 'ascending' : sorted === 'desc' ? 'descending' : undefined
|
|
305
|
+
}
|
|
306
|
+
>
|
|
307
|
+
{column.getCanSort() && !header.isPlaceholder ? (
|
|
308
|
+
<button
|
|
309
|
+
type="button"
|
|
310
|
+
onClick={column.getToggleSortingHandler()}
|
|
311
|
+
className={s.sortButton({
|
|
312
|
+
class: [slots.sortButton, align === 'right' && 'flex-row-reverse'],
|
|
313
|
+
})}
|
|
314
|
+
>
|
|
315
|
+
{label}
|
|
316
|
+
{sorted === 'asc' ? (
|
|
317
|
+
<ArrowUpIcon />
|
|
318
|
+
) : sorted === 'desc' ? (
|
|
319
|
+
<ArrowDownIcon />
|
|
320
|
+
) : (
|
|
321
|
+
<ChevronsUpDownIcon className="opacity-40" />
|
|
322
|
+
)}
|
|
323
|
+
</button>
|
|
324
|
+
) : (
|
|
325
|
+
label
|
|
326
|
+
)}
|
|
327
|
+
</TableHead>
|
|
328
|
+
)
|
|
329
|
+
})}
|
|
330
|
+
</TableRow>
|
|
331
|
+
))}
|
|
332
|
+
</TableHeader>
|
|
333
|
+
<TableBody>{body}</TableBody>
|
|
334
|
+
</Table>
|
|
335
|
+
|
|
336
|
+
{showFooter && (
|
|
337
|
+
<div className={s.footer({ class: slots.footer })}>
|
|
338
|
+
<p>
|
|
339
|
+
{pageIndex * size + 1}–{Math.min((pageIndex + 1) * size, filteredTotal)} of{' '}
|
|
340
|
+
{filteredTotal}
|
|
341
|
+
{selectedCount > 0 && (
|
|
342
|
+
<span className="text-foreground"> · {selectedCount} selected</span>
|
|
343
|
+
)}
|
|
344
|
+
</p>
|
|
345
|
+
<div className={s.pager({ class: slots.pager })}>
|
|
346
|
+
<IconButton
|
|
347
|
+
variant="outline"
|
|
348
|
+
size="sm"
|
|
349
|
+
icon={<ChevronLeftIcon />}
|
|
350
|
+
aria-label="Previous page"
|
|
351
|
+
disabled={!table.getCanPreviousPage()}
|
|
352
|
+
onClick={() => table.previousPage()}
|
|
353
|
+
/>
|
|
354
|
+
<IconButton
|
|
355
|
+
variant="outline"
|
|
356
|
+
size="sm"
|
|
357
|
+
icon={<ChevronRightIcon />}
|
|
358
|
+
aria-label="Next page"
|
|
359
|
+
disabled={!table.getCanNextPage()}
|
|
360
|
+
onClick={() => table.nextPage()}
|
|
361
|
+
/>
|
|
362
|
+
</div>
|
|
363
|
+
</div>
|
|
364
|
+
)}
|
|
365
|
+
</div>
|
|
366
|
+
)
|
|
367
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { standardSchemaResolver } from '@hookform/resolvers/standard-schema'
|
|
2
|
+
import type { StandardSchemaV1 } from '@standard-schema/spec'
|
|
3
|
+
import type { ComponentProps, ReactNode } from 'react'
|
|
4
|
+
import {
|
|
5
|
+
type Control,
|
|
6
|
+
Controller,
|
|
7
|
+
type ControllerRenderProps,
|
|
8
|
+
type FieldPath,
|
|
9
|
+
type FieldValues,
|
|
10
|
+
FormProvider,
|
|
11
|
+
type Resolver,
|
|
12
|
+
type UseFormProps,
|
|
13
|
+
type UseFormReturn,
|
|
14
|
+
useFormState,
|
|
15
|
+
useForm as useHookForm,
|
|
16
|
+
} from 'react-hook-form'
|
|
17
|
+
import { Button, type ButtonProps } from '../../components/button'
|
|
18
|
+
import { Field, type FieldProps } from '../../components/field'
|
|
19
|
+
import { AlertIcon } from '../../internal/icons'
|
|
20
|
+
import { tv } from '../../utils/tv'
|
|
21
|
+
|
|
22
|
+
type AnySchema = StandardSchemaV1<FieldValues, FieldValues>
|
|
23
|
+
type InputOf<S extends AnySchema> = StandardSchemaV1.InferInput<S>
|
|
24
|
+
type OutputOf<S extends AnySchema> = StandardSchemaV1.InferOutput<S>
|
|
25
|
+
|
|
26
|
+
export interface UseFormOptions<S extends AnySchema>
|
|
27
|
+
extends Omit<UseFormProps<InputOf<S>, unknown, OutputOf<S>>, 'resolver'> {
|
|
28
|
+
/** Any Standard Schema validator: zod, valibot, arktype, ... */
|
|
29
|
+
schema: S
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* react-hook-form's `useForm`, validated by a schema. Field names and submitted values are
|
|
34
|
+
* typed from the schema.
|
|
35
|
+
*/
|
|
36
|
+
export function useForm<S extends AnySchema>({
|
|
37
|
+
schema,
|
|
38
|
+
...options
|
|
39
|
+
}: UseFormOptions<S>): UseFormReturn<InputOf<S>, unknown, OutputOf<S>> {
|
|
40
|
+
return useHookForm<InputOf<S>, unknown, OutputOf<S>>({
|
|
41
|
+
mode: 'onTouched',
|
|
42
|
+
...options,
|
|
43
|
+
resolver: standardSchemaResolver(schema) as Resolver<InputOf<S>, unknown, OutputOf<S>>,
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Field-level errors from the server, keyed by field name. `root` shows above the form. */
|
|
48
|
+
export type FormErrors<TInput extends FieldValues> = Partial<
|
|
49
|
+
Record<FieldPath<TInput> | 'root', string>
|
|
50
|
+
>
|
|
51
|
+
|
|
52
|
+
// biome-ignore lint/suspicious/noConfusingVoidType: submit handlers usually return nothing
|
|
53
|
+
export type SubmitResult<TInput extends FieldValues> = void | { errors: FormErrors<TInput> }
|
|
54
|
+
|
|
55
|
+
export interface FormProps<TInput extends FieldValues, TOutput>
|
|
56
|
+
extends Omit<ComponentProps<'form'>, 'onSubmit'> {
|
|
57
|
+
form: UseFormReturn<TInput, unknown, TOutput>
|
|
58
|
+
/**
|
|
59
|
+
* Receives validated values. Return `{ errors }` to show server-side field errors, or throw
|
|
60
|
+
* to show a form-level error. The submit button shows a spinner until this settles.
|
|
61
|
+
*/
|
|
62
|
+
onSubmit: (values: TOutput) => SubmitResult<TInput> | Promise<SubmitResult<TInput>>
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function Form<TInput extends FieldValues, TOutput>({
|
|
66
|
+
form,
|
|
67
|
+
onSubmit,
|
|
68
|
+
children,
|
|
69
|
+
...props
|
|
70
|
+
}: FormProps<TInput, TOutput>) {
|
|
71
|
+
const submit = form.handleSubmit(async (values) => {
|
|
72
|
+
try {
|
|
73
|
+
const result = await onSubmit(values)
|
|
74
|
+
if (!result?.errors) return
|
|
75
|
+
for (const [name, message] of Object.entries(result.errors)) {
|
|
76
|
+
if (message) form.setError(name as FieldPath<TInput>, { type: 'server', message })
|
|
77
|
+
}
|
|
78
|
+
} catch (error) {
|
|
79
|
+
form.setError('root', {
|
|
80
|
+
type: 'server',
|
|
81
|
+
message: error instanceof Error ? error.message : 'Something went wrong. Try again.',
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<FormProvider {...form}>
|
|
88
|
+
<form noValidate onSubmit={submit} {...props}>
|
|
89
|
+
{children}
|
|
90
|
+
</form>
|
|
91
|
+
</FormProvider>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface FormFieldProps<TInput extends FieldValues, TName extends FieldPath<TInput>>
|
|
96
|
+
extends Omit<FieldProps, 'children' | 'error'> {
|
|
97
|
+
// biome-ignore lint/suspicious/noExplicitAny: accepts forms with any transformed output type
|
|
98
|
+
control: Control<TInput, unknown, any>
|
|
99
|
+
/** Typed from the schema: a misspelled name is a compile error. */
|
|
100
|
+
name: TName
|
|
101
|
+
/** Render the control and spread `field` onto it: `{(field) => <Input {...field} />}`. */
|
|
102
|
+
children: (field: ControllerRenderProps<TInput, TName>) => ReactNode
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** One schema field: label, control, description and validation message, all wired. */
|
|
106
|
+
export function FormField<TInput extends FieldValues, TName extends FieldPath<TInput>>({
|
|
107
|
+
control,
|
|
108
|
+
name,
|
|
109
|
+
children,
|
|
110
|
+
...fieldProps
|
|
111
|
+
}: FormFieldProps<TInput, TName>) {
|
|
112
|
+
return (
|
|
113
|
+
<Controller
|
|
114
|
+
control={control}
|
|
115
|
+
name={name}
|
|
116
|
+
render={({ field, fieldState }) => (
|
|
117
|
+
<Field {...fieldProps} error={fieldState.error?.message}>
|
|
118
|
+
{children(field)}
|
|
119
|
+
</Field>
|
|
120
|
+
)}
|
|
121
|
+
/>
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Submit button that shows progress while the form submits. */
|
|
126
|
+
export function FormSubmit(props: Omit<ButtonProps, 'type'>) {
|
|
127
|
+
const { isSubmitting } = useFormState()
|
|
128
|
+
return <Button type="submit" loading={isSubmitting} {...props} />
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export const formErrorStyles = tv({
|
|
132
|
+
slots: {
|
|
133
|
+
base: 'flex items-start gap-2 rounded-md border border-danger/30 bg-danger/8 px-3 py-2.5 text-small text-danger',
|
|
134
|
+
icon: 'mt-0.5 size-4 shrink-0',
|
|
135
|
+
},
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
/** Form-level error (thrown from `onSubmit` or returned as `errors.root`). */
|
|
139
|
+
export function FormError({ className }: { className?: string }) {
|
|
140
|
+
const { errors } = useFormState()
|
|
141
|
+
const message = errors.root?.message
|
|
142
|
+
if (!message) return null
|
|
143
|
+
const s = formErrorStyles()
|
|
144
|
+
return (
|
|
145
|
+
<div role="alert" data-slot="form-error" className={s.base({ class: className })}>
|
|
146
|
+
<AlertIcon className={s.icon()} />
|
|
147
|
+
<span>{message}</span>
|
|
148
|
+
</div>
|
|
149
|
+
)
|
|
150
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import type { ColorMode } from '@glyphui/tokens'
|
|
2
|
+
import {
|
|
3
|
+
createContext,
|
|
4
|
+
type ReactNode,
|
|
5
|
+
useCallback,
|
|
6
|
+
useContext,
|
|
7
|
+
useEffect,
|
|
8
|
+
useMemo,
|
|
9
|
+
useState,
|
|
10
|
+
} from 'react'
|
|
11
|
+
|
|
12
|
+
export type ColorModePreference = ColorMode | 'system'
|
|
13
|
+
|
|
14
|
+
export interface ColorModeContextValue {
|
|
15
|
+
/** What the user chose. */
|
|
16
|
+
mode: ColorModePreference
|
|
17
|
+
/** What is shown: `system` resolved against the OS setting. */
|
|
18
|
+
resolvedMode: ColorMode
|
|
19
|
+
setMode: (mode: ColorModePreference) => void
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const ColorModeContext = createContext<ColorModeContextValue | null>(null)
|
|
23
|
+
|
|
24
|
+
const DARK_QUERY = '(prefers-color-scheme: dark)'
|
|
25
|
+
const isBrowser = typeof window !== 'undefined'
|
|
26
|
+
|
|
27
|
+
export interface ColorModeOptions {
|
|
28
|
+
/** Used when nothing is stored yet. */
|
|
29
|
+
defaultMode?: ColorModePreference
|
|
30
|
+
/** localStorage key. Set to `null` to disable persistence. */
|
|
31
|
+
storageKey?: string | null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function readStoredMode(storageKey: string | null): ColorModePreference | null {
|
|
35
|
+
if (!isBrowser || !storageKey) return null
|
|
36
|
+
try {
|
|
37
|
+
const value = window.localStorage.getItem(storageKey)
|
|
38
|
+
return value === 'light' || value === 'dark' || value === 'system' ? value : null
|
|
39
|
+
} catch {
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function systemMode(): ColorMode {
|
|
45
|
+
return isBrowser && window.matchMedia(DARK_QUERY).matches ? 'dark' : 'light'
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Disables transitions for one frame so a mode switch doesn't animate every color. */
|
|
49
|
+
function suppressTransitions(): () => void {
|
|
50
|
+
const style = document.createElement('style')
|
|
51
|
+
style.textContent = '*,*::before,*::after{transition:none!important}'
|
|
52
|
+
document.head.appendChild(style)
|
|
53
|
+
return () => {
|
|
54
|
+
window.getComputedStyle(document.body)
|
|
55
|
+
requestAnimationFrame(() => style.remove())
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function ColorModeProvider({
|
|
60
|
+
children,
|
|
61
|
+
defaultMode = 'system',
|
|
62
|
+
storageKey = 'glyph-mode',
|
|
63
|
+
}: ColorModeOptions & { children: ReactNode }) {
|
|
64
|
+
const [mode, setModeState] = useState<ColorModePreference>(
|
|
65
|
+
() => readStoredMode(storageKey) ?? defaultMode,
|
|
66
|
+
)
|
|
67
|
+
const [system, setSystem] = useState<ColorMode>(systemMode)
|
|
68
|
+
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
const query = window.matchMedia(DARK_QUERY)
|
|
71
|
+
const onChange = () => setSystem(query.matches ? 'dark' : 'light')
|
|
72
|
+
query.addEventListener('change', onChange)
|
|
73
|
+
return () => query.removeEventListener('change', onChange)
|
|
74
|
+
}, [])
|
|
75
|
+
|
|
76
|
+
const resolvedMode = mode === 'system' ? system : mode
|
|
77
|
+
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
const root = document.documentElement
|
|
80
|
+
if (root.dataset.mode === resolvedMode) return
|
|
81
|
+
const restore = suppressTransitions()
|
|
82
|
+
root.dataset.mode = resolvedMode
|
|
83
|
+
root.style.colorScheme = resolvedMode
|
|
84
|
+
restore()
|
|
85
|
+
}, [resolvedMode])
|
|
86
|
+
|
|
87
|
+
const setMode = useCallback(
|
|
88
|
+
(next: ColorModePreference) => {
|
|
89
|
+
setModeState(next)
|
|
90
|
+
if (!storageKey) return
|
|
91
|
+
try {
|
|
92
|
+
window.localStorage.setItem(storageKey, next)
|
|
93
|
+
} catch {
|
|
94
|
+
// Storage can be unavailable (private mode, blocked cookies); the mode still applies.
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
[storageKey],
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
const value = useMemo(() => ({ mode, resolvedMode, setMode }), [mode, resolvedMode, setMode])
|
|
101
|
+
return <ColorModeContext.Provider value={value}>{children}</ColorModeContext.Provider>
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Read and change the color mode. */
|
|
105
|
+
export function useColorMode(): ColorModeContextValue {
|
|
106
|
+
const value = useContext(ColorModeContext)
|
|
107
|
+
if (!value) throw new Error('useColorMode must be used inside <GlyphProvider>.')
|
|
108
|
+
return value
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Inline script that sets `data-mode` on `<html>` before first paint, so a dark-mode
|
|
113
|
+
* visitor never sees a light flash. Put it in `<head>`.
|
|
114
|
+
*/
|
|
115
|
+
export function getColorModeScript({
|
|
116
|
+
defaultMode = 'system',
|
|
117
|
+
storageKey = 'glyph-mode',
|
|
118
|
+
}: ColorModeOptions = {}): string {
|
|
119
|
+
return `(function(){try{var d=${JSON.stringify(defaultMode)},m=null;${
|
|
120
|
+
storageKey ? `m=localStorage.getItem(${JSON.stringify(storageKey)});` : ''
|
|
121
|
+
}if(m!=='light'&&m!=='dark'&&m!=='system')m=d;if(m==='system')m=matchMedia('${DARK_QUERY}').matches?'dark':'light';var r=document.documentElement;r.dataset.mode=m;r.style.colorScheme=m}catch(e){}})()`
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** `<script>` element version of `getColorModeScript` for SSR frameworks. */
|
|
125
|
+
export function ColorModeScript(props: ColorModeOptions) {
|
|
126
|
+
return (
|
|
127
|
+
<script
|
|
128
|
+
// biome-ignore lint/security/noDangerouslySetInnerHtml: static script built from typed options
|
|
129
|
+
dangerouslySetInnerHTML={{ __html: getColorModeScript(props) }}
|
|
130
|
+
suppressHydrationWarning
|
|
131
|
+
/>
|
|
132
|
+
)
|
|
133
|
+
}
|