@agent-native/toolkit 0.224.0 → 0.225.0-nightly-20261105110131
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/dist/app/review/ReviewCommentComposer.d.ts.map +1 -1
- package/dist/app/review/ReviewCommentComposer.js +3 -5
- package/dist/app/review/ReviewCommentComposer.js.map +1 -1
- package/dist/composer/MentionPopover.d.ts +7 -0
- package/dist/composer/MentionPopover.d.ts.map +1 -1
- package/dist/composer/MentionPopover.js +4 -2
- package/dist/composer/MentionPopover.js.map +1 -1
- package/dist/composer/TiptapComposer.d.ts +6 -0
- package/dist/composer/TiptapComposer.d.ts.map +1 -1
- package/dist/composer/TiptapComposer.js +12 -2
- package/dist/composer/TiptapComposer.js.map +1 -1
- package/dist/hooks/index.d.ts +1 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +1 -0
- package/dist/hooks/index.js.map +1 -1
- package/dist/hooks/use-focus-within-through-press.d.ts +19 -0
- package/dist/hooks/use-focus-within-through-press.d.ts.map +1 -0
- package/dist/hooks/use-focus-within-through-press.js +82 -0
- package/dist/hooks/use-focus-within-through-press.js.map +1 -0
- package/package.json +4 -4
- package/src/app/review/ReviewCommentComposer.spec.tsx +40 -0
- package/src/app/review/ReviewCommentComposer.tsx +3 -6
- package/src/composer/MentionPopover.spec.tsx +76 -0
- package/src/composer/MentionPopover.tsx +11 -1
- package/src/composer/TiptapComposer.spec.ts +32 -0
- package/src/composer/TiptapComposer.tsx +16 -3
- package/src/hooks/index.ts +1 -0
- package/src/hooks/use-focus-within-through-press.spec.tsx +208 -0
- package/src/hooks/use-focus-within-through-press.ts +86 -0
package/dist/hooks/index.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/hooks/index.ts"],"names":[],"mappings":"AAAA,cAAc,iBAAiB,CAAC;AAChC,cAAc,gBAAgB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/hooks/index.ts"],"names":[],"mappings":"AAAA,cAAc,qCAAqC,CAAC;AACpD,cAAc,iBAAiB,CAAC;AAChC,cAAc,gBAAgB,CAAC"}
|
package/dist/hooks/index.js
CHANGED
package/dist/hooks/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/hooks/index.ts"],"names":[],"mappings":"AAAA,cAAc,iBAAiB,CAAC;AAChC,cAAc,gBAAgB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/hooks/index.ts"],"names":[],"mappings":"AAAA,cAAc,qCAAqC,CAAC;AACpD,cAAc,iBAAiB,CAAC;AAChC,cAAc,gBAAgB,CAAC"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
/**
|
|
3
|
+
* Tracks focus inside an element that shrinks when focus leaves it, such as a
|
|
4
|
+
* reply box that rests as one line.
|
|
5
|
+
*
|
|
6
|
+
* Pressing a control outside the element moves focus on pointer down. Shrinking
|
|
7
|
+
* right then moves everything laid out around the element, so the control can
|
|
8
|
+
* slide out from under the pointer before the release, and the click lands
|
|
9
|
+
* somewhere else. Focus lost during a press only counts once that press's click
|
|
10
|
+
* has fired.
|
|
11
|
+
*/
|
|
12
|
+
export declare function useFocusWithinThroughPress(ref: React.RefObject<HTMLElement | null>): {
|
|
13
|
+
focused: boolean;
|
|
14
|
+
focusWithinProps: {
|
|
15
|
+
onFocusCapture: () => void;
|
|
16
|
+
onBlurCapture: (event: React.FocusEvent) => void;
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
//# sourceMappingURL=use-focus-within-through-press.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-focus-within-through-press.d.ts","sourceRoot":"","sources":["../../src/hooks/use-focus-within-through-press.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAM/B;;;;;;;;;GASG;AACH,wBAAgB,0BAA0B,CACxC,GAAG,EAAE,KAAK,CAAC,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC;;;;+BAwDf,KAAK,CAAC,UAAU;;EAY1C"}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
// A tap moves focus and clicks after its release, within the browser's
|
|
3
|
+
// double-tap delay of about 300 ms.
|
|
4
|
+
const TAP_CLICK_WAIT_MS = 400;
|
|
5
|
+
/**
|
|
6
|
+
* Tracks focus inside an element that shrinks when focus leaves it, such as a
|
|
7
|
+
* reply box that rests as one line.
|
|
8
|
+
*
|
|
9
|
+
* Pressing a control outside the element moves focus on pointer down. Shrinking
|
|
10
|
+
* right then moves everything laid out around the element, so the control can
|
|
11
|
+
* slide out from under the pointer before the release, and the click lands
|
|
12
|
+
* somewhere else. Focus lost during a press only counts once that press's click
|
|
13
|
+
* has fired.
|
|
14
|
+
*/
|
|
15
|
+
export function useFocusWithinThroughPress(ref) {
|
|
16
|
+
const [focused, setFocused] = React.useState(false);
|
|
17
|
+
const pressing = React.useRef(false);
|
|
18
|
+
const blurredDuringPress = React.useRef(false);
|
|
19
|
+
React.useEffect(() => {
|
|
20
|
+
let settleTimer;
|
|
21
|
+
let tapTimer;
|
|
22
|
+
const onPressStart = () => {
|
|
23
|
+
clearTimeout(tapTimer);
|
|
24
|
+
pressing.current = true;
|
|
25
|
+
};
|
|
26
|
+
const onPressEnd = () => {
|
|
27
|
+
clearTimeout(tapTimer);
|
|
28
|
+
pressing.current = false;
|
|
29
|
+
if (!blurredDuringPress.current)
|
|
30
|
+
return;
|
|
31
|
+
blurredDuringPress.current = false;
|
|
32
|
+
clearTimeout(settleTimer);
|
|
33
|
+
// The click's own handlers run ahead of this timer.
|
|
34
|
+
settleTimer = setTimeout(() => {
|
|
35
|
+
setFocused(Boolean(ref.current?.contains(document.activeElement)));
|
|
36
|
+
});
|
|
37
|
+
};
|
|
38
|
+
const onPointerUp = (event) => {
|
|
39
|
+
if (event.pointerType !== "touch") {
|
|
40
|
+
onPressEnd();
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
clearTimeout(tapTimer);
|
|
44
|
+
tapTimer = setTimeout(onPressEnd, TAP_CLICK_WAIT_MS);
|
|
45
|
+
};
|
|
46
|
+
window.addEventListener("pointerdown", onPressStart, true);
|
|
47
|
+
window.addEventListener("pointerup", onPointerUp, true);
|
|
48
|
+
window.addEventListener("pointercancel", onPressEnd, true);
|
|
49
|
+
window.addEventListener("click", onPressEnd, true);
|
|
50
|
+
// Neither of these reports a release, so a press could otherwise stick.
|
|
51
|
+
window.addEventListener("contextmenu", onPressEnd, true);
|
|
52
|
+
window.addEventListener("blur", onPressEnd);
|
|
53
|
+
return () => {
|
|
54
|
+
clearTimeout(settleTimer);
|
|
55
|
+
clearTimeout(tapTimer);
|
|
56
|
+
window.removeEventListener("pointerdown", onPressStart, true);
|
|
57
|
+
window.removeEventListener("pointerup", onPointerUp, true);
|
|
58
|
+
window.removeEventListener("pointercancel", onPressEnd, true);
|
|
59
|
+
window.removeEventListener("click", onPressEnd, true);
|
|
60
|
+
window.removeEventListener("contextmenu", onPressEnd, true);
|
|
61
|
+
window.removeEventListener("blur", onPressEnd);
|
|
62
|
+
};
|
|
63
|
+
}, [ref]);
|
|
64
|
+
const focusWithinProps = {
|
|
65
|
+
onFocusCapture: () => {
|
|
66
|
+
blurredDuringPress.current = false;
|
|
67
|
+
setFocused(true);
|
|
68
|
+
},
|
|
69
|
+
onBlurCapture: (event) => {
|
|
70
|
+
const next = event.relatedTarget;
|
|
71
|
+
if (next && ref.current?.contains(next))
|
|
72
|
+
return;
|
|
73
|
+
if (pressing.current) {
|
|
74
|
+
blurredDuringPress.current = true;
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
setFocused(false);
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
return { focused, focusWithinProps };
|
|
81
|
+
}
|
|
82
|
+
//# sourceMappingURL=use-focus-within-through-press.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-focus-within-through-press.js","sourceRoot":"","sources":["../../src/hooks/use-focus-within-through-press.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-native/toolkit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.225.0-nightly-20261105110131",
|
|
4
4
|
"description": "Reusable app-building UI and helpers for Agent-Native apps.",
|
|
5
5
|
"homepage": "https://github.com/BuilderIO/agent-native#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -716,7 +716,7 @@
|
|
|
716
716
|
"y-protocols": "^1.0.7",
|
|
717
717
|
"yjs": "^13.6.33",
|
|
718
718
|
"zod": "^4.3.6",
|
|
719
|
-
"@agent-native/agentkit": "^0.
|
|
719
|
+
"@agent-native/agentkit": "^0.225.0-nightly-20261105110131"
|
|
720
720
|
},
|
|
721
721
|
"devDependencies": {
|
|
722
722
|
"@excalidraw/excalidraw": "0.18.1",
|
|
@@ -740,10 +740,10 @@
|
|
|
740
740
|
"typescript-7": "npm:typescript@^7.0.2",
|
|
741
741
|
"vite": "8.1.0",
|
|
742
742
|
"vitest": "^4.1.5",
|
|
743
|
-
"@agent-native/core": "^0.
|
|
743
|
+
"@agent-native/core": "^0.225.0-nightly-20261105110131"
|
|
744
744
|
},
|
|
745
745
|
"peerDependencies": {
|
|
746
|
-
"@agent-native/core": "
|
|
746
|
+
"@agent-native/core": "0.225.0-nightly-20261105110131",
|
|
747
747
|
"@excalidraw/excalidraw": ">=0.18",
|
|
748
748
|
"@excalidraw/mermaid-to-excalidraw": ">=2",
|
|
749
749
|
"@tanstack/react-query": "^5.99.2",
|
|
@@ -198,6 +198,46 @@ describe("ReviewCommentComposer", () => {
|
|
|
198
198
|
).not.toBeNull();
|
|
199
199
|
});
|
|
200
200
|
|
|
201
|
+
it("keeps the reply variant expanded until a press outside it has finished", async () => {
|
|
202
|
+
await render(
|
|
203
|
+
<ReviewCommentComposer
|
|
204
|
+
variant="reply"
|
|
205
|
+
value=""
|
|
206
|
+
onChange={() => {}}
|
|
207
|
+
onSubmit={() => {}}
|
|
208
|
+
/>,
|
|
209
|
+
);
|
|
210
|
+
const field = container.querySelector("[data-review-composer-field]");
|
|
211
|
+
const dispatchFocus = (type: "focusin" | "focusout") =>
|
|
212
|
+
act(() => {
|
|
213
|
+
editorDom().dispatchEvent(new FocusEvent(type, { bubbles: true }));
|
|
214
|
+
});
|
|
215
|
+
const press = (type: "pointerdown" | "pointerup") =>
|
|
216
|
+
act(() => {
|
|
217
|
+
document.body.dispatchEvent(new Event(type, { bubbles: true }));
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
dispatchFocus("focusin");
|
|
221
|
+
expect(field?.hasAttribute("data-expanded")).toBe(true);
|
|
222
|
+
|
|
223
|
+
vi.useFakeTimers();
|
|
224
|
+
try {
|
|
225
|
+
press("pointerdown");
|
|
226
|
+
dispatchFocus("focusout");
|
|
227
|
+
expect(field?.hasAttribute("data-expanded")).toBe(true);
|
|
228
|
+
|
|
229
|
+
press("pointerup");
|
|
230
|
+
expect(field?.hasAttribute("data-expanded")).toBe(true);
|
|
231
|
+
|
|
232
|
+
act(() => {
|
|
233
|
+
vi.runOnlyPendingTimers();
|
|
234
|
+
});
|
|
235
|
+
expect(field?.hasAttribute("data-expanded")).toBe(false);
|
|
236
|
+
} finally {
|
|
237
|
+
vi.useRealTimers();
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
|
|
201
241
|
it("stays expanded while the reply has content", async () => {
|
|
202
242
|
await render(
|
|
203
243
|
<ReviewCommentComposer
|
|
@@ -2,6 +2,7 @@ import type {
|
|
|
2
2
|
ReviewMention,
|
|
3
3
|
ReviewResolutionTarget,
|
|
4
4
|
} from "@agent-native/core/review/types";
|
|
5
|
+
import { useFocusWithinThroughPress } from "@agent-native/toolkit/hooks/use-focus-within-through-press";
|
|
5
6
|
import { Button } from "@agent-native/toolkit/ui/button";
|
|
6
7
|
import {
|
|
7
8
|
DropdownMenu,
|
|
@@ -156,7 +157,7 @@ export function ReviewCommentComposer({
|
|
|
156
157
|
const editorRef = useRef<ReviewComposerEditorHandle>(null);
|
|
157
158
|
const fieldRef = useRef<HTMLDivElement>(null);
|
|
158
159
|
const [editorReady, setEditorReady] = useState(false);
|
|
159
|
-
const
|
|
160
|
+
const { focused, focusWithinProps } = useFocusWithinThroughPress(fieldRef);
|
|
160
161
|
const [emojiOpen, setEmojiOpen] = useState(false);
|
|
161
162
|
const [mentionOpen, setMentionOpen] = useState(false);
|
|
162
163
|
const [mentionQuery, setMentionQuery] = useState("");
|
|
@@ -439,11 +440,7 @@ export function ReviewCommentComposer({
|
|
|
439
440
|
? "border-input focus-within:border-[color:var(--review-composer-focus,hsl(var(--ring)))]"
|
|
440
441
|
: "border-transparent",
|
|
441
442
|
)}
|
|
442
|
-
|
|
443
|
-
onBlurCapture={(event) => {
|
|
444
|
-
if (!fieldRef.current?.contains(event.relatedTarget as Node | null))
|
|
445
|
-
setFocused(false);
|
|
446
|
-
}}
|
|
443
|
+
{...focusWithinProps}
|
|
447
444
|
onClick={(event) => {
|
|
448
445
|
if (
|
|
449
446
|
!disabled &&
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { act } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
|
|
7
|
+
import { MentionPopover } from "./MentionPopover.js";
|
|
8
|
+
import type { MentionItem } from "./types.js";
|
|
9
|
+
|
|
10
|
+
let container: HTMLDivElement;
|
|
11
|
+
let root: Root;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
15
|
+
container = document.createElement("div");
|
|
16
|
+
document.body.appendChild(container);
|
|
17
|
+
root = createRoot(container);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
act(() => root.unmount());
|
|
22
|
+
container.remove();
|
|
23
|
+
document.body
|
|
24
|
+
.querySelectorAll("[data-radix-popper-content-wrapper]")
|
|
25
|
+
.forEach((element) => element.remove());
|
|
26
|
+
vi.unstubAllGlobals();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const items: MentionItem[] = [
|
|
30
|
+
{
|
|
31
|
+
id: "member:alice",
|
|
32
|
+
label: "Alice",
|
|
33
|
+
source: "host",
|
|
34
|
+
refType: "member",
|
|
35
|
+
section: "People",
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: "ai:claude",
|
|
39
|
+
label: "Claude Haiku 5.5",
|
|
40
|
+
source: "host",
|
|
41
|
+
refType: "ai",
|
|
42
|
+
section: "Agents",
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
function rowLabels(keepSectionOrder: boolean) {
|
|
47
|
+
act(() => {
|
|
48
|
+
root.render(
|
|
49
|
+
<MentionPopover
|
|
50
|
+
type="@"
|
|
51
|
+
position={{ top: 400, left: 0, width: 320 }}
|
|
52
|
+
mentionItems={items}
|
|
53
|
+
skills={[]}
|
|
54
|
+
isLoading={false}
|
|
55
|
+
query=""
|
|
56
|
+
onSelectMention={() => undefined}
|
|
57
|
+
onSelectSkill={() => undefined}
|
|
58
|
+
onClose={() => undefined}
|
|
59
|
+
keepSectionOrder={keepSectionOrder}
|
|
60
|
+
/>,
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
return [...document.querySelectorAll("[data-mention-index]")].map(
|
|
64
|
+
(row) => row.textContent,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe("mention sections", () => {
|
|
69
|
+
it("puts the default search's agents first", () => {
|
|
70
|
+
expect(rowLabels(false)).toEqual(["Claude Haiku 5.5", "Alice"]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("keeps a host's own order, even for a section named Agents", () => {
|
|
74
|
+
expect(rowLabels(true)).toEqual(["Alice", "Claude Haiku 5.5"]);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -59,6 +59,13 @@ interface MentionPopoverProps {
|
|
|
59
59
|
* people and agent pickers where the description is an identity (an email).
|
|
60
60
|
*/
|
|
61
61
|
density?: "default" | "stacked";
|
|
62
|
+
/**
|
|
63
|
+
* Show sections in the order their items arrive. The default search streams
|
|
64
|
+
* its sections as each source resolves, so they are sorted; a host's own list
|
|
65
|
+
* already is in the order it wants, and an "Agents" section of its own would
|
|
66
|
+
* otherwise jump to the top.
|
|
67
|
+
*/
|
|
68
|
+
keepSectionOrder?: boolean;
|
|
62
69
|
}
|
|
63
70
|
|
|
64
71
|
const iconProps = { size: 16, className: "shrink-0 text-muted-foreground" };
|
|
@@ -192,6 +199,7 @@ export const MentionPopover = forwardRef<
|
|
|
192
199
|
onSelectCommand,
|
|
193
200
|
onClose,
|
|
194
201
|
density = "default",
|
|
202
|
+
keepSectionOrder = false,
|
|
195
203
|
} = props;
|
|
196
204
|
const stacked = density === "stacked";
|
|
197
205
|
|
|
@@ -244,6 +252,8 @@ export const MentionPopover = forwardRef<
|
|
|
244
252
|
if (!groups.has(section)) groups.set(section, []);
|
|
245
253
|
groups.get(section)!.push(item);
|
|
246
254
|
}
|
|
255
|
+
if (keepSectionOrder)
|
|
256
|
+
return [...groups].map(([section, items]) => ({ section, items }));
|
|
247
257
|
const sorted: { section: string; items: MentionItem[] }[] = [];
|
|
248
258
|
const knownSections = new Set([
|
|
249
259
|
"Agents",
|
|
@@ -274,7 +284,7 @@ export const MentionPopover = forwardRef<
|
|
|
274
284
|
sorted.push({ section: "Other", items: groups.get("Other")! });
|
|
275
285
|
}
|
|
276
286
|
return sorted;
|
|
277
|
-
}, [type, mentionItems]);
|
|
287
|
+
}, [type, mentionItems, keepSectionOrder]);
|
|
278
288
|
|
|
279
289
|
const flatMentionItems = React.useMemo(() => {
|
|
280
290
|
return groupedMentions.flatMap((g) => g.items);
|
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
hasConfiguredCloudProvider,
|
|
43
43
|
MODEL_SELECTOR_POPOVER_STYLE,
|
|
44
44
|
mentionItemMatchesQuery,
|
|
45
|
+
mentionItemsForQuery,
|
|
45
46
|
modelProviderGroupLabel,
|
|
46
47
|
resolveContextChipBackspaceAction,
|
|
47
48
|
shouldRenderModelSelector,
|
|
@@ -5604,6 +5605,37 @@ describe("mentionItemMatchesQuery", () => {
|
|
|
5604
5605
|
});
|
|
5605
5606
|
});
|
|
5606
5607
|
|
|
5608
|
+
describe("mentionItemsForQuery", () => {
|
|
5609
|
+
const person = {
|
|
5610
|
+
id: "member:sam@gmail.example",
|
|
5611
|
+
label: "Sam Lee",
|
|
5612
|
+
description: "sam@gmail.example",
|
|
5613
|
+
source: "content",
|
|
5614
|
+
refType: "content-comment-member",
|
|
5615
|
+
};
|
|
5616
|
+
const agent = {
|
|
5617
|
+
id: "ai:anthropic:claude-sonnet-5",
|
|
5618
|
+
label: "Claude Sonnet 5",
|
|
5619
|
+
description: "Anthropic",
|
|
5620
|
+
aliases: ["Sonnet", "AI"],
|
|
5621
|
+
source: "content",
|
|
5622
|
+
refType: "content-comment-ai-recipient",
|
|
5623
|
+
};
|
|
5624
|
+
|
|
5625
|
+
it("keeps the given order when nothing is named exactly", () => {
|
|
5626
|
+
expect(mentionItemsForQuery([person, agent], "")).toEqual([person, agent]);
|
|
5627
|
+
expect(mentionItemsForQuery([person, agent], "s")).toEqual([person, agent]);
|
|
5628
|
+
});
|
|
5629
|
+
|
|
5630
|
+
it("puts the item a whole name or alias names ahead of partial matches", () => {
|
|
5631
|
+
expect(mentionItemsForQuery([person, agent], "ai")).toEqual([
|
|
5632
|
+
agent,
|
|
5633
|
+
person,
|
|
5634
|
+
]);
|
|
5635
|
+
expect(mentionItemsForQuery([person, agent], "Sam Lee")).toEqual([person]);
|
|
5636
|
+
});
|
|
5637
|
+
});
|
|
5638
|
+
|
|
5607
5639
|
describe("modelProviderGroupLabel", () => {
|
|
5608
5640
|
it("drops the Builder.io suffix when the family name is unique", () => {
|
|
5609
5641
|
const builder = { engine: "builder", label: "OpenAI · Builder.io" };
|
|
@@ -469,6 +469,20 @@ export function mentionItemMatchesQuery(
|
|
|
469
469
|
);
|
|
470
470
|
}
|
|
471
471
|
|
|
472
|
+
/**
|
|
473
|
+
* The items that match a query, in their given order except that one named
|
|
474
|
+
* exactly by the query comes first, so typing a whole name or alias and
|
|
475
|
+
* pressing Enter picks it over an earlier item that merely contains the text.
|
|
476
|
+
*/
|
|
477
|
+
export function mentionItemsForQuery(
|
|
478
|
+
items: MentionItem[],
|
|
479
|
+
query: string,
|
|
480
|
+
): MentionItem[] {
|
|
481
|
+
const matches = items.filter((item) => mentionItemMatchesQuery(item, query));
|
|
482
|
+
const exact = findExactMentionItem(matches, query.trim());
|
|
483
|
+
return exact ? [exact, ...matches.filter((item) => item !== exact)] : matches;
|
|
484
|
+
}
|
|
485
|
+
|
|
472
486
|
export function findExactMentionItem(
|
|
473
487
|
items: MentionItem[],
|
|
474
488
|
query: string,
|
|
@@ -3204,9 +3218,7 @@ function TiptapComposerSurface({
|
|
|
3204
3218
|
filterMentionItemsForSlots(
|
|
3205
3219
|
[
|
|
3206
3220
|
// Host items arrive unfiltered; the default search filters itself.
|
|
3207
|
-
...hostMentionItems
|
|
3208
|
-
mentionItemMatchesQuery(item, mentionQuery),
|
|
3209
|
-
),
|
|
3221
|
+
...mentionItemsForQuery(hostMentionItems, mentionQuery),
|
|
3210
3222
|
...mentionItems,
|
|
3211
3223
|
].filter(
|
|
3212
3224
|
(item, index, items) =>
|
|
@@ -6856,6 +6868,7 @@ function TiptapComposerSurface({
|
|
|
6856
6868
|
<MentionPopover
|
|
6857
6869
|
ref={popoverRef}
|
|
6858
6870
|
density={mentionPopoverDensity}
|
|
6871
|
+
keepSectionOrder={!includeDefaultMentionSearch}
|
|
6859
6872
|
type={popover?.type ?? "@"}
|
|
6860
6873
|
position={popover?.position ?? null}
|
|
6861
6874
|
mentionItems={inlineMentionItems}
|
package/src/hooks/index.ts
CHANGED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { act, useRef } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
|
|
7
|
+
import { useFocusWithinThroughPress } from "./use-focus-within-through-press.js";
|
|
8
|
+
|
|
9
|
+
function Field() {
|
|
10
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
11
|
+
const { focused, focusWithinProps } = useFocusWithinThroughPress(ref);
|
|
12
|
+
return (
|
|
13
|
+
<div>
|
|
14
|
+
<div ref={ref} data-focused={focused} {...focusWithinProps}>
|
|
15
|
+
<input data-testid="inside" />
|
|
16
|
+
<input data-testid="inside-too" />
|
|
17
|
+
</div>
|
|
18
|
+
<button data-testid="outside" />
|
|
19
|
+
</div>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("useFocusWithinThroughPress", () => {
|
|
24
|
+
let container: HTMLDivElement;
|
|
25
|
+
let root: Root;
|
|
26
|
+
|
|
27
|
+
const inside = () =>
|
|
28
|
+
container.querySelector<HTMLInputElement>('[data-testid="inside"]')!;
|
|
29
|
+
const insideToo = () =>
|
|
30
|
+
container.querySelector<HTMLInputElement>('[data-testid="inside-too"]')!;
|
|
31
|
+
const outside = () =>
|
|
32
|
+
container.querySelector<HTMLButtonElement>('[data-testid="outside"]')!;
|
|
33
|
+
const focused = () =>
|
|
34
|
+
container.querySelector("[data-focused]")?.getAttribute("data-focused");
|
|
35
|
+
|
|
36
|
+
const press = (
|
|
37
|
+
type: "pointerdown" | "pointerup" | "pointercancel" | "contextmenu",
|
|
38
|
+
pointerType = "mouse",
|
|
39
|
+
) =>
|
|
40
|
+
act(() => {
|
|
41
|
+
outside().dispatchEvent(
|
|
42
|
+
Object.assign(new Event(type, { bubbles: true }), { pointerType }),
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
vi.useFakeTimers();
|
|
48
|
+
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
|
49
|
+
container = document.createElement("div");
|
|
50
|
+
document.body.appendChild(container);
|
|
51
|
+
root = createRoot(container);
|
|
52
|
+
act(() => root.render(<Field />));
|
|
53
|
+
act(() => inside().focus());
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
act(() => root.unmount());
|
|
58
|
+
container.remove();
|
|
59
|
+
vi.useRealTimers();
|
|
60
|
+
vi.unstubAllGlobals();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("reports focus entering the element", () => {
|
|
64
|
+
expect(focused()).toBe("true");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("clears at once when focus leaves without a press", () => {
|
|
68
|
+
act(() => outside().focus());
|
|
69
|
+
expect(focused()).toBe("false");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("stays focused when focus moves within the element", () => {
|
|
73
|
+
act(() => insideToo().focus());
|
|
74
|
+
expect(focused()).toBe("true");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("holds through a press outside until that press's click has fired", () => {
|
|
78
|
+
let focusedAtClick: string | null | undefined;
|
|
79
|
+
const onClick = () => {
|
|
80
|
+
focusedAtClick = focused();
|
|
81
|
+
};
|
|
82
|
+
outside().addEventListener("click", onClick);
|
|
83
|
+
|
|
84
|
+
press("pointerdown");
|
|
85
|
+
act(() => outside().focus());
|
|
86
|
+
expect(focused()).toBe("true");
|
|
87
|
+
|
|
88
|
+
press("pointerup");
|
|
89
|
+
expect(focused()).toBe("true");
|
|
90
|
+
act(() => outside().click());
|
|
91
|
+
expect(focusedAtClick).toBe("true");
|
|
92
|
+
|
|
93
|
+
act(() => {
|
|
94
|
+
vi.runOnlyPendingTimers();
|
|
95
|
+
});
|
|
96
|
+
expect(focused()).toBe("false");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("holds through a tap, which moves focus and clicks after its release", () => {
|
|
100
|
+
let focusedAtClick: string | null | undefined;
|
|
101
|
+
const onClick = () => {
|
|
102
|
+
focusedAtClick = focused();
|
|
103
|
+
};
|
|
104
|
+
outside().addEventListener("click", onClick);
|
|
105
|
+
|
|
106
|
+
press("pointerdown", "touch");
|
|
107
|
+
press("pointerup", "touch");
|
|
108
|
+
act(() => outside().focus());
|
|
109
|
+
expect(focused()).toBe("true");
|
|
110
|
+
|
|
111
|
+
act(() => outside().click());
|
|
112
|
+
expect(focusedAtClick).toBe("true");
|
|
113
|
+
|
|
114
|
+
act(() => {
|
|
115
|
+
vi.runOnlyPendingTimers();
|
|
116
|
+
});
|
|
117
|
+
expect(focused()).toBe("false");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("settles a tap that never clicks once the wait runs out", () => {
|
|
121
|
+
press("pointerdown", "touch");
|
|
122
|
+
press("pointerup", "touch");
|
|
123
|
+
act(() => outside().focus());
|
|
124
|
+
expect(focused()).toBe("true");
|
|
125
|
+
|
|
126
|
+
act(() => {
|
|
127
|
+
vi.advanceTimersByTime(400);
|
|
128
|
+
});
|
|
129
|
+
act(() => {
|
|
130
|
+
vi.runOnlyPendingTimers();
|
|
131
|
+
});
|
|
132
|
+
expect(focused()).toBe("false");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("settles when a context menu takes over the press", () => {
|
|
136
|
+
press("pointerdown");
|
|
137
|
+
act(() => outside().focus());
|
|
138
|
+
press("contextmenu");
|
|
139
|
+
act(() => {
|
|
140
|
+
vi.runOnlyPendingTimers();
|
|
141
|
+
});
|
|
142
|
+
expect(focused()).toBe("false");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("settles when the window loses focus during the press", () => {
|
|
146
|
+
press("pointerdown");
|
|
147
|
+
act(() => outside().focus());
|
|
148
|
+
act(() => {
|
|
149
|
+
window.dispatchEvent(new Event("blur"));
|
|
150
|
+
});
|
|
151
|
+
act(() => {
|
|
152
|
+
vi.runOnlyPendingTimers();
|
|
153
|
+
});
|
|
154
|
+
expect(focused()).toBe("false");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("settles after a cancelled press too", () => {
|
|
158
|
+
press("pointerdown");
|
|
159
|
+
act(() => outside().focus());
|
|
160
|
+
press("pointercancel");
|
|
161
|
+
act(() => {
|
|
162
|
+
vi.runOnlyPendingTimers();
|
|
163
|
+
});
|
|
164
|
+
expect(focused()).toBe("false");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("stays focused when focus returns during the press", () => {
|
|
168
|
+
press("pointerdown");
|
|
169
|
+
act(() => outside().focus());
|
|
170
|
+
act(() => inside().focus());
|
|
171
|
+
press("pointerup");
|
|
172
|
+
act(() => {
|
|
173
|
+
vi.runOnlyPendingTimers();
|
|
174
|
+
});
|
|
175
|
+
expect(focused()).toBe("true");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("stays focused when focus returns between the release and the timer", () => {
|
|
179
|
+
press("pointerdown");
|
|
180
|
+
act(() => outside().focus());
|
|
181
|
+
press("pointerup");
|
|
182
|
+
act(() => inside().focus());
|
|
183
|
+
act(() => {
|
|
184
|
+
vi.runOnlyPendingTimers();
|
|
185
|
+
});
|
|
186
|
+
expect(focused()).toBe("true");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("treats a blur after the press as a plain blur", () => {
|
|
190
|
+
press("pointerdown");
|
|
191
|
+
press("pointerup");
|
|
192
|
+
act(() => {
|
|
193
|
+
vi.runOnlyPendingTimers();
|
|
194
|
+
});
|
|
195
|
+
expect(focused()).toBe("true");
|
|
196
|
+
act(() => outside().focus());
|
|
197
|
+
expect(focused()).toBe("false");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("drops a pending settle when it unmounts", () => {
|
|
201
|
+
press("pointerdown");
|
|
202
|
+
act(() => outside().focus());
|
|
203
|
+
press("pointerup");
|
|
204
|
+
act(() => root.unmount());
|
|
205
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
206
|
+
root = createRoot(container);
|
|
207
|
+
});
|
|
208
|
+
});
|