@assistant-ui/react-generative-ui 0.0.21 → 0.0.22

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 (189) hide show
  1. package/LICENSE +1 -1
  2. package/dist/JSONGenerativeUI.client.d.ts.map +1 -1
  3. package/dist/JSONGenerativeUI.client.js +30 -13
  4. package/dist/JSONGenerativeUI.client.js.map +1 -1
  5. package/dist/RadioGroupScope.d.ts +6 -0
  6. package/dist/RadioGroupScope.d.ts.map +1 -0
  7. package/dist/RadioGroupScope.js +25 -0
  8. package/dist/RadioGroupScope.js.map +1 -0
  9. package/dist/a2ui/BindingContext.d.ts +11 -0
  10. package/dist/a2ui/BindingContext.d.ts.map +1 -0
  11. package/dist/a2ui/BindingContext.js +12 -0
  12. package/dist/a2ui/BindingContext.js.map +1 -0
  13. package/dist/a2ui/PresentRenderer.d.ts +11 -0
  14. package/dist/a2ui/PresentRenderer.d.ts.map +1 -0
  15. package/dist/a2ui/PresentRenderer.js +39 -0
  16. package/dist/a2ui/PresentRenderer.js.map +1 -0
  17. package/dist/a2ui/convert.d.ts +6 -0
  18. package/dist/a2ui/convert.d.ts.map +1 -1
  19. package/dist/a2ui/convert.js +344 -83
  20. package/dist/a2ui/convert.js.map +1 -1
  21. package/dist/a2ui/dataModel.d.ts +9 -0
  22. package/dist/a2ui/dataModel.d.ts.map +1 -0
  23. package/dist/a2ui/dataModel.js +44 -0
  24. package/dist/a2ui/dataModel.js.map +1 -0
  25. package/dist/a2ui/reducer.d.ts +5 -0
  26. package/dist/a2ui/reducer.d.ts.map +1 -1
  27. package/dist/a2ui/reducer.js +3 -3
  28. package/dist/a2ui/reducer.js.map +1 -1
  29. package/dist/a2ui/types.d.ts +8 -2
  30. package/dist/a2ui/types.d.ts.map +1 -1
  31. package/dist/a2ui/types.js.map +1 -1
  32. package/dist/a2ui/valueFunctions.d.ts +16 -0
  33. package/dist/a2ui/valueFunctions.d.ts.map +1 -0
  34. package/dist/a2ui/valueFunctions.js +332 -0
  35. package/dist/a2ui/valueFunctions.js.map +1 -0
  36. package/dist/actionRegistry.d.ts +1 -1
  37. package/dist/actionRegistry.js.map +1 -1
  38. package/dist/answeredValues.d.ts +16 -0
  39. package/dist/answeredValues.d.ts.map +1 -0
  40. package/dist/answeredValues.js +37 -0
  41. package/dist/answeredValues.js.map +1 -0
  42. package/dist/constants.d.ts +2 -0
  43. package/dist/constants.d.ts.map +1 -1
  44. package/dist/constants.js +3 -1
  45. package/dist/constants.js.map +1 -1
  46. package/dist/fieldReferences.d.ts +9 -0
  47. package/dist/fieldReferences.d.ts.map +1 -0
  48. package/dist/fieldReferences.js +53 -0
  49. package/dist/fieldReferences.js.map +1 -0
  50. package/dist/generativeUIToJSX.js +3 -3
  51. package/dist/generativeUIToJSX.js.map +1 -1
  52. package/dist/index.js +2 -2
  53. package/dist/index.server.js +2 -2
  54. package/dist/index.shared.js +2 -2
  55. package/dist/ir.d.ts +1 -1
  56. package/dist/ir.js.map +1 -1
  57. package/dist/renderGenerativeUI.d.ts.map +1 -1
  58. package/dist/renderGenerativeUI.js +12 -1
  59. package/dist/renderGenerativeUI.js.map +1 -1
  60. package/dist/slack/constants.d.ts +2 -0
  61. package/dist/slack/constants.d.ts.map +1 -1
  62. package/dist/slack/constants.js +3 -1
  63. package/dist/slack/constants.js.map +1 -1
  64. package/dist/slack/decodeBlockAction.d.ts +3 -1
  65. package/dist/slack/decodeBlockAction.d.ts.map +1 -1
  66. package/dist/slack/decodeBlockAction.js +58 -7
  67. package/dist/slack/decodeBlockAction.js.map +1 -1
  68. package/dist/slack/fromSlackBlocks.d.ts.map +1 -1
  69. package/dist/slack/fromSlackBlocks.js +20 -3
  70. package/dist/slack/fromSlackBlocks.js.map +1 -1
  71. package/dist/slack/toSlackBlocks.d.ts.map +1 -1
  72. package/dist/slack/toSlackBlocks.js +187 -19
  73. package/dist/slack/toSlackBlocks.js.map +1 -1
  74. package/dist/slack/types.d.ts +4 -0
  75. package/dist/slack/types.d.ts.map +1 -1
  76. package/dist/teams/constants.d.ts +1 -1
  77. package/dist/teams/constants.d.ts.map +1 -1
  78. package/dist/teams/constants.js +1 -1
  79. package/dist/teams/constants.js.map +1 -1
  80. package/dist/teams/decodeSubmitData.d.ts +4 -0
  81. package/dist/teams/decodeSubmitData.d.ts.map +1 -1
  82. package/dist/teams/decodeSubmitData.js +6 -1
  83. package/dist/teams/decodeSubmitData.js.map +1 -1
  84. package/dist/teams/toAdaptiveCard.d.ts +5 -4
  85. package/dist/teams/toAdaptiveCard.d.ts.map +1 -1
  86. package/dist/teams/toAdaptiveCard.js +56 -17
  87. package/dist/teams/toAdaptiveCard.js.map +1 -1
  88. package/dist/teams/types.d.ts +4 -1
  89. package/dist/teams/types.d.ts.map +1 -1
  90. package/dist/vocabulary/alert.d.ts +9 -2
  91. package/dist/vocabulary/alert.d.ts.map +1 -1
  92. package/dist/vocabulary/alert.js +116 -19
  93. package/dist/vocabulary/alert.js.map +1 -1
  94. package/dist/vocabulary/collectFormValues.d.ts +2 -1
  95. package/dist/vocabulary/collectFormValues.d.ts.map +1 -1
  96. package/dist/vocabulary/collectFormValues.js +10 -3
  97. package/dist/vocabulary/collectFormValues.js.map +1 -1
  98. package/dist/vocabulary/data.d.ts +22 -5
  99. package/dist/vocabulary/data.d.ts.map +1 -1
  100. package/dist/vocabulary/data.js +278 -83
  101. package/dist/vocabulary/data.js.map +1 -1
  102. package/dist/vocabulary/dispatch.d.ts +3 -2
  103. package/dist/vocabulary/dispatch.d.ts.map +1 -1
  104. package/dist/vocabulary/dispatch.js +33 -4
  105. package/dist/vocabulary/dispatch.js.map +1 -1
  106. package/dist/vocabulary/fact.d.ts +8 -1
  107. package/dist/vocabulary/fact.d.ts.map +1 -1
  108. package/dist/vocabulary/fact.js +37 -11
  109. package/dist/vocabulary/fact.js.map +1 -1
  110. package/dist/vocabulary/form.d.ts.map +1 -1
  111. package/dist/vocabulary/form.js +3 -3
  112. package/dist/vocabulary/form.js.map +1 -1
  113. package/dist/vocabulary/formatValue.d.ts +7 -0
  114. package/dist/vocabulary/formatValue.d.ts.map +1 -0
  115. package/dist/vocabulary/formatValue.js +76 -0
  116. package/dist/vocabulary/formatValue.js.map +1 -0
  117. package/dist/vocabulary/icon.d.ts +1 -1
  118. package/dist/vocabulary/interactive.d.ts +137 -8
  119. package/dist/vocabulary/interactive.d.ts.map +1 -1
  120. package/dist/vocabulary/interactive.js +404 -103
  121. package/dist/vocabulary/interactive.js.map +1 -1
  122. package/dist/vocabulary/layout.d.ts.map +1 -1
  123. package/dist/vocabulary/layout.js +4 -4
  124. package/dist/vocabulary/layout.js.map +1 -1
  125. package/dist/vocabulary/list.js +2 -2
  126. package/dist/vocabulary/list.js.map +1 -1
  127. package/dist/vocabulary/text.d.ts +1 -1
  128. package/package.json +6 -3
  129. package/src/JSONGenerativeUI.client.tsx +84 -40
  130. package/src/JSONGenerativeUI.test.tsx +964 -68
  131. package/src/RadioGroupScope.server.test.tsx +24 -0
  132. package/src/RadioGroupScope.tsx +34 -0
  133. package/src/a2ui/BindingContext.ts +26 -0
  134. package/src/a2ui/PresentRenderer.tsx +83 -0
  135. package/src/a2ui/convert.test.ts +1795 -179
  136. package/src/a2ui/convert.ts +629 -132
  137. package/src/a2ui/dataModel.ts +69 -0
  138. package/src/a2ui/reducer.test.ts +35 -0
  139. package/src/a2ui/reducer.ts +5 -4
  140. package/src/a2ui/types.ts +9 -2
  141. package/src/a2ui/valueFunctions.test.ts +271 -0
  142. package/src/a2ui/valueFunctions.ts +523 -0
  143. package/src/actionRegistry.ts +1 -1
  144. package/src/answeredValues.test.ts +33 -0
  145. package/src/answeredValues.tsx +70 -0
  146. package/src/constants.ts +4 -0
  147. package/src/fieldReferences.test.ts +66 -0
  148. package/src/fieldReferences.ts +83 -0
  149. package/src/generativeUIToJSX.test.ts +18 -0
  150. package/src/generativeUIToJSX.ts +4 -6
  151. package/src/index.server.test.ts +106 -0
  152. package/src/ir.ts +1 -1
  153. package/src/renderGenerativeUI.test.tsx +14 -5
  154. package/src/renderGenerativeUI.tsx +30 -1
  155. package/src/slack/a2uiSlack.test.ts +198 -0
  156. package/src/slack/constants.ts +3 -0
  157. package/src/slack/decodeBlockAction.test.ts +98 -0
  158. package/src/slack/decodeBlockAction.ts +110 -17
  159. package/src/slack/fromSlackBlocks.test.ts +222 -3
  160. package/src/slack/fromSlackBlocks.ts +53 -2
  161. package/src/slack/toSlackBlocks.test.ts +682 -3
  162. package/src/slack/toSlackBlocks.ts +374 -23
  163. package/src/slack/types.ts +4 -0
  164. package/src/teams/constants.ts +1 -1
  165. package/src/teams/decodeSubmitData.test.ts +33 -0
  166. package/src/teams/decodeSubmitData.ts +9 -1
  167. package/src/teams/toAdaptiveCard.test.ts +219 -0
  168. package/src/teams/toAdaptiveCard.ts +93 -19
  169. package/src/teams/types.ts +4 -1
  170. package/src/vocabulary/alert.test.tsx +288 -7
  171. package/src/vocabulary/alert.tsx +168 -26
  172. package/src/vocabulary/collectFormValues.test.ts +28 -1
  173. package/src/vocabulary/collectFormValues.ts +25 -5
  174. package/src/vocabulary/data.test.tsx +248 -6
  175. package/src/vocabulary/data.tsx +398 -100
  176. package/src/vocabulary/dispatch.ts +63 -2
  177. package/src/vocabulary/fact.test.tsx +67 -0
  178. package/src/vocabulary/fact.tsx +40 -9
  179. package/src/vocabulary/form.tsx +7 -2
  180. package/src/vocabulary/formatValue.test.ts +72 -0
  181. package/src/vocabulary/formatValue.ts +127 -0
  182. package/src/vocabulary/index.test.ts +2 -0
  183. package/src/vocabulary/interactive.dispatch.test.tsx +167 -79
  184. package/src/vocabulary/interactive.dom.test.tsx +1699 -0
  185. package/src/vocabulary/interactive.test.tsx +60 -4
  186. package/src/vocabulary/interactive.tsx +770 -151
  187. package/src/vocabulary/layout.test.tsx +6 -4
  188. package/src/vocabulary/layout.tsx +13 -2
  189. package/src/vocabulary/list.tsx +2 -2
@@ -1,5 +1,9 @@
1
- import { describe, it, expect } from "vitest";
1
+ // @vitest-environment jsdom
2
+
3
+ import { act } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
2
5
  import { renderToStaticMarkup } from "react-dom/server";
6
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
7
  import { renderGenerativeUI } from "../renderGenerativeUI";
4
8
  import { alertVocabulary } from "./alert";
5
9
  import { defaultGenerativeUILibrary } from "./index";
@@ -7,6 +11,99 @@ import { defaultGenerativeUILibrary } from "./index";
7
11
  const render = (node: unknown) =>
8
12
  renderToStaticMarkup(<>{renderGenerativeUI(node, alertVocabulary)}</>);
9
13
 
14
+ (
15
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
16
+ ).IS_REACT_ACT_ENVIRONMENT = true;
17
+
18
+ let root: Root | undefined;
19
+
20
+ afterEach(async () => {
21
+ await act(async () => root?.unmount());
22
+ root = undefined;
23
+ document.body.innerHTML = "";
24
+ });
25
+
26
+ const carousel = {
27
+ $type: "Carousel",
28
+ children: [
29
+ { $type: "Card", title: "One" },
30
+ { $type: "Card", title: "Two" },
31
+ { $type: "Card", title: "Three" },
32
+ ],
33
+ };
34
+
35
+ const mountCarousel = async (node: unknown = carousel) => {
36
+ const container = document.createElement("div");
37
+ document.body.append(container);
38
+ root = createRoot(container);
39
+ await act(async () => {
40
+ root!.render(renderGenerativeUI(node, defaultGenerativeUILibrary));
41
+ });
42
+ const track = container.querySelector<HTMLDivElement>(
43
+ '[data-aui="carousel"]',
44
+ );
45
+ if (!track) throw new Error("Carousel track was not rendered.");
46
+ return { container, track };
47
+ };
48
+
49
+ const setCarouselLayout = (
50
+ track: HTMLDivElement,
51
+ {
52
+ scrollWidth,
53
+ clientWidth,
54
+ scrollLeft,
55
+ }: { scrollWidth: number; clientWidth: number; scrollLeft: number },
56
+ ) => {
57
+ const rtl = track.style.direction === "rtl";
58
+ const rect = (left: number, width: number) =>
59
+ ({
60
+ left,
61
+ right: left + width,
62
+ width,
63
+ top: 0,
64
+ bottom: 100,
65
+ height: 100,
66
+ x: left,
67
+ y: 0,
68
+ toJSON: () => ({}),
69
+ }) as DOMRect;
70
+ const trackLeft = 140;
71
+ Object.defineProperties(track, {
72
+ scrollWidth: { configurable: true, value: scrollWidth },
73
+ clientWidth: { configurable: true, value: clientWidth },
74
+ scrollLeft: { configurable: true, value: scrollLeft, writable: true },
75
+ getBoundingClientRect: {
76
+ configurable: true,
77
+ value: () => rect(trackLeft, clientWidth),
78
+ },
79
+ });
80
+ track
81
+ .querySelectorAll<HTMLElement>(':scope > [data-aui="carousel-slide"]')
82
+ .forEach((slide, index) => {
83
+ Object.defineProperty(slide, "getBoundingClientRect", {
84
+ configurable: true,
85
+ value: () => {
86
+ const offset = index * 200 - Math.abs(track.scrollLeft);
87
+ return rtl
88
+ ? rect(trackLeft + clientWidth - offset - 188, 188)
89
+ : rect(trackLeft + offset, 188);
90
+ },
91
+ });
92
+ });
93
+ const scrollBy = vi.fn();
94
+ Object.defineProperty(track, "scrollBy", {
95
+ configurable: true,
96
+ value: scrollBy,
97
+ });
98
+ return scrollBy;
99
+ };
100
+
101
+ const measure = async () => {
102
+ await act(async () => {
103
+ window.dispatchEvent(new Event("resize"));
104
+ });
105
+ };
106
+
10
107
  describe("alertVocabulary", () => {
11
108
  it("Alert renders with title/description/tone, defaulting tone to info", () => {
12
109
  expect(
@@ -69,15 +166,18 @@ describe("alertVocabulary", () => {
69
166
  });
70
167
 
71
168
  it("Carousel without a label renders role=group, no aria-label, and no double-name landmark", () => {
72
- expect(render({ $type: "Carousel" })).toBe(
73
- '<div data-aui="carousel" role="group" aria-roledescription="carousel" tabindex="0"></div>',
74
- );
169
+ const html = render({ $type: "Carousel" });
170
+ expect(html).toContain('data-aui="carousel"');
171
+ expect(html).toContain('role="group"');
172
+ expect(html).toContain('aria-roledescription="carousel"');
173
+ expect(html).toContain('tabindex="0"');
174
+ expect(html).not.toContain("aria-label");
75
175
  });
76
176
 
77
177
  it("Carousel with a label renders role=region and the given aria-label", () => {
78
- expect(render({ $type: "Carousel", label: "Featured" })).toBe(
79
- '<div data-aui="carousel" role="region" aria-roledescription="carousel" aria-label="Featured" tabindex="0"></div>',
80
- );
178
+ const html = render({ $type: "Carousel", label: "Featured" });
179
+ expect(html).toContain('role="region"');
180
+ expect(html).toContain('aria-label="Featured"');
81
181
  });
82
182
 
83
183
  it("Carousel container carries region semantics and an optional label", () => {
@@ -143,4 +243,185 @@ describe("alertVocabulary", () => {
143
243
  expect(html).toContain('aria-label="10 of 10"');
144
244
  expect(html).not.toContain("11 of 10");
145
245
  });
246
+
247
+ it("Carousel server markup keeps the track inside an always-present frame", () => {
248
+ const html = renderToStaticMarkup(
249
+ <>{renderGenerativeUI(carousel, defaultGenerativeUILibrary)}</>,
250
+ );
251
+ expect(html).toMatch(
252
+ /<div data-aui="carousel-frame"><div id="[^"]+" data-aui="carousel"/,
253
+ );
254
+ });
255
+
256
+ it("Carousel does not render controls when its slides fit", async () => {
257
+ const { container, track } = await mountCarousel();
258
+ setCarouselLayout(track, {
259
+ scrollWidth: 300,
260
+ clientWidth: 300,
261
+ scrollLeft: 0,
262
+ });
263
+ await measure();
264
+ expect(
265
+ container.querySelector('[data-aui="carousel-controls"]'),
266
+ ).toBeNull();
267
+ });
268
+
269
+ it("Carousel renders overflow controls with Previous disabled at the start", async () => {
270
+ const { container, track } = await mountCarousel();
271
+ setCarouselLayout(track, {
272
+ scrollWidth: 600,
273
+ clientWidth: 300,
274
+ scrollLeft: 0,
275
+ });
276
+ await measure();
277
+
278
+ const previous = container.querySelector<HTMLButtonElement>(
279
+ '[data-aui="carousel-prev"]',
280
+ );
281
+ const next = container.querySelector<HTMLButtonElement>(
282
+ '[data-aui="carousel-next"]',
283
+ );
284
+ expect(previous).not.toBeNull();
285
+ expect(next).not.toBeNull();
286
+ expect(previous!.disabled).toBe(true);
287
+ expect(next!.disabled).toBe(false);
288
+ expect(previous!.getAttribute("aria-controls")).toBe(track.id);
289
+ expect(next!.getAttribute("aria-controls")).toBe(track.id);
290
+ });
291
+
292
+ it("Carousel remeasures streamed slides and observes each slide", async () => {
293
+ const originalResizeObserver = globalThis.ResizeObserver;
294
+ class TestResizeObserver {
295
+ static instances: TestResizeObserver[] = [];
296
+ readonly observed = new Set<Element>();
297
+
298
+ constructor() {
299
+ TestResizeObserver.instances.push(this);
300
+ }
301
+
302
+ observe(element: Element) {
303
+ this.observed.add(element);
304
+ }
305
+
306
+ disconnect() {}
307
+ }
308
+ globalThis.ResizeObserver =
309
+ TestResizeObserver as unknown as typeof ResizeObserver;
310
+
311
+ try {
312
+ const { container, track } = await mountCarousel({
313
+ $type: "Carousel",
314
+ children: [{ $type: "Card", title: "One" }],
315
+ });
316
+ setCarouselLayout(track, {
317
+ scrollWidth: 600,
318
+ clientWidth: 300,
319
+ scrollLeft: 0,
320
+ });
321
+ await act(async () => {
322
+ root!.render(
323
+ renderGenerativeUI(
324
+ {
325
+ $type: "Carousel",
326
+ children: [
327
+ { $type: "Card", title: "One" },
328
+ { $type: "Card", title: "Two" },
329
+ { $type: "Card", title: "Three" },
330
+ ],
331
+ },
332
+ defaultGenerativeUILibrary,
333
+ ),
334
+ );
335
+ });
336
+
337
+ expect(
338
+ container.querySelector('[data-aui="carousel-controls"]'),
339
+ ).not.toBeNull();
340
+ const observer = TestResizeObserver.instances.at(-1);
341
+ expect([...observer!.observed]).toEqual([
342
+ track,
343
+ ...track.querySelectorAll('[data-aui="carousel-slide"]'),
344
+ ]);
345
+ } finally {
346
+ globalThis.ResizeObserver = originalResizeObserver;
347
+ }
348
+ });
349
+
350
+ it("Carousel moves to the next slide start", async () => {
351
+ const { container, track } = await mountCarousel();
352
+ const scrollBy = setCarouselLayout(track, {
353
+ scrollWidth: 600,
354
+ clientWidth: 300,
355
+ scrollLeft: 0,
356
+ });
357
+ await measure();
358
+
359
+ const next = container.querySelector<HTMLButtonElement>(
360
+ '[data-aui="carousel-next"]',
361
+ );
362
+ await act(async () => next!.click());
363
+ expect(scrollBy).toHaveBeenCalledWith({ left: 200 });
364
+ });
365
+
366
+ it("Carousel moves back one slide from the middle", async () => {
367
+ const { container, track } = await mountCarousel();
368
+ const scrollBy = setCarouselLayout(track, {
369
+ scrollWidth: 600,
370
+ clientWidth: 300,
371
+ scrollLeft: 200,
372
+ });
373
+ await measure();
374
+
375
+ const previous = container.querySelector<HTMLButtonElement>(
376
+ '[data-aui="carousel-prev"]',
377
+ );
378
+ expect(previous!.disabled).toBe(false);
379
+ await act(async () => previous!.click());
380
+ expect(scrollBy).toHaveBeenCalledWith({ left: -200 });
381
+ });
382
+
383
+ it("Carousel pages toward the inline end in right-to-left layouts", async () => {
384
+ const { container, track } = await mountCarousel();
385
+ track.style.direction = "rtl";
386
+ const scrollBy = setCarouselLayout(track, {
387
+ scrollWidth: 600,
388
+ clientWidth: 300,
389
+ scrollLeft: 0,
390
+ });
391
+ await measure();
392
+
393
+ const next = container.querySelector<HTMLButtonElement>(
394
+ '[data-aui="carousel-next"]',
395
+ );
396
+ await act(async () => next!.click());
397
+ expect(scrollBy).toHaveBeenCalledWith({ left: -200 });
398
+
399
+ track.scrollLeft = -300;
400
+ await act(async () => {
401
+ track.dispatchEvent(new Event("scroll"));
402
+ });
403
+ expect(next!.disabled).toBe(true);
404
+ });
405
+
406
+ it("Carousel disables Next at the scroll end", async () => {
407
+ const { container, track } = await mountCarousel();
408
+ setCarouselLayout(track, {
409
+ scrollWidth: 600,
410
+ clientWidth: 300,
411
+ scrollLeft: 300,
412
+ });
413
+ await measure();
414
+
415
+ const next = container.querySelector<HTMLButtonElement>(
416
+ '[data-aui="carousel-next"]',
417
+ );
418
+ expect(next!.disabled).toBe(true);
419
+ });
420
+
421
+ it("Carousel server markup does not contain controls", () => {
422
+ const html = renderToStaticMarkup(
423
+ <>{renderGenerativeUI(carousel, defaultGenerativeUILibrary)}</>,
424
+ );
425
+ expect(html).not.toContain('data-aui="carousel-controls"');
426
+ });
146
427
  });
@@ -1,4 +1,4 @@
1
- import { Children } from "react";
1
+ import * as React from "react";
2
2
  import { z } from "zod";
3
3
  import type { GenerativeUILibrary } from "../types";
4
4
  import { ALERT_TONES } from "../ir";
@@ -6,6 +6,172 @@ import { toTextContent } from "./toTextContent";
6
6
 
7
7
  const MAX_CARDS = 10;
8
8
 
9
+ type CarouselRenderProps = {
10
+ label?: string;
11
+ children?: React.ReactNode;
12
+ };
13
+
14
+ type CarouselMeasurement = {
15
+ overflows: boolean;
16
+ atStart: boolean;
17
+ atEnd: boolean;
18
+ };
19
+
20
+ function CarouselRender({ label, children }: CarouselRenderProps) {
21
+ const carouselId = React.useId();
22
+ const carouselRef = React.useRef<HTMLDivElement>(null);
23
+ const [measurement, setMeasurement] = React.useState<
24
+ CarouselMeasurement | undefined
25
+ >(undefined);
26
+ const cards = React.Children.toArray(children).slice(0, MAX_CARDS);
27
+ const n = cards.length;
28
+
29
+ React.useEffect(() => {
30
+ const carousel = carouselRef.current;
31
+ if (!carousel) return;
32
+
33
+ const measure = () => {
34
+ const maxScrollLeft = Math.max(
35
+ carousel.scrollWidth - carousel.clientWidth,
36
+ 0,
37
+ );
38
+ const position = Math.abs(carousel.scrollLeft);
39
+ const next = {
40
+ overflows: maxScrollLeft > 0,
41
+ atStart: position <= 1,
42
+ atEnd: position >= maxScrollLeft - 1,
43
+ };
44
+ setMeasurement((current) =>
45
+ current?.overflows === next.overflows &&
46
+ current.atStart === next.atStart &&
47
+ current.atEnd === next.atEnd
48
+ ? current
49
+ : next,
50
+ );
51
+ };
52
+
53
+ measure();
54
+ carousel.addEventListener("scroll", measure, { passive: true });
55
+ window.addEventListener("resize", measure);
56
+ const resizeObserver =
57
+ typeof ResizeObserver === "undefined"
58
+ ? undefined
59
+ : new ResizeObserver(measure);
60
+ resizeObserver?.observe(carousel);
61
+ carousel
62
+ .querySelectorAll(':scope > [data-aui="carousel-slide"]')
63
+ .forEach((slide) => resizeObserver?.observe(slide));
64
+
65
+ return () => {
66
+ carousel.removeEventListener("scroll", measure);
67
+ window.removeEventListener("resize", measure);
68
+ resizeObserver?.disconnect();
69
+ };
70
+ }, [n]);
71
+
72
+ const move = (direction: "previous" | "next") => {
73
+ const carousel = carouselRef.current;
74
+ if (!carousel) return;
75
+
76
+ const rtl = getComputedStyle(carousel).direction === "rtl";
77
+ const startOf = (element: Element) => {
78
+ const rect = element.getBoundingClientRect();
79
+ return rtl ? -rect.right : rect.left;
80
+ };
81
+ const edge = startOf(carousel);
82
+ const starts = Array.from(
83
+ carousel.querySelectorAll(':scope > [data-aui="carousel-slide"]'),
84
+ startOf,
85
+ );
86
+ let current = 0;
87
+ starts.forEach((start, index) => {
88
+ if (
89
+ Math.abs(start - edge) < Math.abs((starts[current] ?? start) - edge)
90
+ ) {
91
+ current = index;
92
+ }
93
+ });
94
+ const origin = starts[current];
95
+ const target = starts[current + (direction === "next" ? 1 : -1)];
96
+ if (origin === undefined || target === undefined) return;
97
+ carousel.scrollBy({ left: rtl ? origin - target : target - origin });
98
+ };
99
+
100
+ return (
101
+ <div data-aui="carousel-frame">
102
+ <div
103
+ ref={carouselRef}
104
+ id={carouselId}
105
+ data-aui="carousel"
106
+ role={label ? "region" : "group"}
107
+ aria-roledescription="carousel"
108
+ aria-label={label}
109
+ tabIndex={0}
110
+ >
111
+ {cards.map((card, i) => (
112
+ <div
113
+ key={i}
114
+ data-aui="carousel-slide"
115
+ role="group"
116
+ aria-roledescription="slide"
117
+ aria-label={`${i + 1} of ${n}`}
118
+ >
119
+ {card}
120
+ </div>
121
+ ))}
122
+ </div>
123
+ {measurement?.overflows ? (
124
+ <div data-aui="carousel-controls">
125
+ <button
126
+ type="button"
127
+ data-aui="carousel-prev"
128
+ aria-label="Previous slide"
129
+ aria-controls={carouselId}
130
+ disabled={measurement.atStart}
131
+ onClick={() => move("previous")}
132
+ >
133
+ <svg
134
+ aria-hidden="true"
135
+ viewBox="0 0 24 24"
136
+ width="16"
137
+ height="16"
138
+ fill="none"
139
+ stroke="currentColor"
140
+ strokeWidth="2"
141
+ strokeLinecap="round"
142
+ strokeLinejoin="round"
143
+ >
144
+ <path d="M9 5l7 7-7 7" transform="translate(24 0) scale(-1 1)" />
145
+ </svg>
146
+ </button>
147
+ <button
148
+ type="button"
149
+ data-aui="carousel-next"
150
+ aria-label="Next slide"
151
+ aria-controls={carouselId}
152
+ disabled={measurement.atEnd}
153
+ onClick={() => move("next")}
154
+ >
155
+ <svg
156
+ aria-hidden="true"
157
+ viewBox="0 0 24 24"
158
+ width="16"
159
+ height="16"
160
+ fill="none"
161
+ stroke="currentColor"
162
+ strokeWidth="2"
163
+ strokeLinecap="round"
164
+ strokeLinejoin="round"
165
+ >
166
+ <path d="M9 5l7 7-7 7" />
167
+ </svg>
168
+ </button>
169
+ </div>
170
+ ) : null}
171
+ </div>
172
+ );
173
+ }
174
+
9
175
  export const alertVocabulary = {
10
176
  Alert: {
11
177
  description:
@@ -46,30 +212,6 @@ export const alertVocabulary = {
46
212
  .optional()
47
213
  .describe("Accessible label for the carousel."),
48
214
  }),
49
- render: ({ label, children }) => {
50
- const cards = Children.toArray(children).slice(0, MAX_CARDS);
51
- const n = cards.length;
52
- return (
53
- <div
54
- data-aui="carousel"
55
- role={label ? "region" : "group"}
56
- aria-roledescription="carousel"
57
- aria-label={label}
58
- tabIndex={0}
59
- >
60
- {cards.map((card, i) => (
61
- <div
62
- key={i}
63
- data-aui="carousel-slide"
64
- role="group"
65
- aria-roledescription="slide"
66
- aria-label={`${i + 1} of ${n}`}
67
- >
68
- {card}
69
- </div>
70
- ))}
71
- </div>
72
- );
73
- },
215
+ render: CarouselRender,
74
216
  },
75
217
  } satisfies GenerativeUILibrary;
@@ -5,7 +5,7 @@ import {
5
5
  collectFormValues,
6
6
  type FormControlElementLike,
7
7
  } from "./collectFormValues";
8
- import { GENERATED_NAME_ATTR } from "../constants";
8
+ import { CHECKBOX_GROUP_ATTR, GENERATED_NAME_ATTR } from "../constants";
9
9
 
10
10
  const el = (
11
11
  partial: Partial<FormControlElementLike>,
@@ -51,6 +51,27 @@ describe("collectFormValues", () => {
51
51
  expect(values.size).toBeUndefined();
52
52
  });
53
53
 
54
+ it("resolves a checkbox group to its checked values in document order", () => {
55
+ const option = (value: string, checked: boolean) =>
56
+ el({
57
+ name: "toppings",
58
+ type: "checkbox",
59
+ value,
60
+ checked,
61
+ hasAttribute: (name) => name === CHECKBOX_GROUP_ATTR,
62
+ });
63
+ expect(
64
+ collectFormValues([
65
+ option("basil", true),
66
+ option("olives", false),
67
+ option("onion", true),
68
+ ]),
69
+ ).toEqual({ toppings: ["basil", "onion"] });
70
+ expect(collectFormValues([option("basil", false)])).toEqual({
71
+ toppings: [],
72
+ });
73
+ });
74
+
54
75
  it("resolves select/input/textarea/date controls to their string value", () => {
55
76
  expect(
56
77
  collectFormValues([
@@ -67,6 +88,12 @@ describe("collectFormValues", () => {
67
88
  });
68
89
  });
69
90
 
91
+ it("resolves a range input to its numeric value", () => {
92
+ expect(
93
+ collectFormValues([el({ name: "volume", type: "range", value: "7" })]),
94
+ ).toEqual({ volume: 7 });
95
+ });
96
+
70
97
  it("collects repeated non-radio names into an array in document order", () => {
71
98
  expect(
72
99
  collectFormValues([
@@ -1,5 +1,9 @@
1
1
  import type { FormEvent } from "react";
2
- import { GENERATED_NAME_ATTR } from "../constants";
2
+ import {
3
+ CHECKBOX_GROUP_ATTR,
4
+ FIELD_NAME_ATTR,
5
+ GENERATED_NAME_ATTR,
6
+ } from "../constants";
3
7
 
4
8
  /**
5
9
  * The subset of `HTMLInputElement`/`HTMLSelectElement`/`HTMLTextAreaElement` that {@link collectFormValues} reads. A structural type rather than the DOM interfaces themselves, so a plain object can stand in for a form control in tests.
@@ -11,11 +15,12 @@ export type FormControlElementLike = {
11
15
  readonly checked?: boolean;
12
16
  readonly disabled: boolean;
13
17
  readonly hasAttribute: (name: string) => boolean;
18
+ readonly getAttribute?: ((name: string) => string | null) | undefined;
14
19
  readonly matches?: ((selector: string) => boolean) | undefined;
15
20
  };
16
21
 
17
22
  /**
18
- * Collects a submitted form's named control values into a plain object, keyed by `name`, in document order. Reads each control's live DOM state rather than `FormData`, so a checkbox resolves to its `checked` boolean instead of an on/off string. A radio group resolves to its checked option's `value`, or `undefined` if none is checked. Any other repeated `name` resolves to an array of its controls' values, in document order. Controls without a `name`, that carry `data-aui-generated-name`, or that are effectively disabled, including through an ancestor disabled fieldset outside its first legend, are skipped entirely.
23
+ * Collects a submitted form's named control values into a plain object, keyed by `name`, in document order. Reads each control's live DOM state rather than `FormData`, so a checkbox resolves to its `checked` boolean instead of an on/off string. A radio group resolves to its checked option's `value`, or `undefined` if none is checked. A checkbox group resolves to its checked options' values in document order, or an empty array if none is checked. Any other repeated `name` resolves to an array of its controls' values, in document order. Controls without a `name`, that carry `data-aui-generated-name`, or that are effectively disabled, including through an ancestor disabled fieldset outside its first legend, are skipped entirely.
19
24
  */
20
25
  export function collectFormValues(
21
26
  elements: ArrayLike<FormControlElementLike>,
@@ -23,7 +28,8 @@ export function collectFormValues(
23
28
  const values: Record<string, unknown> = Object.create(null);
24
29
 
25
30
  for (const element of Array.from(elements)) {
26
- const { name, disabled } = element;
31
+ const { disabled } = element;
32
+ const name = element.getAttribute?.(FIELD_NAME_ATTR) ?? element.name;
27
33
  if (
28
34
  !name ||
29
35
  disabled ||
@@ -38,8 +44,22 @@ export function collectFormValues(
38
44
  continue;
39
45
  }
40
46
 
41
- const value: string | boolean =
42
- element.type === "checkbox" ? (element.checked ?? false) : element.value;
47
+ if (
48
+ element.type === "checkbox" &&
49
+ element.hasAttribute(CHECKBOX_GROUP_ATTR)
50
+ ) {
51
+ const existing = values[name];
52
+ const checked = Array.isArray(existing) ? existing : [];
53
+ values[name] = element.checked ? [...checked, element.value] : checked;
54
+ continue;
55
+ }
56
+
57
+ const value: string | number | boolean =
58
+ element.type === "checkbox"
59
+ ? (element.checked ?? false)
60
+ : element.type === "range"
61
+ ? Number(element.value)
62
+ : element.value;
43
63
 
44
64
  if (Object.hasOwn(values, name)) {
45
65
  const existing = values[name];