@assistant-ui/react-native 0.1.27 → 0.1.29

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 (39) hide show
  1. package/dist/index.d.ts +3 -3
  2. package/dist/index.js +3 -3
  3. package/dist/primitives/composer/ComposerInput.d.ts.map +1 -1
  4. package/dist/primitives/composer/ComposerInput.js +8 -0
  5. package/dist/primitives/composer/ComposerInput.js.map +1 -1
  6. package/dist/primitives/thread.d.ts +3 -3
  7. package/dist/primitives/thread.js +4 -3
  8. package/dist/primitives/thread.js.map +1 -1
  9. package/package.json +11 -7
  10. package/src/index.ts +32 -0
  11. package/src/primitives/attachment/AttachmentName.test.tsx +55 -0
  12. package/src/primitives/attachment/AttachmentRemove.test.tsx +81 -0
  13. package/src/primitives/attachment/AttachmentThumb.test.tsx +75 -0
  14. package/src/primitives/branchPicker/BranchPickerCount.test.tsx +55 -0
  15. package/src/primitives/branchPicker/BranchPickerNext.test.tsx +97 -0
  16. package/src/primitives/branchPicker/BranchPickerNumber.test.tsx +56 -0
  17. package/src/primitives/branchPicker/BranchPickerPrevious.test.tsx +100 -0
  18. package/src/primitives/chainOfThought/ChainOfThoughtAccordionTrigger.test.tsx +85 -0
  19. package/src/primitives/composer/ComposerAddAttachment.test.tsx +136 -0
  20. package/src/primitives/composer/ComposerCancel.test.tsx +116 -0
  21. package/src/primitives/composer/ComposerInput.test.tsx +253 -0
  22. package/src/primitives/composer/ComposerInput.tsx +12 -0
  23. package/src/primitives/composer/ComposerSend.test.tsx +116 -0
  24. package/src/primitives/error/ErrorMessage.test.tsx +64 -0
  25. package/src/primitives/error/ErrorRoot.test.tsx +65 -0
  26. package/src/primitives/message/MessageContent.test.tsx +278 -0
  27. package/src/primitives/message/MessageIf.test.tsx +156 -0
  28. package/src/primitives/suggestion/SuggestionDescription.test.tsx +61 -0
  29. package/src/primitives/suggestion/SuggestionTitle.test.tsx +61 -0
  30. package/src/primitives/suggestion/SuggestionTrigger.test.tsx +144 -0
  31. package/src/primitives/thread/ThreadEmpty.test.tsx +56 -0
  32. package/src/primitives/thread/ThreadIf.test.tsx +121 -0
  33. package/src/primitives/thread/ThreadMessages.test.tsx +192 -0
  34. package/src/primitives/thread.ts +1 -0
  35. package/src/primitives/threadList/ThreadListNew.test.tsx +72 -0
  36. package/src/primitives/threadListItem/ThreadListItemArchive.test.tsx +72 -0
  37. package/src/primitives/threadListItem/ThreadListItemDelete.test.tsx +72 -0
  38. package/src/primitives/threadListItem/ThreadListItemTrigger.test.tsx +72 -0
  39. package/src/primitives/threadListItem/ThreadListItemUnarchive.test.tsx +72 -0
@@ -0,0 +1,253 @@
1
+ import { act } from "react";
2
+ import { createRoot, type Root } from "react-dom/client";
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { ComposerInput } from "./ComposerInput";
5
+
6
+ const h = vi.hoisted(() => ({
7
+ setText: vi.fn<(text: string) => void>(),
8
+ sendSpy: vi.fn<() => void>(),
9
+ flushTapSyncSpy: vi.fn(<T,>(fn: () => T) => fn()),
10
+ composerState: { text: "" },
11
+ platform: { os: "web" as "web" | "ios" | "android" },
12
+ }));
13
+
14
+ vi.mock("@assistant-ui/store", () => {
15
+ const aui = {
16
+ composer: () => ({
17
+ setText: h.setText,
18
+ send: h.sendSpy,
19
+ }),
20
+ };
21
+ return {
22
+ useAui: () => aui,
23
+ useAuiState: <T,>(
24
+ selector: (s: { composer: typeof h.composerState }) => T,
25
+ ) => selector({ composer: h.composerState }),
26
+ };
27
+ });
28
+
29
+ vi.mock("@assistant-ui/tap", () => ({
30
+ flushTapSync: h.flushTapSyncSpy,
31
+ }));
32
+
33
+ vi.mock("react-native", async (importOriginal) => {
34
+ const actual = (await importOriginal()) as Record<string, unknown> & {
35
+ Platform: Record<string, unknown>;
36
+ };
37
+ return {
38
+ ...actual,
39
+ Platform: {
40
+ ...actual.Platform,
41
+ get OS() {
42
+ return h.platform.os;
43
+ },
44
+ select: (obj: Record<string, unknown>) =>
45
+ h.platform.os in obj ? obj[h.platform.os] : obj.default,
46
+ },
47
+ };
48
+ });
49
+
50
+ (globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
51
+
52
+ const setNativeValue = (input: HTMLInputElement, value: string) => {
53
+ const setter = Object.getOwnPropertyDescriptor(
54
+ HTMLInputElement.prototype,
55
+ "value",
56
+ )?.set;
57
+ setter?.call(input, value);
58
+ };
59
+
60
+ const fireInput = (input: HTMLInputElement, value: string) => {
61
+ setNativeValue(input, value);
62
+ input.dispatchEvent(new InputEvent("input", { bubbles: true }));
63
+ };
64
+
65
+ const fireKeyDown = (
66
+ input: HTMLInputElement,
67
+ opts: {
68
+ key?: string;
69
+ shiftKey?: boolean;
70
+ isComposing?: boolean;
71
+ keyCode?: number;
72
+ } = {},
73
+ ): KeyboardEvent => {
74
+ const event = new KeyboardEvent("keydown", {
75
+ bubbles: true,
76
+ cancelable: true,
77
+ key: opts.key ?? "Enter",
78
+ shiftKey: opts.shiftKey ?? false,
79
+ isComposing: opts.isComposing ?? false,
80
+ });
81
+ if (opts.keyCode !== undefined) {
82
+ Object.defineProperty(event, "keyCode", { value: opts.keyCode });
83
+ }
84
+ input.dispatchEvent(event);
85
+ return event;
86
+ };
87
+
88
+ describe("ComposerInput", () => {
89
+ let container: HTMLDivElement;
90
+ let root: Root;
91
+
92
+ beforeEach(() => {
93
+ h.setText.mockReset();
94
+ h.sendSpy.mockReset();
95
+ h.flushTapSyncSpy.mockClear();
96
+ h.composerState.text = "";
97
+ h.platform.os = "web";
98
+
99
+ container = document.createElement("div");
100
+ document.body.appendChild(container);
101
+ root = createRoot(container);
102
+ });
103
+
104
+ afterEach(async () => {
105
+ await act(async () => {
106
+ root.unmount();
107
+ });
108
+ container.remove();
109
+ });
110
+
111
+ const mount = async (
112
+ props: Partial<Parameters<typeof ComposerInput>[0]> = {},
113
+ ) => {
114
+ await act(async () => {
115
+ root.render(<ComposerInput {...props} />);
116
+ });
117
+ const input = container.querySelector("input") as HTMLInputElement;
118
+ expect(input).not.toBeNull();
119
+ return input;
120
+ };
121
+
122
+ it("renders an input controlled by the composer text", async () => {
123
+ h.composerState.text = "hello";
124
+ const input = await mount();
125
+ expect(input.value).toBe("hello");
126
+ });
127
+
128
+ describe("web", () => {
129
+ it("routes typing through setText wrapped in flushTapSync", async () => {
130
+ const input = await mount();
131
+
132
+ await act(async () => {
133
+ fireInput(input, "ni");
134
+ });
135
+
136
+ expect(h.setText).toHaveBeenCalledWith("ni");
137
+ expect(h.flushTapSyncSpy).toHaveBeenCalledTimes(1);
138
+ });
139
+
140
+ it("submits on plain Enter under the default submitMode", async () => {
141
+ const input = await mount();
142
+
143
+ let event!: KeyboardEvent;
144
+ await act(async () => {
145
+ event = fireKeyDown(input, { key: "Enter" });
146
+ });
147
+
148
+ expect(h.sendSpy).toHaveBeenCalledTimes(1);
149
+ expect(event.defaultPrevented).toBe(true);
150
+ });
151
+
152
+ it("inserts a newline on Shift+Enter without submitting", async () => {
153
+ const input = await mount();
154
+
155
+ let event!: KeyboardEvent;
156
+ await act(async () => {
157
+ event = fireKeyDown(input, { key: "Enter", shiftKey: true });
158
+ });
159
+
160
+ expect(h.sendSpy).not.toHaveBeenCalled();
161
+ expect(event.defaultPrevented).toBe(false);
162
+ });
163
+
164
+ it("ignores Enter while isComposing is set", async () => {
165
+ const input = await mount();
166
+
167
+ let event!: KeyboardEvent;
168
+ await act(async () => {
169
+ event = fireKeyDown(input, { key: "Enter", isComposing: true });
170
+ });
171
+
172
+ expect(h.sendSpy).not.toHaveBeenCalled();
173
+ expect(event.defaultPrevented).toBe(false);
174
+ });
175
+
176
+ it("ignores Enter while keyCode is the IME sentinel 229", async () => {
177
+ const input = await mount();
178
+
179
+ let event!: KeyboardEvent;
180
+ await act(async () => {
181
+ event = fireKeyDown(input, { key: "Enter", keyCode: 229 });
182
+ });
183
+
184
+ expect(h.sendSpy).not.toHaveBeenCalled();
185
+ expect(event.defaultPrevented).toBe(false);
186
+ });
187
+
188
+ it("does not submit on a non-Enter key", async () => {
189
+ const input = await mount();
190
+
191
+ await act(async () => {
192
+ fireKeyDown(input, { key: "a" });
193
+ });
194
+
195
+ expect(h.sendSpy).not.toHaveBeenCalled();
196
+ });
197
+
198
+ it("does not submit when submitMode is none", async () => {
199
+ const input = await mount({ submitMode: "none" });
200
+
201
+ let event!: KeyboardEvent;
202
+ await act(async () => {
203
+ event = fireKeyDown(input, { key: "Enter" });
204
+ });
205
+
206
+ expect(h.sendSpy).not.toHaveBeenCalled();
207
+ expect(event.defaultPrevented).toBe(false);
208
+ });
209
+
210
+ it("forwards keydown events to a consumer onKeyPress handler", async () => {
211
+ const onKeyPress = vi.fn();
212
+ const input = await mount({ onKeyPress });
213
+
214
+ await act(async () => {
215
+ fireKeyDown(input, { key: "Enter" });
216
+ fireKeyDown(input, { key: "a" });
217
+ });
218
+
219
+ expect(onKeyPress).toHaveBeenCalledTimes(2);
220
+ });
221
+ });
222
+
223
+ describe("native", () => {
224
+ beforeEach(() => {
225
+ h.platform.os = "ios";
226
+ });
227
+
228
+ it("routes typing through setText without flushTapSync", async () => {
229
+ const input = await mount();
230
+
231
+ await act(async () => {
232
+ fireInput(input, "ni");
233
+ });
234
+
235
+ expect(h.setText).toHaveBeenCalledWith("ni");
236
+ expect(h.flushTapSyncSpy).not.toHaveBeenCalled();
237
+ });
238
+
239
+ it("never submits on Enter and leaves the event untouched", async () => {
240
+ const onKeyPress = vi.fn();
241
+ const input = await mount({ onKeyPress });
242
+
243
+ let event!: KeyboardEvent;
244
+ await act(async () => {
245
+ event = fireKeyDown(input, { key: "Enter" });
246
+ });
247
+
248
+ expect(h.sendSpy).not.toHaveBeenCalled();
249
+ expect(event.defaultPrevented).toBe(false);
250
+ expect(onKeyPress).toHaveBeenCalledTimes(1);
251
+ });
252
+ });
253
+ });
@@ -7,6 +7,7 @@ import {
7
7
  type TextInputProps,
8
8
  } from "react-native";
9
9
  import { useAui, useAuiState } from "@assistant-ui/store";
10
+ import { flushTapSync } from "@assistant-ui/tap";
10
11
 
11
12
  export type ComposerInputProps = Omit<
12
13
  TextInputProps,
@@ -46,6 +47,13 @@ export const ComposerInput = ({
46
47
 
47
48
  const onChangeText = useCallback(
48
49
  (value: string) => {
50
+ if (Platform.OS === "web") {
51
+ // Keep the controlled value in sync mid-IME so react-dom does not reset the textarea to a stale value
52
+ flushTapSync(() => {
53
+ aui.composer().setText(value);
54
+ });
55
+ return;
56
+ }
49
57
  aui.composer().setText(value);
50
58
  },
51
59
  [aui],
@@ -66,7 +74,11 @@ export const ComposerInput = ({
66
74
 
67
75
  const nativeEvent = e.nativeEvent as TextInputKeyPressEventData & {
68
76
  shiftKey?: boolean;
77
+ isComposing?: boolean;
78
+ keyCode?: number;
69
79
  };
80
+ // react-native-web forwards the raw DOM keydown before its own composition guard, so re-check it here (mirrors isEventComposing)
81
+ if (nativeEvent.isComposing || nativeEvent.keyCode === 229) return;
70
82
  if (nativeEvent.key === "Enter" && !nativeEvent.shiftKey) {
71
83
  (e as unknown as Event).preventDefault?.();
72
84
  aui.composer().send();
@@ -0,0 +1,116 @@
1
+ import { act } from "react";
2
+ import { createRoot, type Root } from "react-dom/client";
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { ComposerSend } from "./ComposerSend";
5
+
6
+ const h = vi.hoisted(() => ({
7
+ send: vi.fn<() => void>(),
8
+ disabled: false,
9
+ }));
10
+
11
+ vi.mock("@assistant-ui/core/react", () => ({
12
+ useComposerSend: () => ({ send: h.send, disabled: h.disabled }),
13
+ }));
14
+
15
+ (globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
16
+
17
+ const click = (el: Element) => {
18
+ el.dispatchEvent(
19
+ new MouseEvent("click", { bubbles: true, cancelable: true }),
20
+ );
21
+ };
22
+
23
+ describe("ComposerSend", () => {
24
+ let container: HTMLDivElement;
25
+ let root: Root;
26
+
27
+ beforeEach(() => {
28
+ h.send.mockReset();
29
+ h.disabled = false;
30
+
31
+ container = document.createElement("div");
32
+ document.body.appendChild(container);
33
+ root = createRoot(container);
34
+ });
35
+
36
+ afterEach(async () => {
37
+ await act(async () => {
38
+ root.unmount();
39
+ });
40
+ container.remove();
41
+ });
42
+
43
+ const mount = async (
44
+ props: Partial<Parameters<typeof ComposerSend>[0]> = {},
45
+ ) => {
46
+ await act(async () => {
47
+ root.render(
48
+ <ComposerSend testID="send" {...props}>
49
+ send
50
+ </ComposerSend>,
51
+ );
52
+ });
53
+ const el = container.querySelector('[data-testid="send"]');
54
+ expect(el).not.toBeNull();
55
+ return el as Element;
56
+ };
57
+
58
+ it("renders its children", async () => {
59
+ const el = await mount();
60
+ expect(el.textContent).toBe("send");
61
+ });
62
+
63
+ it("calls send on press", async () => {
64
+ const el = await mount();
65
+
66
+ await act(async () => {
67
+ click(el);
68
+ });
69
+
70
+ expect(h.send).toHaveBeenCalledTimes(1);
71
+ });
72
+
73
+ it("does not press when the hook reports disabled", async () => {
74
+ h.disabled = true;
75
+ const el = await mount();
76
+
77
+ await act(async () => {
78
+ click(el);
79
+ });
80
+
81
+ expect(h.send).not.toHaveBeenCalled();
82
+ });
83
+
84
+ it("lets a false prop override the hook's disabled:true", async () => {
85
+ h.disabled = true;
86
+ const el = await mount({ disabled: false });
87
+
88
+ await act(async () => {
89
+ click(el);
90
+ });
91
+
92
+ expect(h.send).toHaveBeenCalledTimes(1);
93
+ });
94
+
95
+ it("lets a true prop override the hook's disabled:false", async () => {
96
+ h.disabled = false;
97
+ const el = await mount({ disabled: true });
98
+
99
+ await act(async () => {
100
+ click(el);
101
+ });
102
+
103
+ expect(h.send).not.toHaveBeenCalled();
104
+ });
105
+
106
+ it("defers to the hook when disabled is undefined", async () => {
107
+ h.disabled = false;
108
+ const el = await mount({ disabled: undefined });
109
+
110
+ await act(async () => {
111
+ click(el);
112
+ });
113
+
114
+ expect(h.send).toHaveBeenCalledTimes(1);
115
+ });
116
+ });
@@ -0,0 +1,64 @@
1
+ import { act } from "react";
2
+ import { createRoot, type Root } from "react-dom/client";
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { Text } from "react-native";
5
+ import { ErrorMessage } from "./ErrorMessage";
6
+
7
+ const h = vi.hoisted(() => ({
8
+ error: undefined as unknown,
9
+ }));
10
+
11
+ vi.mock("@assistant-ui/core/react", () => ({
12
+ useMessageError: () => h.error,
13
+ }));
14
+
15
+ (globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
16
+
17
+ describe("ErrorMessage", () => {
18
+ let container: HTMLDivElement;
19
+ let root: Root;
20
+
21
+ beforeEach(() => {
22
+ h.error = undefined;
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 mount = async (children?: React.ReactNode) => {
36
+ await act(async () => {
37
+ root.render(<ErrorMessage testID="msg">{children}</ErrorMessage>);
38
+ });
39
+ return container.querySelector('[data-testid="msg"]');
40
+ };
41
+
42
+ it("renders nothing when there is no error", async () => {
43
+ expect(await mount()).toBeNull();
44
+ });
45
+
46
+ it("falls back to String(error) when no children are given", async () => {
47
+ h.error = new Error("boom");
48
+ const el = await mount();
49
+ expect(el?.textContent).toBe("Error: boom");
50
+ });
51
+
52
+ it("stringifies a plain string error", async () => {
53
+ h.error = "kaboom";
54
+ const el = await mount();
55
+ expect(el?.textContent).toBe("kaboom");
56
+ });
57
+
58
+ it("renders children verbatim instead of the error when provided", async () => {
59
+ h.error = new Error("boom");
60
+ const el = await mount(<Text>custom</Text>);
61
+ expect(el?.textContent).toBe("custom");
62
+ expect(el?.textContent).not.toContain("boom");
63
+ });
64
+ });
@@ -0,0 +1,65 @@
1
+ import { act } from "react";
2
+ import { createRoot, type Root } from "react-dom/client";
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { Text } from "react-native";
5
+ import { ErrorRoot } from "./ErrorRoot";
6
+
7
+ const h = vi.hoisted(() => ({
8
+ error: undefined as unknown,
9
+ }));
10
+
11
+ vi.mock("@assistant-ui/core/react", () => ({
12
+ useMessageError: () => h.error,
13
+ }));
14
+
15
+ (globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
16
+
17
+ describe("ErrorRoot", () => {
18
+ let container: HTMLDivElement;
19
+ let root: Root;
20
+
21
+ beforeEach(() => {
22
+ h.error = undefined;
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 mount = async () => {
36
+ await act(async () => {
37
+ root.render(
38
+ <ErrorRoot testID="root">
39
+ <Text testID="child">child</Text>
40
+ </ErrorRoot>,
41
+ );
42
+ });
43
+ };
44
+
45
+ it("renders nothing when there is no error", async () => {
46
+ await mount();
47
+ expect(container.querySelector('[data-testid="root"]')).toBeNull();
48
+ expect(container.querySelector('[data-testid="child"]')).toBeNull();
49
+ });
50
+
51
+ it("renders the view with children once an error is present", async () => {
52
+ h.error = new Error("boom");
53
+ await mount();
54
+ expect(container.querySelector('[data-testid="root"]')).not.toBeNull();
55
+ expect(container.querySelector('[data-testid="child"]')?.textContent).toBe(
56
+ "child",
57
+ );
58
+ });
59
+
60
+ it("treats a falsy-but-defined error as present", async () => {
61
+ h.error = "";
62
+ await mount();
63
+ expect(container.querySelector('[data-testid="root"]')).not.toBeNull();
64
+ });
65
+ });