@assistant-ui/react 0.14.0 → 0.14.2
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/README.md +58 -42
- package/dist/client/ExternalThread.d.ts +7 -0
- package/dist/client/ExternalThread.d.ts.map +1 -1
- package/dist/client/ExternalThread.js +24 -16
- package/dist/client/ExternalThread.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/legacy-runtime/cloud/auiV0.d.ts +10 -1
- package/dist/legacy-runtime/cloud/auiV0.d.ts.map +1 -1
- package/dist/legacy-runtime/cloud/auiV0.js +21 -3
- package/dist/legacy-runtime/cloud/auiV0.js.map +1 -1
- package/dist/mcp-apps/McpAppRenderer.d.ts +28 -0
- package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -0
- package/dist/mcp-apps/McpAppRenderer.js +115 -0
- package/dist/mcp-apps/McpAppRenderer.js.map +1 -0
- package/dist/mcp-apps/McpAppsRemoteHost.d.ts +3 -0
- package/dist/mcp-apps/McpAppsRemoteHost.d.ts.map +1 -0
- package/dist/mcp-apps/McpAppsRemoteHost.js +27 -0
- package/dist/mcp-apps/McpAppsRemoteHost.js.map +1 -0
- package/dist/mcp-apps/app-frame.d.ts +3 -0
- package/dist/mcp-apps/app-frame.d.ts.map +1 -0
- package/dist/mcp-apps/app-frame.js +203 -0
- package/dist/mcp-apps/app-frame.js.map +1 -0
- package/dist/mcp-apps/bridge.d.ts +18 -0
- package/dist/mcp-apps/bridge.d.ts.map +1 -0
- package/dist/mcp-apps/bridge.js +290 -0
- package/dist/mcp-apps/bridge.js.map +1 -0
- package/dist/mcp-apps/index.d.ts +4 -0
- package/dist/mcp-apps/index.d.ts.map +1 -0
- package/dist/mcp-apps/index.js +3 -0
- package/dist/mcp-apps/index.js.map +1 -0
- package/dist/mcp-apps/types.d.ts +144 -0
- package/dist/mcp-apps/types.d.ts.map +1 -0
- package/dist/mcp-apps/types.js +3 -0
- package/dist/mcp-apps/types.js.map +1 -0
- package/dist/mcp-apps/utils.d.ts +5 -0
- package/dist/mcp-apps/utils.d.ts.map +1 -0
- package/dist/mcp-apps/utils.js +10 -0
- package/dist/mcp-apps/utils.js.map +1 -0
- package/dist/primitives/composer/ComposerInput.d.ts +6 -0
- package/dist/primitives/composer/ComposerInput.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerInput.js +19 -2
- package/dist/primitives/composer/ComposerInput.js.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopover.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopover.js +17 -1
- package/dist/primitives/composer/trigger/TriggerPopover.js.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverRootContext.d.ts +33 -0
- package/dist/primitives/composer/trigger/TriggerPopoverRootContext.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverRootContext.js +80 -11
- package/dist/primitives/composer/trigger/TriggerPopoverRootContext.js.map +1 -1
- package/dist/primitives/composer/trigger/triggerKeyboardResource.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/triggerKeyboardResource.js +2 -1
- package/dist/primitives/composer/trigger/triggerKeyboardResource.js.map +1 -1
- package/dist/primitives/messagePart/useMessagePartSource.d.ts +22 -3
- package/dist/primitives/messagePart/useMessagePartSource.d.ts.map +1 -1
- package/package.json +6 -6
- package/src/client/ExternalThread.ts +32 -17
- package/src/index.ts +21 -0
- package/src/legacy-runtime/cloud/auiV0.ts +37 -4
- package/src/mcp-apps/McpAppRenderer.tsx +215 -0
- package/src/mcp-apps/McpAppsRemoteHost.ts +52 -0
- package/src/mcp-apps/app-frame.tsx +280 -0
- package/src/mcp-apps/bridge.test.ts +391 -0
- package/src/mcp-apps/bridge.ts +435 -0
- package/src/mcp-apps/index.ts +16 -0
- package/src/mcp-apps/types.ts +158 -0
- package/src/mcp-apps/utils.ts +16 -0
- package/src/primitives/composer/ComposerInput.test.tsx +48 -0
- package/src/primitives/composer/ComposerInput.tsx +20 -2
- package/src/primitives/composer/trigger/TriggerPopover.tsx +21 -1
- package/src/primitives/composer/trigger/TriggerPopoverRootContext.test.tsx +152 -0
- package/src/primitives/composer/trigger/TriggerPopoverRootContext.tsx +134 -17
- package/src/primitives/composer/trigger/triggerKeyboardResource.test.ts +236 -0
- package/src/primitives/composer/trigger/triggerKeyboardResource.ts +2 -1
- package/src/tests/BaseComposerRuntimeCore.test.ts +4 -0
- 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
|
|
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
|
-
|
|
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 {
|
|
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 =
|
|
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
|
-
() => ({
|
|
161
|
-
|
|
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
|
-
{
|
|
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
|
+
});
|