@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
package/src/docs.tsx ADDED
@@ -0,0 +1,737 @@
1
+ import { KitLink } from "./navigation.js";
2
+ import { useEffect, useId, useState, type ReactNode } from "react";
3
+ import { Button, type LinkAction, type Tone } from "./primitives.js";
4
+ import type { NavigationItem } from "./application.js";
5
+ import { CommandPalette, type CommandItem } from "./workspace.js";
6
+ import { Kbd } from "./display.js";
7
+ import { BrandMark } from "./layout.js";
8
+ import type { ItemGroup, LinkItem } from "./navigation.js";
9
+ import { childLevel, headingTag, type HeadingLevel } from "./heading.js";
10
+ import {
11
+ AsComponentRoot,
12
+ MobileNavDialog,
13
+ NavGroups,
14
+ TermList,
15
+ cx,
16
+ componentRoot,
17
+ rovingIndex,
18
+ } from "./internal.js";
19
+
20
+ export interface DocsShellProps {
21
+ className?: string;
22
+ brand: string;
23
+ /** Logo node shown in place of the placeholder brand mark. */
24
+ logo?: ReactNode;
25
+ /** Use the available content width for explorers and workspaces. */
26
+ layout?: "article" | "wide";
27
+ navigationLabel?: string;
28
+ homeHref?: string;
29
+ version?: ReactNode;
30
+ sections?: Pick<LinkItem, "label" | "href" | "current">[];
31
+ /** Groups of pages; items show their icon and count, and children become disclosures. */
32
+ navigation: ItemGroup<NavigationItem>[];
33
+ toc?: { id: string; label: string; current?: boolean }[];
34
+ tocLabel?: string;
35
+ tocFooter?: ReactNode;
36
+ search?: ReactNode;
37
+ actions?: ReactNode;
38
+ children: ReactNode;
39
+ }
40
+
41
+ export function DocsShell({
42
+ className,
43
+ brand,
44
+ logo,
45
+ layout = "article",
46
+ navigationLabel = "Documentation",
47
+ homeHref = "/",
48
+ version,
49
+ sections = [],
50
+ navigation,
51
+ toc = [],
52
+ tocLabel = "On this page",
53
+ tocFooter,
54
+ search,
55
+ actions,
56
+ children,
57
+ }: DocsShellProps) {
58
+ const [open, setOpen] = useState(false);
59
+ const contentId = useId();
60
+ const tree = (
61
+ <nav aria-label={navigationLabel}>
62
+ <NavGroups groups={navigation} onNavigate={() => setOpen(false)} />
63
+ </nav>
64
+ );
65
+ return (
66
+ <div
67
+ {...componentRoot("DocsShell")}
68
+ className={cx("m-docs-shell", className)}
69
+ data-layout={layout === "article" ? undefined : layout}
70
+ >
71
+ <KitLink className="m-skip" href={`#${contentId}`}>
72
+ Skip to content
73
+ </KitLink>
74
+ <header className="m-docs-header">
75
+ <Button
76
+ variant="ghost"
77
+ className="m-menu-trigger"
78
+ onClick={() => setOpen(true)}
79
+ aria-haspopup="dialog"
80
+ aria-expanded={open}
81
+ >
82
+ Menu
83
+ </Button>
84
+ <KitLink className="m-brand" href={homeHref}>
85
+ <BrandMark logo={logo} />
86
+ {brand}
87
+ </KitLink>
88
+ {version && <div className="m-docs-version">{version}</div>}
89
+ {sections.length > 0 && (
90
+ <nav aria-label="Sections" className="m-docs-sections">
91
+ {sections.map((s, index) => (
92
+ <KitLink
93
+ key={`${s.label}:${s.href}:${index}`}
94
+ href={s.href}
95
+ aria-current={s.current ? "page" : undefined}
96
+ >
97
+ {s.label}
98
+ </KitLink>
99
+ ))}
100
+ </nav>
101
+ )}
102
+ <div className="m-actions">
103
+ {search}
104
+ {actions}
105
+ </div>
106
+ </header>
107
+ <div className="m-docs-body">
108
+ <aside className="m-docs-nav">{tree}</aside>
109
+ <main id={contentId} tabIndex={-1}>
110
+ {children}
111
+ </main>
112
+ {toc.length > 0 && (
113
+ <aside className="m-docs-toc">
114
+ <nav aria-label={tocLabel}>
115
+ <h2>{tocLabel}</h2>
116
+ {toc.map((t) => (
117
+ <KitLink
118
+ key={t.id}
119
+ href={`#${t.id}`}
120
+ aria-current={t.current ? "location" : undefined}
121
+ >
122
+ {t.label}
123
+ </KitLink>
124
+ ))}
125
+ </nav>
126
+ {tocFooter && <div className="m-docs-toc-footer">{tocFooter}</div>}
127
+ </aside>
128
+ )}
129
+ </div>
130
+ <MobileNavDialog open={open} onClose={() => setOpen(false)} title={brand}>
131
+ {tree}
132
+ </MobileNavDialog>
133
+ </div>
134
+ );
135
+ }
136
+
137
+ /** True when a key press belongs to a text field or an open dialog, not the page. */
138
+ function ownedKeyPress(target: EventTarget | null) {
139
+ if (!(target instanceof Element)) return false;
140
+ return Boolean(
141
+ target.closest(
142
+ 'input, textarea, select, [contenteditable]:not([contenteditable="false"]), [role="dialog"], [role="alertdialog"], dialog[open]',
143
+ ),
144
+ );
145
+ }
146
+
147
+ export function SearchTrigger({
148
+ className,
149
+ onOpen,
150
+ label = "Search docs",
151
+ }: {
152
+ className?: string;
153
+ onOpen: () => void;
154
+ label?: string;
155
+ }) {
156
+ // The shortcut hint is platform-specific; it renders after mount so server
157
+ // and client markup agree.
158
+ const [apple, setApple] = useState<boolean>();
159
+ useEffect(() => {
160
+ setApple(
161
+ /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent),
162
+ );
163
+ }, []);
164
+ useEffect(() => {
165
+ const onKey = (e: KeyboardEvent) => {
166
+ if (
167
+ !(e.metaKey || e.ctrlKey) ||
168
+ e.altKey ||
169
+ e.shiftKey ||
170
+ e.key.toLowerCase() !== "k" ||
171
+ e.repeat ||
172
+ // Another trigger on the page already handled this press.
173
+ e.defaultPrevented ||
174
+ ownedKeyPress(e.target)
175
+ )
176
+ return;
177
+ e.preventDefault();
178
+ onOpen();
179
+ };
180
+ window.addEventListener("keydown", onKey);
181
+ return () => window.removeEventListener("keydown", onKey);
182
+ }, [onOpen]);
183
+ return (
184
+ <button
185
+ type="button"
186
+ {...componentRoot("SearchTrigger")}
187
+ className={cx("m-search-trigger", className)}
188
+ onClick={onOpen}
189
+ aria-keyshortcuts="Meta+K Control+K"
190
+ >
191
+ <span>{label}</span>
192
+ {apple !== undefined && <Kbd keys={[apple ? "⌘K" : "Ctrl K"]} />}
193
+ </button>
194
+ );
195
+ }
196
+
197
+ export function SearchDialog(props: {
198
+ open: boolean;
199
+ onClose: () => void;
200
+ query: string;
201
+ onQueryChange: (value: string) => void;
202
+ groups: ItemGroup<CommandItem>[];
203
+ attribution?: ReactNode;
204
+ }) {
205
+ return (
206
+ <AsComponentRoot name="SearchDialog" of="CommandPalette">
207
+ <CommandPalette
208
+ {...props}
209
+ label="Search documentation"
210
+ placeholder="Search docs"
211
+ footer={
212
+ <>
213
+ <span>
214
+ <Kbd keys={["↑", "↓"]} /> navigate
215
+ </span>
216
+ <span>
217
+ <Kbd keys={["↵"]} /> open
218
+ </span>
219
+ {props.attribution && (
220
+ <span className="m-palette-attribution">{props.attribution}</span>
221
+ )}
222
+ </>
223
+ }
224
+ />
225
+ </AsComponentRoot>
226
+ );
227
+ }
228
+
229
+ export function Steps({
230
+ id,
231
+ className,
232
+ items,
233
+ itemHeadingLevel = 3,
234
+ }: {
235
+ id?: string;
236
+ className?: string;
237
+ items: {
238
+ title: string;
239
+ id?: string;
240
+ description?: ReactNode;
241
+ content?: ReactNode;
242
+ }[];
243
+ /** Level of each step's title. */
244
+ itemHeadingLevel?: HeadingLevel;
245
+ }) {
246
+ const Heading = headingTag(itemHeadingLevel);
247
+ return (
248
+ <ol
249
+ id={id}
250
+ {...componentRoot("Steps")}
251
+ className={cx("m-steps", className)}
252
+ >
253
+ {items.map((item, i) => (
254
+ <li key={`${item.title}:${i}`}>
255
+ <span className="m-step-number" aria-hidden="true">
256
+ {i + 1}
257
+ </span>
258
+ <div>
259
+ <Heading id={item.id}>{item.title}</Heading>
260
+ {item.description && (
261
+ <div className="m-step-description">{item.description}</div>
262
+ )}
263
+ {item.content}
264
+ </div>
265
+ </li>
266
+ ))}
267
+ </ol>
268
+ );
269
+ }
270
+
271
+ const calloutLabel: Record<Tone, string> = {
272
+ neutral: "Note",
273
+ info: "Note",
274
+ success: "Tip",
275
+ warning: "Warning",
276
+ danger: "Caution",
277
+ };
278
+
279
+ export function Callout({
280
+ id,
281
+ className,
282
+ tone = "neutral",
283
+ title,
284
+ children,
285
+ }: {
286
+ id?: string;
287
+ className?: string;
288
+ /** neutral: a note. info: a highlighted note. success: a tip. warning and danger: a caution, e.g. a breaking change. */
289
+ tone?: Tone;
290
+ /** Visible lead-in that also names the note; defaults to the tone's word, e.g. "Warning". */
291
+ title?: string;
292
+ children: ReactNode;
293
+ }) {
294
+ const labelId = useId();
295
+ return (
296
+ <div
297
+ id={id}
298
+ {...componentRoot("Callout")}
299
+ className={cx("m-callout", className)}
300
+ role="note"
301
+ aria-labelledby={labelId}
302
+ data-tone={tone === "neutral" ? undefined : tone}
303
+ >
304
+ <strong id={labelId}>{title || calloutLabel[tone]}</strong>
305
+ <span aria-hidden="true"> · </span>
306
+ <div>{children}</div>
307
+ </div>
308
+ );
309
+ }
310
+
311
+ export interface FileNode {
312
+ name: string;
313
+ note?: string;
314
+ highlighted?: boolean;
315
+ children?: FileNode[];
316
+ }
317
+
318
+ function FileBranch({ nodes }: { nodes: FileNode[] }) {
319
+ return (
320
+ <ul>
321
+ {nodes.map((node) => (
322
+ <li
323
+ key={node.name}
324
+ data-folder={!!node.children || undefined}
325
+ data-highlighted={node.highlighted || undefined}
326
+ >
327
+ <span className="m-file-row">
328
+ <span aria-hidden="true" className="m-file-caret">
329
+ {node.children ? "▾" : ""}
330
+ </span>
331
+ <span>{node.name}</span>
332
+ {node.note && <small>{node.note}</small>}
333
+ </span>
334
+ {node.children && <FileBranch nodes={node.children} />}
335
+ </li>
336
+ ))}
337
+ </ul>
338
+ );
339
+ }
340
+
341
+ export function FileTree({
342
+ id,
343
+ className,
344
+ label,
345
+ items,
346
+ }: {
347
+ id?: string;
348
+ className?: string;
349
+ label: string;
350
+ items: FileNode[];
351
+ }) {
352
+ return (
353
+ <figure
354
+ id={id}
355
+ {...componentRoot("FileTree")}
356
+ className={cx("m-file-tree", className)}
357
+ aria-label={label}
358
+ >
359
+ <FileBranch nodes={items} />
360
+ </figure>
361
+ );
362
+ }
363
+
364
+ function CopyButton({ text }: { text: string }) {
365
+ const [result, setResult] = useState<"copied" | "failed">();
366
+ useEffect(() => {
367
+ if (!result) return;
368
+ const t = setTimeout(() => setResult(undefined), 1600);
369
+ return () => clearTimeout(t);
370
+ }, [result]);
371
+ const copy = async () => {
372
+ try {
373
+ // A missing API (insecure context) and a denied permission both fail.
374
+ if (!navigator.clipboard) throw Error("Clipboard unavailable");
375
+ await navigator.clipboard.writeText(text);
376
+ setResult("copied");
377
+ } catch {
378
+ setResult("failed");
379
+ }
380
+ };
381
+ return (
382
+ <button
383
+ type="button"
384
+ className="m-code-copy"
385
+ data-result={result}
386
+ onClick={copy}
387
+ >
388
+ <span aria-live="polite">
389
+ {result === "copied"
390
+ ? "Copied"
391
+ : result === "failed"
392
+ ? "Copy failed"
393
+ : "Copy"}
394
+ </span>
395
+ </button>
396
+ );
397
+ }
398
+
399
+ /** Dark code surface with optional language tabs, filename, line numbers and highlighted lines. */
400
+ export function CodeTabs({
401
+ id,
402
+ className,
403
+ tabs,
404
+ filename,
405
+ language,
406
+ lineNumbers = false,
407
+ highlightLines = [],
408
+ prompt,
409
+ }: {
410
+ id?: string;
411
+ className?: string;
412
+ tabs: { id: string; label: string; code: string }[];
413
+ filename?: string;
414
+ language?: string;
415
+ lineNumbers?: boolean;
416
+ highlightLines?: number[];
417
+ prompt?: string;
418
+ }) {
419
+ const [active, setActive] = useState(tabs[0]?.id);
420
+ const base = useId();
421
+ const current = tabs.find((t) => t.id === active) || tabs[0];
422
+ if (!current) return null;
423
+ const lines = current.code.split("\n");
424
+ const tabId = (tab: { id: string }) => `${base}-tab-${tab.id}`;
425
+ const panelId = `${base}-panel`;
426
+ return (
427
+ <figure
428
+ id={id}
429
+ {...componentRoot("CodeTabs")}
430
+ className={cx("m-code-tabs", className)}
431
+ >
432
+ <div className="m-code-bar">
433
+ {tabs.length > 1 ? (
434
+ <div role="tablist" aria-label={filename || "Code examples"}>
435
+ {tabs.map((t) => (
436
+ <button
437
+ key={t.id}
438
+ type="button"
439
+ role="tab"
440
+ id={tabId(t)}
441
+ aria-selected={t.id === current.id}
442
+ aria-controls={panelId}
443
+ tabIndex={t.id === current.id ? 0 : -1}
444
+ onClick={() => setActive(t.id)}
445
+ onKeyDown={(e) => {
446
+ const n = rovingIndex(
447
+ e.key,
448
+ tabs.findIndex((x) => x.id === current.id),
449
+ tabs.length,
450
+ );
451
+ if (n === null) return;
452
+ e.preventDefault();
453
+ const next = tabs[n]!;
454
+ setActive(next.id);
455
+ document.getElementById(tabId(next))?.focus();
456
+ }}
457
+ >
458
+ {t.label}
459
+ </button>
460
+ ))}
461
+ </div>
462
+ ) : (
463
+ <figcaption>{filename || current.label}</figcaption>
464
+ )}
465
+ {language && <span className="m-code-language">{language}</span>}
466
+ <CopyButton text={current.code} />
467
+ </div>
468
+ <pre
469
+ tabIndex={0}
470
+ id={tabs.length > 1 ? panelId : undefined}
471
+ role={tabs.length > 1 ? "tabpanel" : undefined}
472
+ aria-labelledby={tabs.length > 1 ? tabId(current) : undefined}
473
+ >
474
+ <code>
475
+ {lines.map((line, i) => (
476
+ <span
477
+ key={i}
478
+ className="m-code-line"
479
+ data-highlighted={highlightLines.includes(i + 1) || undefined}
480
+ >
481
+ {lineNumbers && (
482
+ <span className="m-code-number" aria-hidden="true">
483
+ {i + 1}
484
+ </span>
485
+ )}
486
+ {prompt && (
487
+ <span className="m-code-prompt" aria-hidden="true">
488
+ {prompt}{" "}
489
+ </span>
490
+ )}
491
+ {line || " "}
492
+ </span>
493
+ ))}
494
+ </code>
495
+ </pre>
496
+ </figure>
497
+ );
498
+ }
499
+
500
+ export function Release({
501
+ id,
502
+ className,
503
+ version,
504
+ date,
505
+ dateLabel,
506
+ changes,
507
+ headingLevel = 3,
508
+ }: {
509
+ id?: string;
510
+ className?: string;
511
+ /** Level of the version heading; change groups sit one level below. */
512
+ headingLevel?: HeadingLevel;
513
+ version: string;
514
+ date: string;
515
+ dateLabel: string;
516
+ changes: {
517
+ type: "added" | "changed" | "fixed" | "deprecated" | "removed";
518
+ items: ReactNode[];
519
+ }[];
520
+ }) {
521
+ const Heading = headingTag(headingLevel);
522
+ const GroupHeading = headingTag(childLevel(headingLevel));
523
+ return (
524
+ <article
525
+ id={id}
526
+ {...componentRoot("Release")}
527
+ className={cx("m-release", className)}
528
+ >
529
+ <header>
530
+ <Heading className="m-release-version">{version}</Heading>
531
+ <time dateTime={date}>{dateLabel}</time>
532
+ </header>
533
+ <div>
534
+ {changes.map((c) => (
535
+ <section key={c.type} data-type={c.type}>
536
+ <GroupHeading className="m-release-type">
537
+ {c.type.charAt(0).toUpperCase() + c.type.slice(1)}
538
+ </GroupHeading>
539
+ <ul>
540
+ {c.items.map((item, i) => (
541
+ <li key={i}>{item}</li>
542
+ ))}
543
+ </ul>
544
+ </section>
545
+ ))}
546
+ </div>
547
+ </article>
548
+ );
549
+ }
550
+
551
+ export function ApiEndpoint({
552
+ id,
553
+ className,
554
+ headingLevel = 2,
555
+ method,
556
+ path,
557
+ title,
558
+ description,
559
+ paramsLabel = "Body parameters",
560
+ params,
561
+ examples,
562
+ }: {
563
+ id?: string;
564
+ className?: string;
565
+ /** Level of the endpoint title; the parameters heading sits one level below. */
566
+ headingLevel?: HeadingLevel;
567
+ method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
568
+ path: string;
569
+ title: string;
570
+ description?: ReactNode;
571
+ paramsLabel?: string;
572
+ params: {
573
+ name: string;
574
+ type: string;
575
+ required?: boolean;
576
+ description: ReactNode;
577
+ }[];
578
+ /** Request/response samples, typically CodeTabs. */
579
+ examples?: ReactNode;
580
+ }) {
581
+ const headingId = useId();
582
+ const Heading = headingTag(headingLevel);
583
+ const ParamsHeading = headingTag(childLevel(headingLevel));
584
+ return (
585
+ <section
586
+ id={id}
587
+ {...componentRoot("ApiEndpoint")}
588
+ className={cx("m-api-endpoint", className)}
589
+ aria-labelledby={headingId}
590
+ >
591
+ <div className="m-api-text">
592
+ <p className="m-api-route">
593
+ <span data-method={method}>{method}</span>
594
+ <code>{path}</code>
595
+ </p>
596
+ <Heading id={headingId} className="m-api-title">
597
+ {title}
598
+ </Heading>
599
+ {description && <div className="m-api-description">{description}</div>}
600
+ <ParamsHeading className="m-api-params-title">
601
+ {paramsLabel}
602
+ </ParamsHeading>
603
+ <TermList
604
+ className="m-param-list"
605
+ items={params.map((p) => ({
606
+ key: p.name,
607
+ term: (
608
+ <>
609
+ <code>{p.name}</code>
610
+ <span>{p.type}</span>
611
+ <span data-required={p.required || undefined}>
612
+ {p.required ? "Required" : "Optional"}
613
+ </span>
614
+ </>
615
+ ),
616
+ details: p.description,
617
+ }))}
618
+ />
619
+ </div>
620
+ {examples && <div className="m-api-examples">{examples}</div>}
621
+ </section>
622
+ );
623
+ }
624
+
625
+ export function PageFeedback({
626
+ id,
627
+ className,
628
+ question = "Was this page helpful?",
629
+ value,
630
+ onChange,
631
+ updated,
632
+ }: {
633
+ id?: string;
634
+ className?: string;
635
+ question?: string;
636
+ value: "yes" | "no" | null;
637
+ onChange: (value: "yes" | "no") => void;
638
+ updated?: ReactNode;
639
+ }) {
640
+ return (
641
+ <div
642
+ id={id}
643
+ {...componentRoot("PageFeedback")}
644
+ className={cx("m-page-feedback", className)}
645
+ >
646
+ <div role="group" aria-label={question}>
647
+ <span>{question}</span>
648
+ {(["yes", "no"] as const).map((v) => (
649
+ <button
650
+ key={v}
651
+ type="button"
652
+ aria-pressed={value === v}
653
+ onClick={() => onChange(v)}
654
+ >
655
+ {v === "yes" ? "Yes" : "No"}
656
+ </button>
657
+ ))}
658
+ </div>
659
+ {updated && <span>{updated}</span>}
660
+ </div>
661
+ );
662
+ }
663
+
664
+ export function PagerLinks({
665
+ id,
666
+ className,
667
+ previous,
668
+ next,
669
+ label = "Previous and next page",
670
+ }: {
671
+ id?: string;
672
+ className?: string;
673
+ previous?: LinkAction;
674
+ next?: LinkAction;
675
+ /** Names the navigation landmark; keep it distinct from other navigation on the page. */
676
+ label?: string;
677
+ }) {
678
+ return (
679
+ <nav
680
+ id={id}
681
+ {...componentRoot("PagerLinks")}
682
+ className={cx("m-pager", className)}
683
+ aria-label={label}
684
+ >
685
+ {previous ? (
686
+ <KitLink href={previous.href} rel="prev">
687
+ <small>Previous</small>
688
+ <span>
689
+ <span aria-hidden="true">← </span>
690
+ {previous.label}
691
+ </span>
692
+ </KitLink>
693
+ ) : (
694
+ <span />
695
+ )}
696
+ {next && (
697
+ <KitLink href={next.href} rel="next" data-next="true">
698
+ <small>Next</small>
699
+ <span>
700
+ {next.label}
701
+ <span aria-hidden="true"> →</span>
702
+ </span>
703
+ </KitLink>
704
+ )}
705
+ </nav>
706
+ );
707
+ }
708
+
709
+ export function LinkCards({
710
+ id,
711
+ className,
712
+ items,
713
+ }: {
714
+ id?: string;
715
+ className?: string;
716
+ items: Pick<LinkItem, "label" | "href" | "description">[];
717
+ }) {
718
+ return (
719
+ <ul
720
+ id={id}
721
+ {...componentRoot("LinkCards")}
722
+ className={cx("m-link-cards", className)}
723
+ >
724
+ {items.map((item, index) => (
725
+ <li key={`${item.label}:${item.href}:${index}`}>
726
+ <KitLink href={item.href}>
727
+ <strong>
728
+ {item.label}
729
+ <span aria-hidden="true"> →</span>
730
+ </strong>
731
+ {item.description && <span>{item.description}</span>}
732
+ </KitLink>
733
+ </li>
734
+ ))}
735
+ </ul>
736
+ );
737
+ }