@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 +22 -2
- package/package.json +35 -5
- package/src/AppShell.tsx +54 -0
- package/src/Button.tsx +47 -0
- package/src/Card.tsx +57 -0
- package/src/Columns.tsx +32 -0
- package/src/DataTable.tsx +224 -0
- package/src/Dialog.tsx +158 -0
- package/src/Field.tsx +91 -0
- package/src/FieldList.tsx +30 -0
- package/src/FilterBar.tsx +24 -0
- package/src/Form.tsx +58 -0
- package/src/PageHeader.tsx +35 -0
- package/src/StatusPill.tsx +18 -0
- package/src/Tabs.tsx +74 -0
- package/src/Timeline.tsx +37 -0
- package/src/Toast.tsx +80 -0
- package/src/class-names.ts +4 -0
- package/src/components.css +1312 -0
- package/src/index.ts +68 -0
- package/src/inputs.tsx +164 -0
- package/src/layout.tsx +40 -0
- package/src/router.tsx +279 -0
- package/src/states.tsx +78 -0
- package/src/styles.css +2 -0
- package/src/theme.css +125 -0
- package/src/tone.ts +1 -0
package/README.md
CHANGED
|
@@ -1,3 +1,23 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @fabrika/ui
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
+
}
|
package/src/AppShell.tsx
ADDED
|
@@ -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
|
+
}
|
package/src/Columns.tsx
ADDED
|
@@ -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
|
+
}
|