@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,1033 @@
1
+ import {
2
+ useEffect,
3
+ useId,
4
+ useRef,
5
+ useState,
6
+ type ReactNode,
7
+ type RefObject,
8
+ } from "react";
9
+ import { KitLink } from "./navigation.js";
10
+ import {
11
+ ActionLink,
12
+ Button,
13
+ Checkbox,
14
+ EmptyState,
15
+ Modal,
16
+ } from "./primitives.js";
17
+ import { Avatar, FilterChip } from "./display.js";
18
+ import { ProgressSteps, type ProgressStep } from "./application.js";
19
+ import { ChevronDown, Star } from "./icons.js";
20
+ import { childLevel, headingTag, type HeadingLevel } from "./heading.js";
21
+ import {
22
+ AsComponentRoot,
23
+ Swatch,
24
+ TermList,
25
+ componentRoot,
26
+ cx,
27
+ } from "./internal.js";
28
+
29
+ export interface ProductImage {
30
+ src: string;
31
+ /** Describe the product shown; images are the main product information. */
32
+ alt: string;
33
+ }
34
+
35
+ export interface ProductCard {
36
+ id: string;
37
+ name: string;
38
+ href: string;
39
+ /** Preformatted price, e.g. "£32" or "From £18". */
40
+ price: string;
41
+ image: ProductImage;
42
+ /** Colour, variant or category shown under the name. */
43
+ meta?: string;
44
+ badge?: string;
45
+ /** Supporting text under the name, e.g. in the card variant. */
46
+ description?: string;
47
+ /** Available colours as data: `swatch` is any CSS colour the consumer supplies; the label is announced. */
48
+ swatches?: { label: string; swatch: string }[];
49
+ /** Control under the card, e.g. an "Add to bag" button; it sits above the card link. */
50
+ action?: ReactNode;
51
+ }
52
+
53
+ export interface ProductListProps {
54
+ /** Accessible name of the list. */
55
+ label: string;
56
+ products: ProductCard[];
57
+ columns?: 2 | 3 | 4;
58
+ /** Heading level of each product name (default 3). */
59
+ itemHeadingLevel?: HeadingLevel;
60
+ /**
61
+ * `cards` frames each product with a border, for listings with full
62
+ * details; `overlay` sets the name and price on a panel over the image.
63
+ */
64
+ variant?: "plain" | "cards" | "overlay";
65
+ id?: string;
66
+ className?: string;
67
+ }
68
+
69
+ /** Grid of product cards; each card's name link covers the card. */
70
+ export function ProductList({
71
+ label,
72
+ products,
73
+ columns = 4,
74
+ itemHeadingLevel = 3,
75
+ variant = "plain",
76
+ id,
77
+ className,
78
+ }: ProductListProps) {
79
+ const Heading = headingTag(itemHeadingLevel);
80
+ const overlay = variant === "overlay";
81
+ return (
82
+ <ul
83
+ id={id}
84
+ {...componentRoot("ProductList")}
85
+ className={cx("m-product-list", className)}
86
+ aria-label={label}
87
+ data-columns={columns}
88
+ data-variant={variant === "plain" ? undefined : variant}
89
+ >
90
+ {products.map((product) => {
91
+ const text = (
92
+ <>
93
+ <div className="m-product-info">
94
+ <Heading className="m-product-name">
95
+ <KitLink href={product.href}>{product.name}</KitLink>
96
+ </Heading>
97
+ {product.meta && <p className="m-product-meta">{product.meta}</p>}
98
+ {product.description && (
99
+ <p className="m-product-summary">{product.description}</p>
100
+ )}
101
+ </div>
102
+ <p className="m-product-price">{product.price}</p>
103
+ </>
104
+ );
105
+ return (
106
+ <li key={product.id}>
107
+ <div className="m-product-image">
108
+ <img
109
+ src={product.image.src}
110
+ alt={product.image.alt}
111
+ loading="lazy"
112
+ />
113
+ {product.badge && (
114
+ <span className="m-product-badge">{product.badge}</span>
115
+ )}
116
+ {overlay && <div className="m-product-overlay">{text}</div>}
117
+ </div>
118
+ {!overlay && text}
119
+ {product.swatches && product.swatches.length > 0 && (
120
+ <ul
121
+ className="m-product-swatches"
122
+ aria-label={`${product.name} colours`}
123
+ >
124
+ {product.swatches.map((colour) => (
125
+ <li key={colour.label}>
126
+ <Swatch color={colour.swatch} label={colour.label} />
127
+ </li>
128
+ ))}
129
+ </ul>
130
+ )}
131
+ {product.action && (
132
+ <div className="m-product-card-action">{product.action}</div>
133
+ )}
134
+ </li>
135
+ );
136
+ })}
137
+ </ul>
138
+ );
139
+ }
140
+
141
+ export interface ProductOption {
142
+ id: string;
143
+ label: string;
144
+ values: {
145
+ value: string;
146
+ label: string;
147
+ /** CSS colour for a swatch; the label remains the accessible name. */
148
+ swatch?: string;
149
+ disabled?: boolean;
150
+ }[];
151
+ }
152
+
153
+ export interface ProductOverviewProps {
154
+ name: string;
155
+ price: string;
156
+ description?: ReactNode;
157
+ images: ProductImage[];
158
+ rating?: { value: number; count: number; href?: string };
159
+ options?: ProductOption[];
160
+ /** Selected value per option id. */
161
+ selected?: Record<string, string>;
162
+ onSelect?: (option: string, value: string) => void;
163
+ /** Called when the add form is submitted (button click or Enter). */
164
+ onAdd?: () => void;
165
+ addLabel?: string;
166
+ /** Shows the add button as busy while the item is being added. */
167
+ loading?: boolean;
168
+ details?: string[];
169
+ /** Level of the product name heading (default 1: the product page's title). */
170
+ headingLevel?: HeadingLevel;
171
+ /** Omit the name heading when a container (e.g. a dialog title) already shows it. */
172
+ hideName?: boolean;
173
+ /** `grid` shows every image at once: the first large, the rest tiered beneath it. */
174
+ gallery?: "thumbnails" | "grid";
175
+ id?: string;
176
+ className?: string;
177
+ }
178
+
179
+ /** Product page body: gallery, price, rating, option pickers, add to bag and details. */
180
+ export function ProductOverview({
181
+ name,
182
+ price,
183
+ description,
184
+ images,
185
+ rating,
186
+ options = [],
187
+ selected = {},
188
+ onSelect,
189
+ onAdd,
190
+ addLabel = "Add to bag",
191
+ loading = false,
192
+ details = [],
193
+ headingLevel = 1,
194
+ hideName = false,
195
+ gallery = "thumbnails",
196
+ id,
197
+ className,
198
+ }: ProductOverviewProps) {
199
+ const [current, setCurrent] = useState(0);
200
+ // A new image set (another product, or another colour) starts at its first image.
201
+ const imageKey = images.map((image) => image.src).join("\n");
202
+ const [shownKey, setShownKey] = useState(imageKey);
203
+ if (shownKey !== imageKey) {
204
+ setShownKey(imageKey);
205
+ setCurrent(0);
206
+ }
207
+ const group = useId();
208
+ const Heading = headingTag(headingLevel);
209
+ const shown = images[current] ?? images[0];
210
+ return (
211
+ <div
212
+ id={id}
213
+ {...componentRoot("ProductOverview")}
214
+ className={cx("m-product-overview", className)}
215
+ >
216
+ {gallery === "grid" ? (
217
+ <div className="m-product-gallery" data-gallery="grid">
218
+ {images.map((image) => (
219
+ <div key={image.src} className="m-product-image">
220
+ <img src={image.src} alt={image.alt} />
221
+ </div>
222
+ ))}
223
+ </div>
224
+ ) : (
225
+ <div className="m-product-gallery">
226
+ {shown && (
227
+ <div className="m-product-image">
228
+ <img src={shown.src} alt={shown.alt} />
229
+ </div>
230
+ )}
231
+ {images.length > 1 && (
232
+ <div
233
+ className="m-product-thumbs"
234
+ role="group"
235
+ aria-label="Product images"
236
+ >
237
+ {images.map((image, index) => (
238
+ <button
239
+ key={image.src}
240
+ type="button"
241
+ aria-pressed={index === current}
242
+ aria-label={`Show image ${index + 1} of ${images.length}: ${image.alt}`}
243
+ onClick={() => setCurrent(index)}
244
+ >
245
+ <img src={image.src} alt="" />
246
+ </button>
247
+ ))}
248
+ </div>
249
+ )}
250
+ </div>
251
+ )}
252
+ <div className="m-product-details">
253
+ {!hideName && <Heading className="m-product-name">{name}</Heading>}
254
+ <p className="m-product-price">{price}</p>
255
+ {rating && (
256
+ <div className="m-rating">
257
+ <Stars
258
+ value={rating.value}
259
+ label={`Rated ${rating.value} out of 5 from ${rating.count} reviews`}
260
+ />
261
+ {rating.href ? (
262
+ <KitLink href={rating.href}>{rating.count} reviews</KitLink>
263
+ ) : (
264
+ <span aria-hidden="true">{rating.count} reviews</span>
265
+ )}
266
+ </div>
267
+ )}
268
+ {description && (
269
+ <div className="m-product-description">{description}</div>
270
+ )}
271
+ <form
272
+ className="m-product-form"
273
+ onSubmit={(event) => {
274
+ event.preventDefault();
275
+ if (!loading) onAdd?.();
276
+ }}
277
+ >
278
+ {options.map((option) => (
279
+ <fieldset
280
+ key={option.id}
281
+ className="m-product-option"
282
+ data-swatches={option.values.some((v) => v.swatch) || undefined}
283
+ >
284
+ <legend>{option.label}</legend>
285
+ <div>
286
+ {option.values.map((value) => (
287
+ <label
288
+ key={value.value}
289
+ data-disabled={value.disabled || undefined}
290
+ >
291
+ <input
292
+ type="radio"
293
+ name={`${group}-${option.id}`}
294
+ value={value.value}
295
+ checked={selected[option.id] === value.value}
296
+ disabled={value.disabled}
297
+ onChange={() => onSelect?.(option.id, value.value)}
298
+ />
299
+ {value.swatch ? (
300
+ <Swatch color={value.swatch} label={value.label} />
301
+ ) : (
302
+ <span>{value.label}</span>
303
+ )}
304
+ </label>
305
+ ))}
306
+ </div>
307
+ </fieldset>
308
+ ))}
309
+ <Button type="submit" loading={loading}>
310
+ {addLabel}
311
+ </Button>
312
+ </form>
313
+ {details.length > 0 && (
314
+ <ul className="m-product-highlights">
315
+ {details.map((detail) => (
316
+ <li key={detail}>{detail}</li>
317
+ ))}
318
+ </ul>
319
+ )}
320
+ </div>
321
+ </div>
322
+ );
323
+ }
324
+
325
+ export interface CartItem {
326
+ id: string;
327
+ name: string;
328
+ href: string;
329
+ price: string;
330
+ quantity: number;
331
+ image: ProductImage;
332
+ /** Chosen options, e.g. "Natural · Small". */
333
+ meta?: string;
334
+ /** Availability note, e.g. "In stock" or "Ships in 3 weeks". */
335
+ stock?: string;
336
+ }
337
+
338
+ /** Content of an empty state: a title, an optional description and action. */
339
+ export interface EmptyContent {
340
+ title: string;
341
+ description?: string;
342
+ action?: ReactNode;
343
+ }
344
+
345
+ export interface ShoppingCartProps {
346
+ items: CartItem[];
347
+ onQuantityChange: (id: string, quantity: number) => void;
348
+ onRemove: (id: string) => void;
349
+ /** Accessible name of the list of lines. */
350
+ label?: string;
351
+ maxQuantity?: number;
352
+ /** Level of each line's product name, and of the empty state's title (default 3). */
353
+ headingLevel?: HeadingLevel;
354
+ /** Shown when there are no items. */
355
+ empty?: EmptyContent;
356
+ /**
357
+ * Beside the lines when wide and below them when narrow, e.g. an
358
+ * OrderSummary with shipping and tax estimates and the checkout action.
359
+ */
360
+ summary?: ReactNode;
361
+ /**
362
+ * Products to suggest under the bag, shown as a ProductList with a heading
363
+ * (default level 2; product names one level below). Also shown when the
364
+ * bag is empty.
365
+ */
366
+ related?: {
367
+ title: string;
368
+ products: ProductCard[];
369
+ headingLevel?: HeadingLevel;
370
+ };
371
+ id?: string;
372
+ className?: string;
373
+ }
374
+
375
+ /**
376
+ * Bag contents with per-line quantity and remove controls. After a removal,
377
+ * focus moves to the next line's Remove button (the previous one after the
378
+ * last line), or to the empty state once the bag is empty.
379
+ */
380
+ export function ShoppingCart({
381
+ summary,
382
+ related,
383
+ id,
384
+ className,
385
+ ...props
386
+ }: ShoppingCartProps) {
387
+ if (!summary && !related)
388
+ return <CartLines {...props} id={id} className={className} root />;
389
+ return (
390
+ <div
391
+ id={id}
392
+ {...componentRoot("ShoppingCart")}
393
+ className={cx("m-cart-layout", className)}
394
+ data-summary={summary ? true : undefined}
395
+ >
396
+ <CartLines {...props} />
397
+ {summary && <div className="m-cart-summary">{summary}</div>}
398
+ {related && <CartRelated {...related} />}
399
+ </div>
400
+ );
401
+ }
402
+
403
+ function CartRelated({
404
+ title,
405
+ products,
406
+ headingLevel = 2,
407
+ }: NonNullable<ShoppingCartProps["related"]>) {
408
+ const titleId = useId();
409
+ const Heading = headingTag(headingLevel);
410
+ return (
411
+ <section className="m-cart-related" aria-labelledby={titleId}>
412
+ <Heading id={titleId} className="m-cart-related-title">
413
+ {title}
414
+ </Heading>
415
+ <ProductList
416
+ label={title}
417
+ products={products}
418
+ itemHeadingLevel={childLevel(headingLevel)}
419
+ />
420
+ </section>
421
+ );
422
+ }
423
+
424
+ function CartLines({
425
+ items,
426
+ onQuantityChange,
427
+ onRemove,
428
+ label = "Items in your bag",
429
+ maxQuantity = 8,
430
+ headingLevel = 3,
431
+ empty = {
432
+ title: "Your bag is empty",
433
+ description: "Items you add will appear here.",
434
+ },
435
+ id,
436
+ className,
437
+ root: isRoot = false,
438
+ }: Omit<ShoppingCartProps, "summary" | "related"> & {
439
+ /** The lines are ShoppingCart's root (no summary or related products). */
440
+ root?: boolean;
441
+ }) {
442
+ const base = useId();
443
+ const Heading = headingTag(headingLevel);
444
+ const root = useRef<HTMLElement>(null);
445
+ // The line whose Remove button was pressed, until the consumer drops it.
446
+ const removing = useRef<{ index: number; id: string } | null>(null);
447
+ useEffect(() => {
448
+ const pending = removing.current;
449
+ if (!pending || items.some((item) => item.id === pending.id)) return;
450
+ removing.current = null;
451
+ const buttons =
452
+ root.current?.querySelectorAll<HTMLButtonElement>(".m-cart-remove");
453
+ if (buttons?.length)
454
+ buttons[Math.min(pending.index, buttons.length - 1)].focus();
455
+ else root.current?.focus();
456
+ }, [items]);
457
+ if (!items.length)
458
+ return (
459
+ <div
460
+ id={id}
461
+ {...componentRoot("ShoppingCart", isRoot)}
462
+ className={cx("m-cart-empty", className)}
463
+ ref={root as RefObject<HTMLDivElement>}
464
+ tabIndex={-1}
465
+ >
466
+ <EmptyState
467
+ title={empty.title}
468
+ description={empty.description}
469
+ action={empty.action}
470
+ headingLevel={headingLevel}
471
+ />
472
+ </div>
473
+ );
474
+ return (
475
+ <ul
476
+ id={id}
477
+ {...componentRoot("ShoppingCart", isRoot)}
478
+ className={cx("m-cart", className)}
479
+ aria-label={label}
480
+ ref={root as RefObject<HTMLUListElement>}
481
+ >
482
+ {items.map((item, index) => {
483
+ const quantityId = `${base}-${item.id}`;
484
+ const choices = Array.from(
485
+ { length: Math.max(maxQuantity, item.quantity) },
486
+ (_, i) => i + 1,
487
+ );
488
+ return (
489
+ <li key={item.id}>
490
+ <div className="m-product-image">
491
+ <img src={item.image.src} alt={item.image.alt} loading="lazy" />
492
+ </div>
493
+ <div className="m-cart-info">
494
+ <Heading className="m-cart-item-title">
495
+ <KitLink href={item.href}>{item.name}</KitLink>
496
+ </Heading>
497
+ {item.meta && <p className="m-product-meta">{item.meta}</p>}
498
+ <p className="m-product-price">{item.price}</p>
499
+ {item.stock && <p className="m-cart-stock">{item.stock}</p>}
500
+ </div>
501
+ <div className="m-cart-controls">
502
+ <label className="m-sr-only" htmlFor={quantityId}>
503
+ Quantity, {item.name}
504
+ </label>
505
+ <div className="m-select-control">
506
+ <select
507
+ id={quantityId}
508
+ value={item.quantity}
509
+ onChange={(event) =>
510
+ onQuantityChange(item.id, Number(event.target.value))
511
+ }
512
+ >
513
+ {choices.map((n) => (
514
+ <option key={n} value={n}>
515
+ {n}
516
+ </option>
517
+ ))}
518
+ </select>
519
+ <ChevronDown />
520
+ </div>
521
+ <button
522
+ type="button"
523
+ className="m-cart-remove"
524
+ onClick={() => {
525
+ removing.current = { index, id: item.id };
526
+ onRemove(item.id);
527
+ }}
528
+ >
529
+ Remove<span className="m-sr-only"> {item.name}</span>
530
+ </button>
531
+ </div>
532
+ </li>
533
+ );
534
+ })}
535
+ </ul>
536
+ );
537
+ }
538
+
539
+ /** Cost breakdown with a distinguished total and a checkout action. */
540
+ export function OrderSummary({
541
+ title = "Order summary",
542
+ lines,
543
+ total,
544
+ action,
545
+ headingLevel = 2,
546
+ children,
547
+ id,
548
+ className,
549
+ }: {
550
+ title?: string;
551
+ /**
552
+ * Cost lines. `hint` explains an estimate under its label, e.g. "Calculated
553
+ * at checkout" or a link to how shipping is worked out.
554
+ */
555
+ lines: { label: string; value: string; hint?: ReactNode }[];
556
+ total: { label: string; value: string };
557
+ action?: ReactNode;
558
+ headingLevel?: HeadingLevel;
559
+ /** Notes such as a discount code field or delivery estimate. */
560
+ children?: ReactNode;
561
+ id?: string;
562
+ className?: string;
563
+ }) {
564
+ const titleId = useId();
565
+ const Heading = headingTag(headingLevel);
566
+ return (
567
+ <section
568
+ id={id}
569
+ {...componentRoot("OrderSummary")}
570
+ className={cx("m-order-summary", className)}
571
+ aria-labelledby={titleId}
572
+ >
573
+ <Heading id={titleId} className="m-order-summary-title">
574
+ {title}
575
+ </Heading>
576
+ <TermList
577
+ items={lines.map((line) => ({
578
+ key: line.label,
579
+ term: line.hint ? (
580
+ <>
581
+ {line.label}
582
+ <span className="m-order-hint">{line.hint}</span>
583
+ </>
584
+ ) : (
585
+ line.label
586
+ ),
587
+ details: line.value,
588
+ }))}
589
+ >
590
+ <div className="m-order-total">
591
+ <dt>{total.label}</dt>
592
+ <dd>{total.value}</dd>
593
+ </div>
594
+ </TermList>
595
+ {children}
596
+ {action && <div className="m-order-action">{action}</div>}
597
+ </section>
598
+ );
599
+ }
600
+
601
+ /** Product overview in a dialog, with a link to the full product page. */
602
+ export function ProductQuickView({
603
+ open,
604
+ onClose,
605
+ href,
606
+ detailsLabel = "View full details",
607
+ className,
608
+ ...product
609
+ }: Omit<
610
+ ProductOverviewProps,
611
+ "headingLevel" | "hideName" | "id" | "className"
612
+ > & {
613
+ open: boolean;
614
+ onClose: () => void;
615
+ /** Full product page. */
616
+ href: string;
617
+ detailsLabel?: string;
618
+ /** Class on the dialog's content wrapper. */
619
+ className?: string;
620
+ }) {
621
+ return (
622
+ <AsComponentRoot name="ProductQuickView" of="Modal">
623
+ <Modal open={open} onClose={onClose} title={product.name} size="lg">
624
+ <div className={cx("m-quick-view", className)}>
625
+ <ProductOverview {...product} hideName />
626
+ <ActionLink label={detailsLabel} href={href} variant="ghost" />
627
+ </div>
628
+ </Modal>
629
+ </AsComponentRoot>
630
+ );
631
+ }
632
+
633
+ export interface FilterFacet {
634
+ id: string;
635
+ label: string;
636
+ options: { value: string; label: string; count?: number }[];
637
+ }
638
+
639
+ /**
640
+ * Collapsible checkbox facets with counts, plus removable chips for active
641
+ * filters. Every change, Clear all included, calls `onChange` once with the
642
+ * complete next selection.
643
+ */
644
+ export function ProductFilters({
645
+ facets,
646
+ selected,
647
+ onChange,
648
+ label = "Filters",
649
+ id,
650
+ className,
651
+ }: {
652
+ facets: FilterFacet[];
653
+ /** Selected values per facet id. */
654
+ selected: Record<string, string[]>;
655
+ /** The complete next selection, keyed by facet id. */
656
+ onChange: (selected: Record<string, string[]>) => void;
657
+ label?: string;
658
+ id?: string;
659
+ className?: string;
660
+ }) {
661
+ const root = useRef<HTMLElement>(null);
662
+ const active = facets.flatMap((facet) =>
663
+ (selected[facet.id] || []).map((value) => ({
664
+ facet,
665
+ value,
666
+ label:
667
+ facet.options.find((option) => option.value === value)?.label ?? value,
668
+ })),
669
+ );
670
+ const toggle = (facet: string, value: string, on: boolean) => {
671
+ const current = selected[facet] || [];
672
+ onChange({
673
+ ...selected,
674
+ [facet]: on ? [...current, value] : current.filter((v) => v !== value),
675
+ });
676
+ };
677
+ const clearAll = () => {
678
+ onChange(
679
+ Object.fromEntries(
680
+ [...Object.keys(selected), ...facets.map((facet) => facet.id)].map(
681
+ (facet) => [facet, []],
682
+ ),
683
+ ),
684
+ );
685
+ // The chips and this button disappear: continue from the first facet.
686
+ root.current?.querySelector("summary")?.focus();
687
+ };
688
+ // A removed chip takes its button with it: focus the chip that takes its
689
+ // place (or the previous one), else the first facet.
690
+ const removedChip = useRef<number | null>(null);
691
+ const activeKey = active
692
+ .map((item) => `${item.facet.id}:${item.value}`)
693
+ .join();
694
+ useEffect(() => {
695
+ const index = removedChip.current;
696
+ if (index === null) return;
697
+ removedChip.current = null;
698
+ const buttons = root.current?.querySelectorAll<HTMLButtonElement>(
699
+ ".m-filter-chips .m-chip button",
700
+ );
701
+ if (buttons?.length) buttons[Math.min(index, buttons.length - 1)].focus();
702
+ else root.current?.querySelector("summary")?.focus();
703
+ }, [activeKey]);
704
+ return (
705
+ <section
706
+ id={id}
707
+ ref={root}
708
+ {...componentRoot("ProductFilters")}
709
+ className={cx("m-filters", className)}
710
+ aria-label={label}
711
+ >
712
+ {active.length > 0 && (
713
+ <div
714
+ className="m-filter-chips"
715
+ role="group"
716
+ aria-label="Active filters"
717
+ >
718
+ {active.map((item, index) => (
719
+ <FilterChip
720
+ key={`${item.facet.id}:${item.value}`}
721
+ label={item.label}
722
+ onRemove={() => {
723
+ removedChip.current = index;
724
+ toggle(item.facet.id, item.value, false);
725
+ }}
726
+ />
727
+ ))}
728
+ <button type="button" className="m-filter-clear" onClick={clearAll}>
729
+ Clear all
730
+ </button>
731
+ </div>
732
+ )}
733
+ {facets.map((facet) => {
734
+ const count = (selected[facet.id] || []).length;
735
+ return (
736
+ <details key={facet.id} open>
737
+ <summary>
738
+ {facet.label}
739
+ {count > 0 && <span className="m-filter-active">{count}</span>}
740
+ </summary>
741
+ <fieldset>
742
+ <legend className="m-sr-only">{facet.label}</legend>
743
+ {facet.options.map((option) => (
744
+ <Checkbox
745
+ key={option.value}
746
+ checked={(selected[facet.id] || []).includes(option.value)}
747
+ onChange={(event) =>
748
+ toggle(facet.id, option.value, event.target.checked)
749
+ }
750
+ label={
751
+ <>
752
+ {option.label}
753
+ {option.count !== undefined && (
754
+ <span className="m-filter-count">({option.count})</span>
755
+ )}
756
+ </>
757
+ }
758
+ />
759
+ ))}
760
+ </fieldset>
761
+ </details>
762
+ );
763
+ })}
764
+ </section>
765
+ );
766
+ }
767
+
768
+ function Stars({ value, label }: { value: number; label: string }) {
769
+ return (
770
+ <div className="m-stars" role="img" aria-label={label}>
771
+ {[1, 2, 3, 4, 5].map((n) => (
772
+ <span key={n} data-filled={value >= n - 0.25 || undefined}>
773
+ <Star />
774
+ </span>
775
+ ))}
776
+ </div>
777
+ );
778
+ }
779
+
780
+ export interface Review {
781
+ id: string;
782
+ author: string;
783
+ rating: number;
784
+ date: string;
785
+ title: string;
786
+ body: ReactNode;
787
+ /** Reviewer's photo, shown with `avatars`; without it the Avatar shows initials. */
788
+ avatar?: string;
789
+ }
790
+
791
+ /** Rating summary with a star distribution, followed by individual reviews. */
792
+ export function ProductReviews({
793
+ title = "Customer reviews",
794
+ average,
795
+ count,
796
+ distribution,
797
+ reviews,
798
+ action,
799
+ avatars = false,
800
+ headingLevel = 2,
801
+ id,
802
+ className,
803
+ }: {
804
+ title?: string;
805
+ average: number;
806
+ count: number;
807
+ distribution: { stars: number; count: number }[];
808
+ reviews: Review[];
809
+ /** E.g. a "Write a review" button. */
810
+ action?: ReactNode;
811
+ /** Shows each reviewer's Avatar (their `avatar` photo or initials) beside their name. */
812
+ avatars?: boolean;
813
+ /** Level of the title; each review's title is one level below. */
814
+ headingLevel?: HeadingLevel;
815
+ id?: string;
816
+ className?: string;
817
+ }) {
818
+ const base = useId();
819
+ const Heading = headingTag(headingLevel);
820
+ const ReviewHeading = headingTag(childLevel(headingLevel));
821
+ return (
822
+ <section
823
+ id={id}
824
+ {...componentRoot("ProductReviews")}
825
+ className={cx("m-reviews", className)}
826
+ aria-labelledby={base}
827
+ data-avatars={avatars || undefined}
828
+ >
829
+ <div className="m-reviews-summary">
830
+ <Heading id={base} className="m-reviews-title">
831
+ {title}
832
+ </Heading>
833
+ <div className="m-rating">
834
+ <Stars
835
+ value={average}
836
+ label={`Rated ${average} out of 5 from ${count} reviews`}
837
+ />
838
+ <span aria-hidden="true">Based on {count} reviews</span>
839
+ </div>
840
+ <TermList
841
+ className="m-reviews-distribution"
842
+ items={distribution.map((row) => ({
843
+ key: String(row.stars),
844
+ term: (
845
+ <>
846
+ {row.stars} {row.stars === 1 ? "star" : "stars"}
847
+ </>
848
+ ),
849
+ details: (
850
+ <>
851
+ <meter
852
+ min={0}
853
+ max={count}
854
+ value={row.count}
855
+ aria-label={`${row.stars} ${row.stars === 1 ? "star" : "stars"}: ${row.count} ${row.count === 1 ? "review" : "reviews"}`}
856
+ />
857
+ <span>{row.count}</span>
858
+ </>
859
+ ),
860
+ }))}
861
+ />
862
+ {action}
863
+ </div>
864
+ <div className="m-review-list">
865
+ {reviews.map((review) => (
866
+ <article
867
+ key={review.id}
868
+ className="m-review"
869
+ aria-labelledby={`${base}-${review.id}`}
870
+ >
871
+ <header>
872
+ {avatars && (
873
+ <Avatar
874
+ className="m-review-avatar"
875
+ name={review.author}
876
+ src={review.avatar}
877
+ decorative
878
+ />
879
+ )}
880
+ <Stars
881
+ value={review.rating}
882
+ label={`Rated ${review.rating} out of 5`}
883
+ />
884
+ <ReviewHeading
885
+ id={`${base}-${review.id}`}
886
+ className="m-review-title"
887
+ >
888
+ {review.title}
889
+ </ReviewHeading>
890
+ <p className="m-review-meta">
891
+ {review.author} · {review.date}
892
+ </p>
893
+ </header>
894
+ <div className="m-review-body">{review.body}</div>
895
+ </article>
896
+ ))}
897
+ </div>
898
+ </section>
899
+ );
900
+ }
901
+
902
+ export interface Order {
903
+ id: string;
904
+ number: string;
905
+ date: string;
906
+ total: string;
907
+ status: string;
908
+ href: string;
909
+ /** Order-level controls in the header, e.g. "View invoice" or an actions menu. */
910
+ actions?: ReactNode;
911
+ /**
912
+ * Delivery stages shown as a tracker under the status, e.g. Order placed,
913
+ * Processing, Shipped and Delivered, with the latest stage `current`.
914
+ */
915
+ progress?: ProgressStep[];
916
+ items: {
917
+ id: string;
918
+ name: string;
919
+ image: ProductImage;
920
+ quantity: number;
921
+ price: string;
922
+ href?: string;
923
+ /** Per-item controls, e.g. "Buy again" and "View product". */
924
+ actions?: ReactNode;
925
+ }[];
926
+ }
927
+
928
+ /** Past orders, each with its summary, status and line items. */
929
+ export function OrderHistory({
930
+ orders,
931
+ headingLevel = 2,
932
+ empty = {
933
+ title: "No orders yet",
934
+ description: "Orders you place will appear here.",
935
+ },
936
+ id,
937
+ className,
938
+ }: {
939
+ orders: Order[];
940
+ /** Level of each order's heading, and of the empty state's title (default 2). */
941
+ headingLevel?: HeadingLevel;
942
+ /** Shown when there are no orders. */
943
+ empty?: EmptyContent;
944
+ id?: string;
945
+ className?: string;
946
+ }) {
947
+ const base = useId();
948
+ if (!orders.length)
949
+ return (
950
+ <div
951
+ id={id}
952
+ {...componentRoot("OrderHistory")}
953
+ className={cx("m-order-history", className)}
954
+ >
955
+ <EmptyState
956
+ title={empty.title}
957
+ description={empty.description}
958
+ action={empty.action}
959
+ headingLevel={headingLevel}
960
+ />
961
+ </div>
962
+ );
963
+ const Heading = headingTag(headingLevel);
964
+ return (
965
+ <div
966
+ id={id}
967
+ {...componentRoot("OrderHistory")}
968
+ className={cx("m-order-history", className)}
969
+ >
970
+ {orders.map((order) => {
971
+ const titleId = `${base}-${order.id}`;
972
+ return (
973
+ <section key={order.id} className="m-order" aria-labelledby={titleId}>
974
+ <header className="m-order-header">
975
+ <Heading id={titleId} className="m-order-title">
976
+ Order {order.number}
977
+ </Heading>
978
+ <TermList
979
+ items={[
980
+ { term: "Date placed", details: order.date },
981
+ { term: "Total", details: order.total },
982
+ ]}
983
+ />
984
+ <KitLink href={order.href}>
985
+ View order<span className="m-sr-only"> {order.number}</span>
986
+ </KitLink>
987
+ {order.actions && (
988
+ <div className="m-order-actions">{order.actions}</div>
989
+ )}
990
+ </header>
991
+ <p className="m-order-status">{order.status}</p>
992
+ {order.progress && order.progress.length > 0 && (
993
+ <ProgressSteps
994
+ className="m-order-progress"
995
+ steps={order.progress}
996
+ variant="bar"
997
+ navigation={false}
998
+ label={`Order ${order.number} progress`}
999
+ />
1000
+ )}
1001
+ <ul className="m-order-items">
1002
+ {order.items.map((item) => (
1003
+ <li key={item.id}>
1004
+ <div className="m-product-image">
1005
+ <img
1006
+ src={item.image.src}
1007
+ alt={item.image.alt}
1008
+ loading="lazy"
1009
+ />
1010
+ </div>
1011
+ <div>
1012
+ <p className="m-order-item-name">
1013
+ {item.href ? (
1014
+ <KitLink href={item.href}>{item.name}</KitLink>
1015
+ ) : (
1016
+ item.name
1017
+ )}
1018
+ </p>
1019
+ <p className="m-product-meta">Qty {item.quantity}</p>
1020
+ </div>
1021
+ <p className="m-product-price">{item.price}</p>
1022
+ {item.actions && (
1023
+ <div className="m-order-item-actions">{item.actions}</div>
1024
+ )}
1025
+ </li>
1026
+ ))}
1027
+ </ul>
1028
+ </section>
1029
+ );
1030
+ })}
1031
+ </div>
1032
+ );
1033
+ }