@assistant-ui/react-generative-ui 0.0.20 → 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.
- package/LICENSE +1 -1
- package/dist/JSONGenerativeUI.client.d.ts.map +1 -1
- package/dist/JSONGenerativeUI.client.js +30 -13
- package/dist/JSONGenerativeUI.client.js.map +1 -1
- package/dist/RadioGroupScope.d.ts +6 -0
- package/dist/RadioGroupScope.d.ts.map +1 -0
- package/dist/RadioGroupScope.js +25 -0
- package/dist/RadioGroupScope.js.map +1 -0
- package/dist/a2ui/BindingContext.d.ts +11 -0
- package/dist/a2ui/BindingContext.d.ts.map +1 -0
- package/dist/a2ui/BindingContext.js +12 -0
- package/dist/a2ui/BindingContext.js.map +1 -0
- package/dist/a2ui/PresentRenderer.d.ts +11 -0
- package/dist/a2ui/PresentRenderer.d.ts.map +1 -0
- package/dist/a2ui/PresentRenderer.js +39 -0
- package/dist/a2ui/PresentRenderer.js.map +1 -0
- package/dist/a2ui/convert.d.ts +6 -0
- package/dist/a2ui/convert.d.ts.map +1 -1
- package/dist/a2ui/convert.js +369 -87
- package/dist/a2ui/convert.js.map +1 -1
- package/dist/a2ui/dataModel.d.ts +9 -0
- package/dist/a2ui/dataModel.d.ts.map +1 -0
- package/dist/a2ui/dataModel.js +44 -0
- package/dist/a2ui/dataModel.js.map +1 -0
- package/dist/a2ui/reducer.d.ts +5 -0
- package/dist/a2ui/reducer.d.ts.map +1 -1
- package/dist/a2ui/reducer.js +3 -3
- package/dist/a2ui/reducer.js.map +1 -1
- package/dist/a2ui/types.d.ts +8 -2
- package/dist/a2ui/types.d.ts.map +1 -1
- package/dist/a2ui/types.js.map +1 -1
- package/dist/a2ui/valueFunctions.d.ts +16 -0
- package/dist/a2ui/valueFunctions.d.ts.map +1 -0
- package/dist/a2ui/valueFunctions.js +332 -0
- package/dist/a2ui/valueFunctions.js.map +1 -0
- package/dist/actionRegistry.d.ts +1 -1
- package/dist/actionRegistry.js.map +1 -1
- package/dist/answeredValues.d.ts +16 -0
- package/dist/answeredValues.d.ts.map +1 -0
- package/dist/answeredValues.js +37 -0
- package/dist/answeredValues.js.map +1 -0
- package/dist/constants.d.ts +2 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +3 -1
- package/dist/constants.js.map +1 -1
- package/dist/fieldReferences.d.ts +9 -0
- package/dist/fieldReferences.d.ts.map +1 -0
- package/dist/fieldReferences.js +53 -0
- package/dist/fieldReferences.js.map +1 -0
- package/dist/generativeUIToJSX.js +3 -3
- package/dist/generativeUIToJSX.js.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.server.js +2 -2
- package/dist/index.shared.js +2 -2
- package/dist/ir.d.ts +1 -1
- package/dist/ir.js.map +1 -1
- package/dist/renderGenerativeUI.d.ts.map +1 -1
- package/dist/renderGenerativeUI.js +12 -1
- package/dist/renderGenerativeUI.js.map +1 -1
- package/dist/slack/constants.d.ts +2 -0
- package/dist/slack/constants.d.ts.map +1 -1
- package/dist/slack/constants.js +3 -1
- package/dist/slack/constants.js.map +1 -1
- package/dist/slack/decodeBlockAction.d.ts +3 -1
- package/dist/slack/decodeBlockAction.d.ts.map +1 -1
- package/dist/slack/decodeBlockAction.js +58 -7
- package/dist/slack/decodeBlockAction.js.map +1 -1
- package/dist/slack/fromSlackBlocks.d.ts.map +1 -1
- package/dist/slack/fromSlackBlocks.js +20 -3
- package/dist/slack/fromSlackBlocks.js.map +1 -1
- package/dist/slack/toSlackBlocks.d.ts.map +1 -1
- package/dist/slack/toSlackBlocks.js +187 -19
- package/dist/slack/toSlackBlocks.js.map +1 -1
- package/dist/slack/types.d.ts +4 -0
- package/dist/slack/types.d.ts.map +1 -1
- package/dist/teams/constants.d.ts +1 -1
- package/dist/teams/constants.d.ts.map +1 -1
- package/dist/teams/constants.js +1 -1
- package/dist/teams/constants.js.map +1 -1
- package/dist/teams/decodeSubmitData.d.ts +4 -0
- package/dist/teams/decodeSubmitData.d.ts.map +1 -1
- package/dist/teams/decodeSubmitData.js +6 -1
- package/dist/teams/decodeSubmitData.js.map +1 -1
- package/dist/teams/toAdaptiveCard.d.ts +5 -4
- package/dist/teams/toAdaptiveCard.d.ts.map +1 -1
- package/dist/teams/toAdaptiveCard.js +56 -17
- package/dist/teams/toAdaptiveCard.js.map +1 -1
- package/dist/teams/types.d.ts +4 -1
- package/dist/teams/types.d.ts.map +1 -1
- package/dist/vocabulary/alert.d.ts +9 -2
- package/dist/vocabulary/alert.d.ts.map +1 -1
- package/dist/vocabulary/alert.js +116 -19
- package/dist/vocabulary/alert.js.map +1 -1
- package/dist/vocabulary/collectFormValues.d.ts +2 -1
- package/dist/vocabulary/collectFormValues.d.ts.map +1 -1
- package/dist/vocabulary/collectFormValues.js +10 -3
- package/dist/vocabulary/collectFormValues.js.map +1 -1
- package/dist/vocabulary/data.d.ts +22 -5
- package/dist/vocabulary/data.d.ts.map +1 -1
- package/dist/vocabulary/data.js +278 -83
- package/dist/vocabulary/data.js.map +1 -1
- package/dist/vocabulary/dispatch.d.ts +3 -2
- package/dist/vocabulary/dispatch.d.ts.map +1 -1
- package/dist/vocabulary/dispatch.js +33 -4
- package/dist/vocabulary/dispatch.js.map +1 -1
- package/dist/vocabulary/fact.d.ts +8 -1
- package/dist/vocabulary/fact.d.ts.map +1 -1
- package/dist/vocabulary/fact.js +37 -11
- package/dist/vocabulary/fact.js.map +1 -1
- package/dist/vocabulary/form.d.ts.map +1 -1
- package/dist/vocabulary/form.js +3 -3
- package/dist/vocabulary/form.js.map +1 -1
- package/dist/vocabulary/formatValue.d.ts +7 -0
- package/dist/vocabulary/formatValue.d.ts.map +1 -0
- package/dist/vocabulary/formatValue.js +76 -0
- package/dist/vocabulary/formatValue.js.map +1 -0
- package/dist/vocabulary/icon.d.ts +1 -1
- package/dist/vocabulary/interactive.d.ts +137 -8
- package/dist/vocabulary/interactive.d.ts.map +1 -1
- package/dist/vocabulary/interactive.js +404 -103
- package/dist/vocabulary/interactive.js.map +1 -1
- package/dist/vocabulary/layout.d.ts.map +1 -1
- package/dist/vocabulary/layout.js +4 -4
- package/dist/vocabulary/layout.js.map +1 -1
- package/dist/vocabulary/list.js +2 -2
- package/dist/vocabulary/list.js.map +1 -1
- package/dist/vocabulary/text.d.ts +1 -1
- package/package.json +6 -3
- package/src/JSONGenerativeUI.client.tsx +84 -40
- package/src/JSONGenerativeUI.test.tsx +964 -68
- package/src/RadioGroupScope.server.test.tsx +24 -0
- package/src/RadioGroupScope.tsx +34 -0
- package/src/a2ui/BindingContext.ts +26 -0
- package/src/a2ui/PresentRenderer.tsx +83 -0
- package/src/a2ui/convert.test.ts +1982 -145
- package/src/a2ui/convert.ts +677 -137
- package/src/a2ui/dataModel.ts +69 -0
- package/src/a2ui/reducer.test.ts +35 -0
- package/src/a2ui/reducer.ts +5 -4
- package/src/a2ui/types.ts +9 -2
- package/src/a2ui/valueFunctions.test.ts +271 -0
- package/src/a2ui/valueFunctions.ts +523 -0
- package/src/actionRegistry.ts +1 -1
- package/src/answeredValues.test.ts +33 -0
- package/src/answeredValues.tsx +70 -0
- package/src/constants.ts +4 -0
- package/src/fieldReferences.test.ts +66 -0
- package/src/fieldReferences.ts +83 -0
- package/src/generativeUIToJSX.test.ts +18 -0
- package/src/generativeUIToJSX.ts +4 -6
- package/src/index.server.test.ts +106 -0
- package/src/ir.ts +1 -1
- package/src/renderGenerativeUI.test.tsx +14 -5
- package/src/renderGenerativeUI.tsx +30 -1
- package/src/slack/a2uiSlack.test.ts +198 -0
- package/src/slack/constants.ts +3 -0
- package/src/slack/decodeBlockAction.test.ts +98 -0
- package/src/slack/decodeBlockAction.ts +110 -17
- package/src/slack/fromSlackBlocks.test.ts +222 -3
- package/src/slack/fromSlackBlocks.ts +53 -2
- package/src/slack/toSlackBlocks.test.ts +682 -3
- package/src/slack/toSlackBlocks.ts +374 -23
- package/src/slack/types.ts +4 -0
- package/src/teams/constants.ts +1 -1
- package/src/teams/decodeSubmitData.test.ts +33 -0
- package/src/teams/decodeSubmitData.ts +9 -1
- package/src/teams/toAdaptiveCard.test.ts +219 -0
- package/src/teams/toAdaptiveCard.ts +93 -19
- package/src/teams/types.ts +4 -1
- package/src/vocabulary/alert.test.tsx +288 -7
- package/src/vocabulary/alert.tsx +168 -26
- package/src/vocabulary/collectFormValues.test.ts +28 -1
- package/src/vocabulary/collectFormValues.ts +25 -5
- package/src/vocabulary/data.test.tsx +248 -6
- package/src/vocabulary/data.tsx +398 -100
- package/src/vocabulary/dispatch.ts +63 -2
- package/src/vocabulary/fact.test.tsx +67 -0
- package/src/vocabulary/fact.tsx +40 -9
- package/src/vocabulary/form.tsx +7 -2
- package/src/vocabulary/formatValue.test.ts +72 -0
- package/src/vocabulary/formatValue.ts +127 -0
- package/src/vocabulary/index.test.ts +2 -0
- package/src/vocabulary/interactive.dispatch.test.tsx +167 -79
- package/src/vocabulary/interactive.dom.test.tsx +1699 -0
- package/src/vocabulary/interactive.test.tsx +60 -4
- package/src/vocabulary/interactive.tsx +770 -151
- package/src/vocabulary/layout.test.tsx +6 -4
- package/src/vocabulary/layout.tsx +13 -2
- package/src/vocabulary/list.tsx +2 -2
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
73
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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
|
});
|
package/src/vocabulary/alert.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
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:
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
42
|
-
element.type === "checkbox"
|
|
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];
|