@assistant-ui/react 0.14.0 → 0.14.3

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 (78) hide show
  1. package/README.md +58 -42
  2. package/dist/client/ExternalThread.d.ts +7 -0
  3. package/dist/client/ExternalThread.d.ts.map +1 -1
  4. package/dist/client/ExternalThread.js +24 -16
  5. package/dist/client/ExternalThread.js.map +1 -1
  6. package/dist/index.d.ts +4 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +2 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/legacy-runtime/cloud/auiV0.d.ts +10 -1
  11. package/dist/legacy-runtime/cloud/auiV0.d.ts.map +1 -1
  12. package/dist/legacy-runtime/cloud/auiV0.js +21 -3
  13. package/dist/legacy-runtime/cloud/auiV0.js.map +1 -1
  14. package/dist/mcp-apps/McpAppRenderer.d.ts +33 -0
  15. package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -0
  16. package/dist/mcp-apps/McpAppRenderer.js +115 -0
  17. package/dist/mcp-apps/McpAppRenderer.js.map +1 -0
  18. package/dist/mcp-apps/McpAppsRemoteHost.d.ts +3 -0
  19. package/dist/mcp-apps/McpAppsRemoteHost.d.ts.map +1 -0
  20. package/dist/mcp-apps/McpAppsRemoteHost.js +27 -0
  21. package/dist/mcp-apps/McpAppsRemoteHost.js.map +1 -0
  22. package/dist/mcp-apps/app-frame.d.ts +3 -0
  23. package/dist/mcp-apps/app-frame.d.ts.map +1 -0
  24. package/dist/mcp-apps/app-frame.js +218 -0
  25. package/dist/mcp-apps/app-frame.js.map +1 -0
  26. package/dist/mcp-apps/bridge.d.ts +18 -0
  27. package/dist/mcp-apps/bridge.d.ts.map +1 -0
  28. package/dist/mcp-apps/bridge.js +290 -0
  29. package/dist/mcp-apps/bridge.js.map +1 -0
  30. package/dist/mcp-apps/index.d.ts +5 -0
  31. package/dist/mcp-apps/index.d.ts.map +1 -0
  32. package/dist/mcp-apps/index.js +4 -0
  33. package/dist/mcp-apps/index.js.map +1 -0
  34. package/dist/mcp-apps/types.d.ts +149 -0
  35. package/dist/mcp-apps/types.d.ts.map +1 -0
  36. package/dist/mcp-apps/types.js +3 -0
  37. package/dist/mcp-apps/types.js.map +1 -0
  38. package/dist/mcp-apps/utils.d.ts +5 -0
  39. package/dist/mcp-apps/utils.d.ts.map +1 -0
  40. package/dist/mcp-apps/utils.js +10 -0
  41. package/dist/mcp-apps/utils.js.map +1 -0
  42. package/dist/primitives/composer/ComposerInput.d.ts +6 -0
  43. package/dist/primitives/composer/ComposerInput.d.ts.map +1 -1
  44. package/dist/primitives/composer/ComposerInput.js +19 -2
  45. package/dist/primitives/composer/ComposerInput.js.map +1 -1
  46. package/dist/primitives/composer/trigger/TriggerPopover.d.ts.map +1 -1
  47. package/dist/primitives/composer/trigger/TriggerPopover.js +17 -1
  48. package/dist/primitives/composer/trigger/TriggerPopover.js.map +1 -1
  49. package/dist/primitives/composer/trigger/TriggerPopoverRootContext.d.ts +33 -0
  50. package/dist/primitives/composer/trigger/TriggerPopoverRootContext.d.ts.map +1 -1
  51. package/dist/primitives/composer/trigger/TriggerPopoverRootContext.js +80 -11
  52. package/dist/primitives/composer/trigger/TriggerPopoverRootContext.js.map +1 -1
  53. package/dist/primitives/composer/trigger/triggerKeyboardResource.d.ts.map +1 -1
  54. package/dist/primitives/composer/trigger/triggerKeyboardResource.js +2 -1
  55. package/dist/primitives/composer/trigger/triggerKeyboardResource.js.map +1 -1
  56. package/dist/primitives/messagePart/useMessagePartSource.d.ts +22 -3
  57. package/dist/primitives/messagePart/useMessagePartSource.d.ts.map +1 -1
  58. package/package.json +6 -5
  59. package/src/client/ExternalThread.ts +32 -17
  60. package/src/index.ts +25 -0
  61. package/src/legacy-runtime/cloud/auiV0.ts +37 -4
  62. package/src/mcp-apps/McpAppRenderer.tsx +221 -0
  63. package/src/mcp-apps/McpAppsRemoteHost.ts +52 -0
  64. package/src/mcp-apps/app-frame.tsx +303 -0
  65. package/src/mcp-apps/bridge.test.ts +391 -0
  66. package/src/mcp-apps/bridge.ts +435 -0
  67. package/src/mcp-apps/index.ts +17 -0
  68. package/src/mcp-apps/types.ts +163 -0
  69. package/src/mcp-apps/utils.ts +16 -0
  70. package/src/primitives/composer/ComposerInput.test.tsx +48 -0
  71. package/src/primitives/composer/ComposerInput.tsx +20 -2
  72. package/src/primitives/composer/trigger/TriggerPopover.tsx +21 -1
  73. package/src/primitives/composer/trigger/TriggerPopoverRootContext.test.tsx +152 -0
  74. package/src/primitives/composer/trigger/TriggerPopoverRootContext.tsx +134 -17
  75. package/src/primitives/composer/trigger/triggerKeyboardResource.test.ts +236 -0
  76. package/src/primitives/composer/trigger/triggerKeyboardResource.ts +2 -1
  77. package/src/tests/BaseComposerRuntimeCore.test.ts +4 -0
  78. package/src/tests/auiV0Encode.test.ts +55 -0
@@ -23,6 +23,7 @@ import { useOnScrollToBottom } from "../../utils/hooks/useOnScrollToBottom";
23
23
  import { useAuiState, useAui } from "@assistant-ui/store";
24
24
  import { flushResourcesSync } from "@assistant-ui/tap";
25
25
  import { useComposerInputPluginRegistryOptional } from "./ComposerInputPluginContext";
26
+ import { useTriggerPopoverActiveAriaOptional } from "./trigger/TriggerPopoverRootContext";
26
27
 
27
28
  export namespace ComposerPrimitiveInput {
28
29
  export type Element = HTMLTextAreaElement;
@@ -100,6 +101,12 @@ export namespace ComposerPrimitiveInput {
100
101
  * keyboard shortcuts, file paste support, and intelligent focus management.
101
102
  * It integrates with the composer context to manage message state and submission.
102
103
  *
104
+ * When rendered inside `Unstable_TriggerPopoverRoot` and a popover is open, the
105
+ * underlying `<textarea>` automatically receives `aria-controls`,
106
+ * `aria-expanded`, `aria-haspopup`, and `aria-activedescendant` for the
107
+ * combobox relationship. These computed attributes override user-provided
108
+ * values for those four ARIA props while the popover is open.
109
+ *
103
110
  * @example
104
111
  * ```tsx
105
112
  * // Ctrl/Cmd+Enter to submit (plain Enter inserts newline)
@@ -142,6 +149,7 @@ export const ComposerPrimitiveInput = forwardRef<
142
149
  ) => {
143
150
  const aui = useAui();
144
151
  const pluginRegistry = useComposerInputPluginRegistryOptional();
152
+ const activeAria = useTriggerPopoverActiveAriaOptional();
145
153
 
146
154
  const effectiveSubmitMode =
147
155
  submitMode ?? (submitOnEnter === false ? "none" : "enter");
@@ -197,13 +205,13 @@ export const ComposerPrimitiveInput = forwardRef<
197
205
  const threadState = aui.thread().getState();
198
206
  const hasQueue = threadState.capabilities.queue;
199
207
 
200
- // Steer hotkey: Cmd/Ctrl+Shift+Enter (respects submitMode="none" and isEmpty)
208
+ // Steer hotkey: Cmd/Ctrl+Shift+Enter (respects submitMode="none" and canSend)
201
209
  if (
202
210
  e.shiftKey &&
203
211
  (e.ctrlKey || e.metaKey) &&
204
212
  hasQueue &&
205
213
  effectiveSubmitMode !== "none" &&
206
- !aui.composer().getState().isEmpty
214
+ aui.composer().getState().canSend
207
215
  ) {
208
216
  e.preventDefault();
209
217
  aui.composer().send({ steer: true });
@@ -287,10 +295,20 @@ export const ComposerPrimitiveInput = forwardRef<
287
295
  return aui.on("threadListItem.switchedTo", focus);
288
296
  }, [unstable_focusOnThreadSwitched, focus, aui]);
289
297
 
298
+ const ariaComboboxProps = activeAria
299
+ ? {
300
+ "aria-controls": activeAria.popoverId,
301
+ "aria-expanded": true as const,
302
+ "aria-haspopup": "listbox" as const,
303
+ "aria-activedescendant": activeAria.highlightedItemId,
304
+ }
305
+ : {};
306
+
290
307
  const inputProps = {
291
308
  name: "input" as const,
292
309
  value,
293
310
  ...rest,
311
+ ...ariaComboboxProps,
294
312
  ref: ref as React.ForwardedRef<HTMLTextAreaElement>,
295
313
  disabled: isDisabled,
296
314
  onChange: composeEventHandlers(
@@ -23,7 +23,10 @@ import {
23
23
  type TriggerPopoverResourceOutput,
24
24
  } from "./TriggerPopoverResource";
25
25
  import type { TriggerBehavior } from "./triggerSelectionResource";
26
- import { useTriggerPopoverRootContext } from "./TriggerPopoverRootContext";
26
+ import {
27
+ useTriggerPopoverAriaPublish,
28
+ useTriggerPopoverRootContext,
29
+ } from "./TriggerPopoverRootContext";
27
30
 
28
31
  const TriggerPopoverScopeContext =
29
32
  createContext<TriggerPopoverResourceOutput | null>(null);
@@ -179,6 +182,23 @@ export const ComposerPrimitiveTriggerPopover = forwardRef<
179
182
 
180
183
  const open = behavior !== null && resource.open;
181
184
 
185
+ const aria = useTriggerPopoverAriaPublish();
186
+
187
+ useEffect(() => {
188
+ if (!open) return undefined;
189
+ return () => {
190
+ aria.setActiveAria(char, null);
191
+ };
192
+ }, [aria, char, open]);
193
+
194
+ useEffect(() => {
195
+ if (!open) return;
196
+ aria.setActiveAria(char, {
197
+ popoverId,
198
+ highlightedItemId: resource.highlightedItemId,
199
+ });
200
+ }, [aria, char, popoverId, open, resource.highlightedItemId]);
201
+
182
202
  return (
183
203
  <TriggerBehaviorRegistrationContext.Provider value={registration}>
184
204
  <TriggerPopoverScopeContext.Provider value={resource}>
@@ -0,0 +1,152 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+ import { act, type FC } from "react";
5
+ import { createRoot, type Root } from "react-dom/client";
6
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
7
+ import {
8
+ ComposerPrimitiveTriggerPopoverRoot,
9
+ type TriggerPopoverActiveAria,
10
+ useTriggerPopoverActiveAriaOptional,
11
+ useTriggerPopoverAriaPublish,
12
+ } from "./TriggerPopoverRootContext";
13
+
14
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
15
+
16
+ type PublishHandle = ReturnType<typeof useTriggerPopoverAriaPublish>;
17
+
18
+ describe("TriggerPopoverRootContext active ARIA", () => {
19
+ let container: HTMLDivElement;
20
+ let root: Root;
21
+
22
+ beforeEach(() => {
23
+ container = document.createElement("div");
24
+ document.body.appendChild(container);
25
+ root = createRoot(container);
26
+ });
27
+
28
+ afterEach(async () => {
29
+ await act(async () => {
30
+ root.unmount();
31
+ });
32
+ container.remove();
33
+ });
34
+
35
+ const renderWithRoot = async () => {
36
+ const publishRef = { current: null as PublishHandle | null };
37
+ const ariaRef = { current: null as TriggerPopoverActiveAria | null };
38
+
39
+ const Probe: FC = () => {
40
+ publishRef.current = useTriggerPopoverAriaPublish();
41
+ ariaRef.current = useTriggerPopoverActiveAriaOptional();
42
+ return null;
43
+ };
44
+
45
+ await act(async () => {
46
+ root.render(
47
+ <ComposerPrimitiveTriggerPopoverRoot>
48
+ <Probe />
49
+ </ComposerPrimitiveTriggerPopoverRoot>,
50
+ );
51
+ });
52
+
53
+ return {
54
+ publish: () => publishRef.current as PublishHandle,
55
+ aria: () => ariaRef.current,
56
+ };
57
+ };
58
+
59
+ it("returns null initially inside a root", async () => {
60
+ const { aria } = await renderWithRoot();
61
+ expect(aria()).toBeNull();
62
+ });
63
+
64
+ it("publishes a descriptor and surfaces it via the hook", async () => {
65
+ const { publish, aria } = await renderWithRoot();
66
+
67
+ await act(async () => {
68
+ publish().setActiveAria("@", {
69
+ popoverId: "popover-mention",
70
+ highlightedItemId: "popover-mention-option-a",
71
+ });
72
+ });
73
+
74
+ expect(aria()).toEqual({
75
+ popoverId: "popover-mention",
76
+ highlightedItemId: "popover-mention-option-a",
77
+ });
78
+ });
79
+
80
+ it("clears the descriptor when the owning char releases it", async () => {
81
+ const { publish, aria } = await renderWithRoot();
82
+
83
+ await act(async () => {
84
+ publish().setActiveAria("@", {
85
+ popoverId: "popover-mention",
86
+ highlightedItemId: undefined,
87
+ });
88
+ });
89
+ expect(aria()).not.toBeNull();
90
+
91
+ await act(async () => {
92
+ publish().setActiveAria("@", null);
93
+ });
94
+ expect(aria()).toBeNull();
95
+ });
96
+
97
+ it("ignores a clear call from a non-owning char", async () => {
98
+ const { publish, aria } = await renderWithRoot();
99
+
100
+ await act(async () => {
101
+ publish().setActiveAria("@", {
102
+ popoverId: "popover-mention",
103
+ highlightedItemId: undefined,
104
+ });
105
+ });
106
+
107
+ await act(async () => {
108
+ publish().setActiveAria("/", null);
109
+ });
110
+
111
+ expect(aria()).toEqual({
112
+ popoverId: "popover-mention",
113
+ highlightedItemId: undefined,
114
+ });
115
+ });
116
+
117
+ it("replaces the descriptor when a different char takes over", async () => {
118
+ const { publish, aria } = await renderWithRoot();
119
+
120
+ await act(async () => {
121
+ publish().setActiveAria("@", {
122
+ popoverId: "popover-mention",
123
+ highlightedItemId: "popover-mention-option-a",
124
+ });
125
+ });
126
+ await act(async () => {
127
+ publish().setActiveAria("/", {
128
+ popoverId: "popover-slash",
129
+ highlightedItemId: "popover-slash-option-x",
130
+ });
131
+ });
132
+
133
+ expect(aria()).toEqual({
134
+ popoverId: "popover-slash",
135
+ highlightedItemId: "popover-slash-option-x",
136
+ });
137
+ });
138
+
139
+ it("returns null when the consumer is rendered outside a root", async () => {
140
+ const ariaRef = { current: null as TriggerPopoverActiveAria | null };
141
+ const Solo: FC = () => {
142
+ ariaRef.current = useTriggerPopoverActiveAriaOptional();
143
+ return null;
144
+ };
145
+
146
+ await act(async () => {
147
+ root.render(<Solo />);
148
+ });
149
+
150
+ expect(ariaRef.current).toBeNull();
151
+ });
152
+ });
@@ -29,17 +29,43 @@ export type TriggerPopoverLifecycleListener = {
29
29
  removed(char: string): void;
30
30
  };
31
31
 
32
+ /**
33
+ * ARIA descriptor of the popover that is currently open. Consumed by the
34
+ * focused element (typically the composer textarea) so it can advertise the
35
+ * combobox relationship per the WAI-ARIA editable combobox pattern.
36
+ */
37
+ export type TriggerPopoverActiveAria = {
38
+ popoverId: string;
39
+ highlightedItemId: string | undefined;
40
+ };
41
+
32
42
  export type TriggerPopoverRootContextValue = {
33
43
  register(trigger: RegisteredTrigger): () => void;
34
44
  getTriggers(): ReadonlyMap<string, RegisteredTrigger>;
35
45
  subscribe(listener: () => void): () => void;
36
46
  /** Subscribe to per-trigger add/remove events. */
37
47
  subscribeLifecycle(listener: TriggerPopoverLifecycleListener): () => void;
48
+ /** ARIA descriptor of the open popover, or null if none is open. */
49
+ getActiveAria(): TriggerPopoverActiveAria | null;
50
+ /** Subscribe to changes in the active ARIA descriptor. */
51
+ subscribeAria(listener: () => void): () => void;
52
+ };
53
+
54
+ /**
55
+ * Write-side of the ARIA descriptor, scoped to `TriggerPopover` children of a
56
+ * `TriggerPopoverRoot`. Intentionally not exposed on the public root context
57
+ * value: external consumers can read ARIA state but cannot publish or clear it.
58
+ */
59
+ type TriggerPopoverAriaPublish = {
60
+ setActiveAria(char: string, aria: TriggerPopoverActiveAria | null): void;
38
61
  };
39
62
 
40
63
  const TriggerPopoverRootContext =
41
64
  createContext<TriggerPopoverRootContextValue | null>(null);
42
65
 
66
+ const TriggerPopoverAriaPublishContext =
67
+ createContext<TriggerPopoverAriaPublish | null>(null);
68
+
43
69
  export const useTriggerPopoverRootContext = () => {
44
70
  const ctx = useContext(TriggerPopoverRootContext);
45
71
  if (!ctx)
@@ -52,6 +78,19 @@ export const useTriggerPopoverRootContext = () => {
52
78
  export const useTriggerPopoverRootContextOptional = () =>
53
79
  useContext(TriggerPopoverRootContext);
54
80
 
81
+ /**
82
+ * Internal hook used by `TriggerPopover` children to publish their open and
83
+ * highlight state. Not exported from the trigger module.
84
+ */
85
+ export const useTriggerPopoverAriaPublish = (): TriggerPopoverAriaPublish => {
86
+ const ctx = useContext(TriggerPopoverAriaPublishContext);
87
+ if (!ctx)
88
+ throw new Error(
89
+ "useTriggerPopoverAriaPublish must be used within ComposerPrimitive.TriggerPopoverRoot",
90
+ );
91
+ return ctx;
92
+ };
93
+
55
94
  /**
56
95
  * Live map of registered triggers, re-rendering on change. Prefer
57
96
  * `subscribeLifecycle` for incremental add/remove handling.
@@ -75,24 +114,57 @@ export const useTriggerPopoverTriggersOptional = () => {
75
114
  );
76
115
  };
77
116
 
117
+ const getNullAria = () => null;
118
+
119
+ /**
120
+ * Returns the ARIA descriptor of the currently open trigger popover, or
121
+ * `null` if none is open or the consumer is rendered outside a
122
+ * `TriggerPopoverRoot`.
123
+ */
124
+ export const useTriggerPopoverActiveAriaOptional =
125
+ (): TriggerPopoverActiveAria | null => {
126
+ const ctx = useTriggerPopoverRootContextOptional();
127
+ return useSyncExternalStore(
128
+ ctx ? ctx.subscribeAria : noopSubscribe,
129
+ ctx ? ctx.getActiveAria : getNullAria,
130
+ ctx ? ctx.getActiveAria : getNullAria,
131
+ );
132
+ };
133
+
78
134
  export namespace ComposerPrimitiveTriggerPopoverRoot {
79
135
  export type Props = {
80
136
  children: ReactNode;
81
137
  };
82
138
  }
83
139
 
140
+ /**
141
+ * Local helper for the simple "notify-all listeners" subscribable pattern.
142
+ * Used twice in this file (trigger registry, active ARIA); kept inline to
143
+ * avoid pulling a single-use abstraction into the wider tree.
144
+ */
145
+ function useSimpleSubscribable() {
146
+ const listenersRef = useRef<Set<() => void>>(new Set());
147
+ const notify = useCallback(() => {
148
+ for (const listener of listenersRef.current) listener();
149
+ }, []);
150
+ const subscribe = useCallback((listener: () => void) => {
151
+ listenersRef.current.add(listener);
152
+ return () => {
153
+ listenersRef.current.delete(listener);
154
+ };
155
+ }, []);
156
+ return { notify, subscribe };
157
+ }
158
+
84
159
  const TriggerPopoverRootInner: FC<
85
160
  ComposerPrimitiveTriggerPopoverRoot.Props
86
161
  > = ({ children }) => {
87
162
  const triggersRef = useRef<ReadonlyMap<string, RegisteredTrigger>>(new Map());
88
- const listenersRef = useRef<Set<() => void>>(new Set());
89
163
  const lifecycleListenersRef = useRef<Set<TriggerPopoverLifecycleListener>>(
90
164
  new Set(),
91
165
  );
92
166
 
93
- const notify = useCallback(() => {
94
- for (const listener of listenersRef.current) listener();
95
- }, []);
167
+ const { notify, subscribe } = useSimpleSubscribable();
96
168
 
97
169
  const register = useCallback<TriggerPopoverRootContextValue["register"]>(
98
170
  (trigger) => {
@@ -137,16 +209,6 @@ const TriggerPopoverRootInner: FC<
137
209
  TriggerPopoverRootContextValue["getTriggers"]
138
210
  >(() => triggersRef.current, []);
139
211
 
140
- const subscribe = useCallback<TriggerPopoverRootContextValue["subscribe"]>(
141
- (listener) => {
142
- listenersRef.current.add(listener);
143
- return () => {
144
- listenersRef.current.delete(listener);
145
- };
146
- },
147
- [],
148
- );
149
-
150
212
  const subscribeLifecycle = useCallback<
151
213
  TriggerPopoverRootContextValue["subscribeLifecycle"]
152
214
  >((listener) => {
@@ -156,14 +218,69 @@ const TriggerPopoverRootInner: FC<
156
218
  };
157
219
  }, []);
158
220
 
221
+ const activeAriaRef = useRef<TriggerPopoverActiveAria | null>(null);
222
+ const activeAriaCharRef = useRef<string | null>(null);
223
+ const { notify: notifyAria, subscribe: subscribeAria } =
224
+ useSimpleSubscribable();
225
+
226
+ const setActiveAria = useCallback<TriggerPopoverAriaPublish["setActiveAria"]>(
227
+ (char, aria) => {
228
+ if (aria === null) {
229
+ if (activeAriaCharRef.current !== char) return;
230
+ activeAriaRef.current = null;
231
+ activeAriaCharRef.current = null;
232
+ notifyAria();
233
+ return;
234
+ }
235
+ const prev = activeAriaRef.current;
236
+ if (
237
+ activeAriaCharRef.current === char &&
238
+ prev !== null &&
239
+ prev.popoverId === aria.popoverId &&
240
+ prev.highlightedItemId === aria.highlightedItemId
241
+ ) {
242
+ return;
243
+ }
244
+ activeAriaRef.current = aria;
245
+ activeAriaCharRef.current = char;
246
+ notifyAria();
247
+ },
248
+ [notifyAria],
249
+ );
250
+
251
+ const getActiveAria = useCallback<
252
+ TriggerPopoverRootContextValue["getActiveAria"]
253
+ >(() => activeAriaRef.current, []);
254
+
159
255
  const value = useMemo<TriggerPopoverRootContextValue>(
160
- () => ({ register, getTriggers, subscribe, subscribeLifecycle }),
161
- [register, getTriggers, subscribe, subscribeLifecycle],
256
+ () => ({
257
+ register,
258
+ getTriggers,
259
+ subscribe,
260
+ subscribeLifecycle,
261
+ getActiveAria,
262
+ subscribeAria,
263
+ }),
264
+ [
265
+ register,
266
+ getTriggers,
267
+ subscribe,
268
+ subscribeLifecycle,
269
+ getActiveAria,
270
+ subscribeAria,
271
+ ],
272
+ );
273
+
274
+ const ariaPublishValue = useMemo<TriggerPopoverAriaPublish>(
275
+ () => ({ setActiveAria }),
276
+ [setActiveAria],
162
277
  );
163
278
 
164
279
  return (
165
280
  <TriggerPopoverRootContext.Provider value={value}>
166
- {children}
281
+ <TriggerPopoverAriaPublishContext.Provider value={ariaPublishValue}>
282
+ {children}
283
+ </TriggerPopoverAriaPublishContext.Provider>
167
284
  </TriggerPopoverRootContext.Provider>
168
285
  );
169
286
  };
@@ -0,0 +1,236 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { createResourceRoot } from "@assistant-ui/tap";
3
+ import type {
4
+ Unstable_TriggerCategory,
5
+ Unstable_TriggerItem,
6
+ } from "@assistant-ui/core";
7
+ import { TriggerKeyboardResource } from "./triggerKeyboardResource";
8
+
9
+ const item = (id: string): Unstable_TriggerItem => ({
10
+ id,
11
+ type: "command",
12
+ label: id,
13
+ });
14
+
15
+ const category = (id: string): Unstable_TriggerCategory => ({
16
+ id,
17
+ label: id,
18
+ });
19
+
20
+ const makeKeyEvent = (key: string, shiftKey = false) => ({
21
+ key,
22
+ shiftKey,
23
+ preventDefault: vi.fn(),
24
+ });
25
+
26
+ const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
27
+
28
+ const render = (
29
+ overrides: Partial<Parameters<typeof TriggerKeyboardResource>[0]> = {},
30
+ ) => {
31
+ const props = {
32
+ navigableList: [item("a"), item("b"), item("c")],
33
+ isSearchMode: false,
34
+ activeCategoryId: null as string | null,
35
+ query: "",
36
+ popoverId: "popover",
37
+ open: true,
38
+ selectItem: vi.fn(),
39
+ selectCategory: vi.fn(),
40
+ goBack: vi.fn(),
41
+ close: vi.fn(),
42
+ ...overrides,
43
+ };
44
+ const root = createResourceRoot();
45
+ const sub = root.render(TriggerKeyboardResource(props));
46
+ return { sub, props };
47
+ };
48
+
49
+ describe("TriggerKeyboardResource", () => {
50
+ it("selects highlighted item on Tab", () => {
51
+ const { sub, props } = render();
52
+ const e = makeKeyEvent("Tab");
53
+
54
+ const consumed = sub.getValue().handleKeyDown(e);
55
+
56
+ expect(consumed).toBe(true);
57
+ expect(e.preventDefault).toHaveBeenCalled();
58
+ expect(props.selectItem).toHaveBeenCalledWith(props.navigableList[0]);
59
+ expect(props.selectCategory).not.toHaveBeenCalled();
60
+ });
61
+
62
+ it("selects category on Tab when entry is a category", () => {
63
+ const { sub, props } = render({
64
+ navigableList: [category("cat-1"), item("b")],
65
+ });
66
+
67
+ const consumed = sub.getValue().handleKeyDown(makeKeyEvent("Tab"));
68
+
69
+ expect(consumed).toBe(true);
70
+ expect(props.selectCategory).toHaveBeenCalledWith("cat-1");
71
+ expect(props.selectItem).not.toHaveBeenCalled();
72
+ });
73
+
74
+ it("lets Shift+Tab pass through for native focus traversal", () => {
75
+ const { sub, props } = render();
76
+ const e = makeKeyEvent("Tab", true);
77
+
78
+ const consumed = sub.getValue().handleKeyDown(e);
79
+
80
+ expect(consumed).toBe(false);
81
+ expect(e.preventDefault).not.toHaveBeenCalled();
82
+ expect(props.selectItem).not.toHaveBeenCalled();
83
+ });
84
+
85
+ it("swallows Tab when the navigable list is empty", () => {
86
+ const { sub, props } = render({ navigableList: [] });
87
+ const e = makeKeyEvent("Tab");
88
+
89
+ const consumed = sub.getValue().handleKeyDown(e);
90
+
91
+ expect(consumed).toBe(true);
92
+ expect(e.preventDefault).toHaveBeenCalled();
93
+ expect(props.selectItem).not.toHaveBeenCalled();
94
+ expect(props.selectCategory).not.toHaveBeenCalled();
95
+ });
96
+
97
+ it("does nothing when the popover is closed", () => {
98
+ const { sub, props } = render({ open: false });
99
+ const e = makeKeyEvent("Tab");
100
+
101
+ const consumed = sub.getValue().handleKeyDown(e);
102
+
103
+ expect(consumed).toBe(false);
104
+ expect(e.preventDefault).not.toHaveBeenCalled();
105
+ expect(props.selectItem).not.toHaveBeenCalled();
106
+ });
107
+
108
+ it("moves the highlight forward on ArrowDown", async () => {
109
+ const { sub } = render();
110
+
111
+ const consumed = sub.getValue().handleKeyDown(makeKeyEvent("ArrowDown"));
112
+ await tick();
113
+
114
+ expect(consumed).toBe(true);
115
+ expect(sub.getValue().highlightedIndex).toBe(1);
116
+ });
117
+
118
+ it("wraps the highlight to the top on ArrowDown past the last entry", async () => {
119
+ const { sub } = render();
120
+ const handle = sub.getValue().handleKeyDown;
121
+
122
+ handle(makeKeyEvent("ArrowDown"));
123
+ handle(makeKeyEvent("ArrowDown"));
124
+ handle(makeKeyEvent("ArrowDown"));
125
+ await tick();
126
+
127
+ expect(sub.getValue().highlightedIndex).toBe(0);
128
+ });
129
+
130
+ it("moves the highlight backward on ArrowUp", async () => {
131
+ const { sub } = render();
132
+ const handle = sub.getValue().handleKeyDown;
133
+
134
+ handle(makeKeyEvent("ArrowDown"));
135
+ await tick();
136
+ handle(makeKeyEvent("ArrowUp"));
137
+ await tick();
138
+
139
+ expect(sub.getValue().highlightedIndex).toBe(0);
140
+ });
141
+
142
+ it("wraps the highlight to the bottom on ArrowUp from the first entry", async () => {
143
+ const { sub } = render();
144
+
145
+ sub.getValue().handleKeyDown(makeKeyEvent("ArrowUp"));
146
+ await tick();
147
+
148
+ expect(sub.getValue().highlightedIndex).toBe(2);
149
+ });
150
+
151
+ it("keeps the highlight at 0 on ArrowDown when navigableList is empty", async () => {
152
+ const { sub } = render({ navigableList: [] });
153
+ const e = makeKeyEvent("ArrowDown");
154
+
155
+ const consumed = sub.getValue().handleKeyDown(e);
156
+ await tick();
157
+
158
+ expect(consumed).toBe(true);
159
+ expect(e.preventDefault).toHaveBeenCalled();
160
+ expect(sub.getValue().highlightedIndex).toBe(0);
161
+ });
162
+
163
+ it("selects the highlighted item on Enter", () => {
164
+ const { sub, props } = render();
165
+ const e = makeKeyEvent("Enter");
166
+
167
+ const consumed = sub.getValue().handleKeyDown(e);
168
+
169
+ expect(consumed).toBe(true);
170
+ expect(e.preventDefault).toHaveBeenCalled();
171
+ expect(props.selectItem).toHaveBeenCalledWith(props.navigableList[0]);
172
+ });
173
+
174
+ it("lets Shift+Enter pass through for newline insertion", () => {
175
+ const { sub, props } = render();
176
+ const e = makeKeyEvent("Enter", true);
177
+
178
+ const consumed = sub.getValue().handleKeyDown(e);
179
+
180
+ expect(consumed).toBe(false);
181
+ expect(e.preventDefault).not.toHaveBeenCalled();
182
+ expect(props.selectItem).not.toHaveBeenCalled();
183
+ });
184
+
185
+ it("closes the popover on Escape", () => {
186
+ const { sub, props } = render();
187
+ const e = makeKeyEvent("Escape");
188
+
189
+ const consumed = sub.getValue().handleKeyDown(e);
190
+
191
+ expect(consumed).toBe(true);
192
+ expect(e.preventDefault).toHaveBeenCalled();
193
+ expect(props.close).toHaveBeenCalled();
194
+ });
195
+
196
+ it("drills back on Backspace when a category is active and the query is empty", () => {
197
+ const { sub, props } = render({
198
+ activeCategoryId: "cat-1",
199
+ query: "",
200
+ });
201
+ const e = makeKeyEvent("Backspace");
202
+
203
+ const consumed = sub.getValue().handleKeyDown(e);
204
+
205
+ expect(consumed).toBe(true);
206
+ expect(e.preventDefault).toHaveBeenCalled();
207
+ expect(props.goBack).toHaveBeenCalled();
208
+ });
209
+
210
+ it("lets Backspace pass through when the query is non-empty", () => {
211
+ const { sub, props } = render({
212
+ activeCategoryId: "cat-1",
213
+ query: "foo",
214
+ });
215
+ const e = makeKeyEvent("Backspace");
216
+
217
+ const consumed = sub.getValue().handleKeyDown(e);
218
+
219
+ expect(consumed).toBe(false);
220
+ expect(e.preventDefault).not.toHaveBeenCalled();
221
+ expect(props.goBack).not.toHaveBeenCalled();
222
+ });
223
+
224
+ it("lets Backspace pass through when no category is active", () => {
225
+ const { sub, props } = render({
226
+ activeCategoryId: null,
227
+ query: "",
228
+ });
229
+ const e = makeKeyEvent("Backspace");
230
+
231
+ const consumed = sub.getValue().handleKeyDown(e);
232
+
233
+ expect(consumed).toBe(false);
234
+ expect(props.goBack).not.toHaveBeenCalled();
235
+ });
236
+ });