@fabrika/ui 0.0.0-stage → 0.0.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/README.md CHANGED
@@ -1,3 +1,23 @@
1
- # Temporary Holding Version
1
+ # @fabrika/ui
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ React components for internal admin screens on the Fabrika paved road. Accessible markup, keyboard support, and styling
4
+ only through CSS custom properties.
5
+
6
+ ```ts
7
+ // the application's start module
8
+ import '@fabrika/ui/styles.css'
9
+ import './theme-overrides.css' // e.g. :root { --fui-accent: #0f766e; }
10
+ ```
11
+
12
+ ```tsx
13
+ import { AppShell, createRouter, DataTable, PageHeader } from '@fabrika/ui'
14
+
15
+ const { Router, useRoute, href } = createRouter({
16
+ deals: '/deals',
17
+ deal: '/deals/:id',
18
+ })
19
+ ```
20
+
21
+ - `styles.css` — the theme tokens plus every component rule; the one file to import.
22
+ - `theme.css` — the tokens alone (`--fui-*`), light on `:root`, dark on `:root[data-theme='dark']`.
23
+ - Every plain `<a href>` inside `<Router>` navigates in-app; add `data-router="native"` to opt a link out.
package/package.json CHANGED
@@ -1,6 +1,36 @@
1
1
  {
2
- "name": "@fabrika/ui",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@fabrika/ui",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "description": "React component kit for the paved road: accessible admin components styled through CSS custom properties.",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/contember/fabrika-platform.git",
10
+ "directory": "packages/ui"
11
+ },
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "files": [
16
+ "src",
17
+ "!src/**/__tests__/**"
18
+ ],
19
+ "exports": {
20
+ ".": {
21
+ "bun": "./src/index.ts",
22
+ "types": "./src/index.ts",
23
+ "default": "./src/index.ts"
24
+ },
25
+ "./styles.css": "./src/styles.css",
26
+ "./theme.css": "./src/theme.css"
27
+ },
28
+ "scripts": {
29
+ "typecheck": "tsc --noEmit",
30
+ "test": "bun test --isolate"
31
+ },
32
+ "peerDependencies": {
33
+ "react": "^19.0.0",
34
+ "react-dom": "^19.0.0"
35
+ }
36
+ }
@@ -0,0 +1,54 @@
1
+ import type { ReactNode } from 'react'
2
+ import { classNames } from './class-names'
3
+ import { CountBadge } from './states'
4
+
5
+ export interface NavItem {
6
+ readonly label: string
7
+ readonly href: string
8
+ readonly active?: boolean
9
+ readonly count?: number
10
+ }
11
+
12
+ export interface AppShellProps {
13
+ readonly brand: ReactNode
14
+ readonly nav: readonly NavItem[]
15
+ /** The signed-in caller, shown at the end of the top bar. */
16
+ readonly user?: ReactNode
17
+ readonly actions?: ReactNode
18
+ readonly children: ReactNode
19
+ readonly navLabel?: string
20
+ readonly className?: string
21
+ }
22
+
23
+ export function AppShell({ brand, nav, user, actions, children, navLabel = 'Main', className }: AppShellProps) {
24
+ return (
25
+ <div className={classNames('fui-app', 'fui-shell', className)}>
26
+ <div className="fui-shell__sidebar">
27
+ <div className="fui-shell__brand">{brand}</div>
28
+ <nav className="fui-shell__nav" aria-label={navLabel}>
29
+ <ul className="fui-shell__nav-list">
30
+ {nav.map((item) => (
31
+ <li key={item.href}>
32
+ <a
33
+ href={item.href}
34
+ className={classNames('fui-shell__nav-link', item.active && 'fui-shell__nav-link--active')}
35
+ aria-current={item.active ? 'page' : undefined}
36
+ >
37
+ <span className="fui-shell__nav-label">{item.label}</span>
38
+ {item.count !== undefined && <CountBadge count={item.count} />}
39
+ </a>
40
+ </li>
41
+ ))}
42
+ </ul>
43
+ </nav>
44
+ </div>
45
+ <div className="fui-shell__body">
46
+ <header className="fui-shell__topbar">
47
+ <div className="fui-shell__actions">{actions}</div>
48
+ {user !== undefined && <div className="fui-shell__user">{user}</div>}
49
+ </header>
50
+ <main className="fui-shell__main">{children}</main>
51
+ </div>
52
+ </div>
53
+ )
54
+ }
package/src/Button.tsx ADDED
@@ -0,0 +1,47 @@
1
+ import type { ComponentProps } from 'react'
2
+ import { classNames } from './class-names'
3
+
4
+ export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger'
5
+ export type ButtonSize = 'sm' | 'md'
6
+
7
+ function buttonClassName(variant: ButtonVariant, size: ButtonSize, className: string | undefined): string {
8
+ return classNames('fui-button', `fui-button--${variant}`, size === 'sm' && 'fui-button--sm', className)
9
+ }
10
+
11
+ export interface ButtonProps extends Readonly<ComponentProps<'button'>> {
12
+ readonly variant?: ButtonVariant
13
+ readonly size?: ButtonSize
14
+ /** Disables the button and shows a spinner beside its label until the work it started settles. */
15
+ readonly loading?: boolean
16
+ }
17
+
18
+ export function Button(
19
+ { variant = 'secondary', size = 'md', loading = false, type = 'button', disabled, className, children, ...rest }: ButtonProps,
20
+ ) {
21
+ return (
22
+ <button
23
+ {...rest}
24
+ type={type}
25
+ disabled={disabled || loading}
26
+ aria-busy={loading || undefined}
27
+ className={buttonClassName(variant, size, classNames(loading && 'fui-button--loading', className))}
28
+ >
29
+ {loading && <span className="fui-spinner fui-spinner--inline" aria-hidden="true" />}
30
+ {children}
31
+ </button>
32
+ )
33
+ }
34
+
35
+ export interface ButtonLinkProps extends Readonly<ComponentProps<'a'>> {
36
+ readonly href: string
37
+ readonly variant?: ButtonVariant
38
+ readonly size?: ButtonSize
39
+ }
40
+
41
+ export function ButtonLink({ variant = 'secondary', size = 'md', className, children, ...rest }: ButtonLinkProps) {
42
+ return (
43
+ <a {...rest} className={buttonClassName(variant, size, className)}>
44
+ {children}
45
+ </a>
46
+ )
47
+ }
package/src/Card.tsx ADDED
@@ -0,0 +1,57 @@
1
+ import { type ReactNode, useId } from 'react'
2
+ import { classNames } from './class-names'
3
+
4
+ export interface CardProps {
5
+ readonly title?: ReactNode
6
+ readonly actions?: ReactNode
7
+ readonly children: ReactNode
8
+ readonly footer?: ReactNode
9
+ readonly className?: string
10
+ }
11
+
12
+ export function Card({ title, actions, children, footer, className }: CardProps) {
13
+ const titleId = useId()
14
+ const hasHeader = title !== undefined || actions !== undefined
15
+ return (
16
+ <section className={classNames('fui-card', className)} aria-labelledby={title === undefined ? undefined : titleId}>
17
+ {hasHeader && (
18
+ <div className="fui-card__header">
19
+ {title !== undefined && (
20
+ <h2 className="fui-card__title" id={titleId}>
21
+ {title}
22
+ </h2>
23
+ )}
24
+ {actions !== undefined && <div className="fui-card__actions">{actions}</div>}
25
+ </div>
26
+ )}
27
+ <div className="fui-card__body">{children}</div>
28
+ {footer !== undefined && <div className="fui-card__footer">{footer}</div>}
29
+ </section>
30
+ )
31
+ }
32
+
33
+ export interface SectionProps {
34
+ readonly title: ReactNode
35
+ readonly description?: ReactNode
36
+ readonly actions?: ReactNode
37
+ readonly children: ReactNode
38
+ readonly className?: string
39
+ }
40
+
41
+ export function Section({ title, description, actions, children, className }: SectionProps) {
42
+ const titleId = useId()
43
+ return (
44
+ <section className={classNames('fui-section', className)} aria-labelledby={titleId}>
45
+ <div className="fui-section__header">
46
+ <div className="fui-section__heading">
47
+ <h2 className="fui-section__title" id={titleId}>
48
+ {title}
49
+ </h2>
50
+ {description !== undefined && <p className="fui-section__description">{description}</p>}
51
+ </div>
52
+ {actions !== undefined && <div className="fui-section__actions">{actions}</div>}
53
+ </div>
54
+ <div className="fui-section__body">{children}</div>
55
+ </section>
56
+ )
57
+ }
@@ -0,0 +1,32 @@
1
+ import { type ReactNode, useId } from 'react'
2
+ import { classNames } from './class-names'
3
+
4
+ export interface ColumnsProps {
5
+ readonly children: ReactNode
6
+ readonly className?: string
7
+ }
8
+
9
+ /** A main column that grows and a `SidePanel` beside it; they stack on narrow screens. */
10
+ export function Columns({ children, className }: ColumnsProps) {
11
+ return <div className={classNames('fui-columns', className)}>{children}</div>
12
+ }
13
+
14
+ export interface SidePanelProps {
15
+ readonly title?: ReactNode
16
+ readonly children: ReactNode
17
+ readonly className?: string
18
+ }
19
+
20
+ export function SidePanel({ title, children, className }: SidePanelProps) {
21
+ const titleId = useId()
22
+ return (
23
+ <aside className={classNames('fui-side-panel', className)} aria-labelledby={title === undefined ? undefined : titleId}>
24
+ {title !== undefined && (
25
+ <h2 className="fui-side-panel__title" id={titleId}>
26
+ {title}
27
+ </h2>
28
+ )}
29
+ {children}
30
+ </aside>
31
+ )
32
+ }
@@ -0,0 +1,224 @@
1
+ import { type ReactNode, useCallback, useMemo, useState } from 'react'
2
+ import { Button } from './Button'
3
+ import { classNames } from './class-names'
4
+ import { Checkbox } from './inputs'
5
+
6
+ export interface Column<Row> {
7
+ readonly key: string
8
+ readonly header: ReactNode
9
+ readonly cell: (row: Row) => ReactNode
10
+ readonly align?: 'start' | 'end'
11
+ /** Takes the width the other columns leave; the remaining columns shrink to their content. */
12
+ readonly grow?: boolean
13
+ }
14
+
15
+ export interface TableSelection {
16
+ readonly selected: ReadonlySet<string>
17
+ readonly onChange: (next: ReadonlySet<string>) => void
18
+ }
19
+
20
+ export interface LoadMore {
21
+ readonly hasMore: boolean
22
+ readonly loading: boolean
23
+ readonly onLoadMore: () => void
24
+ readonly label?: string
25
+ }
26
+
27
+ export type TableStatus = 'loading' | 'error' | 'ready'
28
+
29
+ export interface DataTableProps<Row> {
30
+ readonly caption?: ReactNode
31
+ readonly columns: readonly Column<Row>[]
32
+ readonly rows: readonly Row[]
33
+ readonly rowKey: (row: Row) => string
34
+ readonly status?: TableStatus
35
+ readonly error?: ReactNode
36
+ readonly onRetry?: () => void
37
+ readonly empty?: ReactNode
38
+ readonly selection?: TableSelection
39
+ readonly selectionActions?: (selectedKeys: readonly string[]) => ReactNode
40
+ readonly loadMore?: LoadMore
41
+ readonly selectRowLabel?: (row: Row) => string
42
+ readonly selectAllLabel?: string
43
+ readonly selectedLabel?: (count: number) => string
44
+ readonly selectionToolbarLabel?: string
45
+ readonly clearSelectionLabel?: string
46
+ readonly loadingLabel?: string
47
+ readonly retryLabel?: string
48
+ readonly className?: string
49
+ }
50
+
51
+ const SKELETON_ROWS = 3
52
+
53
+ export function DataTable<Row>({
54
+ caption,
55
+ columns,
56
+ rows,
57
+ rowKey,
58
+ status = 'ready',
59
+ error,
60
+ onRetry,
61
+ empty = 'Nothing here yet',
62
+ selection,
63
+ selectionActions,
64
+ loadMore,
65
+ selectRowLabel = () => 'Select row',
66
+ selectAllLabel = 'Select all rows',
67
+ selectedLabel = (count) => `${count} selected`,
68
+ selectionToolbarLabel = 'Selection',
69
+ clearSelectionLabel = 'Clear selection',
70
+ loadingLabel = 'Loading…',
71
+ retryLabel = 'Retry',
72
+ className,
73
+ }: DataTableProps<Row>) {
74
+ const columnCount = columns.length + (selection ? 1 : 0)
75
+ // A failed later page keeps the rows already loaded; the error row and its retry follow them.
76
+ const showRows = status === 'ready' || (status === 'error' && rows.length > 0)
77
+ const visibleKeys = showRows ? rows.map(rowKey) : []
78
+ const selectedKeys = selection ? [...selection.selected] : []
79
+ const selectedVisible = selection ? visibleKeys.filter((key) => selection.selected.has(key)).length : 0
80
+ const allVisibleSelected = visibleKeys.length > 0 && selectedVisible === visibleKeys.length
81
+ const someVisibleSelected = selectedVisible > 0 && !allVisibleSelected
82
+
83
+ const setAllVisible = (checked: boolean) => {
84
+ if (!selection) return
85
+ const next = new Set(selection.selected)
86
+ for (const key of visibleKeys) {
87
+ if (checked) next.add(key)
88
+ else next.delete(key)
89
+ }
90
+ selection.onChange(next)
91
+ }
92
+
93
+ const setRow = (key: string, checked: boolean) => {
94
+ if (!selection) return
95
+ const next = new Set(selection.selected)
96
+ if (checked) next.add(key)
97
+ else next.delete(key)
98
+ selection.onChange(next)
99
+ }
100
+
101
+ const cellClassName = (column: Column<Row>) =>
102
+ classNames('fui-table__cell', column.align === 'end' && 'fui-table__cell--end', column.grow && 'fui-table__cell--grow')
103
+
104
+ return (
105
+ <div className={classNames('fui-data-table', className)}>
106
+ {selection && selectedKeys.length > 0 && (
107
+ <div className="fui-data-table__toolbar" role="toolbar" aria-label={selectionToolbarLabel}>
108
+ <span className="fui-data-table__count">{selectedLabel(selectedKeys.length)}</span>
109
+ <div className="fui-data-table__actions">{selectionActions?.(selectedKeys)}</div>
110
+ <Button variant="ghost" size="sm" onClick={() => selection.onChange(new Set())}>
111
+ {clearSelectionLabel}
112
+ </Button>
113
+ </div>
114
+ )}
115
+ <div className="fui-data-table__scroll">
116
+ <table className="fui-table">
117
+ {caption !== undefined && <caption className="fui-table__caption">{caption}</caption>}
118
+ <thead>
119
+ <tr>
120
+ {selection && (
121
+ <th className="fui-table__head fui-table__select" scope="col">
122
+ <Checkbox
123
+ label={selectAllLabel}
124
+ hideLabel
125
+ checked={allVisibleSelected}
126
+ indeterminate={someVisibleSelected}
127
+ disabled={visibleKeys.length === 0}
128
+ onChange={setAllVisible}
129
+ />
130
+ </th>
131
+ )}
132
+ {columns.map((column) => (
133
+ <th key={column.key} scope="col" className={classNames('fui-table__head', cellClassName(column))}>
134
+ {column.header}
135
+ </th>
136
+ ))}
137
+ </tr>
138
+ </thead>
139
+ <tbody>
140
+ {status === 'loading'
141
+ && Array.from({ length: SKELETON_ROWS }, (_, index) => (
142
+ <tr key={index} className="fui-table__row fui-table__row--skeleton">
143
+ {Array.from({ length: columnCount }, (_, cell) => (
144
+ <td key={cell} className="fui-table__cell">
145
+ {index === 0 && cell === 0 && (
146
+ <span className="fui-visually-hidden" role="status">
147
+ {loadingLabel}
148
+ </span>
149
+ )}
150
+ <span className="fui-skeleton" aria-hidden="true" />
151
+ </td>
152
+ ))}
153
+ </tr>
154
+ ))}
155
+ {status === 'ready' && rows.length === 0 && (
156
+ <tr className="fui-table__row">
157
+ <td className="fui-table__cell fui-table__message" colSpan={columnCount}>
158
+ {empty}
159
+ </td>
160
+ </tr>
161
+ )}
162
+ {showRows && rows.map((row) => {
163
+ const key = rowKey(row)
164
+ const selected = selection?.selected.has(key) ?? false
165
+ return (
166
+ <tr key={key} className={classNames('fui-table__row', selected && 'fui-table__row--selected')}>
167
+ {selection && (
168
+ <td className="fui-table__cell fui-table__select">
169
+ <Checkbox label={selectRowLabel(row)} hideLabel checked={selected} onChange={(checked) => setRow(key, checked)} />
170
+ </td>
171
+ )}
172
+ {columns.map((column) => (
173
+ <td key={column.key} className={cellClassName(column)}>
174
+ {column.cell(row)}
175
+ </td>
176
+ ))}
177
+ </tr>
178
+ )
179
+ })}
180
+ {status === 'error' && (
181
+ <tr className="fui-table__row">
182
+ <td className="fui-table__cell fui-table__message fui-table__message--error" colSpan={columnCount}>
183
+ <span role="alert">{error}</span>
184
+ {onRetry && (
185
+ <Button size="sm" onClick={onRetry}>
186
+ {retryLabel}
187
+ </Button>
188
+ )}
189
+ </td>
190
+ </tr>
191
+ )}
192
+ </tbody>
193
+ </table>
194
+ </div>
195
+ {loadMore?.hasMore && (
196
+ <div className="fui-data-table__more">
197
+ <Button loading={loadMore.loading} onClick={loadMore.onLoadMore}>
198
+ {loadMore.label ?? 'Load more'}
199
+ </Button>
200
+ </div>
201
+ )}
202
+ </div>
203
+ )
204
+ }
205
+
206
+ export interface Selection extends TableSelection {
207
+ readonly toggle: (key: string) => void
208
+ readonly clear: () => void
209
+ }
210
+
211
+ /** Selection state shaped to pass straight to `DataTable`'s `selection`. */
212
+ export function useSelection(initial?: Iterable<string>): Selection {
213
+ const [selected, setSelected] = useState<ReadonlySet<string>>(() => new Set(initial))
214
+ const toggle = useCallback((key: string) => {
215
+ setSelected((current) => {
216
+ const next = new Set(current)
217
+ if (next.has(key)) next.delete(key)
218
+ else next.add(key)
219
+ return next
220
+ })
221
+ }, [])
222
+ const clear = useCallback(() => setSelected(new Set()), [])
223
+ return useMemo(() => ({ selected, onChange: setSelected, toggle, clear }), [selected, toggle, clear])
224
+ }
package/src/Dialog.tsx ADDED
@@ -0,0 +1,158 @@
1
+ import { type KeyboardEvent, type MouseEvent, type ReactNode, useEffect, useId, useRef } from 'react'
2
+ import { Button } from './Button'
3
+ import { classNames } from './class-names'
4
+
5
+ const FOCUSABLE = [
6
+ 'a[href]',
7
+ 'button:not([disabled])',
8
+ 'input:not([disabled]):not([type="hidden"])',
9
+ 'select:not([disabled])',
10
+ 'textarea:not([disabled])',
11
+ '[tabindex]:not([tabindex="-1"])',
12
+ ].join(',')
13
+
14
+ function focusableIn(root: HTMLElement): HTMLElement[] {
15
+ return [...root.querySelectorAll<HTMLElement>(FOCUSABLE)]
16
+ }
17
+
18
+ export interface DialogProps {
19
+ readonly open: boolean
20
+ readonly title: ReactNode
21
+ readonly description?: ReactNode
22
+ readonly onClose: () => void
23
+ readonly children?: ReactNode
24
+ readonly actions?: ReactNode
25
+ readonly closeLabel?: string
26
+ readonly className?: string
27
+ }
28
+
29
+ /** A modal dialog rendered in place over a fixed overlay; it traps focus and gives it back on close. */
30
+ export function Dialog({ open, title, description, onClose, children, actions, closeLabel = 'Close', className }: DialogProps) {
31
+ const dialogRef = useRef<HTMLDivElement>(null)
32
+ const id = useId()
33
+ const titleId = `${id}-title`
34
+ const descriptionId = description === undefined ? undefined : `${id}-description`
35
+
36
+ useEffect(() => {
37
+ if (!open) return
38
+ const opener = document.activeElement
39
+ const dialog = dialogRef.current
40
+ if (dialog) (focusableIn(dialog)[0] ?? dialog).focus()
41
+ return () => {
42
+ if (opener instanceof HTMLElement && opener.isConnected) opener.focus()
43
+ }
44
+ }, [open])
45
+
46
+ if (!open) return null
47
+
48
+ const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
49
+ if (event.key === 'Escape') {
50
+ event.stopPropagation()
51
+ onClose()
52
+ return
53
+ }
54
+ if (event.key !== 'Tab') return
55
+ const dialog = event.currentTarget
56
+ const focusable = focusableIn(dialog)
57
+ const first = focusable[0]
58
+ const last = focusable.at(-1)
59
+ if (first === undefined || last === undefined) {
60
+ event.preventDefault()
61
+ dialog.focus()
62
+ return
63
+ }
64
+ const active = document.activeElement
65
+ if (event.shiftKey && (active === first || active === dialog)) {
66
+ event.preventDefault()
67
+ last.focus()
68
+ } else if (!event.shiftKey && active === last) {
69
+ event.preventDefault()
70
+ first.focus()
71
+ }
72
+ }
73
+
74
+ const handleOverlayClick = (event: MouseEvent<HTMLDivElement>) => {
75
+ if (event.target === event.currentTarget) onClose()
76
+ }
77
+
78
+ return (
79
+ <div className="fui-dialog-overlay" onClick={handleOverlayClick}>
80
+ <div
81
+ ref={dialogRef}
82
+ className={classNames('fui-dialog', className)}
83
+ role="dialog"
84
+ aria-modal="true"
85
+ aria-labelledby={titleId}
86
+ aria-describedby={descriptionId}
87
+ tabIndex={-1}
88
+ onKeyDown={handleKeyDown}
89
+ >
90
+ <div className="fui-dialog__header">
91
+ <h2 className="fui-dialog__title" id={titleId}>
92
+ {title}
93
+ </h2>
94
+ <button type="button" className="fui-dialog__close" aria-label={closeLabel} onClick={onClose}>
95
+ <span aria-hidden="true">×</span>
96
+ </button>
97
+ </div>
98
+ {description !== undefined && (
99
+ <p className="fui-dialog__description" id={descriptionId}>
100
+ {description}
101
+ </p>
102
+ )}
103
+ {children !== undefined && <div className="fui-dialog__body">{children}</div>}
104
+ {actions !== undefined && <div className="fui-dialog__actions">{actions}</div>}
105
+ </div>
106
+ </div>
107
+ )
108
+ }
109
+
110
+ export interface ConfirmDialogProps {
111
+ readonly open: boolean
112
+ readonly title: ReactNode
113
+ readonly message: ReactNode
114
+ readonly confirmLabel: string
115
+ readonly cancelLabel?: string
116
+ readonly tone?: 'primary' | 'danger'
117
+ /** The confirmed work is running: the confirm button spins and the dialog cannot be dismissed. */
118
+ readonly pending?: boolean
119
+ readonly onConfirm: () => void
120
+ readonly onCancel: () => void
121
+ readonly closeLabel?: string
122
+ }
123
+
124
+ export function ConfirmDialog({
125
+ open,
126
+ title,
127
+ message,
128
+ confirmLabel,
129
+ cancelLabel = 'Cancel',
130
+ tone = 'primary',
131
+ pending = false,
132
+ onConfirm,
133
+ onCancel,
134
+ closeLabel,
135
+ }: ConfirmDialogProps) {
136
+ const close = () => {
137
+ if (!pending) onCancel()
138
+ }
139
+ return (
140
+ <Dialog
141
+ open={open}
142
+ title={title}
143
+ description={message}
144
+ onClose={close}
145
+ closeLabel={closeLabel}
146
+ actions={
147
+ <>
148
+ <Button onClick={close} disabled={pending}>
149
+ {cancelLabel}
150
+ </Button>
151
+ <Button variant={tone} loading={pending} onClick={onConfirm}>
152
+ {confirmLabel}
153
+ </Button>
154
+ </>
155
+ }
156
+ />
157
+ )
158
+ }