@tonycodes-ui/mortar 0.0.0-stage → 0.12.0
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/AGENTS.md +10 -0
- package/CHANGELOG.md +268 -0
- package/CONTRIBUTING.md +60 -0
- package/DESIGN.md +51 -0
- package/LICENSE +21 -0
- package/README.md +155 -2
- package/TESTING.md +83 -0
- package/baseline-policy.json +16 -0
- package/bin/mortar.mjs +258 -0
- package/catalog.json +9900 -0
- package/cli/audit.mjs +943 -0
- package/cli/data.mjs +176 -0
- package/cli/mcp.mjs +216 -0
- package/cli/migrations.mjs +128 -0
- package/cli/packet.mjs +267 -0
- package/cli/search.mjs +246 -0
- package/cli/tools.mjs +229 -0
- package/design-principles.json +30 -0
- package/dist/agent-data.json +1 -0
- package/dist/application.d.ts +340 -0
- package/dist/application.js +240 -0
- package/dist/auth.d.ts +121 -0
- package/dist/auth.js +97 -0
- package/dist/brand.d.ts +69 -0
- package/dist/brand.js +266 -0
- package/dist/calendar.d.ts +75 -0
- package/dist/calendar.js +310 -0
- package/dist/choice.d.ts +36 -0
- package/dist/choice.js +36 -0
- package/dist/composer.d.ts +28 -0
- package/dist/composer.js +47 -0
- package/dist/display.d.ts +136 -0
- package/dist/display.js +75 -0
- package/dist/docs.d.ts +145 -0
- package/dist/docs.js +141 -0
- package/dist/ecommerce.d.ts +261 -0
- package/dist/ecommerce.js +177 -0
- package/dist/feedback.d.ts +80 -0
- package/dist/feedback.js +98 -0
- package/dist/foundation.d.ts +42 -0
- package/dist/foundation.js +121 -0
- package/dist/heading.d.ts +5 -0
- package/dist/heading.js +9 -0
- package/dist/icons.d.ts +50 -0
- package/dist/icons.js +70 -0
- package/dist/illustration.d.ts +47 -0
- package/dist/illustration.js +47 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.js +18 -0
- package/dist/inspection.d.ts +24 -0
- package/dist/inspection.js +14 -0
- package/dist/internal.d.ts +514 -0
- package/dist/internal.js +332 -0
- package/dist/layout.d.ts +107 -0
- package/dist/layout.js +66 -0
- package/dist/marketing-sections.d.ts +161 -0
- package/dist/marketing-sections.js +58 -0
- package/dist/marketing.d.ts +277 -0
- package/dist/marketing.js +163 -0
- package/dist/navigation.d.ts +35 -0
- package/dist/navigation.js +44 -0
- package/dist/primitives.d.ts +258 -0
- package/dist/primitives.js +244 -0
- package/dist/showcase.d.ts +29 -0
- package/dist/showcase.js +10 -0
- package/dist/workspace.d.ts +80 -0
- package/dist/workspace.js +48 -0
- package/docs/agents.md +127 -0
- package/docs/auth-flows.md +71 -0
- package/docs/migrating-to-0.6.md +94 -0
- package/docs/theming.md +170 -0
- package/package.json +94 -4
- package/skills/mortar/SKILL.md +36 -0
- package/src/application.css +1487 -0
- package/src/application.tsx +1413 -0
- package/src/auth.tsx +452 -0
- package/src/brand.ts +335 -0
- package/src/calendar.css +468 -0
- package/src/calendar.tsx +868 -0
- package/src/choice.tsx +240 -0
- package/src/composer.tsx +158 -0
- package/src/data.css +690 -0
- package/src/display.tsx +452 -0
- package/src/docs.css +704 -0
- package/src/docs.tsx +737 -0
- package/src/ecommerce.css +810 -0
- package/src/ecommerce.tsx +1033 -0
- package/src/feedback.tsx +388 -0
- package/src/foundation.css +1867 -0
- package/src/foundation.tsx +227 -0
- package/src/heading.ts +13 -0
- package/src/icons.tsx +237 -0
- package/src/illustration.tsx +169 -0
- package/src/index.ts +233 -0
- package/src/inspection.tsx +91 -0
- package/src/internal.tsx +700 -0
- package/src/layout.tsx +342 -0
- package/src/marketing-sections.tsx +621 -0
- package/src/marketing.css +2579 -0
- package/src/marketing.tsx +1235 -0
- package/src/navigation.tsx +118 -0
- package/src/overlays.css +350 -0
- package/src/primitives.tsx +1135 -0
- package/src/showcase.tsx +120 -0
- package/src/tokens.css +449 -0
- package/src/workspace.tsx +333 -0
- package/styles.css +56 -0
|
@@ -0,0 +1,1413 @@
|
|
|
1
|
+
import { KitLink, type ItemGroup, type LinkItem } from "./navigation.js";
|
|
2
|
+
import {
|
|
3
|
+
useId,
|
|
4
|
+
useRef,
|
|
5
|
+
useEffect,
|
|
6
|
+
useState,
|
|
7
|
+
type FormEvent,
|
|
8
|
+
type ReactNode,
|
|
9
|
+
} from "react";
|
|
10
|
+
import { Button, EmptyState, Input, Notice, type Tone } from "./primitives.js";
|
|
11
|
+
import { BrandMark, Divider } from "./layout.js";
|
|
12
|
+
import { AuthHeader, ProviderButtons, type SignInProvider } from "./auth.js";
|
|
13
|
+
import { ArrowDown, ArrowUp, Check, SortArrows } from "./icons.js";
|
|
14
|
+
import { headingTag, type HeadingLevel } from "./heading.js";
|
|
15
|
+
import {
|
|
16
|
+
LinkContent,
|
|
17
|
+
NavGroups,
|
|
18
|
+
NavTreeLinks,
|
|
19
|
+
MobileNavDialog,
|
|
20
|
+
TermList,
|
|
21
|
+
cx,
|
|
22
|
+
componentRoot,
|
|
23
|
+
classedIntroSlots,
|
|
24
|
+
useDomId,
|
|
25
|
+
} from "./internal.js";
|
|
26
|
+
/**
|
|
27
|
+
* A navigation link; `icon` replaces the default marker in the sidebar. With
|
|
28
|
+
* `children` the item becomes a disclosure: its own `href` is the first link
|
|
29
|
+
* inside it, and it opens when it or a child is current.
|
|
30
|
+
*/
|
|
31
|
+
export interface NavigationItem extends LinkItem {
|
|
32
|
+
/** Sub-pages; the item becomes a disclosure, open when it or a child is current. */
|
|
33
|
+
children?: NavigationItem[];
|
|
34
|
+
}
|
|
35
|
+
export interface AppShellProps {
|
|
36
|
+
brand: string;
|
|
37
|
+
/** Logo node shown in place of the placeholder brand mark. */
|
|
38
|
+
logo?: ReactNode;
|
|
39
|
+
workspace?: string;
|
|
40
|
+
/** Destination for the brand link; defaults to the first navigation item. */
|
|
41
|
+
homeHref?: string;
|
|
42
|
+
/** Account menu or other content pinned to the bottom of the sidebar. */
|
|
43
|
+
footer?: ReactNode;
|
|
44
|
+
navigation: NavigationItem[];
|
|
45
|
+
/** Labelled groups of links under the main navigation. */
|
|
46
|
+
sections?: ItemGroup<NavigationItem>[];
|
|
47
|
+
header?: ReactNode;
|
|
48
|
+
actions?: ReactNode;
|
|
49
|
+
/** Secondary column beside the main content (multi-column layout). */
|
|
50
|
+
aside?: ReactNode;
|
|
51
|
+
/** Accessible name for the secondary column. */
|
|
52
|
+
asideLabel?: string;
|
|
53
|
+
children: ReactNode;
|
|
54
|
+
className?: string;
|
|
55
|
+
}
|
|
56
|
+
function SidebarContent({
|
|
57
|
+
brand,
|
|
58
|
+
logo,
|
|
59
|
+
workspace,
|
|
60
|
+
homeHref,
|
|
61
|
+
navigation,
|
|
62
|
+
sections = [],
|
|
63
|
+
footer,
|
|
64
|
+
label = "Primary",
|
|
65
|
+
onNavigate,
|
|
66
|
+
}: Omit<SidebarProps, "className"> & { onNavigate?: () => void }) {
|
|
67
|
+
return (
|
|
68
|
+
<>
|
|
69
|
+
{brand && (
|
|
70
|
+
<KitLink
|
|
71
|
+
className="m-workspace"
|
|
72
|
+
href={homeHref || navigation[0]?.href || "#"}
|
|
73
|
+
>
|
|
74
|
+
<BrandMark logo={logo} />
|
|
75
|
+
<span>
|
|
76
|
+
<strong>{brand}</strong>
|
|
77
|
+
{workspace && <small>{workspace}</small>}
|
|
78
|
+
</span>
|
|
79
|
+
</KitLink>
|
|
80
|
+
)}
|
|
81
|
+
<nav aria-label={label}>
|
|
82
|
+
<NavTreeLinks items={navigation} marker onNavigate={onNavigate} />
|
|
83
|
+
<NavGroups groups={sections} marker onNavigate={onNavigate} />
|
|
84
|
+
</nav>
|
|
85
|
+
{footer && <div className="m-user">{footer}</div>}
|
|
86
|
+
</>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
export interface SidebarProps {
|
|
90
|
+
brand?: string;
|
|
91
|
+
logo?: ReactNode;
|
|
92
|
+
workspace?: string;
|
|
93
|
+
/** Destination for the brand link; defaults to the first navigation item. */
|
|
94
|
+
homeHref?: string;
|
|
95
|
+
navigation: NavigationItem[];
|
|
96
|
+
/** Labelled groups of links under the main navigation. */
|
|
97
|
+
sections?: ItemGroup<NavigationItem>[];
|
|
98
|
+
/** Account menu or other content pinned to the bottom. */
|
|
99
|
+
footer?: ReactNode;
|
|
100
|
+
/** Accessible name of the navigation landmark. */
|
|
101
|
+
label?: string;
|
|
102
|
+
className?: string;
|
|
103
|
+
}
|
|
104
|
+
/** Vertical application navigation; AppShell composes the same content. */
|
|
105
|
+
export function Sidebar({ className, ...props }: SidebarProps) {
|
|
106
|
+
return (
|
|
107
|
+
<div
|
|
108
|
+
{...componentRoot("Sidebar")}
|
|
109
|
+
className={cx("m-sidebar-panel", className)}
|
|
110
|
+
>
|
|
111
|
+
<SidebarContent {...props} />
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
export function AppShell({
|
|
116
|
+
brand,
|
|
117
|
+
logo,
|
|
118
|
+
workspace,
|
|
119
|
+
homeHref,
|
|
120
|
+
footer,
|
|
121
|
+
navigation,
|
|
122
|
+
sections = [],
|
|
123
|
+
header,
|
|
124
|
+
actions,
|
|
125
|
+
aside,
|
|
126
|
+
asideLabel = "Details",
|
|
127
|
+
children,
|
|
128
|
+
className,
|
|
129
|
+
}: AppShellProps) {
|
|
130
|
+
const [open, setOpen] = useState(false);
|
|
131
|
+
const contentId = useDomId("m-content-");
|
|
132
|
+
const sidebar = {
|
|
133
|
+
brand,
|
|
134
|
+
logo,
|
|
135
|
+
workspace,
|
|
136
|
+
homeHref,
|
|
137
|
+
navigation,
|
|
138
|
+
sections,
|
|
139
|
+
footer,
|
|
140
|
+
};
|
|
141
|
+
const main = (
|
|
142
|
+
<main id={contentId} tabIndex={-1}>
|
|
143
|
+
{children}
|
|
144
|
+
</main>
|
|
145
|
+
);
|
|
146
|
+
return (
|
|
147
|
+
<div
|
|
148
|
+
{...componentRoot("AppShell")}
|
|
149
|
+
className={cx("m-app-shell", className)}
|
|
150
|
+
>
|
|
151
|
+
<KitLink className="m-skip" href={`#${contentId}`}>
|
|
152
|
+
Skip to content
|
|
153
|
+
</KitLink>
|
|
154
|
+
<aside className="m-sidebar">
|
|
155
|
+
<SidebarContent {...sidebar} />
|
|
156
|
+
</aside>
|
|
157
|
+
<div className="m-app-main">
|
|
158
|
+
<header className="m-app-header">
|
|
159
|
+
<Button
|
|
160
|
+
variant="ghost"
|
|
161
|
+
className="m-menu-trigger"
|
|
162
|
+
aria-haspopup="dialog"
|
|
163
|
+
aria-expanded={open}
|
|
164
|
+
onClick={() => setOpen(true)}
|
|
165
|
+
>
|
|
166
|
+
Menu
|
|
167
|
+
</Button>
|
|
168
|
+
<div className="m-header-context">{header || workspace || brand}</div>
|
|
169
|
+
<div className="m-actions">{actions}</div>
|
|
170
|
+
</header>
|
|
171
|
+
{aside ? (
|
|
172
|
+
<div className="m-app-columns">
|
|
173
|
+
{main}
|
|
174
|
+
<aside className="m-app-aside" aria-label={asideLabel}>
|
|
175
|
+
{aside}
|
|
176
|
+
</aside>
|
|
177
|
+
</div>
|
|
178
|
+
) : (
|
|
179
|
+
main
|
|
180
|
+
)}
|
|
181
|
+
</div>
|
|
182
|
+
<MobileNavDialog open={open} onClose={() => setOpen(false)} title={brand}>
|
|
183
|
+
<SidebarContent {...sidebar} onNavigate={() => setOpen(false)} />
|
|
184
|
+
</MobileNavDialog>
|
|
185
|
+
</div>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
export interface StackedShellProps {
|
|
189
|
+
brand: string;
|
|
190
|
+
logo?: ReactNode;
|
|
191
|
+
homeHref?: string;
|
|
192
|
+
navigation: LinkItem[];
|
|
193
|
+
actions?: ReactNode;
|
|
194
|
+
/** Page heading shown above the content, e.g. a PageHeading. */
|
|
195
|
+
header?: ReactNode;
|
|
196
|
+
width?: "md" | "lg" | "xl" | "full";
|
|
197
|
+
children: ReactNode;
|
|
198
|
+
className?: string;
|
|
199
|
+
}
|
|
200
|
+
/** Top navigation with a centred page column: Tailwind's stacked application layout. */
|
|
201
|
+
export function StackedShell({
|
|
202
|
+
brand,
|
|
203
|
+
logo,
|
|
204
|
+
homeHref,
|
|
205
|
+
navigation,
|
|
206
|
+
actions,
|
|
207
|
+
header,
|
|
208
|
+
width = "lg",
|
|
209
|
+
children,
|
|
210
|
+
className,
|
|
211
|
+
}: StackedShellProps) {
|
|
212
|
+
const contentId = useDomId("m-stacked-");
|
|
213
|
+
return (
|
|
214
|
+
<div
|
|
215
|
+
{...componentRoot("StackedShell")}
|
|
216
|
+
className={cx("m-stacked-shell", className)}
|
|
217
|
+
>
|
|
218
|
+
<KitLink className="m-skip" href={`#${contentId}`}>
|
|
219
|
+
Skip to content
|
|
220
|
+
</KitLink>
|
|
221
|
+
<Navbar
|
|
222
|
+
brand={brand}
|
|
223
|
+
logo={logo}
|
|
224
|
+
homeHref={homeHref}
|
|
225
|
+
items={navigation}
|
|
226
|
+
actions={actions}
|
|
227
|
+
/>
|
|
228
|
+
<main id={contentId} tabIndex={-1} data-width={width}>
|
|
229
|
+
{header && <div className="m-stacked-header">{header}</div>}
|
|
230
|
+
{children}
|
|
231
|
+
</main>
|
|
232
|
+
</div>
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
export interface NavbarProps {
|
|
236
|
+
brand: string;
|
|
237
|
+
logo?: ReactNode;
|
|
238
|
+
homeHref?: string;
|
|
239
|
+
/** Top-level links; the navbar has no sub-menus. */
|
|
240
|
+
items: LinkItem[];
|
|
241
|
+
actions?: ReactNode;
|
|
242
|
+
/** Accessible name of the navigation landmark. */
|
|
243
|
+
label?: string;
|
|
244
|
+
className?: string;
|
|
245
|
+
}
|
|
246
|
+
/** Horizontal application navigation that collapses into a menu on narrow screens. */
|
|
247
|
+
export function Navbar({
|
|
248
|
+
brand,
|
|
249
|
+
logo,
|
|
250
|
+
homeHref = "/",
|
|
251
|
+
items,
|
|
252
|
+
actions,
|
|
253
|
+
label = "Primary",
|
|
254
|
+
className,
|
|
255
|
+
}: NavbarProps) {
|
|
256
|
+
const [open, setOpen] = useState(false);
|
|
257
|
+
const id = useId();
|
|
258
|
+
return (
|
|
259
|
+
<header {...componentRoot("Navbar")} className={cx("m-navbar", className)}>
|
|
260
|
+
<KitLink className="m-brand" href={homeHref}>
|
|
261
|
+
<BrandMark logo={logo} />
|
|
262
|
+
{brand}
|
|
263
|
+
</KitLink>
|
|
264
|
+
<Button
|
|
265
|
+
variant="ghost"
|
|
266
|
+
className="m-navbar-toggle"
|
|
267
|
+
aria-expanded={open}
|
|
268
|
+
aria-controls={id}
|
|
269
|
+
onClick={() => setOpen(!open)}
|
|
270
|
+
>
|
|
271
|
+
Menu
|
|
272
|
+
</Button>
|
|
273
|
+
<nav id={id} aria-label={label} data-open={open || undefined}>
|
|
274
|
+
{items.map((item, index) => (
|
|
275
|
+
<KitLink
|
|
276
|
+
key={`${item.label}:${item.href}:${index}`}
|
|
277
|
+
href={item.href}
|
|
278
|
+
aria-current={item.current ? "page" : undefined}
|
|
279
|
+
onClick={() => setOpen(false)}
|
|
280
|
+
>
|
|
281
|
+
<LinkContent
|
|
282
|
+
prefix="nav"
|
|
283
|
+
icon={item.icon}
|
|
284
|
+
label={item.label}
|
|
285
|
+
count={item.count}
|
|
286
|
+
/>
|
|
287
|
+
</KitLink>
|
|
288
|
+
))}
|
|
289
|
+
</nav>
|
|
290
|
+
{actions && <div className="m-actions">{actions}</div>}
|
|
291
|
+
</header>
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
export interface BreadcrumbItem {
|
|
295
|
+
label: string;
|
|
296
|
+
href?: string;
|
|
297
|
+
}
|
|
298
|
+
export interface BreadcrumbsProps {
|
|
299
|
+
items: BreadcrumbItem[];
|
|
300
|
+
label?: string;
|
|
301
|
+
separator?: "slash" | "chevron";
|
|
302
|
+
className?: string;
|
|
303
|
+
}
|
|
304
|
+
/** Trail to the current page; the item without an href is the current page. */
|
|
305
|
+
export function Breadcrumbs({
|
|
306
|
+
items,
|
|
307
|
+
label = "Breadcrumb",
|
|
308
|
+
separator = "slash",
|
|
309
|
+
className,
|
|
310
|
+
}: BreadcrumbsProps) {
|
|
311
|
+
return (
|
|
312
|
+
<nav
|
|
313
|
+
{...componentRoot("Breadcrumbs")}
|
|
314
|
+
className={cx("m-breadcrumbs", className)}
|
|
315
|
+
aria-label={label}
|
|
316
|
+
data-separator={separator === "slash" ? undefined : separator}
|
|
317
|
+
>
|
|
318
|
+
<ol>
|
|
319
|
+
{items.map((b, i) => (
|
|
320
|
+
<li key={i}>
|
|
321
|
+
{b.href ? (
|
|
322
|
+
<KitLink href={b.href}>{b.label}</KitLink>
|
|
323
|
+
) : (
|
|
324
|
+
<span aria-current="page">{b.label}</span>
|
|
325
|
+
)}
|
|
326
|
+
</li>
|
|
327
|
+
))}
|
|
328
|
+
</ol>
|
|
329
|
+
</nav>
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
export type TabNavItem = LinkItem;
|
|
333
|
+
export interface TabNavProps {
|
|
334
|
+
label: string;
|
|
335
|
+
items: TabNavItem[];
|
|
336
|
+
/** Underlined tabs (default) or filled pills. */
|
|
337
|
+
variant?: "underline" | "pills";
|
|
338
|
+
/** Stretch the tabs to share the full width. */
|
|
339
|
+
fullWidth?: boolean;
|
|
340
|
+
className?: string;
|
|
341
|
+
}
|
|
342
|
+
/** Tabs that link between pages, e.g. settings sections. Uses links, not ARIA tabs. */
|
|
343
|
+
export function TabNav({
|
|
344
|
+
label,
|
|
345
|
+
items,
|
|
346
|
+
variant = "underline",
|
|
347
|
+
fullWidth = false,
|
|
348
|
+
className,
|
|
349
|
+
}: TabNavProps) {
|
|
350
|
+
return (
|
|
351
|
+
<nav
|
|
352
|
+
{...componentRoot("TabNav")}
|
|
353
|
+
className={cx("m-tab-nav", className)}
|
|
354
|
+
aria-label={label}
|
|
355
|
+
data-variant={variant === "underline" ? undefined : variant}
|
|
356
|
+
data-full-width={fullWidth || undefined}
|
|
357
|
+
>
|
|
358
|
+
<ul>
|
|
359
|
+
{items.map((item) => (
|
|
360
|
+
<li key={item.href}>
|
|
361
|
+
<KitLink
|
|
362
|
+
href={item.href}
|
|
363
|
+
aria-current={item.current ? "page" : undefined}
|
|
364
|
+
>
|
|
365
|
+
<LinkContent
|
|
366
|
+
prefix="tab"
|
|
367
|
+
icon={item.icon}
|
|
368
|
+
label={item.label}
|
|
369
|
+
count={item.count}
|
|
370
|
+
/>
|
|
371
|
+
</KitLink>
|
|
372
|
+
</li>
|
|
373
|
+
))}
|
|
374
|
+
</ul>
|
|
375
|
+
</nav>
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
export interface ProgressStep {
|
|
379
|
+
id: string;
|
|
380
|
+
name: string;
|
|
381
|
+
description?: string;
|
|
382
|
+
/** Lets people revisit the step, e.g. a completed form page. */
|
|
383
|
+
href?: string;
|
|
384
|
+
status: "complete" | "current" | "upcoming";
|
|
385
|
+
}
|
|
386
|
+
export interface ProgressStepsProps {
|
|
387
|
+
steps: ProgressStep[];
|
|
388
|
+
label?: string;
|
|
389
|
+
/**
|
|
390
|
+
* simple: bars with step numbers; circles: vertical with descriptions;
|
|
391
|
+
* bullets: "Step 2 of 4" with dots; bar: one track filled up to the current
|
|
392
|
+
* step with the names beneath, e.g. an order's delivery progress.
|
|
393
|
+
*/
|
|
394
|
+
variant?: "simple" | "circles" | "bullets" | "bar";
|
|
395
|
+
/**
|
|
396
|
+
* `false` renders a labelled group instead of a navigation landmark, for
|
|
397
|
+
* progress that reports status rather than offering wayfinding, e.g. an
|
|
398
|
+
* order tracker (default true).
|
|
399
|
+
*/
|
|
400
|
+
navigation?: boolean;
|
|
401
|
+
className?: string;
|
|
402
|
+
}
|
|
403
|
+
/** Where someone is in a multi-step flow. The current step is marked aria-current="step". */
|
|
404
|
+
export function ProgressSteps({
|
|
405
|
+
steps,
|
|
406
|
+
label = "Progress",
|
|
407
|
+
variant = "simple",
|
|
408
|
+
navigation = true,
|
|
409
|
+
className,
|
|
410
|
+
}: ProgressStepsProps) {
|
|
411
|
+
const current = steps.findIndex((step) => step.status === "current");
|
|
412
|
+
const Root = navigation ? "nav" : "div";
|
|
413
|
+
return (
|
|
414
|
+
<Root
|
|
415
|
+
{...componentRoot("ProgressSteps")}
|
|
416
|
+
className={cx("m-progress-steps", className)}
|
|
417
|
+
role={navigation ? undefined : "group"}
|
|
418
|
+
aria-label={label}
|
|
419
|
+
data-variant={variant === "simple" ? undefined : variant}
|
|
420
|
+
>
|
|
421
|
+
{variant === "bullets" && current >= 0 && (
|
|
422
|
+
<p>
|
|
423
|
+
Step {current + 1} of {steps.length}
|
|
424
|
+
</p>
|
|
425
|
+
)}
|
|
426
|
+
<ol>
|
|
427
|
+
{steps.map((step, index) => {
|
|
428
|
+
const body = (
|
|
429
|
+
<>
|
|
430
|
+
{(variant === "circles" || variant === "bullets") && (
|
|
431
|
+
<span className="m-progress-step-marker" aria-hidden="true">
|
|
432
|
+
{variant === "circles" && step.status === "complete" && (
|
|
433
|
+
<Check variant="step" />
|
|
434
|
+
)}
|
|
435
|
+
</span>
|
|
436
|
+
)}
|
|
437
|
+
<span
|
|
438
|
+
className={
|
|
439
|
+
variant === "bullets" ? "m-sr-only" : "m-progress-step-text"
|
|
440
|
+
}
|
|
441
|
+
>
|
|
442
|
+
{variant === "simple" && (
|
|
443
|
+
<span className="m-progress-step-number">
|
|
444
|
+
Step {index + 1}
|
|
445
|
+
</span>
|
|
446
|
+
)}
|
|
447
|
+
<span className="m-progress-step-name">{step.name}</span>
|
|
448
|
+
{step.description && variant === "circles" && (
|
|
449
|
+
<span className="m-progress-step-description">
|
|
450
|
+
{step.description}
|
|
451
|
+
</span>
|
|
452
|
+
)}
|
|
453
|
+
{step.status === "complete" && (
|
|
454
|
+
<span className="m-sr-only"> (completed)</span>
|
|
455
|
+
)}
|
|
456
|
+
</span>
|
|
457
|
+
</>
|
|
458
|
+
);
|
|
459
|
+
const ariaCurrent =
|
|
460
|
+
step.status === "current" ? ("step" as const) : undefined;
|
|
461
|
+
return (
|
|
462
|
+
<li key={step.id} data-status={step.status}>
|
|
463
|
+
{step.href ? (
|
|
464
|
+
<KitLink href={step.href} aria-current={ariaCurrent}>
|
|
465
|
+
{body}
|
|
466
|
+
</KitLink>
|
|
467
|
+
) : (
|
|
468
|
+
<span aria-current={ariaCurrent}>{body}</span>
|
|
469
|
+
)}
|
|
470
|
+
</li>
|
|
471
|
+
);
|
|
472
|
+
})}
|
|
473
|
+
</ol>
|
|
474
|
+
</Root>
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
export interface ActionPanelProps {
|
|
478
|
+
title: string;
|
|
479
|
+
description?: string;
|
|
480
|
+
action?: ReactNode;
|
|
481
|
+
children?: ReactNode;
|
|
482
|
+
/** `danger` marks a destructive action, e.g. deleting an account. */
|
|
483
|
+
tone?: "default" | "danger";
|
|
484
|
+
headingLevel?: HeadingLevel;
|
|
485
|
+
id?: string;
|
|
486
|
+
className?: string;
|
|
487
|
+
}
|
|
488
|
+
/** Card with a title, explanation and one decisive action, e.g. deleting an account. */
|
|
489
|
+
export function ActionPanel({
|
|
490
|
+
title,
|
|
491
|
+
description,
|
|
492
|
+
action,
|
|
493
|
+
children,
|
|
494
|
+
tone = "default",
|
|
495
|
+
headingLevel = 2,
|
|
496
|
+
id,
|
|
497
|
+
className,
|
|
498
|
+
}: ActionPanelProps) {
|
|
499
|
+
const headingId = useId();
|
|
500
|
+
const [, headingSlot, descriptionSlot] = classedIntroSlots(
|
|
501
|
+
{
|
|
502
|
+
title,
|
|
503
|
+
description,
|
|
504
|
+
id: headingId,
|
|
505
|
+
headingLevel,
|
|
506
|
+
descriptionClassName: null,
|
|
507
|
+
},
|
|
508
|
+
"m-action-panel-title",
|
|
509
|
+
);
|
|
510
|
+
return (
|
|
511
|
+
<section
|
|
512
|
+
id={id}
|
|
513
|
+
{...componentRoot("ActionPanel")}
|
|
514
|
+
className={cx("m-action-panel", className)}
|
|
515
|
+
aria-labelledby={headingId}
|
|
516
|
+
data-tone={tone === "default" ? undefined : tone}
|
|
517
|
+
>
|
|
518
|
+
<div className="m-action-panel-text">
|
|
519
|
+
{headingSlot}
|
|
520
|
+
{descriptionSlot}
|
|
521
|
+
{children}
|
|
522
|
+
</div>
|
|
523
|
+
{action && <div className="m-action-panel-action">{action}</div>}
|
|
524
|
+
</section>
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
export interface PageHeadingProps {
|
|
528
|
+
title: string;
|
|
529
|
+
description?: string;
|
|
530
|
+
eyebrow?: string;
|
|
531
|
+
actions?: ReactNode;
|
|
532
|
+
breadcrumbs?: BreadcrumbItem[];
|
|
533
|
+
/** The page's h1 by default; use 2 when the page already has one. */
|
|
534
|
+
headingLevel?: HeadingLevel;
|
|
535
|
+
/** Facts under the title, e.g. "Full-time", "Remote", "Closes 9 January". */
|
|
536
|
+
meta?: { label: string; icon?: ReactNode }[];
|
|
537
|
+
/** Avatar or logo beside the title (decorative). */
|
|
538
|
+
avatar?: ReactNode;
|
|
539
|
+
id?: string;
|
|
540
|
+
className?: string;
|
|
541
|
+
}
|
|
542
|
+
export function PageHeading({
|
|
543
|
+
title,
|
|
544
|
+
description,
|
|
545
|
+
eyebrow,
|
|
546
|
+
actions,
|
|
547
|
+
breadcrumbs,
|
|
548
|
+
headingLevel = 1,
|
|
549
|
+
meta = [],
|
|
550
|
+
avatar,
|
|
551
|
+
id,
|
|
552
|
+
className,
|
|
553
|
+
}: PageHeadingProps) {
|
|
554
|
+
const text = (
|
|
555
|
+
<div>
|
|
556
|
+
{...classedIntroSlots(
|
|
557
|
+
{
|
|
558
|
+
eyebrow,
|
|
559
|
+
title,
|
|
560
|
+
description,
|
|
561
|
+
headingLevel,
|
|
562
|
+
descriptionClassName: null,
|
|
563
|
+
},
|
|
564
|
+
"m-page-title",
|
|
565
|
+
)}
|
|
566
|
+
{meta.length > 0 && (
|
|
567
|
+
<ul className="m-heading-meta">
|
|
568
|
+
{meta.map((item, index) => (
|
|
569
|
+
<li key={`${item.label}:${index}`}>
|
|
570
|
+
{item.icon && <span aria-hidden="true">{item.icon}</span>}
|
|
571
|
+
{item.label}
|
|
572
|
+
</li>
|
|
573
|
+
))}
|
|
574
|
+
</ul>
|
|
575
|
+
)}
|
|
576
|
+
</div>
|
|
577
|
+
);
|
|
578
|
+
return (
|
|
579
|
+
<header
|
|
580
|
+
id={id}
|
|
581
|
+
{...componentRoot("PageHeading")}
|
|
582
|
+
className={cx("m-page-heading", className)}
|
|
583
|
+
>
|
|
584
|
+
{breadcrumbs && <Breadcrumbs items={breadcrumbs} />}
|
|
585
|
+
<div className="m-heading-row">
|
|
586
|
+
{avatar ? (
|
|
587
|
+
<div className="m-heading-main">
|
|
588
|
+
<span className="m-heading-avatar" aria-hidden="true">
|
|
589
|
+
{avatar}
|
|
590
|
+
</span>
|
|
591
|
+
{text}
|
|
592
|
+
</div>
|
|
593
|
+
) : (
|
|
594
|
+
text
|
|
595
|
+
)}
|
|
596
|
+
{actions && <div className="m-actions">{actions}</div>}
|
|
597
|
+
</div>
|
|
598
|
+
</header>
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
export interface MetricGroupProps {
|
|
602
|
+
label: string;
|
|
603
|
+
items: {
|
|
604
|
+
label: string;
|
|
605
|
+
value: string;
|
|
606
|
+
change?: string;
|
|
607
|
+
tone?: Tone;
|
|
608
|
+
/** Decorative icon before the label. */
|
|
609
|
+
icon?: ReactNode;
|
|
610
|
+
/** Direction of the change: adds an arrow and announces "Increased by" or "Decreased by". */
|
|
611
|
+
trend?: "up" | "down";
|
|
612
|
+
/** Prior value for comparison, shown as "from …" after the value. */
|
|
613
|
+
previous?: string;
|
|
614
|
+
}[];
|
|
615
|
+
/** `shared` joins metrics with shared borders; `cards` separates them into individual cards. */
|
|
616
|
+
variant?: "shared" | "cards";
|
|
617
|
+
className?: string;
|
|
618
|
+
}
|
|
619
|
+
export function MetricGroup({
|
|
620
|
+
label,
|
|
621
|
+
items,
|
|
622
|
+
variant = "shared",
|
|
623
|
+
className,
|
|
624
|
+
}: MetricGroupProps) {
|
|
625
|
+
return (
|
|
626
|
+
<TermList
|
|
627
|
+
{...componentRoot("MetricGroup")}
|
|
628
|
+
className={cx("m-metrics", className)}
|
|
629
|
+
aria-label={label}
|
|
630
|
+
data-variant={variant === "cards" ? "cards" : undefined}
|
|
631
|
+
items={items.map((item) => ({
|
|
632
|
+
key: item.label,
|
|
633
|
+
term: (
|
|
634
|
+
<>
|
|
635
|
+
{item.icon && (
|
|
636
|
+
<span className="m-metric-icon" aria-hidden="true">
|
|
637
|
+
{item.icon}
|
|
638
|
+
</span>
|
|
639
|
+
)}
|
|
640
|
+
{item.label}
|
|
641
|
+
</>
|
|
642
|
+
),
|
|
643
|
+
details: (
|
|
644
|
+
<>
|
|
645
|
+
<strong>{item.value}</strong>
|
|
646
|
+
{item.previous && (
|
|
647
|
+
<span className="m-metric-previous">from {item.previous}</span>
|
|
648
|
+
)}
|
|
649
|
+
{item.change && (
|
|
650
|
+
<span
|
|
651
|
+
className="m-metric-change"
|
|
652
|
+
data-tone={
|
|
653
|
+
item.tone && item.tone !== "neutral" ? item.tone : undefined
|
|
654
|
+
}
|
|
655
|
+
data-trend={item.trend}
|
|
656
|
+
>
|
|
657
|
+
{item.trend && (
|
|
658
|
+
<>
|
|
659
|
+
{item.trend === "up" ? <ArrowUp /> : <ArrowDown />}
|
|
660
|
+
<span className="m-sr-only">
|
|
661
|
+
{item.trend === "up" ? "Increased by " : "Decreased by "}
|
|
662
|
+
</span>
|
|
663
|
+
</>
|
|
664
|
+
)}
|
|
665
|
+
{item.change}
|
|
666
|
+
</span>
|
|
667
|
+
)}
|
|
668
|
+
</>
|
|
669
|
+
),
|
|
670
|
+
}))}
|
|
671
|
+
/>
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
export type SortDirection = "ascending" | "descending";
|
|
675
|
+
export interface TableSort {
|
|
676
|
+
key: string;
|
|
677
|
+
direction: SortDirection;
|
|
678
|
+
}
|
|
679
|
+
export interface DataTableColumn<T> {
|
|
680
|
+
key: string;
|
|
681
|
+
label: string;
|
|
682
|
+
render: (row: T) => ReactNode;
|
|
683
|
+
/** `end` aligns numbers and amounts to the trailing edge. */
|
|
684
|
+
align?: "start" | "end";
|
|
685
|
+
/** Enable header sorting. Supply sortValue unless row[key] is a string, number or Date. */
|
|
686
|
+
sortable?: boolean;
|
|
687
|
+
sortValue?: (row: T) => string | number | Date | null | undefined;
|
|
688
|
+
/** CSS width, e.g. "12rem" or "30%". */
|
|
689
|
+
width?: string;
|
|
690
|
+
/** Clip long values to one line with an ellipsis. */
|
|
691
|
+
truncate?: boolean;
|
|
692
|
+
}
|
|
693
|
+
export interface DataTableProps<T extends { id: string }> {
|
|
694
|
+
caption: string;
|
|
695
|
+
columns: DataTableColumn<T>[];
|
|
696
|
+
rows: T[];
|
|
697
|
+
/** Selected row ids, which may include rows on other pages. */
|
|
698
|
+
selectedIds?: string[];
|
|
699
|
+
onSelectionChange?: (ids: string[]) => void;
|
|
700
|
+
rowLabel: (row: T) => string;
|
|
701
|
+
/** Link each row's first cell to a detail page; the whole row becomes the click target. */
|
|
702
|
+
rowHref?: (row: T) => string | undefined;
|
|
703
|
+
/** Initial sort when the table sorts locally. */
|
|
704
|
+
defaultSort?: TableSort;
|
|
705
|
+
/** Controlled sort. With onSortChange the consumer orders rows (e.g. server-side). */
|
|
706
|
+
sort?: TableSort | null;
|
|
707
|
+
onSortChange?: (sort: TableSort) => void;
|
|
708
|
+
/** Group rows under full-width header rows. Groups keep the order in which they first appear in rows; sorting applies within each group. */
|
|
709
|
+
groupBy?: (row: T) => string;
|
|
710
|
+
/** Scroll height; the header sticks while rows scroll. */
|
|
711
|
+
maxHeight?: string;
|
|
712
|
+
density?: "default" | "compact";
|
|
713
|
+
striped?: boolean;
|
|
714
|
+
toolbar?: ReactNode;
|
|
715
|
+
bulkActions?: ReactNode;
|
|
716
|
+
/** Rendered under the rows inside the table card, e.g. Pagination. */
|
|
717
|
+
footer?: ReactNode;
|
|
718
|
+
/** Shown when there are no rows. */
|
|
719
|
+
empty?: { title: string; description?: string; action?: ReactNode };
|
|
720
|
+
/** Level of the empty state's heading. */
|
|
721
|
+
headingLevel?: HeadingLevel;
|
|
722
|
+
loading?: boolean;
|
|
723
|
+
error?: string;
|
|
724
|
+
id?: string;
|
|
725
|
+
className?: string;
|
|
726
|
+
}
|
|
727
|
+
const collator = new Intl.Collator(undefined, {
|
|
728
|
+
numeric: true,
|
|
729
|
+
sensitivity: "base",
|
|
730
|
+
});
|
|
731
|
+
const isEmpty = (v: unknown) => v === null || v === undefined || v === "";
|
|
732
|
+
function compareValues(a: unknown, b: unknown) {
|
|
733
|
+
if (isEmpty(a) || isEmpty(b))
|
|
734
|
+
return isEmpty(a) === isEmpty(b) ? 0 : isEmpty(a) ? 1 : -1;
|
|
735
|
+
if (a instanceof Date && b instanceof Date) return a.getTime() - b.getTime();
|
|
736
|
+
if (typeof a === "number" && typeof b === "number") return a - b;
|
|
737
|
+
return collator.compare(String(a), String(b));
|
|
738
|
+
}
|
|
739
|
+
/** Stable sort; empty values always sort last regardless of direction. */
|
|
740
|
+
export function sortRows<T>(
|
|
741
|
+
rows: T[],
|
|
742
|
+
column: Pick<DataTableColumn<T>, "key" | "sortValue">,
|
|
743
|
+
direction: SortDirection,
|
|
744
|
+
) {
|
|
745
|
+
const value =
|
|
746
|
+
column.sortValue ||
|
|
747
|
+
((row: T) => (row as Record<string, unknown>)[column.key] as string);
|
|
748
|
+
const sign = direction === "ascending" ? 1 : -1;
|
|
749
|
+
return rows
|
|
750
|
+
.map((row, index) => ({ row, index, v: value(row) }))
|
|
751
|
+
.sort(
|
|
752
|
+
(a, b) =>
|
|
753
|
+
// Empty values sort last in both directions, so only real values flip.
|
|
754
|
+
(isEmpty(a.v) || isEmpty(b.v) ? 1 : sign) * compareValues(a.v, b.v) ||
|
|
755
|
+
a.index - b.index,
|
|
756
|
+
)
|
|
757
|
+
.map((entry) => entry.row);
|
|
758
|
+
}
|
|
759
|
+
function SelectAll({
|
|
760
|
+
checked,
|
|
761
|
+
partial,
|
|
762
|
+
onChange,
|
|
763
|
+
}: {
|
|
764
|
+
checked: boolean;
|
|
765
|
+
partial: boolean;
|
|
766
|
+
onChange: () => void;
|
|
767
|
+
}) {
|
|
768
|
+
const ref = useRef<HTMLInputElement>(null);
|
|
769
|
+
useEffect(() => {
|
|
770
|
+
if (ref.current) ref.current.indeterminate = partial;
|
|
771
|
+
}, [partial]);
|
|
772
|
+
return (
|
|
773
|
+
<input
|
|
774
|
+
ref={ref}
|
|
775
|
+
type="checkbox"
|
|
776
|
+
aria-label="Select all rows"
|
|
777
|
+
checked={checked}
|
|
778
|
+
onChange={onChange}
|
|
779
|
+
/>
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
/** Buckets ordered rows by group; groups keep their first-appearance order in the unsorted rows. */
|
|
783
|
+
function groupRows<T>(rows: T[], ordered: T[], groupBy: (row: T) => string) {
|
|
784
|
+
const groups = new Map<string, T[]>(rows.map((row) => [groupBy(row), []]));
|
|
785
|
+
ordered.forEach((row) => groups.get(groupBy(row))!.push(row));
|
|
786
|
+
return [...groups];
|
|
787
|
+
}
|
|
788
|
+
/** Full-width header row that labels the rows of its tbody. */
|
|
789
|
+
function TableGroupRow({ label, span }: { label: string; span: number }) {
|
|
790
|
+
return (
|
|
791
|
+
<tr className="m-table-group">
|
|
792
|
+
<th colSpan={span} scope="rowgroup">
|
|
793
|
+
{label}
|
|
794
|
+
</th>
|
|
795
|
+
</tr>
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
const columnAlign = (align?: "start" | "end") =>
|
|
799
|
+
align === "end" ? "end" : undefined;
|
|
800
|
+
export function DataTable<T extends { id: string }>({
|
|
801
|
+
caption,
|
|
802
|
+
columns,
|
|
803
|
+
rows,
|
|
804
|
+
selectedIds = [],
|
|
805
|
+
onSelectionChange,
|
|
806
|
+
rowLabel,
|
|
807
|
+
rowHref,
|
|
808
|
+
defaultSort,
|
|
809
|
+
sort: controlledSort,
|
|
810
|
+
onSortChange,
|
|
811
|
+
groupBy,
|
|
812
|
+
maxHeight,
|
|
813
|
+
density = "default",
|
|
814
|
+
striped = false,
|
|
815
|
+
toolbar,
|
|
816
|
+
bulkActions,
|
|
817
|
+
footer,
|
|
818
|
+
empty = {
|
|
819
|
+
title: "No records",
|
|
820
|
+
description: "Records will appear here when they are available.",
|
|
821
|
+
},
|
|
822
|
+
headingLevel = 3,
|
|
823
|
+
loading,
|
|
824
|
+
error,
|
|
825
|
+
id,
|
|
826
|
+
className,
|
|
827
|
+
}: DataTableProps<T>) {
|
|
828
|
+
const [localSort, setLocalSort] = useState<TableSort | null>(
|
|
829
|
+
defaultSort || null,
|
|
830
|
+
);
|
|
831
|
+
const sort = controlledSort !== undefined ? controlledSort : localSort;
|
|
832
|
+
const sortColumn = sort && columns.find((c) => c.key === sort.key);
|
|
833
|
+
const visibleRows =
|
|
834
|
+
sort && sortColumn && !onSortChange
|
|
835
|
+
? sortRows(rows, sortColumn, sort.direction)
|
|
836
|
+
: rows;
|
|
837
|
+
const toggleSort = (key: string) => {
|
|
838
|
+
const next: TableSort = {
|
|
839
|
+
key,
|
|
840
|
+
direction:
|
|
841
|
+
sort?.key === key && sort.direction === "ascending"
|
|
842
|
+
? "descending"
|
|
843
|
+
: "ascending",
|
|
844
|
+
};
|
|
845
|
+
if (controlledSort === undefined) setLocalSort(next);
|
|
846
|
+
onSortChange?.(next);
|
|
847
|
+
};
|
|
848
|
+
const pageIds = rows.map((row) => row.id);
|
|
849
|
+
const selectedHere = pageIds.filter((rowId) => selectedIds.includes(rowId));
|
|
850
|
+
const allHere = rows.length > 0 && selectedHere.length === rows.length;
|
|
851
|
+
// Select all adds or removes this page's rows and keeps selections made on
|
|
852
|
+
// other pages.
|
|
853
|
+
const toggleAll = () =>
|
|
854
|
+
onSelectionChange?.(
|
|
855
|
+
allHere
|
|
856
|
+
? selectedIds.filter((rowId) => !pageIds.includes(rowId))
|
|
857
|
+
: [
|
|
858
|
+
...selectedIds,
|
|
859
|
+
...pageIds.filter((rowId) => !selectedIds.includes(rowId)),
|
|
860
|
+
],
|
|
861
|
+
);
|
|
862
|
+
const span = columns.length + (onSelectionChange ? 1 : 0);
|
|
863
|
+
const renderRow = (row: T) => {
|
|
864
|
+
const href = rowHref?.(row);
|
|
865
|
+
return (
|
|
866
|
+
<tr
|
|
867
|
+
key={row.id}
|
|
868
|
+
data-selected={selectedIds.includes(row.id) || undefined}
|
|
869
|
+
data-linked={href ? true : undefined}
|
|
870
|
+
>
|
|
871
|
+
{onSelectionChange && (
|
|
872
|
+
<td>
|
|
873
|
+
<input
|
|
874
|
+
type="checkbox"
|
|
875
|
+
aria-label={`Select ${rowLabel(row)}`}
|
|
876
|
+
checked={selectedIds.includes(row.id)}
|
|
877
|
+
onChange={() =>
|
|
878
|
+
onSelectionChange(
|
|
879
|
+
selectedIds.includes(row.id)
|
|
880
|
+
? selectedIds.filter((rowId) => rowId !== row.id)
|
|
881
|
+
: [...selectedIds, row.id],
|
|
882
|
+
)
|
|
883
|
+
}
|
|
884
|
+
/>
|
|
885
|
+
</td>
|
|
886
|
+
)}
|
|
887
|
+
{columns.map((col, index) => {
|
|
888
|
+
const content = col.truncate ? (
|
|
889
|
+
<span className="m-truncate">{col.render(row)}</span>
|
|
890
|
+
) : (
|
|
891
|
+
col.render(row)
|
|
892
|
+
);
|
|
893
|
+
return index === 0 ? (
|
|
894
|
+
<th
|
|
895
|
+
scope="row"
|
|
896
|
+
key={col.key}
|
|
897
|
+
data-align={columnAlign(col.align)}
|
|
898
|
+
data-truncate={col.truncate || undefined}
|
|
899
|
+
>
|
|
900
|
+
{href ? (
|
|
901
|
+
<KitLink className="m-row-link" href={href}>
|
|
902
|
+
{content}
|
|
903
|
+
</KitLink>
|
|
904
|
+
) : (
|
|
905
|
+
content
|
|
906
|
+
)}
|
|
907
|
+
</th>
|
|
908
|
+
) : (
|
|
909
|
+
<td
|
|
910
|
+
key={col.key}
|
|
911
|
+
data-align={columnAlign(col.align)}
|
|
912
|
+
data-truncate={col.truncate || undefined}
|
|
913
|
+
>
|
|
914
|
+
{content}
|
|
915
|
+
</td>
|
|
916
|
+
);
|
|
917
|
+
})}
|
|
918
|
+
</tr>
|
|
919
|
+
);
|
|
920
|
+
};
|
|
921
|
+
return (
|
|
922
|
+
<section
|
|
923
|
+
id={id}
|
|
924
|
+
{...componentRoot("DataTable")}
|
|
925
|
+
className={cx("m-data-table", className)}
|
|
926
|
+
aria-label={caption}
|
|
927
|
+
data-density={density === "default" ? undefined : density}
|
|
928
|
+
data-striped={striped || undefined}
|
|
929
|
+
>
|
|
930
|
+
{(toolbar || selectedIds.length > 0) && (
|
|
931
|
+
<div className="m-table-toolbar">
|
|
932
|
+
{selectedIds.length > 0 ? (
|
|
933
|
+
<>
|
|
934
|
+
<strong>{selectedIds.length} selected</strong>
|
|
935
|
+
<div className="m-actions">
|
|
936
|
+
{bulkActions}
|
|
937
|
+
<Button variant="ghost" onClick={() => onSelectionChange?.([])}>
|
|
938
|
+
Clear selection
|
|
939
|
+
</Button>
|
|
940
|
+
</div>
|
|
941
|
+
</>
|
|
942
|
+
) : (
|
|
943
|
+
toolbar
|
|
944
|
+
)}
|
|
945
|
+
</div>
|
|
946
|
+
)}
|
|
947
|
+
{/* Mounted before loading starts so screen readers announce the change. */}
|
|
948
|
+
<p
|
|
949
|
+
className={loading ? "m-table-state" : "m-sr-only"}
|
|
950
|
+
role="status"
|
|
951
|
+
data-loading={loading || undefined}
|
|
952
|
+
>
|
|
953
|
+
{loading ? "Loading records…" : ""}
|
|
954
|
+
</p>
|
|
955
|
+
{loading ? null : error ? (
|
|
956
|
+
<Notice title="Unable to load records" tone="danger" announce>
|
|
957
|
+
{error}
|
|
958
|
+
</Notice>
|
|
959
|
+
) : !rows.length ? (
|
|
960
|
+
<EmptyState
|
|
961
|
+
title={empty.title}
|
|
962
|
+
description={empty.description}
|
|
963
|
+
action={empty.action}
|
|
964
|
+
headingLevel={headingLevel}
|
|
965
|
+
/>
|
|
966
|
+
) : (
|
|
967
|
+
<div
|
|
968
|
+
className="m-table-scroll"
|
|
969
|
+
role="region"
|
|
970
|
+
aria-label={`${caption} scroll area`}
|
|
971
|
+
tabIndex={0}
|
|
972
|
+
style={maxHeight ? { maxHeight } : undefined}
|
|
973
|
+
data-sticky={maxHeight ? true : undefined}
|
|
974
|
+
>
|
|
975
|
+
<table>
|
|
976
|
+
<caption className="m-sr-only">{caption}</caption>
|
|
977
|
+
{columns.some((col) => col.width) && (
|
|
978
|
+
<colgroup>
|
|
979
|
+
{onSelectionChange && <col className="m-table-select-col" />}
|
|
980
|
+
{columns.map((col) => (
|
|
981
|
+
<col
|
|
982
|
+
key={col.key}
|
|
983
|
+
style={col.width ? { width: col.width } : undefined}
|
|
984
|
+
/>
|
|
985
|
+
))}
|
|
986
|
+
</colgroup>
|
|
987
|
+
)}
|
|
988
|
+
<thead>
|
|
989
|
+
<tr>
|
|
990
|
+
{onSelectionChange && (
|
|
991
|
+
<th scope="col">
|
|
992
|
+
<SelectAll
|
|
993
|
+
checked={allHere}
|
|
994
|
+
partial={selectedHere.length > 0 && !allHere}
|
|
995
|
+
onChange={toggleAll}
|
|
996
|
+
/>
|
|
997
|
+
</th>
|
|
998
|
+
)}
|
|
999
|
+
{columns.map((col) => {
|
|
1000
|
+
const direction =
|
|
1001
|
+
sort?.key === col.key ? sort.direction : undefined;
|
|
1002
|
+
return (
|
|
1003
|
+
<th
|
|
1004
|
+
key={col.key}
|
|
1005
|
+
scope="col"
|
|
1006
|
+
data-align={columnAlign(col.align)}
|
|
1007
|
+
aria-sort={col.sortable ? direction || "none" : undefined}
|
|
1008
|
+
>
|
|
1009
|
+
{col.sortable ? (
|
|
1010
|
+
<button
|
|
1011
|
+
type="button"
|
|
1012
|
+
className="m-sort-button"
|
|
1013
|
+
onClick={() => toggleSort(col.key)}
|
|
1014
|
+
>
|
|
1015
|
+
{col.label}
|
|
1016
|
+
<SortArrows direction={direction} />
|
|
1017
|
+
</button>
|
|
1018
|
+
) : (
|
|
1019
|
+
col.label
|
|
1020
|
+
)}
|
|
1021
|
+
</th>
|
|
1022
|
+
);
|
|
1023
|
+
})}
|
|
1024
|
+
</tr>
|
|
1025
|
+
</thead>
|
|
1026
|
+
{groupBy ? (
|
|
1027
|
+
groupRows(rows, visibleRows, groupBy).map(([label, group]) => (
|
|
1028
|
+
<tbody key={label}>
|
|
1029
|
+
<TableGroupRow label={label} span={span} />
|
|
1030
|
+
{group.map(renderRow)}
|
|
1031
|
+
</tbody>
|
|
1032
|
+
))
|
|
1033
|
+
) : (
|
|
1034
|
+
<tbody>{visibleRows.map(renderRow)}</tbody>
|
|
1035
|
+
)}
|
|
1036
|
+
</table>
|
|
1037
|
+
</div>
|
|
1038
|
+
)}
|
|
1039
|
+
{footer && <div className="m-table-footer">{footer}</div>}
|
|
1040
|
+
</section>
|
|
1041
|
+
);
|
|
1042
|
+
}
|
|
1043
|
+
export interface TableProps {
|
|
1044
|
+
caption: string;
|
|
1045
|
+
captionVisible?: boolean;
|
|
1046
|
+
/** `align: "end"` aligns numbers and amounts to the trailing edge. */
|
|
1047
|
+
columns: { key: string; label: string; align?: "start" | "end" }[];
|
|
1048
|
+
rows?: Record<string, ReactNode>[];
|
|
1049
|
+
/** Labelled row groups, each its own tbody under a full-width header row. Ungrouped rows render first. */
|
|
1050
|
+
groups?: { label: string; rows: Record<string, ReactNode>[] }[];
|
|
1051
|
+
/** Treat the first column as row headers. */
|
|
1052
|
+
rowHeaders?: boolean;
|
|
1053
|
+
striped?: boolean;
|
|
1054
|
+
id?: string;
|
|
1055
|
+
className?: string;
|
|
1056
|
+
}
|
|
1057
|
+
/** Plain semantic table for documents, settings summaries and comparisons without row actions. */
|
|
1058
|
+
export function Table({
|
|
1059
|
+
caption,
|
|
1060
|
+
captionVisible = false,
|
|
1061
|
+
columns,
|
|
1062
|
+
rows = [],
|
|
1063
|
+
groups,
|
|
1064
|
+
rowHeaders = true,
|
|
1065
|
+
striped = false,
|
|
1066
|
+
id,
|
|
1067
|
+
className,
|
|
1068
|
+
}: TableProps) {
|
|
1069
|
+
const renderRow = (row: Record<string, ReactNode>, i: number) => (
|
|
1070
|
+
<tr key={i}>
|
|
1071
|
+
{columns.map((col, index) =>
|
|
1072
|
+
index === 0 && rowHeaders ? (
|
|
1073
|
+
<th key={col.key} scope="row" data-align={columnAlign(col.align)}>
|
|
1074
|
+
{row[col.key]}
|
|
1075
|
+
</th>
|
|
1076
|
+
) : (
|
|
1077
|
+
<td key={col.key} data-align={columnAlign(col.align)}>
|
|
1078
|
+
{row[col.key]}
|
|
1079
|
+
</td>
|
|
1080
|
+
),
|
|
1081
|
+
)}
|
|
1082
|
+
</tr>
|
|
1083
|
+
);
|
|
1084
|
+
return (
|
|
1085
|
+
<div
|
|
1086
|
+
id={id}
|
|
1087
|
+
{...componentRoot("Table")}
|
|
1088
|
+
className={cx("m-table", className)}
|
|
1089
|
+
role="region"
|
|
1090
|
+
aria-label={caption}
|
|
1091
|
+
tabIndex={0}
|
|
1092
|
+
data-striped={striped || undefined}
|
|
1093
|
+
>
|
|
1094
|
+
<table>
|
|
1095
|
+
<caption className={captionVisible ? undefined : "m-sr-only"}>
|
|
1096
|
+
{caption}
|
|
1097
|
+
</caption>
|
|
1098
|
+
<thead>
|
|
1099
|
+
<tr>
|
|
1100
|
+
{columns.map((col) => (
|
|
1101
|
+
<th key={col.key} scope="col" data-align={columnAlign(col.align)}>
|
|
1102
|
+
{col.label}
|
|
1103
|
+
</th>
|
|
1104
|
+
))}
|
|
1105
|
+
</tr>
|
|
1106
|
+
</thead>
|
|
1107
|
+
{(!groups || rows.length > 0) && <tbody>{rows.map(renderRow)}</tbody>}
|
|
1108
|
+
{groups?.map((group, g) => (
|
|
1109
|
+
<tbody key={g}>
|
|
1110
|
+
<TableGroupRow label={group.label} span={columns.length} />
|
|
1111
|
+
{group.rows.map(renderRow)}
|
|
1112
|
+
</tbody>
|
|
1113
|
+
))}
|
|
1114
|
+
</table>
|
|
1115
|
+
</div>
|
|
1116
|
+
);
|
|
1117
|
+
}
|
|
1118
|
+
/** Page numbers to show: first, last and the current page's neighbours, with gaps. */
|
|
1119
|
+
function pageWindow(page: number, count: number) {
|
|
1120
|
+
const pages = new Set([1, count, page - 1, page, page + 1]);
|
|
1121
|
+
const shown = [...pages]
|
|
1122
|
+
.filter((n) => n >= 1 && n <= count)
|
|
1123
|
+
.sort((a, b) => a - b);
|
|
1124
|
+
const out: (number | "gap")[] = [];
|
|
1125
|
+
shown.forEach((n, i) => {
|
|
1126
|
+
if (i && n - shown[i - 1] > 1) out.push("gap");
|
|
1127
|
+
out.push(n);
|
|
1128
|
+
});
|
|
1129
|
+
return out;
|
|
1130
|
+
}
|
|
1131
|
+
export interface PaginationProps {
|
|
1132
|
+
page: number;
|
|
1133
|
+
pageCount: number;
|
|
1134
|
+
onChange: (page: number) => void;
|
|
1135
|
+
/** Show individual page buttons between Previous and Next. */
|
|
1136
|
+
numbered?: boolean;
|
|
1137
|
+
/** "Showing 51 to 60 of 114 results". */
|
|
1138
|
+
summary?: { from: number; to: number; total: number; noun?: string };
|
|
1139
|
+
/** Accessible name; give each pagination on a page a distinct one. */
|
|
1140
|
+
label?: string;
|
|
1141
|
+
className?: string;
|
|
1142
|
+
}
|
|
1143
|
+
export function Pagination({
|
|
1144
|
+
page,
|
|
1145
|
+
pageCount,
|
|
1146
|
+
onChange,
|
|
1147
|
+
numbered = false,
|
|
1148
|
+
summary,
|
|
1149
|
+
label = "Pagination",
|
|
1150
|
+
className,
|
|
1151
|
+
}: PaginationProps) {
|
|
1152
|
+
const count = Math.max(1, pageCount);
|
|
1153
|
+
const atStart = page <= 1;
|
|
1154
|
+
const atEnd = page >= count;
|
|
1155
|
+
// Previous and Next use aria-disabled rather than disabled at the ends, so
|
|
1156
|
+
// the button someone just pressed keeps focus instead of dropping it to the
|
|
1157
|
+
// page body.
|
|
1158
|
+
return (
|
|
1159
|
+
<nav
|
|
1160
|
+
{...componentRoot("Pagination")}
|
|
1161
|
+
className={cx("m-pagination", className)}
|
|
1162
|
+
aria-label={label}
|
|
1163
|
+
>
|
|
1164
|
+
{summary ? (
|
|
1165
|
+
<p aria-live="polite">
|
|
1166
|
+
Showing <strong>{summary.from}</strong> to{" "}
|
|
1167
|
+
<strong>{summary.to}</strong> of <strong>{summary.total}</strong>{" "}
|
|
1168
|
+
{summary.noun ?? "results"}
|
|
1169
|
+
</p>
|
|
1170
|
+
) : (
|
|
1171
|
+
<span aria-live="polite">
|
|
1172
|
+
Page {page} of {count}
|
|
1173
|
+
</span>
|
|
1174
|
+
)}
|
|
1175
|
+
<div className="m-actions">
|
|
1176
|
+
<Button
|
|
1177
|
+
variant="secondary"
|
|
1178
|
+
aria-disabled={atStart || undefined}
|
|
1179
|
+
onClick={() => !atStart && onChange(page - 1)}
|
|
1180
|
+
>
|
|
1181
|
+
Previous
|
|
1182
|
+
</Button>
|
|
1183
|
+
{numbered && (
|
|
1184
|
+
<div className="m-pagination-pages">
|
|
1185
|
+
{pageWindow(page, count).map((n, i) =>
|
|
1186
|
+
n === "gap" ? (
|
|
1187
|
+
<span
|
|
1188
|
+
key={`gap-${i}`}
|
|
1189
|
+
className="m-pagination-gap"
|
|
1190
|
+
aria-hidden="true"
|
|
1191
|
+
>
|
|
1192
|
+
…
|
|
1193
|
+
</span>
|
|
1194
|
+
) : (
|
|
1195
|
+
<button
|
|
1196
|
+
key={n}
|
|
1197
|
+
type="button"
|
|
1198
|
+
aria-current={n === page ? "page" : undefined}
|
|
1199
|
+
aria-label={`Page ${n}`}
|
|
1200
|
+
onClick={() => onChange(n)}
|
|
1201
|
+
>
|
|
1202
|
+
{n}
|
|
1203
|
+
</button>
|
|
1204
|
+
),
|
|
1205
|
+
)}
|
|
1206
|
+
</div>
|
|
1207
|
+
)}
|
|
1208
|
+
<Button
|
|
1209
|
+
variant="secondary"
|
|
1210
|
+
aria-disabled={atEnd || undefined}
|
|
1211
|
+
onClick={() => !atEnd && onChange(page + 1)}
|
|
1212
|
+
>
|
|
1213
|
+
Next
|
|
1214
|
+
</Button>
|
|
1215
|
+
</div>
|
|
1216
|
+
</nav>
|
|
1217
|
+
);
|
|
1218
|
+
}
|
|
1219
|
+
export interface SettingsSectionProps {
|
|
1220
|
+
title: string;
|
|
1221
|
+
description?: string;
|
|
1222
|
+
children: ReactNode;
|
|
1223
|
+
headingLevel?: HeadingLevel;
|
|
1224
|
+
id?: string;
|
|
1225
|
+
className?: string;
|
|
1226
|
+
}
|
|
1227
|
+
export function SettingsSection({
|
|
1228
|
+
title,
|
|
1229
|
+
description,
|
|
1230
|
+
children,
|
|
1231
|
+
headingLevel = 2,
|
|
1232
|
+
id,
|
|
1233
|
+
className,
|
|
1234
|
+
}: SettingsSectionProps) {
|
|
1235
|
+
const headingId = useId();
|
|
1236
|
+
return (
|
|
1237
|
+
<section
|
|
1238
|
+
id={id}
|
|
1239
|
+
{...componentRoot("SettingsSection")}
|
|
1240
|
+
className={cx("m-settings-section", className)}
|
|
1241
|
+
aria-labelledby={headingId}
|
|
1242
|
+
>
|
|
1243
|
+
<div>
|
|
1244
|
+
{...classedIntroSlots(
|
|
1245
|
+
{
|
|
1246
|
+
title,
|
|
1247
|
+
description,
|
|
1248
|
+
id: headingId,
|
|
1249
|
+
headingLevel,
|
|
1250
|
+
descriptionClassName: null,
|
|
1251
|
+
},
|
|
1252
|
+
"m-settings-title",
|
|
1253
|
+
)}
|
|
1254
|
+
</div>
|
|
1255
|
+
<div className="m-stack">{children}</div>
|
|
1256
|
+
</section>
|
|
1257
|
+
);
|
|
1258
|
+
}
|
|
1259
|
+
export interface ActivityFeedProps {
|
|
1260
|
+
label: string;
|
|
1261
|
+
items: {
|
|
1262
|
+
id: string;
|
|
1263
|
+
title: string;
|
|
1264
|
+
description?: string;
|
|
1265
|
+
time: string;
|
|
1266
|
+
tone?: Tone;
|
|
1267
|
+
/** Avatar of who acted; replaces the tone dot. */
|
|
1268
|
+
actor?: ReactNode;
|
|
1269
|
+
/** Comment text, shown as a quote under the title. */
|
|
1270
|
+
body?: ReactNode;
|
|
1271
|
+
}[];
|
|
1272
|
+
className?: string;
|
|
1273
|
+
}
|
|
1274
|
+
export function ActivityFeed({ label, items, className }: ActivityFeedProps) {
|
|
1275
|
+
return (
|
|
1276
|
+
<ol
|
|
1277
|
+
{...componentRoot("ActivityFeed")}
|
|
1278
|
+
className={cx("m-activity", className)}
|
|
1279
|
+
aria-label={label}
|
|
1280
|
+
>
|
|
1281
|
+
{items.map((item) => (
|
|
1282
|
+
<li key={item.id}>
|
|
1283
|
+
{item.actor ? (
|
|
1284
|
+
<span className="m-activity-actor">{item.actor}</span>
|
|
1285
|
+
) : (
|
|
1286
|
+
<span
|
|
1287
|
+
aria-hidden="true"
|
|
1288
|
+
className="m-activity-dot"
|
|
1289
|
+
data-tone={
|
|
1290
|
+
item.tone && item.tone !== "neutral" ? item.tone : undefined
|
|
1291
|
+
}
|
|
1292
|
+
/>
|
|
1293
|
+
)}
|
|
1294
|
+
<div>
|
|
1295
|
+
<strong>{item.title}</strong>
|
|
1296
|
+
{item.description && <p>{item.description}</p>}
|
|
1297
|
+
{item.body && (
|
|
1298
|
+
<blockquote className="m-activity-body">{item.body}</blockquote>
|
|
1299
|
+
)}
|
|
1300
|
+
</div>
|
|
1301
|
+
<span>{item.time}</span>
|
|
1302
|
+
</li>
|
|
1303
|
+
))}
|
|
1304
|
+
</ol>
|
|
1305
|
+
);
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
export type { SignInProvider };
|
|
1309
|
+
export interface SignInPanelProps {
|
|
1310
|
+
title: string;
|
|
1311
|
+
/** The page's h1 by default; use 2 when the panel sits inside a page that already has one. */
|
|
1312
|
+
headingLevel?: HeadingLevel;
|
|
1313
|
+
/** Image or quote beside the form on wide screens (split-screen layout). */
|
|
1314
|
+
aside?: ReactNode;
|
|
1315
|
+
description?: string;
|
|
1316
|
+
logo?: ReactNode;
|
|
1317
|
+
providers?: SignInProvider[];
|
|
1318
|
+
email?: {
|
|
1319
|
+
onSubmit: (email: string) => void;
|
|
1320
|
+
label?: string;
|
|
1321
|
+
placeholder?: string;
|
|
1322
|
+
submitLabel?: string;
|
|
1323
|
+
status?: "idle" | "submitting" | "success" | "error";
|
|
1324
|
+
message?: string;
|
|
1325
|
+
};
|
|
1326
|
+
footer?: ReactNode;
|
|
1327
|
+
dividerLabel?: string;
|
|
1328
|
+
id?: string;
|
|
1329
|
+
className?: string;
|
|
1330
|
+
}
|
|
1331
|
+
/**
|
|
1332
|
+
* Brand-consistent sign-in surface. Mortar renders the form; the consumer's auth
|
|
1333
|
+
* SDK supplies provider redirects and the email-link request through callbacks.
|
|
1334
|
+
*/
|
|
1335
|
+
export function SignInPanel({
|
|
1336
|
+
title,
|
|
1337
|
+
description,
|
|
1338
|
+
logo,
|
|
1339
|
+
providers = [],
|
|
1340
|
+
email,
|
|
1341
|
+
footer,
|
|
1342
|
+
dividerLabel = "or",
|
|
1343
|
+
headingLevel = 1,
|
|
1344
|
+
aside,
|
|
1345
|
+
id,
|
|
1346
|
+
className,
|
|
1347
|
+
}: SignInPanelProps) {
|
|
1348
|
+
const [value, setValue] = useState("");
|
|
1349
|
+
const headingId = useId();
|
|
1350
|
+
const submit = (e: FormEvent) => {
|
|
1351
|
+
e.preventDefault();
|
|
1352
|
+
if (email?.status !== "submitting") email?.onSubmit(value);
|
|
1353
|
+
};
|
|
1354
|
+
const success = email?.status === "success";
|
|
1355
|
+
const panel = (
|
|
1356
|
+
<section
|
|
1357
|
+
id={aside ? undefined : id}
|
|
1358
|
+
{...componentRoot("SignInPanel", !aside)}
|
|
1359
|
+
className={cx("m-sign-in", !aside && className)}
|
|
1360
|
+
aria-labelledby={headingId}
|
|
1361
|
+
>
|
|
1362
|
+
<AuthHeader
|
|
1363
|
+
titleClassName="m-sign-in-title"
|
|
1364
|
+
logo={logo}
|
|
1365
|
+
title={title}
|
|
1366
|
+
description={description}
|
|
1367
|
+
headingLevel={headingLevel}
|
|
1368
|
+
headingId={headingId}
|
|
1369
|
+
/>
|
|
1370
|
+
{providers.length > 0 && <ProviderButtons providers={providers} />}
|
|
1371
|
+
{providers.length > 0 && email && (
|
|
1372
|
+
<Divider label={dividerLabel} soft spacing="none" />
|
|
1373
|
+
)}
|
|
1374
|
+
{email && (
|
|
1375
|
+
<form onSubmit={submit} aria-busy={email.status === "submitting"}>
|
|
1376
|
+
<Input
|
|
1377
|
+
label={email.label || "Email address"}
|
|
1378
|
+
type="email"
|
|
1379
|
+
autoComplete="email"
|
|
1380
|
+
placeholder={email.placeholder}
|
|
1381
|
+
required
|
|
1382
|
+
value={value}
|
|
1383
|
+
onChange={(e) => setValue(e.target.value)}
|
|
1384
|
+
error={email.status === "error" ? email.message : undefined}
|
|
1385
|
+
disabled={email.status === "submitting"}
|
|
1386
|
+
/>
|
|
1387
|
+
{/* Always mounted, so the confirmation is announced when it appears. */}
|
|
1388
|
+
<p role="status" className={success ? undefined : "m-sr-only"}>
|
|
1389
|
+
{success
|
|
1390
|
+
? email.message || "Check your inbox for a sign-in link."
|
|
1391
|
+
: ""}
|
|
1392
|
+
</p>
|
|
1393
|
+
<Button type="submit" loading={email.status === "submitting"}>
|
|
1394
|
+
{email.submitLabel || "Email me a sign-in link"}
|
|
1395
|
+
</Button>
|
|
1396
|
+
</form>
|
|
1397
|
+
)}
|
|
1398
|
+
{footer && <footer>{footer}</footer>}
|
|
1399
|
+
</section>
|
|
1400
|
+
);
|
|
1401
|
+
return aside ? (
|
|
1402
|
+
<div
|
|
1403
|
+
id={id}
|
|
1404
|
+
{...componentRoot("SignInPanel")}
|
|
1405
|
+
className={cx("m-sign-in-split", className)}
|
|
1406
|
+
>
|
|
1407
|
+
{panel}
|
|
1408
|
+
<div className="m-sign-in-aside">{aside}</div>
|
|
1409
|
+
</div>
|
|
1410
|
+
) : (
|
|
1411
|
+
panel
|
|
1412
|
+
);
|
|
1413
|
+
}
|