@agent-native/toolkit 0.224.0 → 0.224.1-nightly-20261105013048

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.
@@ -1,3 +1,4 @@
1
+ export * from "./use-focus-within-through-press.js";
1
2
  export * from "./use-mobile.js";
2
3
  export * from "./use-toast.js";
3
4
  //# sourceMappingURL=index.d.ts.map
@@ -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"}
@@ -1,3 +1,4 @@
1
+ export * from "./use-focus-within-through-press.js";
1
2
  export * from "./use-mobile.js";
2
3
  export * from "./use-toast.js";
3
4
  //# sourceMappingURL=index.js.map
@@ -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.224.0",
3
+ "version": "0.224.1-nightly-20261105013048",
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.224.0"
719
+ "@agent-native/agentkit": "^0.224.1-nightly-20261105013048"
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.224.0"
743
+ "@agent-native/core": "^0.224.1-nightly-20261105013048"
744
744
  },
745
745
  "peerDependencies": {
746
- "@agent-native/core": ">=0.224.0-0",
746
+ "@agent-native/core": "0.224.1-nightly-20261105013048",
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 [focused, setFocused] = useState(false);
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
- onFocusCapture={() => setFocused(true)}
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.filter((item) =>
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}
@@ -1,2 +1,3 @@
1
+ export * from "./use-focus-within-through-press.js";
1
2
  export * from "./use-mobile.js";
2
3
  export * from "./use-toast.js";
@@ -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
+ });