@assistant-ui/react 0.12.28 → 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 -18
- package/dist/client/ExternalThread.js.map +1 -1
- package/dist/client/InMemoryThreadList.d.ts.map +1 -1
- package/dist/client/InMemoryThreadList.js +3 -0
- package/dist/client/InMemoryThreadList.js.map +1 -1
- package/dist/client/SingleThreadList.d.ts.map +1 -1
- package/dist/client/SingleThreadList.js +3 -0
- package/dist/client/SingleThreadList.js.map +1 -1
- package/dist/context/providers/ThreadViewportProvider.d.ts.map +1 -1
- package/dist/context/providers/ThreadViewportProvider.js +2 -10
- package/dist/context/providers/ThreadViewportProvider.js.map +1 -1
- package/dist/context/stores/ThreadViewport.d.ts +46 -4
- package/dist/context/stores/ThreadViewport.d.ts.map +1 -1
- package/dist/context/stores/ThreadViewport.js +51 -7
- package/dist/context/stores/ThreadViewport.js.map +1 -1
- package/dist/index.d.ts +5 -30
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -28
- 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/legacy-runtime/runtime-cores/assistant-transport/utils.d.ts +1 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/utils.d.ts.map +1 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/utils.js +1 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/utils.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 +28 -6
- 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/message/MessageRoot.d.ts +6 -30
- package/dist/primitives/message/MessageRoot.d.ts.map +1 -1
- package/dist/primitives/message/MessageRoot.js +68 -25
- package/dist/primitives/message/MessageRoot.js.map +1 -1
- package/dist/primitives/messagePart/useMessagePartSource.d.ts +22 -3
- package/dist/primitives/messagePart/useMessagePartSource.d.ts.map +1 -1
- package/dist/primitives/thread/ThreadViewport.d.ts +38 -0
- package/dist/primitives/thread/ThreadViewport.d.ts.map +1 -1
- package/dist/primitives/thread/ThreadViewport.js +53 -5
- package/dist/primitives/thread/ThreadViewport.js.map +1 -1
- package/dist/primitives/thread/ThreadViewportFooter.d.ts +2 -1
- package/dist/primitives/thread/ThreadViewportFooter.d.ts.map +1 -1
- package/dist/primitives/thread/ThreadViewportFooter.js +2 -1
- package/dist/primitives/thread/ThreadViewportFooter.js.map +1 -1
- package/dist/primitives/thread/topAnchor/computeTopAnchorSlack.d.ts +22 -0
- package/dist/primitives/thread/topAnchor/computeTopAnchorSlack.d.ts.map +1 -0
- package/dist/primitives/thread/topAnchor/computeTopAnchorSlack.js +53 -0
- package/dist/primitives/thread/topAnchor/computeTopAnchorSlack.js.map +1 -0
- package/dist/primitives/thread/topAnchor/createReserveObservers.d.ts +5 -0
- package/dist/primitives/thread/topAnchor/createReserveObservers.d.ts.map +1 -0
- package/dist/primitives/thread/topAnchor/createReserveObservers.js +38 -0
- package/dist/primitives/thread/topAnchor/createReserveObservers.js.map +1 -0
- package/dist/primitives/thread/topAnchor/mountTopAnchorReserve.d.ts +22 -0
- package/dist/primitives/thread/topAnchor/mountTopAnchorReserve.d.ts.map +1 -0
- package/dist/primitives/thread/topAnchor/mountTopAnchorReserve.js +75 -0
- package/dist/primitives/thread/topAnchor/mountTopAnchorReserve.js.map +1 -0
- package/dist/primitives/thread/topAnchor/topAnchorTurn.d.ts +15 -0
- package/dist/primitives/thread/topAnchor/topAnchorTurn.d.ts.map +1 -0
- package/dist/primitives/thread/topAnchor/topAnchorTurn.js +13 -0
- package/dist/primitives/thread/topAnchor/topAnchorTurn.js.map +1 -0
- package/dist/primitives/thread/topAnchor/topAnchorUtils.d.ts +15 -0
- package/dist/primitives/thread/topAnchor/topAnchorUtils.d.ts.map +1 -0
- package/dist/primitives/thread/topAnchor/topAnchorUtils.js +51 -0
- package/dist/primitives/thread/topAnchor/topAnchorUtils.js.map +1 -0
- package/dist/primitives/thread/topAnchor/useTopAnchorReserve.d.ts +7 -0
- package/dist/primitives/thread/topAnchor/useTopAnchorReserve.d.ts.map +1 -0
- package/dist/primitives/thread/topAnchor/useTopAnchorReserve.js +18 -0
- package/dist/primitives/thread/topAnchor/useTopAnchorReserve.js.map +1 -0
- package/dist/primitives/thread/useThreadViewportAutoScroll.d.ts.map +1 -1
- package/dist/primitives/thread/useThreadViewportAutoScroll.js +13 -1
- package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
- package/dist/primitives/thread.d.ts +0 -1
- package/dist/primitives/thread.d.ts.map +1 -1
- package/dist/primitives/thread.js +0 -1
- package/dist/primitives/thread.js.map +1 -1
- package/dist/primitives/threadList/ThreadListLoadMore.d.ts +13 -0
- package/dist/primitives/threadList/ThreadListLoadMore.d.ts.map +1 -0
- package/dist/primitives/threadList/ThreadListLoadMore.js +11 -0
- package/dist/primitives/threadList/ThreadListLoadMore.js.map +1 -0
- package/dist/primitives/threadList.d.ts +1 -0
- package/dist/primitives/threadList.d.ts.map +1 -1
- package/dist/primitives/threadList.js +1 -0
- package/dist/primitives/threadList.js.map +1 -1
- package/dist/utils/hooks/useManagedRef.d.ts.map +1 -1
- package/dist/utils/hooks/useManagedRef.js +1 -0
- package/dist/utils/hooks/useManagedRef.js.map +1 -1
- package/dist/utils/hooks/useOnResizeContent.d.ts.map +1 -1
- package/dist/utils/hooks/useOnResizeContent.js +1 -2
- package/dist/utils/hooks/useOnResizeContent.js.map +1 -1
- package/package.json +13 -13
- package/src/client/ExternalThread.ts +32 -19
- package/src/client/InMemoryThreadList.ts +3 -0
- package/src/client/SingleThreadList.ts +3 -0
- package/src/context/providers/ThreadViewportProvider.tsx +2 -12
- package/src/context/stores/ThreadViewport.ts +111 -11
- package/src/index.ts +20 -34
- package/src/legacy-runtime/cloud/auiV0.ts +37 -4
- package/src/legacy-runtime/runtime-cores/assistant-transport/utils.ts +1 -5
- 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 +280 -0
- package/src/primitives/composer/ComposerInput.tsx +29 -6
- 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/primitives/message/MessageRoot.tsx +135 -57
- package/src/primitives/thread/ThreadViewport.tsx +95 -4
- package/src/primitives/thread/ThreadViewportFooter.tsx +2 -1
- package/src/primitives/thread/topAnchor/computeTopAnchorSlack.test.ts +131 -0
- package/src/primitives/thread/topAnchor/computeTopAnchorSlack.ts +94 -0
- package/src/primitives/thread/topAnchor/createReserveObservers.ts +50 -0
- package/src/primitives/thread/topAnchor/mountTopAnchorReserve.test.ts +131 -0
- package/src/primitives/thread/topAnchor/mountTopAnchorReserve.ts +127 -0
- package/src/primitives/thread/topAnchor/topAnchorTurn.test.ts +46 -0
- package/src/primitives/thread/topAnchor/topAnchorTurn.ts +30 -0
- package/src/primitives/thread/topAnchor/topAnchorUtils.ts +58 -0
- package/src/primitives/thread/topAnchor/useTopAnchorReserve.ts +19 -0
- package/src/primitives/thread/useThreadViewportAutoScroll.ts +15 -1
- package/src/primitives/thread.ts +0 -1
- package/src/primitives/threadList/ThreadListLoadMore.tsx +24 -0
- package/src/primitives/threadList.ts +1 -0
- package/src/tests/BaseComposerRuntimeCore.test.ts +4 -0
- package/src/tests/RemoteThreadListRuntime.adapterProvider.test.tsx +138 -0
- package/src/tests/RemoteThreadListRuntime.deferredProvider.test.tsx +28 -17
- package/src/tests/auiV0Encode.test.ts +55 -0
- package/src/utils/hooks/useManagedRef.ts +1 -0
- package/src/utils/hooks/useOnResizeContent.ts +1 -2
- package/dist/legacy-runtime/runtime-cores/external-store/getExternalStoreMessage.d.ts +0 -3
- package/dist/legacy-runtime/runtime-cores/external-store/getExternalStoreMessage.d.ts.map +0 -1
- package/dist/legacy-runtime/runtime-cores/external-store/getExternalStoreMessage.js +0 -3
- package/dist/legacy-runtime/runtime-cores/external-store/getExternalStoreMessage.js.map +0 -1
- package/dist/primitives/thread/ThreadViewportSlack.d.ts +0 -20
- package/dist/primitives/thread/ThreadViewportSlack.d.ts.map +0 -1
- package/dist/primitives/thread/ThreadViewportSlack.js +0 -80
- package/dist/primitives/thread/ThreadViewportSlack.js.map +0 -1
- package/src/legacy-runtime/runtime-cores/external-store/getExternalStoreMessage.ts +0 -6
- package/src/primitives/thread/ThreadViewportSlack.tsx +0 -116
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { act } from "react";
|
|
5
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
7
|
+
import { ComposerPrimitiveInput } from "./ComposerInput";
|
|
8
|
+
|
|
9
|
+
const setText = vi.fn<(text: string) => void>();
|
|
10
|
+
const setCursorPosition = vi.fn<(pos: number) => void>();
|
|
11
|
+
|
|
12
|
+
const composerState = {
|
|
13
|
+
isEditing: true,
|
|
14
|
+
text: "",
|
|
15
|
+
type: "thread" as const,
|
|
16
|
+
isEmpty: true,
|
|
17
|
+
canCancel: false,
|
|
18
|
+
dictation: undefined as undefined | { inputDisabled: boolean },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const threadState = {
|
|
22
|
+
isDisabled: false,
|
|
23
|
+
isRunning: false,
|
|
24
|
+
capabilities: { queue: false, attachments: false },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const plugin = {
|
|
28
|
+
handleKeyDown: () => false,
|
|
29
|
+
setCursorPosition,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
let pluginRegistry: { getPlugins: () => (typeof plugin)[] } | null = null;
|
|
33
|
+
|
|
34
|
+
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
|
35
|
+
|
|
36
|
+
vi.mock("@assistant-ui/store", () => {
|
|
37
|
+
const aui = {
|
|
38
|
+
composer: () => ({
|
|
39
|
+
setText,
|
|
40
|
+
getState: () => composerState,
|
|
41
|
+
cancel: () => {},
|
|
42
|
+
send: () => {},
|
|
43
|
+
addAttachment: async () => {},
|
|
44
|
+
}),
|
|
45
|
+
thread: () => ({
|
|
46
|
+
getState: () => threadState,
|
|
47
|
+
}),
|
|
48
|
+
on: () => () => {},
|
|
49
|
+
};
|
|
50
|
+
type Selector<T> = (s: {
|
|
51
|
+
composer: typeof composerState;
|
|
52
|
+
thread: typeof threadState;
|
|
53
|
+
}) => T;
|
|
54
|
+
return {
|
|
55
|
+
useAui: () => aui,
|
|
56
|
+
useAuiState: <T,>(selector: Selector<T>) =>
|
|
57
|
+
selector({ composer: composerState, thread: threadState }),
|
|
58
|
+
};
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
vi.mock("@assistant-ui/tap", () => ({
|
|
62
|
+
flushResourcesSync: (fn: () => void) => fn(),
|
|
63
|
+
}));
|
|
64
|
+
|
|
65
|
+
vi.mock("./ComposerInputPluginContext", () => ({
|
|
66
|
+
useComposerInputPluginRegistryOptional: () => pluginRegistry,
|
|
67
|
+
}));
|
|
68
|
+
|
|
69
|
+
let activeAria: {
|
|
70
|
+
popoverId: string;
|
|
71
|
+
highlightedItemId: string | undefined;
|
|
72
|
+
} | null = null;
|
|
73
|
+
|
|
74
|
+
vi.mock("./trigger/TriggerPopoverRootContext", () => ({
|
|
75
|
+
useTriggerPopoverActiveAriaOptional: () => activeAria,
|
|
76
|
+
}));
|
|
77
|
+
|
|
78
|
+
vi.mock("@radix-ui/react-use-escape-keydown", () => ({
|
|
79
|
+
useEscapeKeydown: () => {},
|
|
80
|
+
}));
|
|
81
|
+
|
|
82
|
+
vi.mock("../../utils/hooks/useOnScrollToBottom", () => ({
|
|
83
|
+
useOnScrollToBottom: () => {},
|
|
84
|
+
}));
|
|
85
|
+
|
|
86
|
+
const setNativeValue = (textarea: HTMLTextAreaElement, value: string) => {
|
|
87
|
+
const setter = Object.getOwnPropertyDescriptor(
|
|
88
|
+
HTMLTextAreaElement.prototype,
|
|
89
|
+
"value",
|
|
90
|
+
)?.set;
|
|
91
|
+
setter?.call(textarea, value);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const fireInput = (
|
|
95
|
+
textarea: HTMLTextAreaElement,
|
|
96
|
+
value: string,
|
|
97
|
+
isComposing: boolean,
|
|
98
|
+
) => {
|
|
99
|
+
setNativeValue(textarea, value);
|
|
100
|
+
textarea.dispatchEvent(
|
|
101
|
+
new InputEvent("input", { bubbles: true, isComposing }),
|
|
102
|
+
);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const fireCompositionStart = (textarea: HTMLTextAreaElement) => {
|
|
106
|
+
textarea.dispatchEvent(
|
|
107
|
+
new CompositionEvent("compositionstart", { bubbles: true }),
|
|
108
|
+
);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const fireCompositionEnd = (textarea: HTMLTextAreaElement, value: string) => {
|
|
112
|
+
setNativeValue(textarea, value);
|
|
113
|
+
textarea.dispatchEvent(
|
|
114
|
+
new CompositionEvent("compositionend", { bubbles: true }),
|
|
115
|
+
);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
describe("ComposerPrimitiveInput", () => {
|
|
119
|
+
let container: HTMLDivElement;
|
|
120
|
+
let root: Root;
|
|
121
|
+
|
|
122
|
+
beforeEach(() => {
|
|
123
|
+
setText.mockReset();
|
|
124
|
+
setCursorPosition.mockReset();
|
|
125
|
+
composerState.isEditing = true;
|
|
126
|
+
composerState.text = "";
|
|
127
|
+
composerState.isEmpty = true;
|
|
128
|
+
composerState.dictation = undefined;
|
|
129
|
+
threadState.isDisabled = false;
|
|
130
|
+
threadState.isRunning = false;
|
|
131
|
+
threadState.capabilities = { queue: false, attachments: false };
|
|
132
|
+
pluginRegistry = null;
|
|
133
|
+
activeAria = null;
|
|
134
|
+
|
|
135
|
+
container = document.createElement("div");
|
|
136
|
+
document.body.appendChild(container);
|
|
137
|
+
root = createRoot(container);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
afterEach(async () => {
|
|
141
|
+
await act(async () => {
|
|
142
|
+
root.unmount();
|
|
143
|
+
});
|
|
144
|
+
container.remove();
|
|
145
|
+
vi.restoreAllMocks();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const mount = async () => {
|
|
149
|
+
await act(async () => {
|
|
150
|
+
root.render(<ComposerPrimitiveInput data-testid="input" />);
|
|
151
|
+
});
|
|
152
|
+
const textarea = container.querySelector("textarea") as HTMLTextAreaElement;
|
|
153
|
+
expect(textarea).not.toBeNull();
|
|
154
|
+
return textarea;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
it("syncs setText during active composition so React 19 cannot reset the textarea", async () => {
|
|
158
|
+
const textarea = await mount();
|
|
159
|
+
|
|
160
|
+
await act(async () => {
|
|
161
|
+
fireCompositionStart(textarea);
|
|
162
|
+
fireInput(textarea, "ㄱ", true);
|
|
163
|
+
});
|
|
164
|
+
expect(setText).toHaveBeenCalledWith("ㄱ");
|
|
165
|
+
|
|
166
|
+
await act(async () => {
|
|
167
|
+
fireInput(textarea, "가", true);
|
|
168
|
+
});
|
|
169
|
+
expect(setText).toHaveBeenLastCalledWith("가");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("commits the final value on compositionend", async () => {
|
|
173
|
+
const textarea = await mount();
|
|
174
|
+
|
|
175
|
+
await act(async () => {
|
|
176
|
+
fireCompositionStart(textarea);
|
|
177
|
+
fireInput(textarea, "가", true);
|
|
178
|
+
});
|
|
179
|
+
expect(setText).toHaveBeenCalledTimes(1);
|
|
180
|
+
|
|
181
|
+
await act(async () => {
|
|
182
|
+
fireCompositionEnd(textarea, "가");
|
|
183
|
+
});
|
|
184
|
+
expect(setText).toHaveBeenCalledTimes(2);
|
|
185
|
+
expect(setText).toHaveBeenLastCalledWith("가");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("recovers when compositionend is dropped before the next input", async () => {
|
|
189
|
+
const textarea = await mount();
|
|
190
|
+
|
|
191
|
+
await act(async () => {
|
|
192
|
+
fireCompositionStart(textarea);
|
|
193
|
+
fireInput(textarea, "hello", false);
|
|
194
|
+
});
|
|
195
|
+
expect(setText).toHaveBeenCalledWith("hello");
|
|
196
|
+
|
|
197
|
+
await act(async () => {
|
|
198
|
+
fireInput(textarea, "hello!", false);
|
|
199
|
+
});
|
|
200
|
+
expect(setText).toHaveBeenLastCalledWith("hello!");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("skips plugin cursor tracking during composition but resumes after", async () => {
|
|
204
|
+
pluginRegistry = { getPlugins: () => [plugin] };
|
|
205
|
+
const textarea = await mount();
|
|
206
|
+
|
|
207
|
+
await act(async () => {
|
|
208
|
+
fireCompositionStart(textarea);
|
|
209
|
+
fireInput(textarea, "ㄱ", true);
|
|
210
|
+
});
|
|
211
|
+
expect(setText).toHaveBeenCalledWith("ㄱ");
|
|
212
|
+
expect(setCursorPosition).not.toHaveBeenCalled();
|
|
213
|
+
|
|
214
|
+
await act(async () => {
|
|
215
|
+
fireCompositionEnd(textarea, "가");
|
|
216
|
+
});
|
|
217
|
+
expect(setCursorPosition).toHaveBeenCalled();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("tracks plugin cursor for non-composition input", async () => {
|
|
221
|
+
pluginRegistry = { getPlugins: () => [plugin] };
|
|
222
|
+
const textarea = await mount();
|
|
223
|
+
|
|
224
|
+
await act(async () => {
|
|
225
|
+
fireInput(textarea, "abc", false);
|
|
226
|
+
});
|
|
227
|
+
expect(setText).toHaveBeenCalledWith("abc");
|
|
228
|
+
expect(setCursorPosition).toHaveBeenCalled();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("ignores input and compositionend when the composer is not editing", async () => {
|
|
232
|
+
composerState.isEditing = false;
|
|
233
|
+
const textarea = await mount();
|
|
234
|
+
|
|
235
|
+
await act(async () => {
|
|
236
|
+
fireInput(textarea, "abc", false);
|
|
237
|
+
fireCompositionStart(textarea);
|
|
238
|
+
fireCompositionEnd(textarea, "가");
|
|
239
|
+
});
|
|
240
|
+
expect(setText).not.toHaveBeenCalled();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("does not apply ARIA combobox attributes when no trigger popover is open", async () => {
|
|
244
|
+
activeAria = null;
|
|
245
|
+
const textarea = await mount();
|
|
246
|
+
|
|
247
|
+
expect(textarea.getAttribute("aria-controls")).toBeNull();
|
|
248
|
+
expect(textarea.getAttribute("aria-expanded")).toBeNull();
|
|
249
|
+
expect(textarea.getAttribute("aria-haspopup")).toBeNull();
|
|
250
|
+
expect(textarea.getAttribute("aria-activedescendant")).toBeNull();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("applies ARIA combobox attributes when a trigger popover is open", async () => {
|
|
254
|
+
activeAria = {
|
|
255
|
+
popoverId: "popover-1",
|
|
256
|
+
highlightedItemId: "popover-1-option-foo",
|
|
257
|
+
};
|
|
258
|
+
const textarea = await mount();
|
|
259
|
+
|
|
260
|
+
expect(textarea.getAttribute("aria-controls")).toBe("popover-1");
|
|
261
|
+
expect(textarea.getAttribute("aria-expanded")).toBe("true");
|
|
262
|
+
expect(textarea.getAttribute("aria-haspopup")).toBe("listbox");
|
|
263
|
+
expect(textarea.getAttribute("aria-activedescendant")).toBe(
|
|
264
|
+
"popover-1-option-foo",
|
|
265
|
+
);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("omits aria-activedescendant when no item is highlighted", async () => {
|
|
269
|
+
activeAria = {
|
|
270
|
+
popoverId: "popover-1",
|
|
271
|
+
highlightedItemId: undefined,
|
|
272
|
+
};
|
|
273
|
+
const textarea = await mount();
|
|
274
|
+
|
|
275
|
+
expect(textarea.getAttribute("aria-controls")).toBe("popover-1");
|
|
276
|
+
expect(textarea.getAttribute("aria-expanded")).toBe("true");
|
|
277
|
+
expect(textarea.getAttribute("aria-haspopup")).toBe("listbox");
|
|
278
|
+
expect(textarea.getAttribute("aria-activedescendant")).toBeNull();
|
|
279
|
+
});
|
|
280
|
+
});
|
|
@@ -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,23 +295,38 @@ 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(
|
|
297
315
|
onChange,
|
|
298
316
|
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
299
317
|
if (!aui.composer().getState().isEditing) return;
|
|
300
|
-
const
|
|
301
|
-
(e.nativeEvent as { isComposing?: boolean }).isComposing === true
|
|
302
|
-
|
|
303
|
-
if (
|
|
318
|
+
const nativeIsComposing =
|
|
319
|
+
(e.nativeEvent as { isComposing?: boolean }).isComposing === true;
|
|
320
|
+
// recover stuck compositionRef when the browser drops compositionend
|
|
321
|
+
if (compositionRef.current && !nativeIsComposing) {
|
|
322
|
+
compositionRef.current = false;
|
|
323
|
+
}
|
|
324
|
+
const isComposing = nativeIsComposing || compositionRef.current;
|
|
325
|
+
// keep controlled value in sync mid-IME so react does not reset the textarea to a stale value
|
|
304
326
|
flushResourcesSync(() => {
|
|
305
327
|
aui.composer().setText(e.target.value);
|
|
306
328
|
});
|
|
329
|
+
if (isComposing) return;
|
|
307
330
|
const pos = e.target.selectionStart ?? e.target.value.length;
|
|
308
331
|
if (pluginRegistry) {
|
|
309
332
|
for (const plugin of pluginRegistry.getPlugins()) {
|
|
@@ -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
|
+
});
|