@agent-native/toolkit 0.21.3 → 0.22.0-nightly-20260926081002

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 (141) hide show
  1. package/agent-native.eject.json +11 -0
  2. package/dist/agentkit.d.ts +5 -1
  3. package/dist/agentkit.d.ts.map +1 -1
  4. package/dist/agentkit.js +2 -0
  5. package/dist/agentkit.js.map +1 -1
  6. package/dist/app-shell/PromptHome.d.ts +26 -0
  7. package/dist/app-shell/PromptHome.d.ts.map +1 -0
  8. package/dist/app-shell/PromptHome.js +19 -0
  9. package/dist/app-shell/PromptHome.js.map +1 -0
  10. package/dist/app-shell/PromptHome.spec.d.ts +2 -0
  11. package/dist/app-shell/PromptHome.spec.d.ts.map +1 -0
  12. package/dist/app-shell/PromptHome.spec.js +206 -0
  13. package/dist/app-shell/PromptHome.spec.js.map +1 -0
  14. package/dist/app-shell/TemplateLibraryGrid.d.ts +48 -0
  15. package/dist/app-shell/TemplateLibraryGrid.d.ts.map +1 -0
  16. package/dist/app-shell/TemplateLibraryGrid.js +20 -0
  17. package/dist/app-shell/TemplateLibraryGrid.js.map +1 -0
  18. package/dist/app-shell/TemplatePreviewDialog.d.ts +41 -0
  19. package/dist/app-shell/TemplatePreviewDialog.d.ts.map +1 -0
  20. package/dist/app-shell/TemplatePreviewDialog.js +50 -0
  21. package/dist/app-shell/TemplatePreviewDialog.js.map +1 -0
  22. package/dist/app-shell/TemplatePreviewDialog.spec.d.ts +2 -0
  23. package/dist/app-shell/TemplatePreviewDialog.spec.d.ts.map +1 -0
  24. package/dist/app-shell/TemplatePreviewDialog.spec.js +258 -0
  25. package/dist/app-shell/TemplatePreviewDialog.spec.js.map +1 -0
  26. package/dist/app-shell/index.d.ts +3 -0
  27. package/dist/app-shell/index.d.ts.map +1 -1
  28. package/dist/app-shell/index.js +3 -0
  29. package/dist/app-shell/index.js.map +1 -1
  30. package/dist/app-shell/prompt-home.css +159 -0
  31. package/dist/composer/ComposerContextMenu.d.ts +48 -0
  32. package/dist/composer/ComposerContextMenu.d.ts.map +1 -0
  33. package/dist/composer/ComposerContextMenu.js +295 -0
  34. package/dist/composer/ComposerContextMenu.js.map +1 -0
  35. package/dist/composer/ComposerContextMenu.spec.d.ts +2 -0
  36. package/dist/composer/ComposerContextMenu.spec.d.ts.map +1 -0
  37. package/dist/composer/ComposerContextMenu.spec.js +270 -0
  38. package/dist/composer/ComposerContextMenu.spec.js.map +1 -0
  39. package/dist/composer/ComposerContextMenuSearch.d.ts +11 -0
  40. package/dist/composer/ComposerContextMenuSearch.d.ts.map +1 -0
  41. package/dist/composer/ComposerContextMenuSearch.js +42 -0
  42. package/dist/composer/ComposerContextMenuSearch.js.map +1 -0
  43. package/dist/composer/ComposerContextPicker.d.ts +7 -0
  44. package/dist/composer/ComposerContextPicker.d.ts.map +1 -0
  45. package/dist/composer/ComposerContextPicker.js +71 -0
  46. package/dist/composer/ComposerContextPicker.js.map +1 -0
  47. package/dist/composer/ComposerContextPicker.spec.d.ts +2 -0
  48. package/dist/composer/ComposerContextPicker.spec.d.ts.map +1 -0
  49. package/dist/composer/ComposerContextPicker.spec.js +454 -0
  50. package/dist/composer/ComposerContextPicker.spec.js.map +1 -0
  51. package/dist/composer/ComposerContextPickerDialog.d.ts +8 -0
  52. package/dist/composer/ComposerContextPickerDialog.d.ts.map +1 -0
  53. package/dist/composer/ComposerContextPickerDialog.js +101 -0
  54. package/dist/composer/ComposerContextPickerDialog.js.map +1 -0
  55. package/dist/composer/ComposerContextPickerDialog.spec.d.ts +2 -0
  56. package/dist/composer/ComposerContextPickerDialog.spec.d.ts.map +1 -0
  57. package/dist/composer/ComposerContextPickerDialog.spec.js +329 -0
  58. package/dist/composer/ComposerContextPickerDialog.spec.js.map +1 -0
  59. package/dist/composer/ComposerContextSearchInput.d.ts +7 -0
  60. package/dist/composer/ComposerContextSearchInput.d.ts.map +1 -0
  61. package/dist/composer/ComposerContextSearchInput.js +18 -0
  62. package/dist/composer/ComposerContextSearchInput.js.map +1 -0
  63. package/dist/composer/PromptComposer.context.spec.d.ts +2 -0
  64. package/dist/composer/PromptComposer.context.spec.d.ts.map +1 -0
  65. package/dist/composer/PromptComposer.context.spec.js +383 -0
  66. package/dist/composer/PromptComposer.context.spec.js.map +1 -0
  67. package/dist/composer/PromptComposer.d.ts +19 -5
  68. package/dist/composer/PromptComposer.d.ts.map +1 -1
  69. package/dist/composer/PromptComposer.js +30 -25
  70. package/dist/composer/PromptComposer.js.map +1 -1
  71. package/dist/composer/PromptComposer.spec.js +24 -1
  72. package/dist/composer/PromptComposer.spec.js.map +1 -1
  73. package/dist/composer/TiptapComposer.d.ts +13 -2
  74. package/dist/composer/TiptapComposer.d.ts.map +1 -1
  75. package/dist/composer/TiptapComposer.js +89 -32
  76. package/dist/composer/TiptapComposer.js.map +1 -1
  77. package/dist/composer/context-items.d.ts +12 -0
  78. package/dist/composer/context-items.d.ts.map +1 -0
  79. package/dist/composer/context-items.js +46 -0
  80. package/dist/composer/context-items.js.map +1 -0
  81. package/dist/composer/context-items.spec.d.ts +2 -0
  82. package/dist/composer/context-items.spec.d.ts.map +1 -0
  83. package/dist/composer/context-items.spec.js +43 -0
  84. package/dist/composer/context-items.spec.js.map +1 -0
  85. package/dist/composer/index.d.ts +4 -1
  86. package/dist/composer/index.d.ts.map +1 -1
  87. package/dist/composer/index.js +3 -1
  88. package/dist/composer/index.js.map +1 -1
  89. package/dist/composer/runtime-adapters.d.ts +6 -1
  90. package/dist/composer/runtime-adapters.d.ts.map +1 -1
  91. package/dist/composer/runtime-adapters.js.map +1 -1
  92. package/dist/composer/useComposerContextPicker.d.ts +111 -0
  93. package/dist/composer/useComposerContextPicker.d.ts.map +1 -0
  94. package/dist/composer/useComposerContextPicker.js +299 -0
  95. package/dist/composer/useComposerContextPicker.js.map +1 -0
  96. package/dist/design-system/default-adapter.d.ts.map +1 -1
  97. package/dist/design-system/default-adapter.js +13 -7
  98. package/dist/design-system/default-adapter.js.map +1 -1
  99. package/dist/design-system/default-adapter.spec.js +13 -0
  100. package/dist/design-system/default-adapter.spec.js.map +1 -1
  101. package/dist/design-system/types.d.ts +4 -1
  102. package/dist/design-system/types.d.ts.map +1 -1
  103. package/dist/styles.css +1 -0
  104. package/dist/ui/command.d.ts +8 -2
  105. package/dist/ui/command.d.ts.map +1 -1
  106. package/dist/ui/command.js +9 -2
  107. package/dist/ui/command.js.map +1 -1
  108. package/dist/ui/command.spec.js +20 -1
  109. package/dist/ui/command.spec.js.map +1 -1
  110. package/package.json +2 -1
  111. package/src/agentkit.ts +26 -0
  112. package/src/app-shell/PromptHome.spec.tsx +404 -0
  113. package/src/app-shell/PromptHome.tsx +111 -0
  114. package/src/app-shell/TemplateLibraryGrid.tsx +212 -0
  115. package/src/app-shell/TemplatePreviewDialog.spec.tsx +473 -0
  116. package/src/app-shell/TemplatePreviewDialog.tsx +224 -0
  117. package/src/app-shell/index.ts +3 -0
  118. package/src/app-shell/prompt-home.css +159 -0
  119. package/src/composer/ComposerContextMenu.spec.tsx +343 -0
  120. package/src/composer/ComposerContextMenu.tsx +610 -0
  121. package/src/composer/ComposerContextMenuSearch.tsx +69 -0
  122. package/src/composer/ComposerContextPicker.spec.tsx +520 -0
  123. package/src/composer/ComposerContextPicker.tsx +280 -0
  124. package/src/composer/ComposerContextPickerDialog.spec.tsx +405 -0
  125. package/src/composer/ComposerContextPickerDialog.tsx +274 -0
  126. package/src/composer/ComposerContextSearchInput.tsx +48 -0
  127. package/src/composer/PromptComposer.context.spec.tsx +577 -0
  128. package/src/composer/PromptComposer.spec.ts +24 -1
  129. package/src/composer/PromptComposer.tsx +60 -26
  130. package/src/composer/TiptapComposer.tsx +198 -48
  131. package/src/composer/context-items.spec.ts +68 -0
  132. package/src/composer/context-items.ts +72 -0
  133. package/src/composer/index.ts +27 -0
  134. package/src/composer/runtime-adapters.tsx +6 -1
  135. package/src/composer/useComposerContextPicker.ts +441 -0
  136. package/src/design-system/default-adapter.spec.tsx +23 -0
  137. package/src/design-system/default-adapter.tsx +52 -23
  138. package/src/design-system/types.ts +4 -1
  139. package/src/styles.css +1 -0
  140. package/src/ui/command.spec.tsx +33 -1
  141. package/src/ui/command.tsx +52 -9
@@ -0,0 +1,212 @@
1
+ import {
2
+ ActionButton,
3
+ Skeleton,
4
+ Surface,
5
+ } from "@agent-native/toolkit/design-system";
6
+ import { useId, useRef, type ReactElement, type ReactNode } from "react";
7
+
8
+ export interface TemplateLibraryItem {
9
+ id: string;
10
+ title: string;
11
+ description?: string | null;
12
+ disabled?: boolean;
13
+ }
14
+
15
+ type TemplateLibraryActivation<T extends TemplateLibraryItem> =
16
+ | {
17
+ onSelect: (item: T, element: HTMLElement) => void;
18
+ renderLink?: never;
19
+ }
20
+ | {
21
+ renderLink: (item: T, children: ReactNode) => ReactElement;
22
+ onSelect?: never;
23
+ };
24
+
25
+ export type TemplateLibraryCardProps<T extends TemplateLibraryItem> = {
26
+ item: T;
27
+ preview: ReactNode;
28
+ actions?: ReactNode;
29
+ metadata?: ReactNode;
30
+ actionsVisible?: boolean;
31
+ selected?: boolean;
32
+ pending?: boolean;
33
+ disabled?: boolean;
34
+ } & TemplateLibraryActivation<T>;
35
+
36
+ export function TemplateLibraryCard<T extends TemplateLibraryItem>({
37
+ item,
38
+ preview,
39
+ actions,
40
+ metadata,
41
+ actionsVisible = false,
42
+ selected = false,
43
+ pending = false,
44
+ disabled = false,
45
+ onSelect,
46
+ renderLink,
47
+ }: TemplateLibraryCardProps<T>) {
48
+ const titleId = useId();
49
+ const container = useRef<HTMLDivElement>(null);
50
+ const unavailable = disabled || item.disabled || pending;
51
+ const card = (
52
+ <Surface
53
+ padding="none"
54
+ elevation="none"
55
+ interactive={Boolean(onSelect) && !unavailable}
56
+ aria-labelledby={titleId}
57
+ aria-pressed={onSelect && !unavailable ? selected : undefined}
58
+ onPress={
59
+ onSelect && !unavailable
60
+ ? (event) => {
61
+ const element = event?.currentTarget ?? container.current;
62
+ if (element) onSelect(item, element);
63
+ }
64
+ : undefined
65
+ }
66
+ >
67
+ <Surface padding="none">
68
+ <div className="agent-template-library-preview">{preview}</div>
69
+ </Surface>
70
+ <div className="agent-template-library-caption grid content-start gap-1 pt-3">
71
+ <h3 id={titleId} className="truncate text-sm font-medium">
72
+ {item.title}
73
+ </h3>
74
+ {item.description ? (
75
+ <p className="line-clamp-2 text-sm text-muted-foreground">
76
+ {item.description}
77
+ </p>
78
+ ) : null}
79
+ {metadata ? (
80
+ <div className="min-w-0 text-xs text-muted-foreground">
81
+ {metadata}
82
+ </div>
83
+ ) : null}
84
+ </div>
85
+ </Surface>
86
+ );
87
+ return (
88
+ <div
89
+ ref={container}
90
+ className="agent-template-library-card min-w-0"
91
+ data-selected={selected || undefined}
92
+ aria-busy={pending || undefined}
93
+ >
94
+ <div
95
+ className="agent-template-library-primary"
96
+ role={unavailable ? (renderLink ? "link" : "button") : undefined}
97
+ aria-pressed={unavailable && onSelect ? selected : undefined}
98
+ aria-disabled={unavailable || undefined}
99
+ >
100
+ {renderLink && !unavailable ? renderLink(item, card) : card}
101
+ </div>
102
+ {actions ? (
103
+ <div
104
+ className={`agent-template-library-actions flex items-center gap-2 pt-3${
105
+ actionsVisible ? " !opacity-100" : ""
106
+ }`}
107
+ >
108
+ {actions}
109
+ </div>
110
+ ) : null}
111
+ </div>
112
+ );
113
+ }
114
+
115
+ export type TemplateLibraryGridProps<T extends TemplateLibraryItem> = {
116
+ items: readonly T[];
117
+ renderPreview: (item: T) => ReactNode;
118
+ renderActions?: (item: T) => ReactNode;
119
+ renderMetadata?: (item: T) => ReactNode;
120
+ actionsVisible?: (item: T) => boolean;
121
+ isSelected?: (item: T) => boolean;
122
+ selectedId?: string | null;
123
+ pendingId?: string | null;
124
+ disabled?: boolean;
125
+ loading?: boolean;
126
+ error?: string | null;
127
+ onRetry?: () => void;
128
+ labels: { loading: string; empty: string; retry: string };
129
+ empty?: ReactNode;
130
+ } & TemplateLibraryActivation<T>;
131
+
132
+ export function TemplateLibraryGrid<T extends TemplateLibraryItem>({
133
+ items,
134
+ renderPreview,
135
+ renderActions,
136
+ renderMetadata,
137
+ actionsVisible,
138
+ isSelected,
139
+ selectedId,
140
+ pendingId,
141
+ disabled,
142
+ loading = false,
143
+ error,
144
+ onRetry,
145
+ labels,
146
+ empty,
147
+ onSelect,
148
+ renderLink,
149
+ }: TemplateLibraryGridProps<T>) {
150
+ return (
151
+ <div className="grid gap-4" aria-busy={loading || undefined}>
152
+ {error ? (
153
+ <div className="flex flex-wrap items-center gap-3" role="alert">
154
+ <span className="text-sm text-destructive">{error}</span>
155
+ {onRetry ? (
156
+ <ActionButton
157
+ emphasis="outline"
158
+ disabled={loading}
159
+ onPress={onRetry}
160
+ >
161
+ {labels.retry}
162
+ </ActionButton>
163
+ ) : null}
164
+ </div>
165
+ ) : null}
166
+ {items.length ? (
167
+ <div className="agent-template-library-grid">
168
+ {items.map((item) => (
169
+ <TemplateLibraryCard
170
+ key={item.id}
171
+ item={item}
172
+ preview={renderPreview(item)}
173
+ actions={renderActions?.(item)}
174
+ metadata={renderMetadata?.(item)}
175
+ actionsVisible={actionsVisible?.(item)}
176
+ selected={isSelected?.(item) ?? item.id === selectedId}
177
+ pending={item.id === pendingId}
178
+ disabled={disabled}
179
+ {...(renderLink ? { renderLink } : { onSelect: onSelect! })}
180
+ />
181
+ ))}
182
+ </div>
183
+ ) : error ? null : loading ? (
184
+ <div
185
+ className="agent-template-library-grid"
186
+ role="status"
187
+ aria-label={labels.loading}
188
+ >
189
+ {Array.from({ length: 4 }, (_, index) => (
190
+ <div key={index}>
191
+ <Surface padding="none">
192
+ <div className="agent-template-library-preview">
193
+ <Skeleton shape="rectangle" width="100%" height="100%" />
194
+ </div>
195
+ </Surface>
196
+ <div className="pt-3">
197
+ <Skeleton width="70%" height="1.25rem" />
198
+ </div>
199
+ </div>
200
+ ))}
201
+ </div>
202
+ ) : (
203
+ <div
204
+ role="status"
205
+ className="py-8 text-center text-sm text-muted-foreground"
206
+ >
207
+ {empty ?? labels.empty}
208
+ </div>
209
+ )}
210
+ </div>
211
+ );
212
+ }
@@ -0,0 +1,473 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import {
4
+ DesignSystemContext,
5
+ Dialog,
6
+ } from "@agent-native/toolkit/design-system";
7
+ import { act, useRef, useState, type ReactNode } from "react";
8
+ import { createRoot, type Root } from "react-dom/client";
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
10
+
11
+ import {
12
+ DropdownMenu,
13
+ DropdownMenuContent,
14
+ DropdownMenuGroup,
15
+ DropdownMenuItem,
16
+ DropdownMenuTrigger,
17
+ } from "../ui/dropdown-menu.js";
18
+ import { TemplateLibraryCard } from "./TemplateLibraryGrid.js";
19
+ import { TemplatePreviewDialog } from "./TemplatePreviewDialog.js";
20
+
21
+ const labels = {
22
+ close: "Close preview",
23
+ useTemplate: "Use template",
24
+ loading: "Loading preview",
25
+ empty: "No preview available",
26
+ retry: "Retry",
27
+ thumbnails: "Slides",
28
+ };
29
+ const thumbnails = [
30
+ { id: "one", title: "Overview", preview: <img alt="" src="/one.png" /> },
31
+ { id: "two", title: "Unavailable", preview: null, disabled: true },
32
+ { id: "three", title: "Details", preview: <img alt="" src="/three.png" /> },
33
+ ];
34
+ const props = { open: true, onOpenChange: vi.fn(), title: "Pitch", labels };
35
+
36
+ describe("template preview dialog", () => {
37
+ let container: HTMLDivElement;
38
+ let root: Root;
39
+ beforeEach(() => {
40
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
41
+ container = document.createElement("div");
42
+ document.body.appendChild(container);
43
+ root = createRoot(container);
44
+ });
45
+ afterEach(() => {
46
+ act(() => root.unmount());
47
+ container.remove();
48
+ vi.restoreAllMocks();
49
+ vi.unstubAllGlobals();
50
+ });
51
+ const render = async (node: ReactNode) => {
52
+ await act(async () => {
53
+ root.render(node);
54
+ await new Promise((resolve) => setTimeout(resolve, 10));
55
+ });
56
+ };
57
+ const button = (label: string) =>
58
+ [...document.querySelectorAll<HTMLElement>("button, [role=button]")].find(
59
+ (element) =>
60
+ element.getAttribute("aria-label") === label ||
61
+ element.textContent === label,
62
+ )!;
63
+ const key = (element: HTMLElement, value: string) =>
64
+ act(() =>
65
+ element.dispatchEvent(
66
+ new KeyboardEvent("keydown", {
67
+ key: value,
68
+ bubbles: true,
69
+ cancelable: true,
70
+ }),
71
+ ),
72
+ );
73
+
74
+ it("opens a title-only semantic dialog without a dangling description", async () => {
75
+ const warning = vi.spyOn(console, "warn");
76
+ await render(
77
+ <TemplatePreviewDialog {...props} open={false}>
78
+ <div>Real preview</div>
79
+ </TemplatePreviewDialog>,
80
+ );
81
+ expect(document.querySelector('[role="dialog"]')).toBeNull();
82
+ await render(
83
+ <TemplatePreviewDialog {...props}>
84
+ <div>Real preview</div>
85
+ </TemplatePreviewDialog>,
86
+ );
87
+ const dialog = document.querySelector('[role="dialog"]')!;
88
+ expect(dialog.getAttribute("data-size")).toBe("viewport");
89
+ expect(
90
+ document.getElementById(dialog.getAttribute("aria-labelledby")!)
91
+ ?.textContent,
92
+ ).toBe("Pitch");
93
+ expect(dialog.hasAttribute("aria-describedby")).toBe(false);
94
+ expect(dialog.textContent).toContain("Real preview");
95
+ expect(dialog.querySelector("nav")).toBeNull();
96
+ expect(
97
+ [...dialog.querySelectorAll("button")].map(
98
+ (element) => element.textContent,
99
+ ),
100
+ ).toEqual([labels.close]);
101
+ expect(warning).not.toHaveBeenCalled();
102
+ });
103
+
104
+ it("replaces the close control with the primary use action without disabling click-away dismissal", async () => {
105
+ const useTemplate = vi.fn();
106
+ await render(
107
+ <TemplatePreviewDialog {...props} onUseTemplate={useTemplate}>
108
+ <div>Real preview</div>
109
+ </TemplatePreviewDialog>,
110
+ );
111
+ expect(button(labels.useTemplate)).toBeTruthy();
112
+ expect(
113
+ document.querySelector(`button[aria-label="${labels.close}"]`),
114
+ ).toBeNull();
115
+ act(() => button(labels.useTemplate).click());
116
+ expect(useTemplate).toHaveBeenCalledOnce();
117
+ });
118
+
119
+ it("preserves the semantic description association when supplied", async () => {
120
+ await render(
121
+ <Dialog
122
+ open
123
+ onOpenChange={vi.fn()}
124
+ title="Preview"
125
+ description="Saved content"
126
+ >
127
+ <div>Content</div>
128
+ </Dialog>,
129
+ );
130
+ const dialog = document.querySelector('[role="dialog"]')!;
131
+ expect(
132
+ document.getElementById(dialog.getAttribute("aria-describedby")!)
133
+ ?.textContent,
134
+ ).toBe("Saved content");
135
+ });
136
+
137
+ it("keeps direct card activation separate from the caption menu preview handoff", async () => {
138
+ const copy = vi.fn();
139
+ function Harness() {
140
+ const [open, setOpen] = useState(false);
141
+ const pending = useRef(false);
142
+ const trigger = useRef<HTMLButtonElement>(null);
143
+ return (
144
+ <>
145
+ <TemplateLibraryCard
146
+ item={{
147
+ id: "one",
148
+ title: "A long stored template title for a narrow card",
149
+ description: "A stored description beneath the title",
150
+ }}
151
+ preview={<img alt="Template thumbnail" src="/one.png" />}
152
+ onSelect={copy}
153
+ actions={
154
+ <DropdownMenu>
155
+ <DropdownMenuTrigger asChild>
156
+ <button ref={trigger} aria-label="Template options">
157
+ ...
158
+ </button>
159
+ </DropdownMenuTrigger>
160
+ <DropdownMenuContent
161
+ onCloseAutoFocus={(event) => {
162
+ if (!pending.current) return;
163
+ event.preventDefault();
164
+ pending.current = false;
165
+ setOpen(true);
166
+ }}
167
+ >
168
+ <DropdownMenuGroup>
169
+ <DropdownMenuItem
170
+ onSelect={() => {
171
+ pending.current = true;
172
+ }}
173
+ >
174
+ Preview
175
+ </DropdownMenuItem>
176
+ </DropdownMenuGroup>
177
+ </DropdownMenuContent>
178
+ </DropdownMenu>
179
+ }
180
+ />
181
+ <TemplatePreviewDialog
182
+ {...props}
183
+ open={open}
184
+ onOpenChange={setOpen}
185
+ restoreFocusRef={trigger}
186
+ >
187
+ <div>Large rendered content</div>
188
+ </TemplatePreviewDialog>
189
+ </>
190
+ );
191
+ }
192
+ await render(<Harness />);
193
+ act(() => {
194
+ container.querySelector("img")!.click();
195
+ container.querySelector("h3")!.click();
196
+ });
197
+ expect(copy).toHaveBeenCalledTimes(2);
198
+ expect(document.querySelector('[role="dialog"]')).toBeNull();
199
+ expect(container.querySelector("p")?.textContent).toBe(
200
+ "A stored description beneath the title",
201
+ );
202
+ const trigger = button("Template options");
203
+ expect(trigger.closest('[role="button"]')).toBeNull();
204
+ await act(async () => {
205
+ trigger.focus();
206
+ trigger.dispatchEvent(
207
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
208
+ );
209
+ });
210
+ await act(async () =>
211
+ document.querySelector<HTMLElement>('[role="menuitem"]')!.click(),
212
+ );
213
+ await act(async () => {
214
+ await new Promise((resolve) => setTimeout(resolve, 10));
215
+ });
216
+ expect(document.querySelector('[role="menu"]')).toBeNull();
217
+ expect(document.querySelector('[role="dialog"]')?.textContent).toContain(
218
+ "Large rendered content",
219
+ );
220
+ expect(copy).toHaveBeenCalledTimes(2);
221
+ await act(async () => button(labels.close).click());
222
+ await act(async () => {
223
+ await new Promise((resolve) => setTimeout(resolve, 10));
224
+ });
225
+ expect(document.activeElement).toBe(trigger);
226
+ });
227
+
228
+ it.each(["Escape", "close"])(
229
+ "restores focus and retains the draft after %s dismissal",
230
+ async (dismissal) => {
231
+ function Harness() {
232
+ const [open, setOpen] = useState(false);
233
+ const trigger = useRef<HTMLButtonElement>(null);
234
+ const initial = useRef<HTMLButtonElement>(null);
235
+ return (
236
+ <>
237
+ <textarea defaultValue="Unsaved prompt" />
238
+ <button ref={trigger} onClick={() => setOpen(true)}>
239
+ Preview
240
+ </button>
241
+ <TemplatePreviewDialog
242
+ {...props}
243
+ open={open}
244
+ onOpenChange={setOpen}
245
+ restoreFocusRef={trigger}
246
+ initialFocusRef={initial}
247
+ >
248
+ <button ref={initial}>Inspect content</button>
249
+ </TemplatePreviewDialog>
250
+ </>
251
+ );
252
+ }
253
+ await render(<Harness />);
254
+ const draft = container.querySelector("textarea")!;
255
+ const trigger = button("Preview");
256
+ await act(async () => {
257
+ trigger.focus();
258
+ trigger.click();
259
+ });
260
+ expect(document.activeElement).toBe(button("Inspect content"));
261
+ await act(async () => {
262
+ if (dismissal === "Escape")
263
+ document.activeElement!.dispatchEvent(
264
+ new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
265
+ );
266
+ else button(labels.close).click();
267
+ });
268
+ await act(async () => {
269
+ await new Promise((resolve) => setTimeout(resolve, 10));
270
+ });
271
+ expect(document.querySelector('[role="dialog"]')).toBeNull();
272
+ expect(document.activeElement).toBe(trigger);
273
+ expect(container.querySelector("textarea")).toBe(draft);
274
+ expect(draft.value).toBe("Unsaved prompt");
275
+ },
276
+ );
277
+
278
+ it("selects rail items with arrows, Home, End, and click while skipping disabled thumbnails", async () => {
279
+ function Harness() {
280
+ const [selectedId, setSelectedId] = useState("one");
281
+ return (
282
+ <TemplatePreviewDialog
283
+ {...props}
284
+ thumbnails={thumbnails}
285
+ selectedId={selectedId}
286
+ onSelectedIdChange={setSelectedId}
287
+ >
288
+ <div data-current>{selectedId}</div>
289
+ </TemplatePreviewDialog>
290
+ );
291
+ }
292
+ await render(<Harness />);
293
+ const first = button("Overview");
294
+ const last = button("Details");
295
+ const disabled = button("Unavailable");
296
+ expect(document.querySelector("nav")?.getAttribute("aria-label")).toBe(
297
+ labels.thumbnails,
298
+ );
299
+ expect(disabled.getAttribute("aria-disabled")).toBe("true");
300
+ act(() => {
301
+ first.focus();
302
+ disabled.click();
303
+ });
304
+ expect(first.getAttribute("aria-pressed")).toBe("true");
305
+ for (const direction of ["ArrowRight", "ArrowDown", "End"]) {
306
+ key(first, direction);
307
+ expect(document.activeElement).toBe(last);
308
+ expect(last.getAttribute("aria-pressed")).toBe("true");
309
+ key(last, "Home");
310
+ expect(document.activeElement).toBe(first);
311
+ }
312
+ key(first, "ArrowLeft");
313
+ expect(document.activeElement).toBe(last);
314
+ key(last, "ArrowUp");
315
+ expect(document.activeElement).toBe(first);
316
+ act(() => last.click());
317
+ expect(document.querySelector("[data-current]")?.textContent).toBe("three");
318
+ });
319
+
320
+ it("keeps rail previews inert and does not intercept keys in main content", async () => {
321
+ const select = vi.fn();
322
+ await render(
323
+ <TemplatePreviewDialog
324
+ {...props}
325
+ thumbnails={thumbnails}
326
+ selectedId="one"
327
+ onSelectedIdChange={select}
328
+ >
329
+ <input aria-label="Preview field" />
330
+ </TemplatePreviewDialog>,
331
+ );
332
+ expect(
333
+ document.querySelector("nav img")?.closest("[inert]"),
334
+ ).not.toBeNull();
335
+ const input = document.querySelector("input")!;
336
+ act(() => input.focus());
337
+ key(input, "ArrowRight");
338
+ expect(document.activeElement).toBe(input);
339
+ expect(select).not.toHaveBeenCalled();
340
+ });
341
+
342
+ it("renders exactly one readiness state and hides unready content and rail", async () => {
343
+ const base = {
344
+ ...props,
345
+ thumbnails,
346
+ selectedId: "one",
347
+ onSelectedIdChange: vi.fn(),
348
+ };
349
+ await render(
350
+ <TemplatePreviewDialog {...base} loading empty>
351
+ <div>Stale content</div>
352
+ </TemplatePreviewDialog>,
353
+ );
354
+ expect(document.querySelectorAll('[role="status"]')).toHaveLength(1);
355
+ expect(
356
+ document.querySelector('[role="status"]')?.getAttribute("aria-label"),
357
+ ).toBe(labels.loading);
358
+ expect(document.body.textContent).not.toContain(labels.empty);
359
+ expect(document.body.textContent).not.toContain("Stale content");
360
+ expect(document.querySelector("nav")).toBeNull();
361
+ await render(
362
+ <TemplatePreviewDialog
363
+ {...base}
364
+ loading
365
+ empty
366
+ error="Preview unavailable"
367
+ >
368
+ <div>Stale content</div>
369
+ </TemplatePreviewDialog>,
370
+ );
371
+ expect(document.querySelectorAll('[role="alert"]')).toHaveLength(1);
372
+ expect(document.querySelector('[role="status"]')).toBeNull();
373
+ await render(
374
+ <TemplatePreviewDialog {...base} empty>
375
+ <div>Stale content</div>
376
+ </TemplatePreviewDialog>,
377
+ );
378
+ expect(document.querySelector('[role="status"]')?.textContent).toBe(
379
+ labels.empty,
380
+ );
381
+ expect(document.querySelector('[role="alert"]')).toBeNull();
382
+ await render(
383
+ <TemplatePreviewDialog {...base}>
384
+ <div>Ready content</div>
385
+ </TemplatePreviewDialog>,
386
+ );
387
+ expect(document.querySelector('[role="status"]')).toBeNull();
388
+ expect(document.body.textContent).toContain("Ready content");
389
+ expect(document.querySelector("nav")).not.toBeNull();
390
+ });
391
+
392
+ it("exposes retry without allowing duplicate pending requests", async () => {
393
+ const retry = vi.fn();
394
+ await render(
395
+ <TemplatePreviewDialog {...props} error="Read failed" onRetry={retry}>
396
+ <div>Content</div>
397
+ </TemplatePreviewDialog>,
398
+ );
399
+ act(() => button(labels.retry).click());
400
+ expect(retry).toHaveBeenCalledOnce();
401
+ await render(
402
+ <TemplatePreviewDialog
403
+ {...props}
404
+ error="Read failed"
405
+ onRetry={retry}
406
+ loading
407
+ >
408
+ <div>Content</div>
409
+ </TemplatePreviewDialog>,
410
+ );
411
+ expect((button(labels.retry) as HTMLButtonElement).disabled).toBe(true);
412
+ act(() => button(labels.retry).click());
413
+ expect(retry).toHaveBeenCalledOnce();
414
+ });
415
+
416
+ it("honors custom semantic Dialog and native-button Surface implementations", async () => {
417
+ const dialog = vi.fn();
418
+ const select = vi.fn();
419
+ await render(
420
+ <DesignSystemContext.Provider
421
+ value={{
422
+ definition: {
423
+ components: {
424
+ Dialog: (value) => {
425
+ dialog(value);
426
+ return value.open ? (
427
+ <section aria-label="Company dialog">
428
+ {value.children}
429
+ </section>
430
+ ) : null;
431
+ },
432
+ Surface: ({ children, interactive, onPress, ...aria }) =>
433
+ interactive ? (
434
+ <button
435
+ aria-label={aria["aria-label"]}
436
+ aria-pressed={aria["aria-pressed"]}
437
+ onClick={() => onPress?.()}
438
+ >
439
+ {children}
440
+ </button>
441
+ ) : (
442
+ <div>{children}</div>
443
+ ),
444
+ },
445
+ },
446
+ }}
447
+ >
448
+ <TemplatePreviewDialog
449
+ {...props}
450
+ size="large"
451
+ thumbnails={thumbnails}
452
+ selectedId="one"
453
+ onSelectedIdChange={select}
454
+ >
455
+ <div>Rendered design</div>
456
+ </TemplatePreviewDialog>
457
+ </DesignSystemContext.Provider>,
458
+ );
459
+ expect(dialog).toHaveBeenCalledWith(
460
+ expect.objectContaining({
461
+ size: "large",
462
+ title: "Pitch",
463
+ closeLabel: labels.close,
464
+ }),
465
+ );
466
+ expect(dialog.mock.calls[0]![0].footer).toBeUndefined();
467
+ act(() => button("Overview").focus());
468
+ key(button("Overview"), "ArrowRight");
469
+ expect(document.activeElement).toBe(button("Details"));
470
+ expect(select).toHaveBeenCalledWith("three");
471
+ expect(document.body.textContent).toContain("Rendered design");
472
+ });
473
+ });