@glyphui/ui 0.0.0-stage → 0.1.1

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 (112) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +137 -2
  3. package/dist/data-table.d.ts +110 -0
  4. package/dist/data-table.js +225 -0
  5. package/dist/field-DXZD0l8w.js +107 -0
  6. package/dist/field-context-B8cYheXz.js +321 -0
  7. package/dist/form.d.ts +79 -0
  8. package/dist/form.js +84 -0
  9. package/dist/index-BWK4czIG.d.ts +233 -0
  10. package/dist/index.d.ts +2155 -0
  11. package/dist/index.js +1529 -0
  12. package/dist/registry-BGSJoiLq.d.ts +9 -0
  13. package/dist/states-BxPjCUxz.js +287 -0
  14. package/dist/tv-DuMh7UH2.d.ts +6 -0
  15. package/package.json +113 -3
  16. package/src/components/avatar/avatar.styles.ts +22 -0
  17. package/src/components/avatar/avatar.tsx +62 -0
  18. package/src/components/avatar/index.ts +2 -0
  19. package/src/components/badge/badge.styles.ts +69 -0
  20. package/src/components/badge/badge.tsx +30 -0
  21. package/src/components/badge/index.ts +2 -0
  22. package/src/components/button/button.styles.ts +43 -0
  23. package/src/components/button/button.tsx +77 -0
  24. package/src/components/button/index.ts +2 -0
  25. package/src/components/card/card.styles.ts +32 -0
  26. package/src/components/card/card.tsx +91 -0
  27. package/src/components/card/index.ts +11 -0
  28. package/src/components/checkbox/checkbox.tsx +39 -0
  29. package/src/components/checkbox/index.ts +1 -0
  30. package/src/components/container/container.tsx +35 -0
  31. package/src/components/container/index.ts +1 -0
  32. package/src/components/eyebrow/eyebrow.styles.ts +14 -0
  33. package/src/components/eyebrow/eyebrow.tsx +19 -0
  34. package/src/components/eyebrow/index.ts +2 -0
  35. package/src/components/field/field-context.ts +40 -0
  36. package/src/components/field/field.styles.ts +23 -0
  37. package/src/components/field/field.tsx +109 -0
  38. package/src/components/field/index.ts +3 -0
  39. package/src/components/heading/heading.styles.ts +16 -0
  40. package/src/components/heading/heading.tsx +26 -0
  41. package/src/components/heading/index.ts +2 -0
  42. package/src/components/input/index.ts +2 -0
  43. package/src/components/input/input.styles.ts +36 -0
  44. package/src/components/input/input.tsx +33 -0
  45. package/src/components/kbd/index.ts +1 -0
  46. package/src/components/kbd/kbd.tsx +21 -0
  47. package/src/components/label/index.ts +1 -0
  48. package/src/components/label/label.tsx +21 -0
  49. package/src/components/section/index.ts +1 -0
  50. package/src/components/section/section.tsx +39 -0
  51. package/src/components/segmented-control/index.ts +6 -0
  52. package/src/components/segmented-control/segmented-control.tsx +79 -0
  53. package/src/components/select/index.ts +1 -0
  54. package/src/components/select/select.tsx +133 -0
  55. package/src/components/separator/index.ts +1 -0
  56. package/src/components/separator/separator.tsx +29 -0
  57. package/src/components/skeleton/index.ts +1 -0
  58. package/src/components/skeleton/skeleton.tsx +20 -0
  59. package/src/components/spinner/index.ts +2 -0
  60. package/src/components/spinner/spinner.styles.ts +19 -0
  61. package/src/components/spinner/spinner.tsx +42 -0
  62. package/src/components/stack/index.ts +1 -0
  63. package/src/components/stack/stack.tsx +62 -0
  64. package/src/components/switch/index.ts +1 -0
  65. package/src/components/switch/switch.tsx +48 -0
  66. package/src/components/table/index.ts +10 -0
  67. package/src/components/table/table.tsx +90 -0
  68. package/src/components/tabs/index.ts +1 -0
  69. package/src/components/tabs/tabs.tsx +92 -0
  70. package/src/components/text/index.ts +2 -0
  71. package/src/components/text/text.styles.ts +32 -0
  72. package/src/components/text/text.tsx +17 -0
  73. package/src/components/textarea/index.ts +1 -0
  74. package/src/components/textarea/textarea.tsx +27 -0
  75. package/src/components/tooltip/index.ts +1 -0
  76. package/src/components/tooltip/tooltip.tsx +42 -0
  77. package/src/hooks/index.ts +4 -0
  78. package/src/hooks/use-component-config.ts +42 -0
  79. package/src/hooks/use-controllable-state.ts +27 -0
  80. package/src/hooks/use-media-query.ts +14 -0
  81. package/src/hooks/use-scrolled.ts +14 -0
  82. package/src/index.ts +52 -0
  83. package/src/internal/icons.tsx +99 -0
  84. package/src/patterns/app-shell/app-shell-context.ts +27 -0
  85. package/src/patterns/app-shell/app-shell.styles.ts +127 -0
  86. package/src/patterns/app-shell/app-shell.tsx +81 -0
  87. package/src/patterns/app-shell/index.ts +16 -0
  88. package/src/patterns/app-shell/navbar.tsx +119 -0
  89. package/src/patterns/app-shell/page-header.tsx +30 -0
  90. package/src/patterns/app-shell/sidebar.tsx +163 -0
  91. package/src/patterns/async/async.tsx +83 -0
  92. package/src/patterns/async/index.ts +10 -0
  93. package/src/patterns/async/states.tsx +128 -0
  94. package/src/patterns/data-table/data-table.tsx +367 -0
  95. package/src/patterns/data-table/index.ts +2 -0
  96. package/src/patterns/form/form.tsx +150 -0
  97. package/src/patterns/form/index.ts +13 -0
  98. package/src/provider/color-mode.tsx +133 -0
  99. package/src/provider/glyph-context.ts +42 -0
  100. package/src/provider/glyph-provider.tsx +41 -0
  101. package/src/provider/index.ts +18 -0
  102. package/src/provider/theme-scope.tsx +36 -0
  103. package/src/provider/theme-style.tsx +19 -0
  104. package/src/register.ts +69 -0
  105. package/src/themes.ts +56 -0
  106. package/src/utils/cn.ts +12 -0
  107. package/src/utils/compose-refs.ts +11 -0
  108. package/src/utils/registry.ts +9 -0
  109. package/src/utils/styles.ts +13 -0
  110. package/src/utils/tv.ts +7 -0
  111. package/src/utils/tw-merge-config.ts +14 -0
  112. 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,2 @@
1
+ export { type ColumnDef, createColumnHelper, type SortingState } from '@tanstack/react-table'
2
+ export { DataTable, type DataTableProps, dataTableStyles } from './data-table'
@@ -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,13 @@
1
+ export {
2
+ Form,
3
+ FormError,
4
+ type FormErrors,
5
+ FormField,
6
+ type FormFieldProps,
7
+ type FormProps,
8
+ FormSubmit,
9
+ formErrorStyles,
10
+ type SubmitResult,
11
+ type UseFormOptions,
12
+ useForm,
13
+ } from './form'
@@ -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
+ }