@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,37 +1,37 @@
|
|
|
1
|
-
import type { ReactNode } from 'react'
|
|
2
|
-
|
|
3
|
-
type PaginationProps = {
|
|
4
|
-
/** 1-based current page. */
|
|
5
|
-
page: number
|
|
6
|
-
onPageChange: (page: number) => void
|
|
7
|
-
/** Text on the left, e.g. "Page 2 of 5 · 93 entries". Defaults to "Page X of Y" when pageCount is known. */
|
|
8
|
-
summary?: ReactNode | undefined
|
|
9
|
-
/** Hide the whole bar when there is only one page (default true; ignored with hasNext). */
|
|
10
|
-
hideWhenSingle?: boolean | undefined
|
|
11
|
-
} & (
|
|
12
|
-
| { pageCount: number; hasNext?: never }
|
|
13
|
-
/** For cursor/unknown-total lists: whether a next page exists. */
|
|
14
|
-
| { hasNext: boolean; pageCount?: never }
|
|
15
|
-
)
|
|
16
|
-
|
|
17
|
-
export function Pagination({ page, onPageChange, summary, hideWhenSingle = true, pageCount, hasNext }: PaginationProps) {
|
|
18
|
-
const canPrev = page > 1
|
|
19
|
-
const canNext = pageCount !== undefined ? page < pageCount : Boolean(hasNext)
|
|
20
|
-
if (pageCount !== undefined && hideWhenSingle && pageCount <= 1) return null
|
|
21
|
-
|
|
22
|
-
const text = summary ?? (pageCount !== undefined ? `Page ${page} of ${pageCount}` : `Page ${page}`)
|
|
23
|
-
|
|
24
|
-
return (
|
|
25
|
-
<nav aria-label="Pagination" className="flex flex-wrap items-center justify-between gap-3">
|
|
26
|
-
<span className="text-sm" style={{ color: 'var(--m3-secondary)' }}>{text}</span>
|
|
27
|
-
<div className="flex gap-2">
|
|
28
|
-
<button type="button" onClick={() => onPageChange(page - 1)} disabled={!canPrev} aria-label="Previous page" className="btn btn-outline btn-sm text-sm font-normal">
|
|
29
|
-
← Prev
|
|
30
|
-
</button>
|
|
31
|
-
<button type="button" onClick={() => onPageChange(page + 1)} disabled={!canNext} aria-label="Next page" className="btn btn-outline btn-sm text-sm font-normal">
|
|
32
|
-
Next →
|
|
33
|
-
</button>
|
|
34
|
-
</div>
|
|
35
|
-
</nav>
|
|
36
|
-
)
|
|
37
|
-
}
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
type PaginationProps = {
|
|
4
|
+
/** 1-based current page. */
|
|
5
|
+
page: number
|
|
6
|
+
onPageChange: (page: number) => void
|
|
7
|
+
/** Text on the left, e.g. "Page 2 of 5 · 93 entries". Defaults to "Page X of Y" when pageCount is known. */
|
|
8
|
+
summary?: ReactNode | undefined
|
|
9
|
+
/** Hide the whole bar when there is only one page (default true; ignored with hasNext). */
|
|
10
|
+
hideWhenSingle?: boolean | undefined
|
|
11
|
+
} & (
|
|
12
|
+
| { pageCount: number; hasNext?: never }
|
|
13
|
+
/** For cursor/unknown-total lists: whether a next page exists. */
|
|
14
|
+
| { hasNext: boolean; pageCount?: never }
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
export function Pagination({ page, onPageChange, summary, hideWhenSingle = true, pageCount, hasNext }: PaginationProps) {
|
|
18
|
+
const canPrev = page > 1
|
|
19
|
+
const canNext = pageCount !== undefined ? page < pageCount : Boolean(hasNext)
|
|
20
|
+
if (pageCount !== undefined && hideWhenSingle && pageCount <= 1) return null
|
|
21
|
+
|
|
22
|
+
const text = summary ?? (pageCount !== undefined ? `Page ${page} of ${pageCount}` : `Page ${page}`)
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<nav aria-label="Pagination" className="flex flex-wrap items-center justify-between gap-3">
|
|
26
|
+
<span className="text-sm" style={{ color: 'var(--m3-secondary)' }}>{text}</span>
|
|
27
|
+
<div className="flex gap-2">
|
|
28
|
+
<button type="button" onClick={() => onPageChange(page - 1)} disabled={!canPrev} aria-label="Previous page" className="btn btn-outline btn-sm text-sm font-normal">
|
|
29
|
+
← Prev
|
|
30
|
+
</button>
|
|
31
|
+
<button type="button" onClick={() => onPageChange(page + 1)} disabled={!canNext} aria-label="Next page" className="btn btn-outline btn-sm text-sm font-normal">
|
|
32
|
+
Next →
|
|
33
|
+
</button>
|
|
34
|
+
</div>
|
|
35
|
+
</nav>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
@@ -1,57 +1,57 @@
|
|
|
1
|
-
import { useId, type ReactNode } from 'react'
|
|
2
|
-
|
|
3
|
-
export interface QuickStartOption<K extends string> {
|
|
4
|
-
key: K
|
|
5
|
-
label: string
|
|
6
|
-
hint: string
|
|
7
|
-
/** Icon element (a Material Symbol or a brand glyph), rendered inside the tile's icon chip. */
|
|
8
|
-
icon: ReactNode
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
interface QuickStartPanelProps<K extends string> {
|
|
12
|
-
title: string
|
|
13
|
-
description?: ReactNode | undefined
|
|
14
|
-
options: QuickStartOption<K>[]
|
|
15
|
-
onPick: (key: K) => void
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* First-run panel shown in place of an empty list when the first step is a choice, e.g. which kind of
|
|
20
|
-
* channel to create. Each tile starts the create flow with that choice already made.
|
|
21
|
-
*/
|
|
22
|
-
export function QuickStartPanel<K extends string>({ title, description, options, onPick }: QuickStartPanelProps<K>) {
|
|
23
|
-
const titleId = useId()
|
|
24
|
-
return (
|
|
25
|
-
<section
|
|
26
|
-
aria-labelledby={titleId}
|
|
27
|
-
className="rounded-2xl p-5 space-y-4"
|
|
28
|
-
style={{ background: 'var(--m3-surface-container-low)', border: '1px solid var(--m3-outline-variant)' }}
|
|
29
|
-
>
|
|
30
|
-
<div className="space-y-0.5">
|
|
31
|
-
<h2 id={titleId} className="font-headline font-semibold text-[15px]" style={{ color: 'var(--m3-on-surface)' }}>{title}</h2>
|
|
32
|
-
{description && <p className="text-[13px]" style={{ color: 'var(--m3-secondary)' }}>{description}</p>}
|
|
33
|
-
</div>
|
|
34
|
-
<ul className="grid gap-2.5 grid-cols-[repeat(auto-fill,minmax(150px,1fr))]">
|
|
35
|
-
{options.map((option) => (
|
|
36
|
-
<li key={option.key}>
|
|
37
|
-
<button
|
|
38
|
-
type="button"
|
|
39
|
-
onClick={() => onPick(option.key)}
|
|
40
|
-
className="w-full h-full grid gap-2 content-start text-left rounded-[14px] p-3.5 border border-outline-variant bg-surface text-on-surface hover:border-outline hover:bg-surface-container-lowest motion-safe:transition-colors focus-ring"
|
|
41
|
-
>
|
|
42
|
-
<span
|
|
43
|
-
className="grid place-items-center w-[34px] h-[34px] rounded-[10px]"
|
|
44
|
-
aria-hidden="true"
|
|
45
|
-
style={{ background: 'var(--admin-icon-container)', color: 'var(--admin-icon-color)' }}
|
|
46
|
-
>
|
|
47
|
-
{option.icon}
|
|
48
|
-
</span>
|
|
49
|
-
<span className="text-[13px] font-semibold">{option.label}</span>
|
|
50
|
-
<span className="text-xs" style={{ color: 'var(--m3-secondary)' }}>{option.hint}</span>
|
|
51
|
-
</button>
|
|
52
|
-
</li>
|
|
53
|
-
))}
|
|
54
|
-
</ul>
|
|
55
|
-
</section>
|
|
56
|
-
)
|
|
57
|
-
}
|
|
1
|
+
import { useId, type ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
export interface QuickStartOption<K extends string> {
|
|
4
|
+
key: K
|
|
5
|
+
label: string
|
|
6
|
+
hint: string
|
|
7
|
+
/** Icon element (a Material Symbol or a brand glyph), rendered inside the tile's icon chip. */
|
|
8
|
+
icon: ReactNode
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface QuickStartPanelProps<K extends string> {
|
|
12
|
+
title: string
|
|
13
|
+
description?: ReactNode | undefined
|
|
14
|
+
options: QuickStartOption<K>[]
|
|
15
|
+
onPick: (key: K) => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* First-run panel shown in place of an empty list when the first step is a choice, e.g. which kind of
|
|
20
|
+
* channel to create. Each tile starts the create flow with that choice already made.
|
|
21
|
+
*/
|
|
22
|
+
export function QuickStartPanel<K extends string>({ title, description, options, onPick }: QuickStartPanelProps<K>) {
|
|
23
|
+
const titleId = useId()
|
|
24
|
+
return (
|
|
25
|
+
<section
|
|
26
|
+
aria-labelledby={titleId}
|
|
27
|
+
className="rounded-2xl p-5 space-y-4"
|
|
28
|
+
style={{ background: 'var(--m3-surface-container-low)', border: '1px solid var(--m3-outline-variant)' }}
|
|
29
|
+
>
|
|
30
|
+
<div className="space-y-0.5">
|
|
31
|
+
<h2 id={titleId} className="font-headline font-semibold text-[15px]" style={{ color: 'var(--m3-on-surface)' }}>{title}</h2>
|
|
32
|
+
{description && <p className="text-[13px]" style={{ color: 'var(--m3-secondary)' }}>{description}</p>}
|
|
33
|
+
</div>
|
|
34
|
+
<ul className="grid gap-2.5 grid-cols-[repeat(auto-fill,minmax(150px,1fr))]">
|
|
35
|
+
{options.map((option) => (
|
|
36
|
+
<li key={option.key}>
|
|
37
|
+
<button
|
|
38
|
+
type="button"
|
|
39
|
+
onClick={() => onPick(option.key)}
|
|
40
|
+
className="w-full h-full grid gap-2 content-start text-left rounded-[14px] p-3.5 border border-outline-variant bg-surface text-on-surface hover:border-outline hover:bg-surface-container-lowest motion-safe:transition-colors focus-ring"
|
|
41
|
+
>
|
|
42
|
+
<span
|
|
43
|
+
className="grid place-items-center w-[34px] h-[34px] rounded-[10px]"
|
|
44
|
+
aria-hidden="true"
|
|
45
|
+
style={{ background: 'var(--admin-icon-container)', color: 'var(--admin-icon-color)' }}
|
|
46
|
+
>
|
|
47
|
+
{option.icon}
|
|
48
|
+
</span>
|
|
49
|
+
<span className="text-[13px] font-semibold">{option.label}</span>
|
|
50
|
+
<span className="text-xs" style={{ color: 'var(--m3-secondary)' }}>{option.hint}</span>
|
|
51
|
+
</button>
|
|
52
|
+
</li>
|
|
53
|
+
))}
|
|
54
|
+
</ul>
|
|
55
|
+
</section>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
@@ -1,135 +1,135 @@
|
|
|
1
|
-
import clsx from 'clsx'
|
|
2
|
-
import type { CSSProperties, ReactNode } from 'react'
|
|
3
|
-
|
|
4
|
-
interface LoadingStateProps {
|
|
5
|
-
label?: string | undefined
|
|
6
|
-
className?: string | undefined
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
/** Centered spinner with a caption for data that is still loading. */
|
|
10
|
-
export function LoadingState({ label = 'Loading…', className }: LoadingStateProps) {
|
|
11
|
-
return (
|
|
12
|
-
<div role="status" aria-live="polite" className={clsx('flex items-center justify-center gap-3 py-12 text-sm', className)} style={{ color: 'var(--m3-secondary)' }}>
|
|
13
|
-
<span className="material-symbols-outlined animate-spin" aria-hidden="true" style={{ fontSize: '20px' }}>progress_activity</span>
|
|
14
|
-
<span>{label}</span>
|
|
15
|
-
</div>
|
|
16
|
-
)
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
interface ErrorStateProps {
|
|
20
|
-
message?: ReactNode | undefined
|
|
21
|
-
/** Renders a "Try again" button. Pass e.g. `() => void query.refetch()`. */
|
|
22
|
-
onRetry?: (() => void) | undefined
|
|
23
|
-
className?: string | undefined
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/** Failed-to-load panel with an optional retry action. */
|
|
27
|
-
export function ErrorState({ message = 'Something went wrong while loading this data.', onRetry, className }: ErrorStateProps) {
|
|
28
|
-
return (
|
|
29
|
-
<div
|
|
30
|
-
role="alert"
|
|
31
|
-
className={clsx('flex flex-col items-center justify-center gap-3 rounded-2xl px-6 py-10 text-center', className)}
|
|
32
|
-
style={{ background: 'var(--m3-down-bg)', color: 'var(--m3-down)' }}
|
|
33
|
-
>
|
|
34
|
-
<span className="material-symbols-outlined" aria-hidden="true" style={{ fontSize: '28px' }}>error</span>
|
|
35
|
-
<p className="text-sm max-w-md">{message}</p>
|
|
36
|
-
{onRetry && (
|
|
37
|
-
<button type="button" onClick={onRetry} className="btn btn-secondary btn-sm">
|
|
38
|
-
<span className="material-symbols-outlined" aria-hidden="true">refresh</span>
|
|
39
|
-
Try again
|
|
40
|
-
</button>
|
|
41
|
-
)}
|
|
42
|
-
</div>
|
|
43
|
-
)
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
type EmptyStateVariant = 'card' | 'inset' | 'row'
|
|
47
|
-
|
|
48
|
-
interface EmptyStateProps {
|
|
49
|
-
title: ReactNode
|
|
50
|
-
description?: ReactNode | undefined
|
|
51
|
-
/** Material Symbols icon name, e.g. "radio_button_checked". */
|
|
52
|
-
icon?: string | undefined
|
|
53
|
-
/**
|
|
54
|
-
* Optional follow-up action. Leave it out when the page header already offers the same action and
|
|
55
|
-
* put an `EmptyStateLink` in the description instead.
|
|
56
|
-
*/
|
|
57
|
-
action?: ReactNode | undefined
|
|
58
|
-
/**
|
|
59
|
-
* - `card` (default): a standalone compact strip, for pages without a table or list container.
|
|
60
|
-
* - `inset`: a row inside a card or list, separated from what is above it by a divider.
|
|
61
|
-
* - `row`: bare content for a table cell; use `EmptyTableRow` rather than this directly.
|
|
62
|
-
*/
|
|
63
|
-
variant?: EmptyStateVariant | undefined
|
|
64
|
-
className?: string | undefined
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
const EMPTY_VARIANT_CLASS: Record<EmptyStateVariant, string> = {
|
|
68
|
-
card: 'rounded-2xl px-5 py-4',
|
|
69
|
-
inset: 'px-6 py-5',
|
|
70
|
-
row: '',
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
const EMPTY_VARIANT_STYLE: Record<EmptyStateVariant, CSSProperties | undefined> = {
|
|
74
|
-
card: { background: 'var(--m3-surface-container-low)', border: '1px solid var(--m3-outline-variant)' },
|
|
75
|
-
inset: { borderTop: '1px solid var(--m3-outline-variant)' },
|
|
76
|
-
row: undefined,
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/** Compact placeholder for an empty list: an icon, a short explanation and an optional action in one row. */
|
|
80
|
-
export function EmptyState({ title, description, icon = 'inbox', action, variant = 'card', className }: EmptyStateProps) {
|
|
81
|
-
return (
|
|
82
|
-
<div
|
|
83
|
-
className={clsx('flex flex-wrap items-center gap-x-3.5 gap-y-3 text-left', EMPTY_VARIANT_CLASS[variant], className)}
|
|
84
|
-
style={EMPTY_VARIANT_STYLE[variant]}
|
|
85
|
-
>
|
|
86
|
-
<span
|
|
87
|
-
className="grid place-items-center w-10 h-10 rounded-xl flex-none"
|
|
88
|
-
aria-hidden="true"
|
|
89
|
-
style={{ background: 'var(--admin-icon-container)', color: 'var(--admin-icon-color)' }}
|
|
90
|
-
>
|
|
91
|
-
<span className="material-symbols-outlined" style={{ fontSize: '20px' }}>{icon}</span>
|
|
92
|
-
</span>
|
|
93
|
-
<div className="min-w-0 flex-[1_1_15rem] space-y-0.5">
|
|
94
|
-
<p className="font-headline font-semibold text-sm" style={{ color: 'var(--m3-on-surface)' }}>{title}</p>
|
|
95
|
-
{description && <p className="text-[13px]" style={{ color: 'var(--m3-secondary)' }}>{description}</p>}
|
|
96
|
-
</div>
|
|
97
|
-
{action && <div className="flex-none sm:ml-auto">{action}</div>}
|
|
98
|
-
</div>
|
|
99
|
-
)
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
interface EmptyTableRowProps extends Omit<EmptyStateProps, 'variant' | 'className'> {
|
|
103
|
-
/** Number of columns in the table, so the message spans the whole row. */
|
|
104
|
-
colSpan: number
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/** The only `<tbody>` row while a table has no data, so the table and its headers keep their place. */
|
|
108
|
-
export function EmptyTableRow({ colSpan, ...props }: EmptyTableRowProps) {
|
|
109
|
-
return (
|
|
110
|
-
<tr>
|
|
111
|
-
<td colSpan={colSpan} className="px-4 py-7">
|
|
112
|
-
<EmptyState variant="row" {...props} />
|
|
113
|
-
</td>
|
|
114
|
-
</tr>
|
|
115
|
-
)
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
interface EmptyStateLinkProps {
|
|
119
|
-
onClick: () => void
|
|
120
|
-
children: ReactNode
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/** Inline text action inside an empty-state description, e.g. "Alerts stay silent until you [add a channel]." */
|
|
124
|
-
export function EmptyStateLink({ onClick, children }: EmptyStateLinkProps) {
|
|
125
|
-
return (
|
|
126
|
-
<button
|
|
127
|
-
type="button"
|
|
128
|
-
onClick={onClick}
|
|
129
|
-
className="font-semibold underline underline-offset-[3px] rounded focus-ring"
|
|
130
|
-
style={{ color: 'var(--m3-on-surface)' }}
|
|
131
|
-
>
|
|
132
|
-
{children}
|
|
133
|
-
</button>
|
|
134
|
-
)
|
|
135
|
-
}
|
|
1
|
+
import clsx from 'clsx'
|
|
2
|
+
import type { CSSProperties, ReactNode } from 'react'
|
|
3
|
+
|
|
4
|
+
interface LoadingStateProps {
|
|
5
|
+
label?: string | undefined
|
|
6
|
+
className?: string | undefined
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Centered spinner with a caption for data that is still loading. */
|
|
10
|
+
export function LoadingState({ label = 'Loading…', className }: LoadingStateProps) {
|
|
11
|
+
return (
|
|
12
|
+
<div role="status" aria-live="polite" className={clsx('flex items-center justify-center gap-3 py-12 text-sm', className)} style={{ color: 'var(--m3-secondary)' }}>
|
|
13
|
+
<span className="material-symbols-outlined animate-spin" aria-hidden="true" style={{ fontSize: '20px' }}>progress_activity</span>
|
|
14
|
+
<span>{label}</span>
|
|
15
|
+
</div>
|
|
16
|
+
)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface ErrorStateProps {
|
|
20
|
+
message?: ReactNode | undefined
|
|
21
|
+
/** Renders a "Try again" button. Pass e.g. `() => void query.refetch()`. */
|
|
22
|
+
onRetry?: (() => void) | undefined
|
|
23
|
+
className?: string | undefined
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Failed-to-load panel with an optional retry action. */
|
|
27
|
+
export function ErrorState({ message = 'Something went wrong while loading this data.', onRetry, className }: ErrorStateProps) {
|
|
28
|
+
return (
|
|
29
|
+
<div
|
|
30
|
+
role="alert"
|
|
31
|
+
className={clsx('flex flex-col items-center justify-center gap-3 rounded-2xl px-6 py-10 text-center', className)}
|
|
32
|
+
style={{ background: 'var(--m3-down-bg)', color: 'var(--m3-down)' }}
|
|
33
|
+
>
|
|
34
|
+
<span className="material-symbols-outlined" aria-hidden="true" style={{ fontSize: '28px' }}>error</span>
|
|
35
|
+
<p className="text-sm max-w-md">{message}</p>
|
|
36
|
+
{onRetry && (
|
|
37
|
+
<button type="button" onClick={onRetry} className="btn btn-secondary btn-sm">
|
|
38
|
+
<span className="material-symbols-outlined" aria-hidden="true">refresh</span>
|
|
39
|
+
Try again
|
|
40
|
+
</button>
|
|
41
|
+
)}
|
|
42
|
+
</div>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type EmptyStateVariant = 'card' | 'inset' | 'row'
|
|
47
|
+
|
|
48
|
+
interface EmptyStateProps {
|
|
49
|
+
title: ReactNode
|
|
50
|
+
description?: ReactNode | undefined
|
|
51
|
+
/** Material Symbols icon name, e.g. "radio_button_checked". */
|
|
52
|
+
icon?: string | undefined
|
|
53
|
+
/**
|
|
54
|
+
* Optional follow-up action. Leave it out when the page header already offers the same action and
|
|
55
|
+
* put an `EmptyStateLink` in the description instead.
|
|
56
|
+
*/
|
|
57
|
+
action?: ReactNode | undefined
|
|
58
|
+
/**
|
|
59
|
+
* - `card` (default): a standalone compact strip, for pages without a table or list container.
|
|
60
|
+
* - `inset`: a row inside a card or list, separated from what is above it by a divider.
|
|
61
|
+
* - `row`: bare content for a table cell; use `EmptyTableRow` rather than this directly.
|
|
62
|
+
*/
|
|
63
|
+
variant?: EmptyStateVariant | undefined
|
|
64
|
+
className?: string | undefined
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const EMPTY_VARIANT_CLASS: Record<EmptyStateVariant, string> = {
|
|
68
|
+
card: 'rounded-2xl px-5 py-4',
|
|
69
|
+
inset: 'px-6 py-5',
|
|
70
|
+
row: '',
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const EMPTY_VARIANT_STYLE: Record<EmptyStateVariant, CSSProperties | undefined> = {
|
|
74
|
+
card: { background: 'var(--m3-surface-container-low)', border: '1px solid var(--m3-outline-variant)' },
|
|
75
|
+
inset: { borderTop: '1px solid var(--m3-outline-variant)' },
|
|
76
|
+
row: undefined,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Compact placeholder for an empty list: an icon, a short explanation and an optional action in one row. */
|
|
80
|
+
export function EmptyState({ title, description, icon = 'inbox', action, variant = 'card', className }: EmptyStateProps) {
|
|
81
|
+
return (
|
|
82
|
+
<div
|
|
83
|
+
className={clsx('flex flex-wrap items-center gap-x-3.5 gap-y-3 text-left', EMPTY_VARIANT_CLASS[variant], className)}
|
|
84
|
+
style={EMPTY_VARIANT_STYLE[variant]}
|
|
85
|
+
>
|
|
86
|
+
<span
|
|
87
|
+
className="grid place-items-center w-10 h-10 rounded-xl flex-none"
|
|
88
|
+
aria-hidden="true"
|
|
89
|
+
style={{ background: 'var(--admin-icon-container)', color: 'var(--admin-icon-color)' }}
|
|
90
|
+
>
|
|
91
|
+
<span className="material-symbols-outlined" style={{ fontSize: '20px' }}>{icon}</span>
|
|
92
|
+
</span>
|
|
93
|
+
<div className="min-w-0 flex-[1_1_15rem] space-y-0.5">
|
|
94
|
+
<p className="font-headline font-semibold text-sm" style={{ color: 'var(--m3-on-surface)' }}>{title}</p>
|
|
95
|
+
{description && <p className="text-[13px]" style={{ color: 'var(--m3-secondary)' }}>{description}</p>}
|
|
96
|
+
</div>
|
|
97
|
+
{action && <div className="flex-none sm:ml-auto">{action}</div>}
|
|
98
|
+
</div>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
interface EmptyTableRowProps extends Omit<EmptyStateProps, 'variant' | 'className'> {
|
|
103
|
+
/** Number of columns in the table, so the message spans the whole row. */
|
|
104
|
+
colSpan: number
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The only `<tbody>` row while a table has no data, so the table and its headers keep their place. */
|
|
108
|
+
export function EmptyTableRow({ colSpan, ...props }: EmptyTableRowProps) {
|
|
109
|
+
return (
|
|
110
|
+
<tr>
|
|
111
|
+
<td colSpan={colSpan} className="px-4 py-7">
|
|
112
|
+
<EmptyState variant="row" {...props} />
|
|
113
|
+
</td>
|
|
114
|
+
</tr>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
interface EmptyStateLinkProps {
|
|
119
|
+
onClick: () => void
|
|
120
|
+
children: ReactNode
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Inline text action inside an empty-state description, e.g. "Alerts stay silent until you [add a channel]." */
|
|
124
|
+
export function EmptyStateLink({ onClick, children }: EmptyStateLinkProps) {
|
|
125
|
+
return (
|
|
126
|
+
<button
|
|
127
|
+
type="button"
|
|
128
|
+
onClick={onClick}
|
|
129
|
+
className="font-semibold underline underline-offset-[3px] rounded focus-ring"
|
|
130
|
+
style={{ color: 'var(--m3-on-surface)' }}
|
|
131
|
+
>
|
|
132
|
+
{children}
|
|
133
|
+
</button>
|
|
134
|
+
)
|
|
135
|
+
}
|
|
@@ -1,65 +1,65 @@
|
|
|
1
|
-
import clsx from 'clsx'
|
|
2
|
-
import { useId, type ReactNode } from 'react'
|
|
3
|
-
|
|
4
|
-
interface SwitchProps {
|
|
5
|
-
checked: boolean
|
|
6
|
-
onChange: (checked: boolean) => void
|
|
7
|
-
/** Visible label; clicking it toggles the switch. Without it pass `aria-label`. */
|
|
8
|
-
label?: ReactNode | undefined
|
|
9
|
-
description?: ReactNode | undefined
|
|
10
|
-
disabled?: boolean | undefined
|
|
11
|
-
id?: string | undefined
|
|
12
|
-
'aria-label'?: string | undefined
|
|
13
|
-
className?: string | undefined
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/** On/off toggle rendered as <button role="switch">, optionally with a clickable label and a description. */
|
|
17
|
-
export function Switch({ checked, onChange, label, description, disabled = false, id, 'aria-label': ariaLabel, className }: SwitchProps) {
|
|
18
|
-
const generatedId = useId()
|
|
19
|
-
const switchId = id ?? generatedId
|
|
20
|
-
const labelId = `${switchId}-label`
|
|
21
|
-
const descriptionId = `${switchId}-description`
|
|
22
|
-
|
|
23
|
-
const control = (
|
|
24
|
-
<button
|
|
25
|
-
id={switchId}
|
|
26
|
-
type="button"
|
|
27
|
-
role="switch"
|
|
28
|
-
aria-checked={checked}
|
|
29
|
-
aria-label={label ? undefined : ariaLabel}
|
|
30
|
-
aria-labelledby={label ? labelId : undefined}
|
|
31
|
-
aria-describedby={description ? descriptionId : undefined}
|
|
32
|
-
disabled={disabled}
|
|
33
|
-
onClick={() => onChange(!checked)}
|
|
34
|
-
className="relative flex-shrink-0 w-12 h-7 rounded-full transition-colors focus-ring disabled:opacity-50 disabled:cursor-not-allowed"
|
|
35
|
-
style={{ background: checked ? 'var(--admin-control-accent)' : 'var(--m3-outline-variant)' }}
|
|
36
|
-
>
|
|
37
|
-
<span
|
|
38
|
-
aria-hidden="true"
|
|
39
|
-
className="absolute top-1 w-5 h-5 rounded-full transition-all"
|
|
40
|
-
style={{ left: checked ? '24px' : '4px', background: checked ? 'var(--admin-control-on)' : 'var(--m3-outline)' }}
|
|
41
|
-
/>
|
|
42
|
-
</button>
|
|
43
|
-
)
|
|
44
|
-
|
|
45
|
-
if (!label && !description) return <span className={clsx('inline-flex', className)}>{control}</span>
|
|
46
|
-
|
|
47
|
-
return (
|
|
48
|
-
<div className={clsx('flex items-start justify-between gap-4', className)}>
|
|
49
|
-
<div className="min-w-0">
|
|
50
|
-
{label && (
|
|
51
|
-
<label
|
|
52
|
-
id={labelId}
|
|
53
|
-
htmlFor={switchId}
|
|
54
|
-
className={clsx('block text-sm font-medium', disabled ? 'cursor-not-allowed' : 'cursor-pointer')}
|
|
55
|
-
style={{ color: 'var(--m3-on-surface)' }}
|
|
56
|
-
>
|
|
57
|
-
{label}
|
|
58
|
-
</label>
|
|
59
|
-
)}
|
|
60
|
-
{description && <p id={descriptionId} className="text-xs mt-0.5" style={{ color: 'var(--m3-secondary)' }}>{description}</p>}
|
|
61
|
-
</div>
|
|
62
|
-
{control}
|
|
63
|
-
</div>
|
|
64
|
-
)
|
|
65
|
-
}
|
|
1
|
+
import clsx from 'clsx'
|
|
2
|
+
import { useId, type ReactNode } from 'react'
|
|
3
|
+
|
|
4
|
+
interface SwitchProps {
|
|
5
|
+
checked: boolean
|
|
6
|
+
onChange: (checked: boolean) => void
|
|
7
|
+
/** Visible label; clicking it toggles the switch. Without it pass `aria-label`. */
|
|
8
|
+
label?: ReactNode | undefined
|
|
9
|
+
description?: ReactNode | undefined
|
|
10
|
+
disabled?: boolean | undefined
|
|
11
|
+
id?: string | undefined
|
|
12
|
+
'aria-label'?: string | undefined
|
|
13
|
+
className?: string | undefined
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** On/off toggle rendered as <button role="switch">, optionally with a clickable label and a description. */
|
|
17
|
+
export function Switch({ checked, onChange, label, description, disabled = false, id, 'aria-label': ariaLabel, className }: SwitchProps) {
|
|
18
|
+
const generatedId = useId()
|
|
19
|
+
const switchId = id ?? generatedId
|
|
20
|
+
const labelId = `${switchId}-label`
|
|
21
|
+
const descriptionId = `${switchId}-description`
|
|
22
|
+
|
|
23
|
+
const control = (
|
|
24
|
+
<button
|
|
25
|
+
id={switchId}
|
|
26
|
+
type="button"
|
|
27
|
+
role="switch"
|
|
28
|
+
aria-checked={checked}
|
|
29
|
+
aria-label={label ? undefined : ariaLabel}
|
|
30
|
+
aria-labelledby={label ? labelId : undefined}
|
|
31
|
+
aria-describedby={description ? descriptionId : undefined}
|
|
32
|
+
disabled={disabled}
|
|
33
|
+
onClick={() => onChange(!checked)}
|
|
34
|
+
className="relative flex-shrink-0 w-12 h-7 rounded-full transition-colors focus-ring disabled:opacity-50 disabled:cursor-not-allowed"
|
|
35
|
+
style={{ background: checked ? 'var(--admin-control-accent)' : 'var(--m3-outline-variant)' }}
|
|
36
|
+
>
|
|
37
|
+
<span
|
|
38
|
+
aria-hidden="true"
|
|
39
|
+
className="absolute top-1 w-5 h-5 rounded-full transition-all"
|
|
40
|
+
style={{ left: checked ? '24px' : '4px', background: checked ? 'var(--admin-control-on)' : 'var(--m3-outline)' }}
|
|
41
|
+
/>
|
|
42
|
+
</button>
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
if (!label && !description) return <span className={clsx('inline-flex', className)}>{control}</span>
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div className={clsx('flex items-start justify-between gap-4', className)}>
|
|
49
|
+
<div className="min-w-0">
|
|
50
|
+
{label && (
|
|
51
|
+
<label
|
|
52
|
+
id={labelId}
|
|
53
|
+
htmlFor={switchId}
|
|
54
|
+
className={clsx('block text-sm font-medium', disabled ? 'cursor-not-allowed' : 'cursor-pointer')}
|
|
55
|
+
style={{ color: 'var(--m3-on-surface)' }}
|
|
56
|
+
>
|
|
57
|
+
{label}
|
|
58
|
+
</label>
|
|
59
|
+
)}
|
|
60
|
+
{description && <p id={descriptionId} className="text-xs mt-0.5" style={{ color: 'var(--m3-secondary)' }}>{description}</p>}
|
|
61
|
+
</div>
|
|
62
|
+
{control}
|
|
63
|
+
</div>
|
|
64
|
+
)
|
|
65
|
+
}
|