@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.
- package/LICENSE +21 -21
- package/README.md +13 -13
- package/icons.json +170 -170
- package/package.json +71 -71
- package/src/api/client.ts +139 -139
- package/src/components/ConfirmIdentityModal.tsx +82 -82
- package/src/components/ConfirmModal.tsx +83 -83
- package/src/components/CopyButton.tsx +71 -71
- package/src/components/CredentialSection.tsx +270 -270
- package/src/components/ErrorBoundary.tsx +47 -47
- package/src/components/ModalShell.tsx +160 -160
- package/src/components/ResetTwoFactorModal.tsx +74 -74
- package/src/components/SidePanel.tsx +96 -96
- package/src/components/SingleSignOnModal.tsx +161 -161
- package/src/components/SsoConfirmation.tsx +96 -96
- package/src/components/VaultTypePicker.tsx +131 -131
- package/src/components/WhatsNew.tsx +84 -84
- package/src/components/formParts.tsx +59 -59
- package/src/components/ui/Alert.tsx +45 -45
- package/src/components/ui/DataTable.tsx +225 -225
- package/src/components/ui/Field.tsx +63 -63
- package/src/components/ui/PageHeader.tsx +36 -36
- package/src/components/ui/Pagination.tsx +37 -37
- package/src/components/ui/QuickStartPanel.tsx +57 -57
- package/src/components/ui/StateViews.tsx +135 -135
- package/src/components/ui/Switch.tsx +65 -65
- package/src/components/ui/Toast.tsx +104 -104
- package/src/components/ui/index.ts +9 -9
- package/src/config.ts +46 -46
- package/src/hooks/useDarkMode.ts +38 -38
- package/src/hooks/useDebouncedValue.ts +22 -22
- package/src/index.ts +42 -42
- package/src/lib/changelog.ts +93 -93
- package/src/lib/dateFormat.ts +90 -90
- package/src/navigation.ts +6 -6
- package/src/pages/ApiTokens.tsx +361 -361
- package/src/pages/AuditLog.tsx +388 -388
- package/src/pages/Backups.tsx +170 -170
- package/src/pages/ChangePassword.tsx +84 -84
- package/src/pages/Login.tsx +275 -275
- package/src/pages/Settings.tsx +264 -264
- package/src/pages/Setup.tsx +438 -438
- package/src/pages/SsoConfirm.tsx +26 -26
- package/src/pages/SsoTestResult.tsx +104 -104
- package/src/pages/Users.tsx +426 -426
- package/src/pages/Vault.tsx +1036 -1036
- package/src/shell/AdminShell.tsx +256 -256
- package/src/shell/guards.tsx +88 -88
- package/styles/base.css +521 -521
- package/styles/icons.css +4 -4
- 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
|
+
}
|