@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.
Files changed (107) hide show
  1. package/AGENTS.md +10 -0
  2. package/CHANGELOG.md +268 -0
  3. package/CONTRIBUTING.md +60 -0
  4. package/DESIGN.md +51 -0
  5. package/LICENSE +21 -0
  6. package/README.md +155 -2
  7. package/TESTING.md +83 -0
  8. package/baseline-policy.json +16 -0
  9. package/bin/mortar.mjs +258 -0
  10. package/catalog.json +9900 -0
  11. package/cli/audit.mjs +943 -0
  12. package/cli/data.mjs +176 -0
  13. package/cli/mcp.mjs +216 -0
  14. package/cli/migrations.mjs +128 -0
  15. package/cli/packet.mjs +267 -0
  16. package/cli/search.mjs +246 -0
  17. package/cli/tools.mjs +229 -0
  18. package/design-principles.json +30 -0
  19. package/dist/agent-data.json +1 -0
  20. package/dist/application.d.ts +340 -0
  21. package/dist/application.js +240 -0
  22. package/dist/auth.d.ts +121 -0
  23. package/dist/auth.js +97 -0
  24. package/dist/brand.d.ts +69 -0
  25. package/dist/brand.js +266 -0
  26. package/dist/calendar.d.ts +75 -0
  27. package/dist/calendar.js +310 -0
  28. package/dist/choice.d.ts +36 -0
  29. package/dist/choice.js +36 -0
  30. package/dist/composer.d.ts +28 -0
  31. package/dist/composer.js +47 -0
  32. package/dist/display.d.ts +136 -0
  33. package/dist/display.js +75 -0
  34. package/dist/docs.d.ts +145 -0
  35. package/dist/docs.js +141 -0
  36. package/dist/ecommerce.d.ts +261 -0
  37. package/dist/ecommerce.js +177 -0
  38. package/dist/feedback.d.ts +80 -0
  39. package/dist/feedback.js +98 -0
  40. package/dist/foundation.d.ts +42 -0
  41. package/dist/foundation.js +121 -0
  42. package/dist/heading.d.ts +5 -0
  43. package/dist/heading.js +9 -0
  44. package/dist/icons.d.ts +50 -0
  45. package/dist/icons.js +70 -0
  46. package/dist/illustration.d.ts +47 -0
  47. package/dist/illustration.js +47 -0
  48. package/dist/index.d.ts +36 -0
  49. package/dist/index.js +18 -0
  50. package/dist/inspection.d.ts +24 -0
  51. package/dist/inspection.js +14 -0
  52. package/dist/internal.d.ts +514 -0
  53. package/dist/internal.js +332 -0
  54. package/dist/layout.d.ts +107 -0
  55. package/dist/layout.js +66 -0
  56. package/dist/marketing-sections.d.ts +161 -0
  57. package/dist/marketing-sections.js +58 -0
  58. package/dist/marketing.d.ts +277 -0
  59. package/dist/marketing.js +163 -0
  60. package/dist/navigation.d.ts +35 -0
  61. package/dist/navigation.js +44 -0
  62. package/dist/primitives.d.ts +258 -0
  63. package/dist/primitives.js +244 -0
  64. package/dist/showcase.d.ts +29 -0
  65. package/dist/showcase.js +10 -0
  66. package/dist/workspace.d.ts +80 -0
  67. package/dist/workspace.js +48 -0
  68. package/docs/agents.md +127 -0
  69. package/docs/auth-flows.md +71 -0
  70. package/docs/migrating-to-0.6.md +94 -0
  71. package/docs/theming.md +170 -0
  72. package/package.json +94 -4
  73. package/skills/mortar/SKILL.md +36 -0
  74. package/src/application.css +1487 -0
  75. package/src/application.tsx +1413 -0
  76. package/src/auth.tsx +452 -0
  77. package/src/brand.ts +335 -0
  78. package/src/calendar.css +468 -0
  79. package/src/calendar.tsx +868 -0
  80. package/src/choice.tsx +240 -0
  81. package/src/composer.tsx +158 -0
  82. package/src/data.css +690 -0
  83. package/src/display.tsx +452 -0
  84. package/src/docs.css +704 -0
  85. package/src/docs.tsx +737 -0
  86. package/src/ecommerce.css +810 -0
  87. package/src/ecommerce.tsx +1033 -0
  88. package/src/feedback.tsx +388 -0
  89. package/src/foundation.css +1867 -0
  90. package/src/foundation.tsx +227 -0
  91. package/src/heading.ts +13 -0
  92. package/src/icons.tsx +237 -0
  93. package/src/illustration.tsx +169 -0
  94. package/src/index.ts +233 -0
  95. package/src/inspection.tsx +91 -0
  96. package/src/internal.tsx +700 -0
  97. package/src/layout.tsx +342 -0
  98. package/src/marketing-sections.tsx +621 -0
  99. package/src/marketing.css +2579 -0
  100. package/src/marketing.tsx +1235 -0
  101. package/src/navigation.tsx +118 -0
  102. package/src/overlays.css +350 -0
  103. package/src/primitives.tsx +1135 -0
  104. package/src/showcase.tsx +120 -0
  105. package/src/tokens.css +449 -0
  106. package/src/workspace.tsx +333 -0
  107. 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
+ }