@better-forge/ui 0.1.0 → 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 (51) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +13 -13
  3. package/icons.json +170 -170
  4. package/package.json +71 -71
  5. package/src/api/client.ts +139 -139
  6. package/src/components/ConfirmIdentityModal.tsx +82 -82
  7. package/src/components/ConfirmModal.tsx +83 -83
  8. package/src/components/CopyButton.tsx +71 -71
  9. package/src/components/CredentialSection.tsx +270 -270
  10. package/src/components/ErrorBoundary.tsx +47 -47
  11. package/src/components/ModalShell.tsx +160 -160
  12. package/src/components/ResetTwoFactorModal.tsx +74 -74
  13. package/src/components/SidePanel.tsx +96 -96
  14. package/src/components/SingleSignOnModal.tsx +161 -161
  15. package/src/components/SsoConfirmation.tsx +96 -96
  16. package/src/components/VaultTypePicker.tsx +131 -131
  17. package/src/components/WhatsNew.tsx +84 -84
  18. package/src/components/formParts.tsx +59 -59
  19. package/src/components/ui/Alert.tsx +45 -45
  20. package/src/components/ui/DataTable.tsx +225 -225
  21. package/src/components/ui/Field.tsx +63 -63
  22. package/src/components/ui/PageHeader.tsx +36 -36
  23. package/src/components/ui/Pagination.tsx +37 -37
  24. package/src/components/ui/QuickStartPanel.tsx +57 -57
  25. package/src/components/ui/StateViews.tsx +135 -135
  26. package/src/components/ui/Switch.tsx +65 -65
  27. package/src/components/ui/Toast.tsx +104 -104
  28. package/src/components/ui/index.ts +9 -9
  29. package/src/config.ts +46 -46
  30. package/src/hooks/useDarkMode.ts +38 -38
  31. package/src/hooks/useDebouncedValue.ts +22 -22
  32. package/src/index.ts +42 -42
  33. package/src/lib/changelog.ts +93 -93
  34. package/src/lib/dateFormat.ts +90 -90
  35. package/src/navigation.ts +6 -6
  36. package/src/pages/ApiTokens.tsx +361 -361
  37. package/src/pages/AuditLog.tsx +388 -388
  38. package/src/pages/Backups.tsx +170 -170
  39. package/src/pages/ChangePassword.tsx +84 -84
  40. package/src/pages/Login.tsx +275 -275
  41. package/src/pages/Settings.tsx +264 -264
  42. package/src/pages/Setup.tsx +438 -438
  43. package/src/pages/SsoConfirm.tsx +26 -26
  44. package/src/pages/SsoTestResult.tsx +104 -104
  45. package/src/pages/Users.tsx +426 -426
  46. package/src/pages/Vault.tsx +1036 -1036
  47. package/src/shell/AdminShell.tsx +256 -256
  48. package/src/shell/guards.tsx +88 -88
  49. package/styles/base.css +521 -521
  50. package/styles/icons.css +4 -4
  51. package/tailwind-preset.js +79 -79
@@ -1,225 +1,225 @@
1
- import {
2
- createContext, useCallback, useContext, useMemo, useRef, useState,
3
- type KeyboardEvent, type PointerEvent, type ReactNode, type TableHTMLAttributes, type ThHTMLAttributes,
4
- } from 'react'
5
-
6
- export const MIN_COLUMN_WIDTH = 60
7
- export const MAX_COLUMN_WIDTH = 600
8
- const KEY_STEP = 16
9
-
10
- interface ResizeHandleProps {
11
- /** Column name, used for the accessible description. */
12
- label: string
13
- /** Current width in px; shown to assistive technology. */
14
- width: number | undefined
15
- /** Reads the width the column has right now; called when a drag starts and for key presses. */
16
- measure: () => number
17
- onResize: (width: number) => void
18
- /** Restores the default width. */
19
- onReset: () => void
20
- }
21
-
22
- /** Grip on the right edge of a column header: drag, arrow keys, double-click or Home to reset. Place it in a `position: relative` header cell. */
23
- export function ResizeHandle({ label, width, measure, onResize, onReset }: ResizeHandleProps) {
24
- const drag = useRef<{ startX: number; startWidth: number } | null>(null)
25
- const clamp = (w: number) => Math.min(MAX_COLUMN_WIDTH, Math.max(MIN_COLUMN_WIDTH, Math.round(w)))
26
-
27
- function onPointerDown(e: PointerEvent<HTMLSpanElement>) {
28
- e.preventDefault()
29
- e.currentTarget.setPointerCapture(e.pointerId)
30
- drag.current = { startX: e.clientX, startWidth: measure() }
31
- }
32
-
33
- function onPointerMove(e: PointerEvent<HTMLSpanElement>) {
34
- if (drag.current) onResize(clamp(drag.current.startWidth + e.clientX - drag.current.startX))
35
- }
36
-
37
- function onKeyDown(e: KeyboardEvent<HTMLSpanElement>) {
38
- if (e.key === 'ArrowLeft') onResize(clamp(measure() - KEY_STEP))
39
- else if (e.key === 'ArrowRight') onResize(clamp(measure() + KEY_STEP))
40
- else if (e.key === 'Home') onReset()
41
- else return
42
- e.preventDefault()
43
- }
44
-
45
- return (
46
- <span
47
- role="separator"
48
- aria-orientation="vertical"
49
- aria-description={`Resize ${label} column`}
50
- aria-valuenow={width}
51
- aria-valuemin={MIN_COLUMN_WIDTH}
52
- aria-valuemax={MAX_COLUMN_WIDTH}
53
- tabIndex={0}
54
- onPointerDown={onPointerDown}
55
- onPointerMove={onPointerMove}
56
- onPointerUp={() => { drag.current = null }}
57
- onPointerCancel={() => { drag.current = null }}
58
- onKeyDown={onKeyDown}
59
- onDoubleClick={onReset}
60
- className="group focus-ring absolute top-0 right-0 z-10 flex h-full w-3 cursor-col-resize touch-none select-none justify-center"
61
- >
62
- <span
63
- aria-hidden="true"
64
- className="block h-full w-px opacity-60 transition-all group-hover:w-0.5 group-hover:opacity-100 group-focus-visible:w-0.5 group-focus-visible:opacity-100 group-active:w-0.5 group-active:opacity-100"
65
- style={{ background: 'var(--m3-outline-variant)' }}
66
- />
67
- </span>
68
- )
69
- }
70
-
71
- export type SortDirection = 'asc' | 'desc'
72
- export type SortValue = string | number | boolean | null | undefined
73
-
74
- export interface TableSort {
75
- key: string | null
76
- dir: SortDirection
77
- /** Sorts by the column; repeated calls on the same column go ascending, descending, then back to the original order. */
78
- toggle: (key: string) => void
79
- }
80
-
81
- const isEmpty = (v: SortValue) => v == null || v === ''
82
-
83
- function compareValues(a: SortValue, b: SortValue): number {
84
- if (typeof a === 'number' && typeof b === 'number') return a - b
85
- if (typeof a === 'boolean' && typeof b === 'boolean') return Number(a) - Number(b)
86
- return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' })
87
- }
88
-
89
- /** Sorts `rows` by the column picked in a `DataTable` header. `accessors` maps a column key to the value to compare; empty values always go last. */
90
- export function useTableSort<T>(
91
- rows: readonly T[],
92
- accessors: Readonly<Record<string, (row: T) => SortValue>>,
93
- initial?: { key: string; dir?: SortDirection },
94
- ): { rows: T[]; sort: TableSort } {
95
- const [key, setKey] = useState<string | null>(initial?.key ?? null)
96
- const [dir, setDir] = useState<SortDirection>(initial?.dir ?? 'asc')
97
-
98
- const toggle = useCallback((next: string) => {
99
- if (key !== next) { setKey(next); setDir('asc') }
100
- else if (dir === 'asc') setDir('desc')
101
- else { setKey(null); setDir('asc') }
102
- }, [key, dir])
103
-
104
- // The accessors are declared inline by callers; the rows, column and direction are what change the order.
105
- const accessorsRef = useRef(accessors)
106
- accessorsRef.current = accessors
107
-
108
- const sorted = useMemo(() => {
109
- const get = key === null ? undefined : accessorsRef.current[key]
110
- if (!get) return [...rows]
111
- const sign = dir === 'asc' ? 1 : -1
112
- return [...rows].sort((x, y) => {
113
- const a = get(x), b = get(y)
114
- if (isEmpty(a) || isEmpty(b)) return Number(isEmpty(a)) - Number(isEmpty(b))
115
- return sign * compareValues(a, b)
116
- })
117
- }, [rows, key, dir])
118
-
119
- return { rows: sorted, sort: useMemo(() => ({ key, dir, toggle }), [key, dir, toggle]) }
120
- }
121
-
122
- interface TableContext {
123
- sort: TableSort | undefined
124
- widths: number[] | null
125
- resize: (row: HTMLTableRowElement, index: number, width: number) => void
126
- reset: () => void
127
- }
128
-
129
- const DataTableContext = createContext<TableContext | null>(null)
130
-
131
- interface DataTableProps extends TableHTMLAttributes<HTMLTableElement> {
132
- /** From `useTableSort`; without it the headers are not sortable. */
133
- sort?: TableSort
134
- children: ReactNode
135
- }
136
-
137
- /**
138
- * `<table>` whose `DataTh` headers can be sorted and resized. The first resize fixes every column at its
139
- * current width; a drag then moves the boundary to the next column, so the other columns stay where they are.
140
- */
141
- export function DataTable({ sort, children, style, className, ...rest }: DataTableProps) {
142
- const [widths, setWidths] = useState<number[] | null>(null)
143
-
144
- const resize = useCallback((row: HTMLTableRowElement, index: number, width: number) => {
145
- setWidths((current) => {
146
- const next = current ? [...current] : Array.from(row.cells, (cell) => cell.offsetWidth)
147
- const own = next[index]
148
- if (own === undefined) return current
149
- const neighbour = next[index + 1]
150
- let delta = width - own
151
- if (neighbour !== undefined) delta = Math.min(delta, neighbour - MIN_COLUMN_WIDTH)
152
- next[index] = own + delta
153
- if (neighbour !== undefined) next[index + 1] = neighbour - delta
154
- return next
155
- })
156
- }, [])
157
-
158
- const context = useMemo<TableContext>(
159
- () => ({ sort, widths, resize, reset: () => setWidths(null) }),
160
- [sort, widths, resize],
161
- )
162
-
163
- return (
164
- <DataTableContext.Provider value={context}>
165
- <table
166
- {...rest}
167
- className={`${className ?? ''} ${widths ? '[&_td]:break-words [&_th]:overflow-hidden' : ''}`}
168
- style={widths ? { ...style, tableLayout: 'fixed', width: widths.reduce((a, b) => a + b, 0), minWidth: 0 } : style}
169
- >
170
- {widths && <colgroup>{widths.map((w, i) => <col key={i} style={{ width: w }} />)}</colgroup>}
171
- {children}
172
- </table>
173
- </DataTableContext.Provider>
174
- )
175
- }
176
-
177
- interface DataThProps extends ThHTMLAttributes<HTMLTableCellElement> {
178
- /** Column key from `useTableSort`'s accessors; makes the header a sort button. */
179
- sortKey?: string
180
- /** Set false for columns without a title, such as the row actions. */
181
- resizable?: boolean
182
- }
183
-
184
- /** `<th>` for a `DataTable`: sortable with `sortKey`, resizable unless `resizable={false}`. Give it the classes and colours you would give a `<th>`. */
185
- export function DataTh({ sortKey, resizable = true, children, className, style, ...rest }: DataThProps) {
186
- const table = useContext(DataTableContext)
187
- const ref = useRef<HTMLTableCellElement>(null)
188
- const sorted = sortKey !== undefined && table?.sort?.key === sortKey
189
- const dir = table?.sort?.dir
190
- const label = typeof children === 'string' ? children : sortKey ?? 'this'
191
-
192
- return (
193
- <th
194
- ref={ref}
195
- {...rest}
196
- aria-sort={sorted ? (dir === 'asc' ? 'ascending' : 'descending') : rest['aria-sort']}
197
- className={`${className ?? ''} ${resizable && table ? 'relative' : ''}`}
198
- style={sorted ? { ...style, color: 'var(--m3-primary)' } : style}
199
- >
200
- {sortKey !== undefined && table?.sort ? (
201
- <button
202
- type="button"
203
- onClick={() => table.sort?.toggle(sortKey)}
204
- className="inline-flex items-center gap-1 uppercase tracking-wider rounded focus-ring"
205
- style={{ color: 'inherit', userSelect: 'none' }}
206
- >
207
- {children}
208
- {sorted && <span aria-hidden="true" style={{ fontSize: '10px' }}>{dir === 'asc' ? '↑' : '↓'}</span>}
209
- </button>
210
- ) : children}
211
- {resizable && table && (
212
- <ResizeHandle
213
- label={label}
214
- width={table.widths?.[ref.current?.cellIndex ?? -1]}
215
- measure={() => ref.current?.offsetWidth ?? MIN_COLUMN_WIDTH}
216
- onResize={(w) => {
217
- const cell = ref.current
218
- if (cell?.parentElement instanceof HTMLTableRowElement) table.resize(cell.parentElement, cell.cellIndex, w)
219
- }}
220
- onReset={table.reset}
221
- />
222
- )}
223
- </th>
224
- )
225
- }
1
+ import {
2
+ createContext, useCallback, useContext, useMemo, useRef, useState,
3
+ type KeyboardEvent, type PointerEvent, type ReactNode, type TableHTMLAttributes, type ThHTMLAttributes,
4
+ } from 'react'
5
+
6
+ export const MIN_COLUMN_WIDTH = 60
7
+ export const MAX_COLUMN_WIDTH = 600
8
+ const KEY_STEP = 16
9
+
10
+ interface ResizeHandleProps {
11
+ /** Column name, used for the accessible description. */
12
+ label: string
13
+ /** Current width in px; shown to assistive technology. */
14
+ width: number | undefined
15
+ /** Reads the width the column has right now; called when a drag starts and for key presses. */
16
+ measure: () => number
17
+ onResize: (width: number) => void
18
+ /** Restores the default width. */
19
+ onReset: () => void
20
+ }
21
+
22
+ /** Grip on the right edge of a column header: drag, arrow keys, double-click or Home to reset. Place it in a `position: relative` header cell. */
23
+ export function ResizeHandle({ label, width, measure, onResize, onReset }: ResizeHandleProps) {
24
+ const drag = useRef<{ startX: number; startWidth: number } | null>(null)
25
+ const clamp = (w: number) => Math.min(MAX_COLUMN_WIDTH, Math.max(MIN_COLUMN_WIDTH, Math.round(w)))
26
+
27
+ function onPointerDown(e: PointerEvent<HTMLSpanElement>) {
28
+ e.preventDefault()
29
+ e.currentTarget.setPointerCapture(e.pointerId)
30
+ drag.current = { startX: e.clientX, startWidth: measure() }
31
+ }
32
+
33
+ function onPointerMove(e: PointerEvent<HTMLSpanElement>) {
34
+ if (drag.current) onResize(clamp(drag.current.startWidth + e.clientX - drag.current.startX))
35
+ }
36
+
37
+ function onKeyDown(e: KeyboardEvent<HTMLSpanElement>) {
38
+ if (e.key === 'ArrowLeft') onResize(clamp(measure() - KEY_STEP))
39
+ else if (e.key === 'ArrowRight') onResize(clamp(measure() + KEY_STEP))
40
+ else if (e.key === 'Home') onReset()
41
+ else return
42
+ e.preventDefault()
43
+ }
44
+
45
+ return (
46
+ <span
47
+ role="separator"
48
+ aria-orientation="vertical"
49
+ aria-description={`Resize ${label} column`}
50
+ aria-valuenow={width}
51
+ aria-valuemin={MIN_COLUMN_WIDTH}
52
+ aria-valuemax={MAX_COLUMN_WIDTH}
53
+ tabIndex={0}
54
+ onPointerDown={onPointerDown}
55
+ onPointerMove={onPointerMove}
56
+ onPointerUp={() => { drag.current = null }}
57
+ onPointerCancel={() => { drag.current = null }}
58
+ onKeyDown={onKeyDown}
59
+ onDoubleClick={onReset}
60
+ className="group focus-ring absolute top-0 right-0 z-10 flex h-full w-3 cursor-col-resize touch-none select-none justify-center"
61
+ >
62
+ <span
63
+ aria-hidden="true"
64
+ className="block h-full w-px opacity-60 transition-all group-hover:w-0.5 group-hover:opacity-100 group-focus-visible:w-0.5 group-focus-visible:opacity-100 group-active:w-0.5 group-active:opacity-100"
65
+ style={{ background: 'var(--m3-outline-variant)' }}
66
+ />
67
+ </span>
68
+ )
69
+ }
70
+
71
+ export type SortDirection = 'asc' | 'desc'
72
+ export type SortValue = string | number | boolean | null | undefined
73
+
74
+ export interface TableSort {
75
+ key: string | null
76
+ dir: SortDirection
77
+ /** Sorts by the column; repeated calls on the same column go ascending, descending, then back to the original order. */
78
+ toggle: (key: string) => void
79
+ }
80
+
81
+ const isEmpty = (v: SortValue) => v == null || v === ''
82
+
83
+ function compareValues(a: SortValue, b: SortValue): number {
84
+ if (typeof a === 'number' && typeof b === 'number') return a - b
85
+ if (typeof a === 'boolean' && typeof b === 'boolean') return Number(a) - Number(b)
86
+ return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' })
87
+ }
88
+
89
+ /** Sorts `rows` by the column picked in a `DataTable` header. `accessors` maps a column key to the value to compare; empty values always go last. */
90
+ export function useTableSort<T>(
91
+ rows: readonly T[],
92
+ accessors: Readonly<Record<string, (row: T) => SortValue>>,
93
+ initial?: { key: string; dir?: SortDirection },
94
+ ): { rows: T[]; sort: TableSort } {
95
+ const [key, setKey] = useState<string | null>(initial?.key ?? null)
96
+ const [dir, setDir] = useState<SortDirection>(initial?.dir ?? 'asc')
97
+
98
+ const toggle = useCallback((next: string) => {
99
+ if (key !== next) { setKey(next); setDir('asc') }
100
+ else if (dir === 'asc') setDir('desc')
101
+ else { setKey(null); setDir('asc') }
102
+ }, [key, dir])
103
+
104
+ // The accessors are declared inline by callers; the rows, column and direction are what change the order.
105
+ const accessorsRef = useRef(accessors)
106
+ accessorsRef.current = accessors
107
+
108
+ const sorted = useMemo(() => {
109
+ const get = key === null ? undefined : accessorsRef.current[key]
110
+ if (!get) return [...rows]
111
+ const sign = dir === 'asc' ? 1 : -1
112
+ return [...rows].sort((x, y) => {
113
+ const a = get(x), b = get(y)
114
+ if (isEmpty(a) || isEmpty(b)) return Number(isEmpty(a)) - Number(isEmpty(b))
115
+ return sign * compareValues(a, b)
116
+ })
117
+ }, [rows, key, dir])
118
+
119
+ return { rows: sorted, sort: useMemo(() => ({ key, dir, toggle }), [key, dir, toggle]) }
120
+ }
121
+
122
+ interface TableContext {
123
+ sort: TableSort | undefined
124
+ widths: number[] | null
125
+ resize: (row: HTMLTableRowElement, index: number, width: number) => void
126
+ reset: () => void
127
+ }
128
+
129
+ const DataTableContext = createContext<TableContext | null>(null)
130
+
131
+ interface DataTableProps extends TableHTMLAttributes<HTMLTableElement> {
132
+ /** From `useTableSort`; without it the headers are not sortable. */
133
+ sort?: TableSort
134
+ children: ReactNode
135
+ }
136
+
137
+ /**
138
+ * `<table>` whose `DataTh` headers can be sorted and resized. The first resize fixes every column at its
139
+ * current width; a drag then moves the boundary to the next column, so the other columns stay where they are.
140
+ */
141
+ export function DataTable({ sort, children, style, className, ...rest }: DataTableProps) {
142
+ const [widths, setWidths] = useState<number[] | null>(null)
143
+
144
+ const resize = useCallback((row: HTMLTableRowElement, index: number, width: number) => {
145
+ setWidths((current) => {
146
+ const next = current ? [...current] : Array.from(row.cells, (cell) => cell.offsetWidth)
147
+ const own = next[index]
148
+ if (own === undefined) return current
149
+ const neighbour = next[index + 1]
150
+ let delta = width - own
151
+ if (neighbour !== undefined) delta = Math.min(delta, neighbour - MIN_COLUMN_WIDTH)
152
+ next[index] = own + delta
153
+ if (neighbour !== undefined) next[index + 1] = neighbour - delta
154
+ return next
155
+ })
156
+ }, [])
157
+
158
+ const context = useMemo<TableContext>(
159
+ () => ({ sort, widths, resize, reset: () => setWidths(null) }),
160
+ [sort, widths, resize],
161
+ )
162
+
163
+ return (
164
+ <DataTableContext.Provider value={context}>
165
+ <table
166
+ {...rest}
167
+ className={`${className ?? ''} ${widths ? '[&_td]:break-words [&_th]:overflow-hidden' : ''}`}
168
+ style={widths ? { ...style, tableLayout: 'fixed', width: widths.reduce((a, b) => a + b, 0), minWidth: 0 } : style}
169
+ >
170
+ {widths && <colgroup>{widths.map((w, i) => <col key={i} style={{ width: w }} />)}</colgroup>}
171
+ {children}
172
+ </table>
173
+ </DataTableContext.Provider>
174
+ )
175
+ }
176
+
177
+ interface DataThProps extends ThHTMLAttributes<HTMLTableCellElement> {
178
+ /** Column key from `useTableSort`'s accessors; makes the header a sort button. */
179
+ sortKey?: string
180
+ /** Set false for columns without a title, such as the row actions. */
181
+ resizable?: boolean
182
+ }
183
+
184
+ /** `<th>` for a `DataTable`: sortable with `sortKey`, resizable unless `resizable={false}`. Give it the classes and colours you would give a `<th>`. */
185
+ export function DataTh({ sortKey, resizable = true, children, className, style, ...rest }: DataThProps) {
186
+ const table = useContext(DataTableContext)
187
+ const ref = useRef<HTMLTableCellElement>(null)
188
+ const sorted = sortKey !== undefined && table?.sort?.key === sortKey
189
+ const dir = table?.sort?.dir
190
+ const label = typeof children === 'string' ? children : sortKey ?? 'this'
191
+
192
+ return (
193
+ <th
194
+ ref={ref}
195
+ {...rest}
196
+ aria-sort={sorted ? (dir === 'asc' ? 'ascending' : 'descending') : rest['aria-sort']}
197
+ className={`${className ?? ''} ${resizable && table ? 'relative' : ''}`}
198
+ style={sorted ? { ...style, color: 'var(--m3-primary)' } : style}
199
+ >
200
+ {sortKey !== undefined && table?.sort ? (
201
+ <button
202
+ type="button"
203
+ onClick={() => table.sort?.toggle(sortKey)}
204
+ className="inline-flex items-center gap-1 uppercase tracking-wider rounded focus-ring"
205
+ style={{ color: 'inherit', userSelect: 'none' }}
206
+ >
207
+ {children}
208
+ {sorted && <span aria-hidden="true" style={{ fontSize: '10px' }}>{dir === 'asc' ? '↑' : '↓'}</span>}
209
+ </button>
210
+ ) : children}
211
+ {resizable && table && (
212
+ <ResizeHandle
213
+ label={label}
214
+ width={table.widths?.[ref.current?.cellIndex ?? -1]}
215
+ measure={() => ref.current?.offsetWidth ?? MIN_COLUMN_WIDTH}
216
+ onResize={(w) => {
217
+ const cell = ref.current
218
+ if (cell?.parentElement instanceof HTMLTableRowElement) table.resize(cell.parentElement, cell.cellIndex, w)
219
+ }}
220
+ onReset={table.reset}
221
+ />
222
+ )}
223
+ </th>
224
+ )
225
+ }
@@ -1,63 +1,63 @@
1
- import clsx from 'clsx'
2
- import { cloneElement, isValidElement, useId, type ReactElement, type ReactNode } from 'react'
3
-
4
- /** Props Field hands to its control so the label, hint and error are programmatically associated. */
5
- export interface FieldControlProps {
6
- id: string
7
- 'aria-describedby'?: string | undefined
8
- 'aria-invalid'?: true | undefined
9
- 'aria-required'?: true | undefined
10
- }
11
-
12
- interface FieldProps {
13
- label: ReactNode
14
- hint?: ReactNode | undefined
15
- error?: ReactNode | undefined
16
- required?: boolean | undefined
17
- /** Use a specific id for the control (otherwise one is generated, or the child's own `id` is kept). */
18
- id?: string | undefined
19
- /** `caps`: small mono uppercase label (form default); `plain`: small sentence-case label. */
20
- variant?: 'caps' | 'plain' | undefined
21
- className?: string | undefined
22
- /**
23
- * Either a single control element (input/select/textarea/custom component that forwards `id` and aria-*),
24
- * which receives the props via cloneElement, or a render function receiving them to spread where needed.
25
- */
26
- children: ReactElement<Partial<FieldControlProps>> | ((control: FieldControlProps) => ReactNode)
27
- }
28
-
29
- const LABEL_CLASS = {
30
- caps: 'block font-mono text-xs uppercase tracking-wider mb-2',
31
- plain: 'block text-xs mb-1.5',
32
- }
33
-
34
- export function Field({ label, hint, error, required = false, id, variant = 'caps', className, children }: FieldProps) {
35
- const generatedId = useId()
36
- const childId = typeof children !== 'function' && isValidElement(children) ? children.props.id : undefined
37
- const controlId = id ?? childId ?? generatedId
38
- const hintId = hint ? `${controlId}-hint` : undefined
39
- const errorId = error ? `${controlId}-error` : undefined
40
- const existingDescribedBy = typeof children !== 'function' && isValidElement(children) ? children.props['aria-describedby'] : undefined
41
- const describedBy = [existingDescribedBy, hintId, errorId].filter(Boolean).join(' ') || undefined
42
-
43
- const controlProps: FieldControlProps = {
44
- id: controlId,
45
- 'aria-describedby': describedBy,
46
- ...(error ? { 'aria-invalid': true as const } : {}),
47
- ...(required ? { 'aria-required': true as const } : {}),
48
- }
49
-
50
- const control = typeof children === 'function' ? children(controlProps) : cloneElement(children, controlProps)
51
-
52
- return (
53
- <div className={className}>
54
- <label htmlFor={controlId} className={LABEL_CLASS[variant]} style={{ color: 'var(--m3-secondary)' }}>
55
- {label}
56
- {required && <span aria-hidden="true" style={{ color: 'var(--m3-down)' }}> *</span>}
57
- </label>
58
- {control}
59
- {hint && <p id={hintId} className="text-xs mt-1.5" style={{ color: 'var(--m3-secondary)' }}>{hint}</p>}
60
- {error && <p id={errorId} className={clsx('text-xs', hint ? 'mt-1' : 'mt-1.5')} style={{ color: 'var(--m3-down)' }}>{error}</p>}
61
- </div>
62
- )
63
- }
1
+ import clsx from 'clsx'
2
+ import { cloneElement, isValidElement, useId, type ReactElement, type ReactNode } from 'react'
3
+
4
+ /** Props Field hands to its control so the label, hint and error are programmatically associated. */
5
+ export interface FieldControlProps {
6
+ id: string
7
+ 'aria-describedby'?: string | undefined
8
+ 'aria-invalid'?: true | undefined
9
+ 'aria-required'?: true | undefined
10
+ }
11
+
12
+ interface FieldProps {
13
+ label: ReactNode
14
+ hint?: ReactNode | undefined
15
+ error?: ReactNode | undefined
16
+ required?: boolean | undefined
17
+ /** Use a specific id for the control (otherwise one is generated, or the child's own `id` is kept). */
18
+ id?: string | undefined
19
+ /** `caps`: small mono uppercase label (form default); `plain`: small sentence-case label. */
20
+ variant?: 'caps' | 'plain' | undefined
21
+ className?: string | undefined
22
+ /**
23
+ * Either a single control element (input/select/textarea/custom component that forwards `id` and aria-*),
24
+ * which receives the props via cloneElement, or a render function receiving them to spread where needed.
25
+ */
26
+ children: ReactElement<Partial<FieldControlProps>> | ((control: FieldControlProps) => ReactNode)
27
+ }
28
+
29
+ const LABEL_CLASS = {
30
+ caps: 'block font-mono text-xs uppercase tracking-wider mb-2',
31
+ plain: 'block text-xs mb-1.5',
32
+ }
33
+
34
+ export function Field({ label, hint, error, required = false, id, variant = 'caps', className, children }: FieldProps) {
35
+ const generatedId = useId()
36
+ const childId = typeof children !== 'function' && isValidElement(children) ? children.props.id : undefined
37
+ const controlId = id ?? childId ?? generatedId
38
+ const hintId = hint ? `${controlId}-hint` : undefined
39
+ const errorId = error ? `${controlId}-error` : undefined
40
+ const existingDescribedBy = typeof children !== 'function' && isValidElement(children) ? children.props['aria-describedby'] : undefined
41
+ const describedBy = [existingDescribedBy, hintId, errorId].filter(Boolean).join(' ') || undefined
42
+
43
+ const controlProps: FieldControlProps = {
44
+ id: controlId,
45
+ 'aria-describedby': describedBy,
46
+ ...(error ? { 'aria-invalid': true as const } : {}),
47
+ ...(required ? { 'aria-required': true as const } : {}),
48
+ }
49
+
50
+ const control = typeof children === 'function' ? children(controlProps) : cloneElement(children, controlProps)
51
+
52
+ return (
53
+ <div className={className}>
54
+ <label htmlFor={controlId} className={LABEL_CLASS[variant]} style={{ color: 'var(--m3-secondary)' }}>
55
+ {label}
56
+ {required && <span aria-hidden="true" style={{ color: 'var(--m3-down)' }}> *</span>}
57
+ </label>
58
+ {control}
59
+ {hint && <p id={hintId} className="text-xs mt-1.5" style={{ color: 'var(--m3-secondary)' }}>{hint}</p>}
60
+ {error && <p id={errorId} className={clsx('text-xs', hint ? 'mt-1' : 'mt-1.5')} style={{ color: 'var(--m3-down)' }}>{error}</p>}
61
+ </div>
62
+ )
63
+ }
@@ -1,36 +1,36 @@
1
- import clsx from 'clsx'
2
- import type { ReactNode } from 'react'
3
-
4
- interface PageContainerProps {
5
- children: ReactNode
6
- className?: string | undefined
7
- }
8
-
9
- /** Standard page padding and vertical rhythm with the entrance animation every admin page uses. */
10
- export function PageContainer({ children, className }: PageContainerProps) {
11
- return <div className={clsx('p-4 md:p-8 space-y-6 fade-up', className)}>{children}</div>
12
- }
13
-
14
- interface PageHeaderProps {
15
- title: ReactNode
16
- subtitle?: ReactNode | undefined
17
- /** Right-aligned controls (buttons, filters); they wrap under the title on narrow screens. */
18
- actions?: ReactNode | undefined
19
- /** Extra content rendered under the title row, e.g. tabs or a filter bar. */
20
- children?: ReactNode | undefined
21
- }
22
-
23
- export function PageHeader({ title, subtitle, actions, children }: PageHeaderProps) {
24
- return (
25
- <header className="space-y-4">
26
- <div className="flex flex-wrap items-start justify-between gap-x-4 gap-y-3">
27
- <div className="min-w-0">
28
- <h1 className="font-headline font-bold text-2xl" style={{ color: 'var(--m3-on-surface)' }}>{title}</h1>
29
- {subtitle && <p className="text-sm mt-1" style={{ color: 'var(--m3-secondary)' }}>{subtitle}</p>}
30
- </div>
31
- {actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
32
- </div>
33
- {children}
34
- </header>
35
- )
36
- }
1
+ import clsx from 'clsx'
2
+ import type { ReactNode } from 'react'
3
+
4
+ interface PageContainerProps {
5
+ children: ReactNode
6
+ className?: string | undefined
7
+ }
8
+
9
+ /** Standard page padding and vertical rhythm with the entrance animation every admin page uses. */
10
+ export function PageContainer({ children, className }: PageContainerProps) {
11
+ return <div className={clsx('p-4 md:p-8 space-y-6 fade-up', className)}>{children}</div>
12
+ }
13
+
14
+ interface PageHeaderProps {
15
+ title: ReactNode
16
+ subtitle?: ReactNode | undefined
17
+ /** Right-aligned controls (buttons, filters); they wrap under the title on narrow screens. */
18
+ actions?: ReactNode | undefined
19
+ /** Extra content rendered under the title row, e.g. tabs or a filter bar. */
20
+ children?: ReactNode | undefined
21
+ }
22
+
23
+ export function PageHeader({ title, subtitle, actions, children }: PageHeaderProps) {
24
+ return (
25
+ <header className="space-y-4">
26
+ <div className="flex flex-wrap items-start justify-between gap-x-4 gap-y-3">
27
+ <div className="min-w-0">
28
+ <h1 className="font-headline font-bold text-2xl" style={{ color: 'var(--m3-on-surface)' }}>{title}</h1>
29
+ {subtitle && <p className="text-sm mt-1" style={{ color: 'var(--m3-secondary)' }}>{subtitle}</p>}
30
+ </div>
31
+ {actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
32
+ </div>
33
+ {children}
34
+ </header>
35
+ )
36
+ }