@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,1135 @@
1
+ import { KitLink } from "./navigation.js";
2
+ import {
3
+ Fragment,
4
+ forwardRef,
5
+ useEffect,
6
+ useId,
7
+ useLayoutEffect,
8
+ useRef,
9
+ useState,
10
+ type ButtonHTMLAttributes,
11
+ type InputHTMLAttributes,
12
+ type SelectHTMLAttributes,
13
+ type TextareaHTMLAttributes,
14
+ type ReactNode,
15
+ } from "react";
16
+ import {
17
+ DialogPanel,
18
+ DialogTitle,
19
+ Menu,
20
+ MenuButton,
21
+ MenuItems,
22
+ MenuItem,
23
+ MenuSection,
24
+ MenuHeading,
25
+ MenuSeparator,
26
+ } from "@headlessui/react";
27
+ import { ChevronDown, Close } from "./icons.js";
28
+ import { headingTag, type HeadingLevel } from "./heading.js";
29
+ import {
30
+ DialogFrame,
31
+ FieldFeedback,
32
+ LinkContent,
33
+ RequiredMark,
34
+ Swatch,
35
+ cx,
36
+ componentRoot,
37
+ useComponentName,
38
+ focusAway,
39
+ rovingIndex,
40
+ useDomId,
41
+ useField,
42
+ } from "./internal.js";
43
+ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
44
+ /** accent: the brand's accent fill (its accent gradient when it defines one) with on-accent text. */
45
+ variant?: "primary" | "secondary" | "ghost" | "danger" | "accent";
46
+ /**
47
+ * Busy: shows a spinner, sets aria-busy and aria-disabled and ignores
48
+ * activation. The button stays focusable, so focus is never lost.
49
+ */
50
+ loading?: boolean;
51
+ size?: "sm" | "md" | "lg";
52
+ /** Decorative icon; the text remains the accessible name. */
53
+ icon?: ReactNode;
54
+ iconPosition?: "start" | "end";
55
+ /** Show only the icon; the children become screen-reader-only text. */
56
+ iconOnly?: boolean;
57
+ }
58
+ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
59
+ function Button(
60
+ {
61
+ variant = "primary",
62
+ loading = false,
63
+ disabled,
64
+ children,
65
+ className,
66
+ type = "button",
67
+ size = "md",
68
+ icon,
69
+ iconPosition = "start",
70
+ iconOnly = false,
71
+ onClick,
72
+ ...props
73
+ },
74
+ ref,
75
+ ) {
76
+ const glyph = icon && (
77
+ <span className="m-button-icon" aria-hidden="true">
78
+ {icon}
79
+ </span>
80
+ );
81
+ return (
82
+ <button
83
+ {...componentRoot("Button")}
84
+ {...props}
85
+ ref={ref}
86
+ type={type}
87
+ disabled={disabled}
88
+ aria-disabled={(loading && !disabled) || props["aria-disabled"]}
89
+ aria-busy={loading || undefined}
90
+ onClick={(event) => {
91
+ // A busy button keeps focus but must not act or submit its form.
92
+ if (loading) event.preventDefault();
93
+ else onClick?.(event);
94
+ }}
95
+ className={cx("m-button", `m-button-${variant}`, className)}
96
+ data-size={size === "md" ? undefined : size}
97
+ data-icon-only={iconOnly && icon ? true : undefined}
98
+ >
99
+ {loading && <span className="m-spinner" aria-hidden="true" />}
100
+ {iconPosition === "start" && glyph}
101
+ {iconOnly && icon ? (
102
+ <span className="m-sr-only">{children}</span>
103
+ ) : (
104
+ children
105
+ )}
106
+ {iconPosition === "end" && glyph}
107
+ </button>
108
+ );
109
+ },
110
+ );
111
+ export interface LinkAction {
112
+ label: string;
113
+ href: string;
114
+ }
115
+ export function ActionLink({
116
+ label,
117
+ href,
118
+ variant = "primary",
119
+ className,
120
+ }: LinkAction & { variant?: ButtonProps["variant"]; className?: string }) {
121
+ return (
122
+ <KitLink
123
+ {...componentRoot("ActionLink")}
124
+ className={cx("m-button", `m-button-${variant}`, className)}
125
+ href={href}
126
+ >
127
+ {label}
128
+ </KitLink>
129
+ );
130
+ }
131
+ export interface ButtonGroupItem {
132
+ label: string;
133
+ /** Run the item's action. Use href instead for navigation. */
134
+ onSelect?: () => void;
135
+ href?: string;
136
+ icon?: ReactNode;
137
+ /** Show only the icon; the label remains the accessible name. */
138
+ iconOnly?: boolean;
139
+ disabled?: boolean;
140
+ }
141
+ /** Joined row of related secondary actions. Use SegmentedControl to choose one value. */
142
+ export function ButtonGroup({
143
+ label,
144
+ items,
145
+ className,
146
+ }: {
147
+ label: string;
148
+ items: ButtonGroupItem[];
149
+ className?: string;
150
+ }) {
151
+ return (
152
+ <div
153
+ {...componentRoot("ButtonGroup")}
154
+ className={cx("m-button-group", className)}
155
+ role="group"
156
+ aria-label={label}
157
+ >
158
+ {items.map((item) => {
159
+ const content = (
160
+ <>
161
+ {item.icon && (
162
+ <span aria-hidden="true" className="m-button-group-icon">
163
+ {item.icon}
164
+ </span>
165
+ )}
166
+ {!(item.iconOnly && item.icon) && item.label}
167
+ </>
168
+ );
169
+ const name = item.iconOnly && item.icon ? item.label : undefined;
170
+ return item.href && !item.disabled ? (
171
+ <KitLink key={item.label} href={item.href} aria-label={name}>
172
+ {content}
173
+ </KitLink>
174
+ ) : (
175
+ <button
176
+ key={item.label}
177
+ type="button"
178
+ aria-label={name}
179
+ disabled={item.disabled}
180
+ onClick={item.onSelect}
181
+ >
182
+ {content}
183
+ </button>
184
+ );
185
+ })}
186
+ </div>
187
+ );
188
+ }
189
+ export type Tone = "neutral" | "info" | "success" | "warning" | "danger";
190
+ export function Badge({
191
+ children,
192
+ tone = "neutral",
193
+ className,
194
+ }: {
195
+ children: ReactNode;
196
+ tone?: Tone;
197
+ className?: string;
198
+ }) {
199
+ return (
200
+ <span
201
+ {...componentRoot("Badge")}
202
+ className={cx("m-badge", className)}
203
+ data-tone={tone === "neutral" ? undefined : tone}
204
+ >
205
+ {children}
206
+ </span>
207
+ );
208
+ }
209
+ /** Label, hint and error shared by the text fields. Internal: not exported from the package entry. */
210
+ export interface FieldDescription {
211
+ label: string;
212
+ hint?: string;
213
+ /** Marks the field invalid; the message is announced when it appears. */
214
+ error?: string;
215
+ /** On the field's root element. */
216
+ className?: string;
217
+ }
218
+ /** The field label; a required field adds a decorative marker after the text. */
219
+ export function FieldLabel({
220
+ htmlFor,
221
+ label,
222
+ required,
223
+ }: {
224
+ htmlFor: string;
225
+ label: string;
226
+ required?: boolean;
227
+ }) {
228
+ return (
229
+ <label htmlFor={htmlFor}>
230
+ {label}
231
+ {required && <RequiredMark />}
232
+ </label>
233
+ );
234
+ }
235
+ export interface InputProps
236
+ extends InputHTMLAttributes<HTMLInputElement>, FieldDescription {
237
+ prefix?: string;
238
+ suffix?: string;
239
+ /** Decorative icon inside the field, before the text. */
240
+ leadingIcon?: ReactNode;
241
+ /** Decorative icon inside the field, after the text. */
242
+ trailingIcon?: ReactNode;
243
+ /** Button or menu attached to the end of the field, e.g. "Sort" or "Copy". */
244
+ action?: ReactNode;
245
+ }
246
+ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
247
+ {
248
+ label,
249
+ hint,
250
+ error,
251
+ id,
252
+ prefix,
253
+ suffix,
254
+ leadingIcon,
255
+ trailingIcon,
256
+ action,
257
+ className,
258
+ ...props
259
+ },
260
+ ref,
261
+ ) {
262
+ const { fieldId, messageId, describedBy } = useField({
263
+ id,
264
+ hint,
265
+ error,
266
+ describedBy: props["aria-describedby"],
267
+ });
268
+ return (
269
+ <div {...componentRoot("Input")} className={cx("m-field", className)}>
270
+ <FieldLabel htmlFor={fieldId} label={label} required={props.required} />
271
+ <div
272
+ className="m-input-group"
273
+ data-invalid={!!error || undefined}
274
+ data-leading={leadingIcon ? true : undefined}
275
+ data-trailing={trailingIcon ? true : undefined}
276
+ >
277
+ {prefix && <span>{prefix}</span>}
278
+ {leadingIcon && (
279
+ <span className="m-input-icon" aria-hidden="true">
280
+ {leadingIcon}
281
+ </span>
282
+ )}
283
+ <input
284
+ {...props}
285
+ ref={ref}
286
+ id={fieldId}
287
+ aria-invalid={!!error || props["aria-invalid"]}
288
+ aria-describedby={describedBy}
289
+ />
290
+ {trailingIcon && (
291
+ <span className="m-input-icon" aria-hidden="true">
292
+ {trailingIcon}
293
+ </span>
294
+ )}
295
+ {suffix && <span>{suffix}</span>}
296
+ {action && <div className="m-input-action">{action}</div>}
297
+ </div>
298
+ <FieldFeedback id={messageId} hint={hint} error={error} />
299
+ </div>
300
+ );
301
+ });
302
+ export function Select({
303
+ label,
304
+ options,
305
+ id,
306
+ hint,
307
+ error,
308
+ className,
309
+ ...props
310
+ }: SelectHTMLAttributes<HTMLSelectElement> &
311
+ FieldDescription & { options: { value: string; label: string }[] }) {
312
+ const { fieldId, messageId, describedBy } = useField({
313
+ id,
314
+ hint,
315
+ error,
316
+ describedBy: props["aria-describedby"],
317
+ });
318
+ const listbox = !!props.multiple || (props.size || 0) > 1;
319
+ return (
320
+ <div {...componentRoot("Select")} className={cx("m-field", className)}>
321
+ <FieldLabel htmlFor={fieldId} label={label} required={props.required} />
322
+ <div className="m-select-control" data-listbox={listbox || undefined}>
323
+ <select
324
+ {...props}
325
+ id={fieldId}
326
+ aria-invalid={!!error || props["aria-invalid"]}
327
+ aria-describedby={describedBy}
328
+ >
329
+ {options.map((o) => (
330
+ <option key={o.value} value={o.value}>
331
+ {o.label}
332
+ </option>
333
+ ))}
334
+ </select>
335
+ {!listbox && <ChevronDown />}
336
+ </div>
337
+ <FieldFeedback id={messageId} hint={hint} error={error} />
338
+ </div>
339
+ );
340
+ }
341
+ /** Groups related fields under a legend; disabled disables every control inside. */
342
+ export function Fieldset({
343
+ legend,
344
+ description,
345
+ disabled,
346
+ children,
347
+ className,
348
+ }: {
349
+ legend: string;
350
+ description?: string;
351
+ disabled?: boolean;
352
+ children: ReactNode;
353
+ className?: string;
354
+ }) {
355
+ const id = useId();
356
+ return (
357
+ <fieldset
358
+ {...componentRoot("Fieldset")}
359
+ className={cx("m-fieldset", className)}
360
+ disabled={disabled}
361
+ aria-describedby={description ? id : undefined}
362
+ >
363
+ <legend>{legend}</legend>
364
+ {description && <p id={id}>{description}</p>}
365
+ <div className="m-fieldset-fields">{children}</div>
366
+ </fieldset>
367
+ );
368
+ }
369
+ export function TextArea({
370
+ label,
371
+ id,
372
+ hint,
373
+ error,
374
+ className,
375
+ ...props
376
+ }: TextareaHTMLAttributes<HTMLTextAreaElement> & FieldDescription) {
377
+ const { fieldId, messageId, describedBy } = useField({
378
+ id,
379
+ hint,
380
+ error,
381
+ describedBy: props["aria-describedby"],
382
+ });
383
+ return (
384
+ <div {...componentRoot("TextArea")} className={cx("m-field", className)}>
385
+ <FieldLabel htmlFor={fieldId} label={label} required={props.required} />
386
+ <textarea
387
+ {...props}
388
+ id={fieldId}
389
+ aria-invalid={!!error || props["aria-invalid"]}
390
+ aria-describedby={describedBy}
391
+ />
392
+ <FieldFeedback id={messageId} hint={hint} error={error} />
393
+ </div>
394
+ );
395
+ }
396
+ /**
397
+ * Label beside a control, with a description line and an error message under
398
+ * the label, both linked to the control by aria-describedby.
399
+ */
400
+ function DescribedChoice({
401
+ component,
402
+ input,
403
+ id,
404
+ label,
405
+ description,
406
+ error,
407
+ required,
408
+ describedBy,
409
+ className,
410
+ }: {
411
+ component: string;
412
+ input: (attrs: { id: string; "aria-describedby"?: string }) => ReactNode;
413
+ id?: string;
414
+ label: ReactNode;
415
+ description?: ReactNode;
416
+ error?: string;
417
+ required?: boolean;
418
+ /** The consumer's own aria-describedby, kept before the kit's ids. */
419
+ describedBy?: string;
420
+ className?: string;
421
+ }) {
422
+ const generated = useId();
423
+ const fieldId = id || generated;
424
+ const descriptionId = `${fieldId}-description`;
425
+ const messageId = `${fieldId}-message`;
426
+ const ids =
427
+ [describedBy, description && descriptionId, error && messageId]
428
+ .filter(Boolean)
429
+ .join(" ") || undefined;
430
+ return (
431
+ <div
432
+ {...componentRoot(component)}
433
+ className={cx("m-choice", "m-choice-described", className)}
434
+ >
435
+ {input({ id: fieldId, "aria-describedby": ids })}
436
+ <div className="m-choice-text">
437
+ <label htmlFor={fieldId}>
438
+ {label}
439
+ {required && <RequiredMark />}
440
+ </label>
441
+ {description && <p id={descriptionId}>{description}</p>}
442
+ <FieldFeedback id={messageId} error={error} />
443
+ </div>
444
+ </div>
445
+ );
446
+ }
447
+ export function Checkbox({
448
+ label,
449
+ description,
450
+ error,
451
+ className,
452
+ ...props
453
+ }: Omit<InputHTMLAttributes<HTMLInputElement>, "type"> & {
454
+ label: ReactNode;
455
+ /** Secondary line under the label, announced as the description (the checkbox's hint). */
456
+ description?: ReactNode;
457
+ /** Marks the checkbox invalid; the message is announced when it appears. */
458
+ error?: string;
459
+ }) {
460
+ const input = useRef<HTMLInputElement>(null);
461
+ // Once an error has shown, keep the described layout: switching back to the
462
+ // plain label would remount the input and drop focus while the person fixes
463
+ // it. If the layout changes while the input has focus, focus follows it.
464
+ const [described, setDescribed] = useState(false);
465
+ const refocus = useRef(false);
466
+ if (error && !described) {
467
+ refocus.current =
468
+ typeof document !== "undefined" &&
469
+ !!input.current &&
470
+ input.current === document.activeElement;
471
+ setDescribed(true);
472
+ }
473
+ useLayoutEffect(() => {
474
+ if (refocus.current) input.current?.focus();
475
+ refocus.current = false;
476
+ }, [described]);
477
+ if (description || error || described)
478
+ return (
479
+ <DescribedChoice
480
+ component="Checkbox"
481
+ id={props.id}
482
+ label={label}
483
+ description={description}
484
+ error={error}
485
+ required={props.required}
486
+ describedBy={props["aria-describedby"]}
487
+ className={className}
488
+ input={(attrs) => (
489
+ <input
490
+ {...props}
491
+ {...attrs}
492
+ ref={input}
493
+ aria-invalid={error ? true : props["aria-invalid"]}
494
+ type="checkbox"
495
+ />
496
+ )}
497
+ />
498
+ );
499
+ return (
500
+ <label {...componentRoot("Checkbox")} className={cx("m-choice", className)}>
501
+ <input {...props} ref={input} type="checkbox" />
502
+ {label}
503
+ {props.required && <RequiredMark />}
504
+ </label>
505
+ );
506
+ }
507
+ export function Switch({
508
+ label,
509
+ description,
510
+ className,
511
+ ...props
512
+ }: Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "role"> & {
513
+ label: string;
514
+ /** Secondary line under the label, announced as the description. */
515
+ description?: ReactNode;
516
+ }) {
517
+ if (description)
518
+ return (
519
+ <DescribedChoice
520
+ component="Switch"
521
+ id={props.id}
522
+ label={label}
523
+ description={description}
524
+ className={className}
525
+ input={(attrs) => (
526
+ <input
527
+ {...props}
528
+ {...attrs}
529
+ type="checkbox"
530
+ role="switch"
531
+ className="m-switch"
532
+ />
533
+ )}
534
+ />
535
+ );
536
+ return (
537
+ <label {...componentRoot("Switch")} className={cx("m-choice", className)}>
538
+ <input {...props} type="checkbox" role="switch" className="m-switch" />
539
+ {label}
540
+ </label>
541
+ );
542
+ }
543
+ export function RadioGroup({
544
+ label,
545
+ name,
546
+ value,
547
+ onChange,
548
+ options,
549
+ layout = "list",
550
+ hint,
551
+ error,
552
+ required,
553
+ id,
554
+ className,
555
+ }: {
556
+ label: string;
557
+ name: string;
558
+ value: string;
559
+ onChange: (value: string) => void;
560
+ options: {
561
+ value: string;
562
+ label: string;
563
+ description?: string;
564
+ /** CSS colour for the swatches layout; the label stays the accessible name. */
565
+ swatch?: string;
566
+ disabled?: boolean;
567
+ }[];
568
+ /** Stacked cards, a card grid, or colour swatches. */
569
+ layout?: "list" | "grid" | "swatches";
570
+ /** Help under the legend, announced as the group's description. */
571
+ hint?: string;
572
+ /**
573
+ * Marks the group invalid: the message under the legend is announced when
574
+ * it appears and describes the group, and each radio is aria-invalid.
575
+ */
576
+ error?: string;
577
+ /** Marks every radio required and adds a decorative marker to the legend. */
578
+ required?: boolean;
579
+ /** On the fieldset, e.g. the target of an ErrorSummary link. */
580
+ id?: string;
581
+ className?: string;
582
+ }) {
583
+ const { messageId, describedBy } = useField({ id, hint, error });
584
+ return (
585
+ <fieldset
586
+ {...componentRoot("RadioGroup")}
587
+ className={cx("m-radio-group", className)}
588
+ data-layout={layout === "list" ? undefined : layout}
589
+ id={id}
590
+ aria-describedby={describedBy}
591
+ data-invalid={error ? true : undefined}
592
+ >
593
+ <legend>
594
+ {label}
595
+ {required && <RequiredMark />}
596
+ </legend>
597
+ <FieldFeedback id={messageId} hint={hint} error={error} />
598
+ {options.map((o) => (
599
+ <label className="m-radio-card" key={o.value}>
600
+ <input
601
+ type="radio"
602
+ name={name}
603
+ value={o.value}
604
+ checked={value === o.value}
605
+ disabled={o.disabled}
606
+ required={required}
607
+ aria-invalid={error ? true : undefined}
608
+ onChange={() => onChange(o.value)}
609
+ />
610
+ {layout === "swatches" ? (
611
+ <Swatch color={o.swatch} label={o.label} />
612
+ ) : (
613
+ <span>
614
+ <strong>{o.label}</strong>
615
+ {o.description && <span>{o.description}</span>}
616
+ </span>
617
+ )}
618
+ </label>
619
+ ))}
620
+ </fieldset>
621
+ );
622
+ }
623
+ /** Arrow keys follow reading direction: in right-to-left text ArrowLeft moves forward. */
624
+ function logicalKey(key: string, element: Element) {
625
+ if (getComputedStyle(element).direction !== "rtl") return key;
626
+ if (key === "ArrowLeft") return "ArrowRight";
627
+ if (key === "ArrowRight") return "ArrowLeft";
628
+ return key;
629
+ }
630
+ export function Tabs({
631
+ label,
632
+ value,
633
+ onChange,
634
+ items,
635
+ variant = "underline",
636
+ fullWidth = false,
637
+ className,
638
+ }: {
639
+ label: string;
640
+ /** The selected tab's id. A value matching no tab leaves the first tab in the tab order. */
641
+ value: string;
642
+ onChange: (value: string) => void;
643
+ /** Underlined tabs (default) or filled pills. */
644
+ variant?: "underline" | "pills";
645
+ /** Stretch the tabs to share the full width. */
646
+ fullWidth?: boolean;
647
+ items: {
648
+ id: string;
649
+ label: string;
650
+ content: ReactNode;
651
+ /** Shown after the label whenever it is defined, including 0. */
652
+ count?: number | string;
653
+ icon?: ReactNode;
654
+ }[];
655
+ className?: string;
656
+ }) {
657
+ const base = useId();
658
+ const tabId = (id: string) => `${base}-${id}-tab`;
659
+ // The tab in the tab order: the selected one, or the first when none matches.
660
+ const focusable = items.some((item) => item.id === value)
661
+ ? value
662
+ : items[0]?.id;
663
+ return (
664
+ <div {...componentRoot("Tabs")} className={className}>
665
+ <div
666
+ className="m-tabs"
667
+ role="tablist"
668
+ aria-label={label}
669
+ data-variant={variant === "underline" ? undefined : variant}
670
+ data-full-width={fullWidth || undefined}
671
+ onKeyDown={(e) => {
672
+ const focused = items.findIndex(
673
+ (item) => tabId(item.id) === (e.target as Element).id,
674
+ );
675
+ const next = rovingIndex(
676
+ logicalKey(e.key, e.currentTarget),
677
+ focused >= 0
678
+ ? focused
679
+ : items.findIndex((item) => item.id === focusable),
680
+ items.length,
681
+ );
682
+ if (next === null) return;
683
+ e.preventDefault();
684
+ onChange(items[next].id);
685
+ document.getElementById(tabId(items[next].id))?.focus();
686
+ }}
687
+ >
688
+ {items.map((item) => (
689
+ <button
690
+ key={item.id}
691
+ id={tabId(item.id)}
692
+ type="button"
693
+ role="tab"
694
+ aria-selected={item.id === value}
695
+ aria-controls={`${base}-${item.id}-panel`}
696
+ tabIndex={item.id === focusable ? 0 : -1}
697
+ onClick={() => onChange(item.id)}
698
+ >
699
+ <LinkContent
700
+ prefix="tab"
701
+ icon={item.icon}
702
+ label={item.label}
703
+ count={item.count}
704
+ />
705
+ </button>
706
+ ))}
707
+ </div>
708
+ {items.map((item) => (
709
+ <div
710
+ key={item.id}
711
+ hidden={item.id !== value}
712
+ role="tabpanel"
713
+ id={`${base}-${item.id}-panel`}
714
+ aria-labelledby={tabId(item.id)}
715
+ tabIndex={0}
716
+ >
717
+ {item.content}
718
+ </div>
719
+ ))}
720
+ </div>
721
+ );
722
+ }
723
+ export function Modal({
724
+ open,
725
+ onClose,
726
+ title,
727
+ children,
728
+ actions,
729
+ size = "md",
730
+ className,
731
+ }: {
732
+ open: boolean;
733
+ onClose: () => void;
734
+ title: string;
735
+ children: ReactNode;
736
+ actions?: ReactNode;
737
+ /** "lg" widens the panel for rich content such as a product quick view. */
738
+ size?: "md" | "lg";
739
+ /** On the dialog panel. */
740
+ className?: string;
741
+ }) {
742
+ const component = useComponentName("Modal");
743
+ return (
744
+ <DialogFrame open={open} onClose={onClose} component={component}>
745
+ <div className="m-dialog-position">
746
+ <DialogPanel
747
+ className={cx("m-dialog-panel", className)}
748
+ data-size={size === "md" ? undefined : size}
749
+ >
750
+ <div className="m-dialog-heading">
751
+ <DialogTitle>{title}</DialogTitle>
752
+ <Button variant="ghost" onClick={onClose} aria-label="Close dialog">
753
+ Close
754
+ </Button>
755
+ </div>
756
+ {children}
757
+ {actions && <div className="m-actions">{actions}</div>}
758
+ </DialogPanel>
759
+ </div>
760
+ </DialogFrame>
761
+ );
762
+ }
763
+ export interface DropdownItem {
764
+ label: string;
765
+ /** Run an action. Use href instead for navigation. */
766
+ onSelect?: () => void;
767
+ href?: string;
768
+ icon?: ReactNode;
769
+ description?: string;
770
+ /** Items sharing a section are grouped under that heading, separated by a divider. */
771
+ section?: string;
772
+ danger?: boolean;
773
+ disabled?: boolean;
774
+ }
775
+ export function Dropdown({
776
+ label,
777
+ items,
778
+ icon,
779
+ iconOnly = false,
780
+ variant = "secondary",
781
+ align = "end",
782
+ header,
783
+ className,
784
+ }: {
785
+ /** Trigger text, and its accessible name when iconOnly. */
786
+ label: string;
787
+ items: DropdownItem[];
788
+ /** Trigger icon, e.g. a "more" ellipsis or an avatar. */
789
+ icon?: ReactNode;
790
+ iconOnly?: boolean;
791
+ variant?: "secondary" | "ghost";
792
+ align?: "start" | "end";
793
+ /** Content above the items, e.g. "Signed in as …". */
794
+ header?: ReactNode;
795
+ /** On the trigger button. */
796
+ className?: string;
797
+ }) {
798
+ const groups: { section?: string; items: DropdownItem[] }[] = [];
799
+ for (const item of items) {
800
+ const last = groups[groups.length - 1];
801
+ if (last && last.section === item.section) last.items.push(item);
802
+ else groups.push({ section: item.section, items: [item] });
803
+ }
804
+ const entry = (item: DropdownItem) => {
805
+ const content = (
806
+ <>
807
+ {item.icon && (
808
+ <span className="m-menu-icon" aria-hidden="true">
809
+ {item.icon}
810
+ </span>
811
+ )}
812
+ {item.description ? (
813
+ <span className="m-menu-text">
814
+ <span>{item.label}</span>
815
+ <small>{item.description}</small>
816
+ </span>
817
+ ) : (
818
+ item.label
819
+ )}
820
+ </>
821
+ );
822
+ return (
823
+ <MenuItem key={item.label} disabled={item.disabled}>
824
+ {item.href ? (
825
+ <KitLink href={item.href} className={item.danger ? "m-error" : ""}>
826
+ {content}
827
+ </KitLink>
828
+ ) : (
829
+ <button
830
+ type="button"
831
+ className={item.danger ? "m-error" : ""}
832
+ onClick={item.onSelect}
833
+ >
834
+ {content}
835
+ </button>
836
+ )}
837
+ </MenuItem>
838
+ );
839
+ };
840
+ return (
841
+ <Menu>
842
+ <MenuButton
843
+ {...componentRoot("Dropdown")}
844
+ className={cx("m-button", `m-button-${variant}`, className)}
845
+ aria-label={iconOnly && icon ? label : undefined}
846
+ data-icon-only={iconOnly && icon ? true : undefined}
847
+ >
848
+ {icon && (
849
+ <span className="m-button-icon" aria-hidden="true">
850
+ {icon}
851
+ </span>
852
+ )}
853
+ {!(iconOnly && icon) && label}
854
+ {!(iconOnly && icon) && (
855
+ <span className="m-button-icon" aria-hidden="true">
856
+ <ChevronDown />
857
+ </span>
858
+ )}
859
+ </MenuButton>
860
+ <MenuItems anchor={`bottom ${align}`} className="m-menu">
861
+ {header && <div className="m-menu-header">{header}</div>}
862
+ {groups.map((group, index) =>
863
+ group.section ? (
864
+ <Fragment key={`${group.section}:${index}`}>
865
+ {index > 0 && <MenuSeparator className="m-menu-separator" />}
866
+ <MenuSection>
867
+ <MenuHeading className="m-menu-heading">
868
+ {group.section}
869
+ </MenuHeading>
870
+ {group.items.map(entry)}
871
+ </MenuSection>
872
+ </Fragment>
873
+ ) : (
874
+ <Fragment key={`items:${index}`}>
875
+ {index > 0 && <MenuSeparator className="m-menu-separator" />}
876
+ {group.items.map(entry)}
877
+ </Fragment>
878
+ ),
879
+ )}
880
+ </MenuItems>
881
+ </Menu>
882
+ );
883
+ }
884
+ export function Notice({
885
+ title,
886
+ children,
887
+ tone = "neutral",
888
+ announce = false,
889
+ actions,
890
+ onDismiss,
891
+ dismissLabel,
892
+ id,
893
+ className,
894
+ }: {
895
+ title: string;
896
+ children?: ReactNode;
897
+ tone?: Tone;
898
+ /**
899
+ * Announce the notice to assistive technology when it appears and when its
900
+ * title changes: politely, or assertively for the danger tone.
901
+ */
902
+ announce?: boolean;
903
+ /** Links or buttons such as "View details". */
904
+ actions?: ReactNode;
905
+ /** Called by the dismiss button; focus moves to the next control on the page. */
906
+ onDismiss?: () => void;
907
+ /** Accessible name for the dismiss button; defaults to "Dismiss {title}". */
908
+ dismissLabel?: string;
909
+ id?: string;
910
+ className?: string;
911
+ }) {
912
+ const root = useRef<HTMLDivElement>(null);
913
+ // A status region only announces changes made after it is in the document,
914
+ // so a polite notice re-inserts its title once it has mounted; the region is
915
+ // atomic, so the whole notice is read. An alert is announced on insertion.
916
+ const polite = announce && tone !== "danger";
917
+ const [live, setLive] = useState(false);
918
+ useEffect(() => {
919
+ if (polite) setLive(true);
920
+ }, [polite]);
921
+ return (
922
+ <div
923
+ ref={root}
924
+ id={id}
925
+ {...componentRoot("Notice")}
926
+ className={cx("m-notice", className)}
927
+ data-tone={tone === "neutral" ? undefined : tone}
928
+ role={announce ? (tone === "danger" ? "alert" : "status") : undefined}
929
+ aria-atomic={polite || undefined}
930
+ data-dismissible={onDismiss ? true : undefined}
931
+ >
932
+ <strong key={live ? "live" : "initial"}>{title}</strong>
933
+ {children && <div>{children}</div>}
934
+ {actions && <div className="m-notice-actions">{actions}</div>}
935
+ {onDismiss && (
936
+ <button
937
+ type="button"
938
+ className="m-notice-dismiss"
939
+ aria-label={dismissLabel ?? `Dismiss ${title}`}
940
+ onClick={() => {
941
+ onDismiss();
942
+ focusAway(root.current);
943
+ }}
944
+ >
945
+ <Close />
946
+ </button>
947
+ )}
948
+ </div>
949
+ );
950
+ }
951
+ /** One problem in an ErrorSummary. */
952
+ export interface ErrorSummaryItem {
953
+ /** The field's error, worded the same as the message under the field. */
954
+ message: string;
955
+ /** Link to the field; defaults to `#target`. Omit both for a form-level problem without a link. */
956
+ href?: string;
957
+ /**
958
+ * Id of the field the link focuses. Defaults to the id in an in-page
959
+ * `href`. For a group (a RadioGroup's fieldset, a CodeInput) the checked
960
+ * or first enabled control inside takes focus.
961
+ */
962
+ target?: string;
963
+ }
964
+ const FOCUSABLE_CONTROL =
965
+ 'input:not([type="hidden"]):not(:disabled), select:not(:disabled), textarea:not(:disabled), button:not(:disabled), [tabindex]:not([tabindex="-1"])';
966
+ /**
967
+ * Moves focus to the field an ErrorSummary link names, and scrolls its label
968
+ * or legend into view with it, so the question is visible with the control.
969
+ */
970
+ function focusField(target: HTMLElement) {
971
+ const control = target.matches(FOCUSABLE_CONTROL)
972
+ ? target
973
+ : (target.querySelector<HTMLElement>(
974
+ "input:is([type=radio], [type=checkbox]):checked:not(:disabled)",
975
+ ) ?? target.querySelector<HTMLElement>(FOCUSABLE_CONTROL));
976
+ const focusable = control ?? (target.tabIndex >= -1 ? target : null);
977
+ const anchor =
978
+ target instanceof HTMLFieldSetElement
979
+ ? target.querySelector("legend")
980
+ : control instanceof HTMLInputElement ||
981
+ control instanceof HTMLSelectElement ||
982
+ control instanceof HTMLTextAreaElement
983
+ ? control.labels?.[0]
984
+ : null;
985
+ focusable?.focus({ preventScroll: true });
986
+ (anchor ?? focusable ?? target).scrollIntoView({ block: "center" });
987
+ }
988
+ /**
989
+ * The problems that stopped a form submitting, above the form, each linking
990
+ * to its field. It is an alert, and it takes focus when it appears and after
991
+ * every failed submit (`submitCount`), so keyboard and screen reader users
992
+ * start from the list. Following a link focuses the field without changing the
993
+ * URL. Renders nothing while `errors` is empty.
994
+ */
995
+ export function ErrorSummary({
996
+ errors,
997
+ title = "There is a problem",
998
+ headingLevel = 2,
999
+ autoFocus = true,
1000
+ submitCount,
1001
+ id,
1002
+ className,
1003
+ }: {
1004
+ errors: ErrorSummaryItem[];
1005
+ title?: string;
1006
+ headingLevel?: HeadingLevel;
1007
+ /**
1008
+ * Move focus to the summary when it appears and whenever `submitCount`
1009
+ * changes. Turn off only where focus must stay put; the alert still
1010
+ * announces the summary.
1011
+ */
1012
+ autoFocus?: boolean;
1013
+ /**
1014
+ * Count of failed submits. A change moves focus to the summary again even
1015
+ * when the errors are the same; clearing a fixed field's error does not.
1016
+ */
1017
+ submitCount?: number;
1018
+ id?: string;
1019
+ className?: string;
1020
+ }) {
1021
+ const root = useRef<HTMLDivElement>(null);
1022
+ const titleId = useDomId("m-error-summary-");
1023
+ const shown = errors.length > 0;
1024
+ useEffect(() => {
1025
+ if (shown && autoFocus) root.current?.focus();
1026
+ // Focus only on appearing and on a new submit, never while errors clear.
1027
+ }, [shown, submitCount]);
1028
+ if (!shown) return null;
1029
+ const Heading = headingTag(headingLevel);
1030
+ return (
1031
+ <div
1032
+ ref={root}
1033
+ id={id}
1034
+ {...componentRoot("ErrorSummary")}
1035
+ className={cx("m-error-summary", className)}
1036
+ role="alert"
1037
+ aria-labelledby={titleId}
1038
+ tabIndex={-1}
1039
+ >
1040
+ <Heading id={titleId} className="m-error-summary-title">
1041
+ {title}
1042
+ </Heading>
1043
+ <ul>
1044
+ {errors.map((error, index) => {
1045
+ const href =
1046
+ error.href ?? (error.target ? `#${error.target}` : undefined);
1047
+ const target =
1048
+ error.target ??
1049
+ (href?.startsWith("#")
1050
+ ? decodeURIComponent(href.slice(1))
1051
+ : undefined);
1052
+ return (
1053
+ <li key={`${error.message}:${href}:${index}`}>
1054
+ {href ? (
1055
+ <a
1056
+ href={href}
1057
+ onClick={(event) => {
1058
+ if (
1059
+ !target ||
1060
+ event.defaultPrevented ||
1061
+ event.button !== 0 ||
1062
+ event.metaKey ||
1063
+ event.ctrlKey ||
1064
+ event.shiftKey ||
1065
+ event.altKey
1066
+ )
1067
+ return;
1068
+ const field =
1069
+ event.currentTarget.ownerDocument.getElementById(target);
1070
+ if (!field) return;
1071
+ event.preventDefault();
1072
+ focusField(field);
1073
+ }}
1074
+ >
1075
+ {error.message}
1076
+ </a>
1077
+ ) : (
1078
+ error.message
1079
+ )}
1080
+ </li>
1081
+ );
1082
+ })}
1083
+ </ul>
1084
+ </div>
1085
+ );
1086
+ }
1087
+ export function EmptyState({
1088
+ title,
1089
+ description,
1090
+ action,
1091
+ icon,
1092
+ dashed,
1093
+ align = "center",
1094
+ headingLevel = 3,
1095
+ children,
1096
+ id,
1097
+ className,
1098
+ }: {
1099
+ title: string;
1100
+ description?: string;
1101
+ action?: ReactNode;
1102
+ /** Decorative illustration or icon above the title. */
1103
+ icon?: ReactNode;
1104
+ /** Draws a dashed-border box, e.g. a "create your first" tile. */
1105
+ dashed?: boolean;
1106
+ /** `start` left-aligns the content for starting-point and template layouts. */
1107
+ align?: "center" | "start";
1108
+ /** Level of the title heading, so the page keeps a correct outline. */
1109
+ headingLevel?: HeadingLevel;
1110
+ /** Extra content after the action, e.g. starting points or recommendations. */
1111
+ children?: ReactNode;
1112
+ id?: string;
1113
+ className?: string;
1114
+ }) {
1115
+ const Heading = headingTag(headingLevel);
1116
+ return (
1117
+ <div
1118
+ id={id}
1119
+ {...componentRoot("EmptyState")}
1120
+ className={cx("m-empty", className)}
1121
+ data-dashed={dashed || undefined}
1122
+ data-align={align === "start" ? "start" : undefined}
1123
+ >
1124
+ {icon && (
1125
+ <span className="m-empty-icon" aria-hidden="true">
1126
+ {icon}
1127
+ </span>
1128
+ )}
1129
+ <Heading>{title}</Heading>
1130
+ {description && <p>{description}</p>}
1131
+ {action}
1132
+ {children && <div className="m-empty-extra">{children}</div>}
1133
+ </div>
1134
+ );
1135
+ }