@tonycodes-ui/mortar 0.0.0-stage → 0.11.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 (104) hide show
  1. package/AGENTS.md +10 -0
  2. package/CHANGELOG.md +276 -0
  3. package/CONTRIBUTING.md +48 -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 +9703 -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/index.d.ts +34 -0
  47. package/dist/index.js +17 -0
  48. package/dist/inspection.d.ts +24 -0
  49. package/dist/inspection.js +14 -0
  50. package/dist/internal.d.ts +492 -0
  51. package/dist/internal.js +273 -0
  52. package/dist/layout.d.ts +107 -0
  53. package/dist/layout.js +66 -0
  54. package/dist/marketing-sections.d.ts +161 -0
  55. package/dist/marketing-sections.js +58 -0
  56. package/dist/marketing.d.ts +254 -0
  57. package/dist/marketing.js +150 -0
  58. package/dist/navigation.d.ts +35 -0
  59. package/dist/navigation.js +44 -0
  60. package/dist/primitives.d.ts +258 -0
  61. package/dist/primitives.js +244 -0
  62. package/dist/showcase.d.ts +29 -0
  63. package/dist/showcase.js +10 -0
  64. package/dist/workspace.d.ts +80 -0
  65. package/dist/workspace.js +48 -0
  66. package/docs/agents.md +127 -0
  67. package/docs/auth-flows.md +71 -0
  68. package/docs/migrating-to-0.6.md +94 -0
  69. package/docs/theming.md +170 -0
  70. package/package.json +94 -4
  71. package/skills/mortar/SKILL.md +36 -0
  72. package/src/application.css +1487 -0
  73. package/src/application.tsx +1413 -0
  74. package/src/auth.tsx +452 -0
  75. package/src/brand.ts +335 -0
  76. package/src/calendar.css +468 -0
  77. package/src/calendar.tsx +868 -0
  78. package/src/choice.tsx +240 -0
  79. package/src/composer.tsx +158 -0
  80. package/src/data.css +690 -0
  81. package/src/display.tsx +452 -0
  82. package/src/docs.css +704 -0
  83. package/src/docs.tsx +737 -0
  84. package/src/ecommerce.css +810 -0
  85. package/src/ecommerce.tsx +1033 -0
  86. package/src/feedback.tsx +388 -0
  87. package/src/foundation.css +1867 -0
  88. package/src/foundation.tsx +227 -0
  89. package/src/heading.ts +13 -0
  90. package/src/icons.tsx +237 -0
  91. package/src/index.ts +230 -0
  92. package/src/inspection.tsx +91 -0
  93. package/src/internal.tsx +614 -0
  94. package/src/layout.tsx +342 -0
  95. package/src/marketing-sections.tsx +621 -0
  96. package/src/marketing.css +2400 -0
  97. package/src/marketing.tsx +1156 -0
  98. package/src/navigation.tsx +118 -0
  99. package/src/overlays.css +350 -0
  100. package/src/primitives.tsx +1135 -0
  101. package/src/showcase.tsx +120 -0
  102. package/src/tokens.css +449 -0
  103. package/src/workspace.tsx +333 -0
  104. package/styles.css +56 -0
@@ -0,0 +1,91 @@
1
+ import { KitLink } from "./navigation.js";
2
+ import { componentRoot, cx, useComponentName } from "./internal.js";
3
+ export function SegmentedControl({
4
+ label,
5
+ value,
6
+ options,
7
+ onChange,
8
+ className,
9
+ }: {
10
+ label: string;
11
+ value: string;
12
+ options: { value: string; label: string }[];
13
+ onChange: (value: string) => void;
14
+ className?: string;
15
+ }) {
16
+ const name = useComponentName("SegmentedControl");
17
+ return (
18
+ <div
19
+ {...componentRoot(name)}
20
+ className={cx("m-segmented", className)}
21
+ role="group"
22
+ aria-label={label}
23
+ >
24
+ {options.map((option) => (
25
+ <button
26
+ key={option.value}
27
+ type="button"
28
+ aria-pressed={value === option.value}
29
+ onClick={() => onChange(option.value)}
30
+ >
31
+ {option.label}
32
+ </button>
33
+ ))}
34
+ </div>
35
+ );
36
+ }
37
+ /** A scrollable code sample in a figure; the caption names the figure. */
38
+ export function CodeBlock({
39
+ code,
40
+ label = "Code",
41
+ className,
42
+ }: {
43
+ code: string;
44
+ /** Visible caption, and the figure's accessible name. */
45
+ label?: string;
46
+ className?: string;
47
+ }) {
48
+ return (
49
+ <figure
50
+ {...componentRoot("CodeBlock")}
51
+ className={cx("m-code-block", className)}
52
+ >
53
+ <figcaption>{label}</figcaption>
54
+ {/* Focusable so keyboard users can scroll long samples. */}
55
+ <pre tabIndex={0}>
56
+ <code>{code}</code>
57
+ </pre>
58
+ </figure>
59
+ );
60
+ }
61
+ export function PreviewFrame({
62
+ label,
63
+ src,
64
+ id,
65
+ className,
66
+ }: {
67
+ /** Accessible name of the section and the frame. */
68
+ label: string;
69
+ src: string;
70
+ id?: string;
71
+ className?: string;
72
+ }) {
73
+ return (
74
+ <section
75
+ id={id}
76
+ {...componentRoot("PreviewFrame")}
77
+ className={cx("m-preview-frame", className)}
78
+ aria-label={label}
79
+ >
80
+ <KitLink href={src} target="_blank" rel="noopener noreferrer">
81
+ Open full preview
82
+ </KitLink>
83
+ <iframe
84
+ title={label}
85
+ src={src}
86
+ sandbox="allow-scripts"
87
+ referrerPolicy="no-referrer"
88
+ />
89
+ </section>
90
+ );
91
+ }
@@ -0,0 +1,614 @@
1
+ // Shared building blocks for the kit's components. Internal: nothing here is
2
+ // exported from the package entry, so these can change without a release note.
3
+ import {
4
+ Fragment,
5
+ createContext,
6
+ createElement,
7
+ useContext,
8
+ useId,
9
+ type HTMLAttributes,
10
+ type ReactNode,
11
+ cloneElement,
12
+ isValidElement,
13
+ } from "react";
14
+ import { Dialog, DialogPanel, DialogTitle } from "@headlessui/react";
15
+ import { KitLink, type ItemGroup } from "./navigation.js";
16
+ import { ActionLink, Button, type LinkAction } from "./primitives.js";
17
+ import { headingTag, type HeadingLevel } from "./heading.js";
18
+
19
+ /**
20
+ * Names a catalogue component on its root: spread it on the outermost element
21
+ * the component renders, `<nav {...componentRoot("Pagination")}>`. A dialog
22
+ * marks its dialog root (portalled), and a component that renders sibling
23
+ * elements marks the first one it always renders. Pass `root = false` when
24
+ * this element is not the outermost one for the current props, e.g. a wrapper
25
+ * then takes the name. The attribute is a hook for tests and consumers, not
26
+ * for styling; the only selector on it is the internal Prose boundary.
27
+ */
28
+ export function componentRoot(name: string, root = true) {
29
+ return { "data-m-component": root ? name : undefined };
30
+ }
31
+
32
+ const ComponentRootContext = createContext<
33
+ { name: string; of: string } | undefined
34
+ >(undefined);
35
+ /**
36
+ * Renders `children`, one `of` component, as the root of the component `name`:
37
+ * that root then carries `name` (SearchDialog renders a CommandPalette). Only
38
+ * an `of` component that reads `useComponentName` takes the name, so keep the
39
+ * same kind of component out of its slots.
40
+ */
41
+ export function AsComponentRoot({
42
+ name,
43
+ of,
44
+ children,
45
+ }: {
46
+ name: string;
47
+ of: string;
48
+ children: ReactNode;
49
+ }) {
50
+ return (
51
+ <ComponentRootContext.Provider value={{ name, of }}>
52
+ {children}
53
+ </ComponentRootContext.Provider>
54
+ );
55
+ }
56
+ /**
57
+ * The root name of a component that another can render as its root: `name`,
58
+ * or the outer component's name inside its `AsComponentRoot`.
59
+ */
60
+ export function useComponentName(name: string) {
61
+ const outer = useContext(ComponentRootContext);
62
+ return outer?.of === name ? outer.name : name;
63
+ }
64
+
65
+ /** Joins the truthy class names with single spaces. */
66
+ export function cx(...classes: (string | false | null | undefined)[]) {
67
+ return classes.filter(Boolean).join(" ");
68
+ }
69
+
70
+ export interface IntroProps {
71
+ eyebrow?: string;
72
+ /** Omit to render no heading. */
73
+ title?: string;
74
+ description?: string;
75
+ /** Id for the heading, e.g. the target of the section's aria-labelledby. */
76
+ id?: string;
77
+ headingLevel?: HeadingLevel;
78
+ /** Class on the description paragraph; `null` leaves it unstyled. */
79
+ descriptionClassName?: string | null;
80
+ }
81
+ /**
82
+ * The eyebrow, heading and description that open a section, as three child
83
+ * slots. Spread them (`{...introSlots(props)}`) when consumer content follows
84
+ * in the same parent: React derives useId values from each child's position,
85
+ * so keeping the slots flat keeps the ids of that content unchanged.
86
+ */
87
+ export function introSlots({
88
+ eyebrow,
89
+ title,
90
+ description,
91
+ id,
92
+ headingLevel = 2,
93
+ descriptionClassName = "m-section-description",
94
+ }: IntroProps): ReactNode[] {
95
+ const Heading = headingTag(headingLevel);
96
+ return [
97
+ eyebrow && <p className="m-eyebrow">{eyebrow}</p>,
98
+ title !== undefined && <Heading id={id}>{title}</Heading>,
99
+ description && (
100
+ <p className={descriptionClassName ?? undefined}>{description}</p>
101
+ ),
102
+ ];
103
+ }
104
+ /** The section intro as one element, for parents where nothing user-supplied follows it. */
105
+ export function SectionIntro(props: IntroProps) {
106
+ return <>{...introSlots(props)}</>;
107
+ }
108
+
109
+ /** A row of link actions: the first primary, the rest secondary. */
110
+ export function ActionLinks({ actions }: { actions: LinkAction[] }) {
111
+ return (
112
+ <div className="m-actions">
113
+ {actions.map((action, i) => (
114
+ <ActionLink
115
+ key={`${action.label}:${action.href}:${i}`}
116
+ {...action}
117
+ variant={i === 0 ? "primary" : "secondary"}
118
+ />
119
+ ))}
120
+ </div>
121
+ );
122
+ }
123
+
124
+ export interface Term {
125
+ term: ReactNode;
126
+ details: ReactNode;
127
+ /** React key; defaults to the term. */
128
+ key?: string;
129
+ /** Extra content after the details, e.g. a second dd. */
130
+ after?: ReactNode;
131
+ /** Attributes for the term's wrapping div. */
132
+ attributes?: HTMLAttributes<HTMLDivElement> &
133
+ Record<`data-${string}`, unknown>;
134
+ }
135
+ /** A description list whose terms each sit in a div with their details. */
136
+ export function TermList({
137
+ items,
138
+ children,
139
+ ...props
140
+ }: HTMLAttributes<HTMLDListElement> &
141
+ Record<`data-${string}`, unknown> & {
142
+ items: Term[];
143
+ /** Content after the terms, e.g. a total row. */
144
+ children?: ReactNode;
145
+ }) {
146
+ // createElement keeps each element's child count the same as the hand-written
147
+ // lists this replaced (no empty `after` or `children` slot), so useId values
148
+ // inside the details do not move.
149
+ const rows = items.map((item) =>
150
+ createElement(
151
+ "div",
152
+ { key: item.key ?? String(item.term), ...item.attributes },
153
+ <dt>{item.term}</dt>,
154
+ <dd>{item.details}</dd>,
155
+ ...("after" in item ? [item.after] : []),
156
+ ),
157
+ );
158
+ return createElement(
159
+ "dl",
160
+ props,
161
+ rows,
162
+ ...(children === undefined ? [] : [children]),
163
+ );
164
+ }
165
+
166
+ /**
167
+ * Icon, label and count inside a navigation link or tab. `prefix` picks the
168
+ * class family (`m-nav-*` or `m-tab-*`). A count shows whenever it is defined.
169
+ */
170
+ export function LinkContent({
171
+ prefix,
172
+ label,
173
+ icon,
174
+ count,
175
+ marker = false,
176
+ }: {
177
+ prefix: "nav" | "tab";
178
+ label: ReactNode;
179
+ icon?: ReactNode;
180
+ count?: number | string;
181
+ /** Show the dot marker when there is no icon (sidebar links). */
182
+ marker?: boolean;
183
+ }) {
184
+ const glyph = (
185
+ <span className={`m-${prefix}-icon`} aria-hidden="true">
186
+ {icon}
187
+ </span>
188
+ );
189
+ // createElement keeps a fixed child count, so useId values inside a
190
+ // consumer's icon stay put.
191
+ return createElement(
192
+ Fragment,
193
+ null,
194
+ marker ? (
195
+ icon ? (
196
+ glyph
197
+ ) : (
198
+ <span className="m-nav-marker" aria-hidden="true" />
199
+ )
200
+ ) : (
201
+ icon && glyph
202
+ ),
203
+ label,
204
+ count !== undefined && <span className={`m-${prefix}-count`}>{count}</span>,
205
+ );
206
+ }
207
+
208
+ /** A generated id that is safe in CSS selectors and URL fragments. */
209
+ export function useDomId(prefix: string) {
210
+ return prefix + useId().replace(/[^a-zA-Z0-9-]/g, "");
211
+ }
212
+
213
+ /**
214
+ * Next index for arrow, Home and End keys in a horizontal set (wrapping), or
215
+ * null for any other key.
216
+ */
217
+ export function rovingIndex(key: string, index: number, count: number) {
218
+ if (key === "ArrowRight") return (index + 1) % count;
219
+ if (key === "ArrowLeft") return (index - 1 + count) % count;
220
+ if (key === "Home") return 0;
221
+ if (key === "End") return count - 1;
222
+ return null;
223
+ }
224
+
225
+ /** Colour swatch whose label is announced in place of the colour. */
226
+ export function Swatch({ color, label }: { color?: string; label: ReactNode }) {
227
+ return (
228
+ <>
229
+ <span
230
+ className="m-swatch"
231
+ aria-hidden="true"
232
+ style={{ background: color }}
233
+ />
234
+ <span className="m-sr-only">{label}</span>
235
+ </>
236
+ );
237
+ }
238
+
239
+ /** Ids and describedby wiring for a labelled field with a hint or error message. */
240
+ export function useField({
241
+ id,
242
+ hint,
243
+ error,
244
+ describedBy,
245
+ }: {
246
+ id?: string;
247
+ hint?: string;
248
+ error?: string;
249
+ /** The consumer's own aria-describedby, kept before the message id. */
250
+ describedBy?: string;
251
+ }) {
252
+ const generated = useId();
253
+ const fieldId = id || generated;
254
+ const messageId = fieldId + "-message";
255
+ return {
256
+ fieldId,
257
+ messageId,
258
+ describedBy:
259
+ [describedBy, (error || hint) && messageId].filter(Boolean).join(" ") ||
260
+ undefined,
261
+ };
262
+ }
263
+
264
+ /** The hint or error under a field; the error wins and is styled as one. */
265
+ export function FieldMessage({
266
+ id,
267
+ hint,
268
+ error,
269
+ }: {
270
+ id: string;
271
+ hint?: string;
272
+ error?: string;
273
+ }) {
274
+ return error || hint ? (
275
+ <p id={id} className={error ? "m-error" : ""}>
276
+ {error || hint}
277
+ </p>
278
+ ) : null;
279
+ }
280
+
281
+ /** Headless dialog root with the shared backdrop. */
282
+ export function DialogFrame({
283
+ open,
284
+ onClose,
285
+ component,
286
+ children,
287
+ }: {
288
+ open: boolean;
289
+ onClose: () => void;
290
+ /** The catalogue component this dialog is the root of, if any. */
291
+ component?: string;
292
+ children: ReactNode;
293
+ }) {
294
+ return (
295
+ <Dialog
296
+ open={open}
297
+ onClose={onClose}
298
+ className="m-dialog"
299
+ {...componentRoot(component ?? "", component !== undefined)}
300
+ >
301
+ <ThemeScope>
302
+ <div className="m-backdrop" aria-hidden="true" />
303
+ {children}
304
+ </ThemeScope>
305
+ </Dialog>
306
+ );
307
+ }
308
+
309
+ /** Narrow-screen navigation drawer used by AppShell and DocsShell. */
310
+ export function MobileNavDialog({
311
+ open,
312
+ onClose,
313
+ title,
314
+ children,
315
+ }: {
316
+ open: boolean;
317
+ onClose: () => void;
318
+ title: string;
319
+ children: ReactNode;
320
+ }) {
321
+ return (
322
+ <DialogFrame open={open} onClose={onClose}>
323
+ <DialogPanel className="m-mobile-sidebar">
324
+ <div className="m-dialog-heading">
325
+ <DialogTitle>{title}</DialogTitle>
326
+ <Button variant="ghost" onClick={onClose}>
327
+ Close navigation
328
+ </Button>
329
+ </div>
330
+ {children}
331
+ </DialogPanel>
332
+ </DialogFrame>
333
+ );
334
+ }
335
+
336
+ /** `introSlots` with the shared `m-section-title` class on the heading. */
337
+ export function titledIntroSlots(props: IntroProps): ReactNode[] {
338
+ return classedIntroSlots(props, "m-section-title");
339
+ }
340
+ /** `data-tone` for a section surface tone; the default tone sets no attribute. */
341
+ export function sectionTone(tone?: "default" | "muted" | "inverse" | "accent") {
342
+ return tone && tone !== "default" ? tone : undefined;
343
+ }
344
+
345
+ /** `SectionIntro` with the `m-section-title` heading class. */
346
+ export function TitledIntro(props: IntroProps) {
347
+ return <>{...titledIntroSlots(props)}</>;
348
+ }
349
+
350
+ /** A navigation link with optional sub-pages (NavigationItem's shape). */
351
+ export interface NavTreeItem {
352
+ label: string;
353
+ href: string;
354
+ current?: boolean;
355
+ icon?: ReactNode;
356
+ count?: number | string;
357
+ children?: NavTreeItem[];
358
+ }
359
+ /**
360
+ * Navigation links with icon and count. An item with children becomes a
361
+ * disclosure, open when it or a child is current; its own page is the first
362
+ * link inside.
363
+ */
364
+ export function NavTreeLinks({
365
+ items,
366
+ marker = false,
367
+ onNavigate,
368
+ }: {
369
+ items: NavTreeItem[];
370
+ /** Show the dot marker when an item has no icon (sidebar links). */
371
+ marker?: boolean;
372
+ onNavigate?: () => void;
373
+ }) {
374
+ const link = (item: NavTreeItem, key: string) => (
375
+ <KitLink
376
+ key={key}
377
+ href={item.href}
378
+ aria-current={item.current ? "page" : undefined}
379
+ onClick={onNavigate}
380
+ >
381
+ <LinkContent
382
+ prefix="nav"
383
+ marker={marker}
384
+ icon={item.icon}
385
+ label={item.label}
386
+ count={item.count}
387
+ />
388
+ </KitLink>
389
+ );
390
+ return (
391
+ <>
392
+ {items.map((item, index) => {
393
+ const key = `${item.label}:${item.href}:${index}`;
394
+ return item.children?.length ? (
395
+ <details
396
+ key={key}
397
+ className="m-nav-group"
398
+ open={
399
+ item.current ||
400
+ item.children.some((child) => child.current) ||
401
+ undefined
402
+ }
403
+ >
404
+ <summary>
405
+ <LinkContent
406
+ prefix="nav"
407
+ marker={marker}
408
+ icon={item.icon}
409
+ label={item.label}
410
+ />
411
+ </summary>
412
+ <div className="m-nav-children">
413
+ {item.href && link({ ...item, icon: undefined }, "parent")}
414
+ <NavTreeLinks
415
+ items={item.children}
416
+ marker={marker}
417
+ onNavigate={onNavigate}
418
+ />
419
+ </div>
420
+ </details>
421
+ ) : (
422
+ link(item, key)
423
+ );
424
+ })}
425
+ </>
426
+ );
427
+ }
428
+ /**
429
+ * Labelled groups of navigation links. Group labels are not headings, so the
430
+ * page's own h1 stays first in the heading outline.
431
+ */
432
+ export function NavGroups({
433
+ groups,
434
+ marker,
435
+ onNavigate,
436
+ }: {
437
+ groups: ItemGroup<NavTreeItem>[];
438
+ marker?: boolean;
439
+ onNavigate?: () => void;
440
+ }) {
441
+ const groupId = useDomId("m-nav-group-");
442
+ return (
443
+ <>
444
+ {groups.map((group, index) => (
445
+ <div
446
+ key={group.label}
447
+ role="group"
448
+ aria-labelledby={`${groupId}-${index}`}
449
+ >
450
+ <p id={`${groupId}-${index}`} className="m-nav-group-title">
451
+ {group.label}
452
+ </p>
453
+ <NavTreeLinks
454
+ items={group.items}
455
+ marker={marker}
456
+ onNavigate={onNavigate}
457
+ />
458
+ </div>
459
+ ))}
460
+ </>
461
+ );
462
+ }
463
+
464
+ /**
465
+ * The hint or error under a field. Unlike FieldMessage, the error is its own
466
+ * role="alert" element, so it is announced when it appears.
467
+ */
468
+ export function FieldFeedback({
469
+ id,
470
+ hint,
471
+ error,
472
+ }: {
473
+ id: string;
474
+ hint?: string;
475
+ error?: string;
476
+ }) {
477
+ if (error)
478
+ return (
479
+ <p key="error" id={id} className="m-error" role="alert">
480
+ {error}
481
+ </p>
482
+ );
483
+ return hint ? (
484
+ <p key="hint" id={id}>
485
+ {hint}
486
+ </p>
487
+ ) : null;
488
+ }
489
+
490
+ /** Decorative required marker after a field label; the control itself is marked required. */
491
+ export function RequiredMark() {
492
+ return (
493
+ <span className="m-required" aria-hidden="true">
494
+ *
495
+ </span>
496
+ );
497
+ }
498
+
499
+ const TABBABLE =
500
+ 'a[href], button:not(:disabled), input:not(:disabled):not([type="hidden"]), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])';
501
+ /**
502
+ * Call before `element` is removed. If focus is inside it, move focus to the
503
+ * next tabbable element after it (within `scope`, default the document), else
504
+ * the previous one, else `fallback`, so focus never drops to the body.
505
+ */
506
+ export function focusAway(
507
+ element: HTMLElement | null,
508
+ {
509
+ scope,
510
+ fallback,
511
+ }: { scope?: HTMLElement | null; fallback?: HTMLElement | null } = {},
512
+ ) {
513
+ if (!element) return;
514
+ const document = element.ownerDocument;
515
+ if (!element.contains(document.activeElement)) return;
516
+ const candidates = [
517
+ ...(scope ?? document.body).querySelectorAll<HTMLElement>(TABBABLE),
518
+ ].filter(
519
+ (el) =>
520
+ !element.contains(el) &&
521
+ !el.closest("[hidden], [inert]") &&
522
+ el.getClientRects().length > 0,
523
+ );
524
+ const following = (el: HTMLElement) =>
525
+ element.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING;
526
+ // Nearest first: following elements, then preceding ones from the closest.
527
+ // Some candidates have boxes but cannot take focus (content of a closed
528
+ // <details>), so keep trying until one does.
529
+ const ordered = [
530
+ ...candidates.filter(following),
531
+ ...candidates.filter((el) => !following(el)).reverse(),
532
+ ];
533
+ for (const el of fallback ? [...ordered, fallback] : ordered) {
534
+ el.focus();
535
+ if (document.activeElement === el) return;
536
+ }
537
+ }
538
+
539
+ /**
540
+ * The data-mortar-* attributes of the nearest container-scoped ThemeProvider.
541
+ * Optional (undefined outside one), so it does not make dialogs depend on it.
542
+ */
543
+ export const ThemeScopeContext = createContext<
544
+ Record<string, string> | undefined
545
+ >(undefined);
546
+ /**
547
+ * Re-applies a container-scoped theme to content rendered outside its wrapper
548
+ * (dialogs portal to the document body). The wrapper is display: contents, so
549
+ * it carries the tokens without adding a box.
550
+ */
551
+ export function ThemeScope({ children }: { children: ReactNode }) {
552
+ const attributes = useContext(ThemeScopeContext);
553
+ if (!attributes) return <>{children}</>;
554
+ return (
555
+ <div {...attributes} className="m-theme-scope">
556
+ {children}
557
+ </div>
558
+ );
559
+ }
560
+
561
+ /**
562
+ * introSlots with a class on the heading, so a title keeps its styles at any
563
+ * `headingLevel` instead of only as an h2.
564
+ */
565
+ export function classedIntroSlots(
566
+ props: IntroProps,
567
+ titleClassName: string,
568
+ ): ReactNode[] {
569
+ const [eyebrow, heading, description] = introSlots(props);
570
+ return [
571
+ eyebrow,
572
+ isValidElement<{ className?: string }>(heading)
573
+ ? cloneElement(heading, { className: titleClassName })
574
+ : heading,
575
+ description,
576
+ ];
577
+ }
578
+
579
+ /**
580
+ * Call before a consumer callback that removes `element` (e.g. a chip's
581
+ * remove button). Once the removal has rendered, if focus fell to the body,
582
+ * it moves to the nearest focusable element that took the removed one's place,
583
+ * or the one before it, searching up through the ancestors that remain.
584
+ */
585
+ export function focusAfterRemoval(element: HTMLElement) {
586
+ const trail: [HTMLElement, number][] = [];
587
+ for (let node: HTMLElement | null = element; node?.parentElement;) {
588
+ const parent: HTMLElement = node.parentElement;
589
+ trail.push([parent, [...parent.children].indexOf(node)]);
590
+ node = parent;
591
+ }
592
+ setTimeout(() => {
593
+ const active = element.ownerDocument.activeElement;
594
+ if (
595
+ element.isConnected ||
596
+ (active && active !== element.ownerDocument.body)
597
+ )
598
+ return;
599
+ for (const [parent, index] of trail) {
600
+ if (!parent.isConnected) continue;
601
+ const siblings = [...parent.children] as HTMLElement[];
602
+ const candidates = [
603
+ ...siblings.slice(index),
604
+ ...siblings.slice(0, index).reverse(),
605
+ ];
606
+ for (const candidate of candidates) {
607
+ const target = candidate.matches(TABBABLE)
608
+ ? candidate
609
+ : candidate.querySelector<HTMLElement>(TABBABLE);
610
+ if (target) return target.focus();
611
+ }
612
+ }
613
+ });
614
+ }