1import { act, createElement, useLayoutEffect } from "react";
2import { create } from "react-test-renderer";
3import { afterEach, describe, expect, it, vi } from "vite-plus/test";
4
5import { ComposerHandleContext, type ComposerHandleRef } from "../../composerHandleContext";
6import {
7 isInsideCollapsedComposerControls,
8 isInsideComposerFloatingLayer,
9 isInsideRestingComposerControlScope,
10 useComposerMenuProps,
11} from "./composerEventScope";
12
13class FakeElement {
14 constructor(private readonly matchingSelector: string | null) {}
15
16 closest(selector: string): FakeElement | null {
17 return this.matchingSelector !== null &&
18 selector.split(",").some((candidate) => candidate === this.matchingSelector)
19 ? this
20 : null;
21 }
22}
23
24afterEach(() => {
25 vi.unstubAllGlobals();
26});
27
28describe("composer menu focus", () => {
29 it.each([
30 ["an open menu", '[data-chat-composer-floating-layer="true"]', true],
31 ["an unmounted menu", null, true],
32 ["another control", "input", false],
33 ])("closes while focus is on %s", async (_label, selector, shouldFocusComposer) => {
34 const body = new FakeElement(null);
35 const editor = new FakeElement(null);
36 const activeElement = selector === null ? body : new FakeElement(selector);
37 const document = { body, activeElement };
38 const composerRef = {
39 current: { focusAtEnd: () => (document.activeElement = editor) },
40 } as unknown as ComposerHandleRef;
41 vi.stubGlobal("Element", FakeElement);
42 vi.stubGlobal("document", document);
43 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
44 let menuProps: ReturnType<typeof useComposerMenuProps> | undefined;
45 function Probe() {
46 const props = useComposerMenuProps();
47 useLayoutEffect(() => {
48 menuProps = props;
49 }, [props]);
50 return null;
51 }
52 const renderer = await act(() =>
53 create(createElement(ComposerHandleContext, { value: composerRef }, createElement(Probe))),
54 );
55 try {
56 expect(menuProps?.finalFocus?.()).toBe(false);
57 expect(document.activeElement).toBe(shouldFocusComposer ? editor : activeElement);
58 } finally {
59 await act(() => renderer.unmount());
60 }
61 });
62});
63
64describe("composer event scopes", () => {
65 it("recognizes events from the portaled resting controls", () => {
66 vi.stubGlobal("Element", FakeElement);
67
68 const target = new FakeElement('[data-chat-composer-resting-controls="true"]');
69 expect(isInsideRestingComposerControlScope(target as unknown as EventTarget)).toBe(true);
70 });
71
72 it("recognizes events from the composer context strip controls", () => {
73 vi.stubGlobal("Element", FakeElement);
74
75 const target = new FakeElement("[data-composer-context-control]");
76 expect(isInsideRestingComposerControlScope(target as unknown as EventTarget)).toBe(true);
77 });
78
79 it("keeps resting image previews focused without expanding their subtree", () => {
80 vi.stubGlobal("Element", FakeElement);
81
82 const target = new FakeElement('[data-chat-composer-resting-images="true"]');
83 expect(isInsideRestingComposerControlScope(target as unknown as EventTarget)).toBe(true);
84 });
85
86 it("includes composer-owned floating layers in the resting control scope", () => {
87 vi.stubGlobal("Element", FakeElement);
88
89 const target = new FakeElement('[data-chat-composer-floating-layer="true"]');
90 expect(isInsideComposerFloatingLayer(target as unknown as EventTarget)).toBe(true);
91 expect(isInsideRestingComposerControlScope(target as unknown as EventTarget)).toBe(true);
92 });
93
94 it("leaves unrelated floating layers outside the composer scope", () => {
95 vi.stubGlobal("Element", FakeElement);
96
97 const target = new FakeElement('[data-slot="popover-popup"]');
98 expect(isInsideComposerFloatingLayer(target as unknown as EventTarget)).toBe(false);
99 expect(isInsideRestingComposerControlScope(target as unknown as EventTarget)).toBe(false);
100 });
101
102 it("leaves ordinary composer targets outside the portaled control scope", () => {
103 vi.stubGlobal("Element", FakeElement);
104
105 const target = new FakeElement(null);
106 expect(isInsideRestingComposerControlScope(target as unknown as EventTarget)).toBe(false);
107 expect(isInsideRestingComposerControlScope(null)).toBe(false);
108 });
109
110 it("recognizes banner and drawer controls docked above the surface", () => {
111 vi.stubGlobal("Element", FakeElement);
112
113 const target = new FakeElement('[data-chat-composer-collapsed-controls="true"]');
114 expect(isInsideCollapsedComposerControls(target as unknown as EventTarget)).toBe(true);
115 expect(isInsideCollapsedComposerControls(new FakeElement(null) as unknown as EventTarget)).toBe(
116 false,
117 );
118 expect(isInsideCollapsedComposerControls(null)).toBe(false);
119 });
120});