overrideInputFocus.test.tsx3.2 KBView on GitHub import { render, act, waitFor } from '@testing-library/react';
import { EditorContent } from '@tiptap/react';
import { useCedarStore } from '@/modules/cedar-os/src/store/CedarStore';
import { useCedarEditor } from '@/modules/cedar-os/src/components/chatInput/useCedarEditor';
/**
* A button that hands a prompt to the chat (the event popover's "Draft reschedule email",
* a suggestion tile, the queue card's Edit) must leave the caret IN the composer — otherwise
* the next keystroke goes to whatever was focused before, usually the button itself.
*
* The second case is the one that actually bit: those buttons sit in popovers and menus, and
* Radix's focus scope re-focuses the trigger from a `setTimeout(…, 0)` as the overlay unmounts.
*/
// The composer's `[[` file search hits tRPC; irrelevant here and unavailable in jsdom.
jest.mock('@/modules/documents/file-link', () => ({
useCachedFileLinkSearch: () => async () => [],
}));
// jsdom has no layout engine, and ProseMirror measures on every focus (it scrolls the caret
// into view). Stub the three geometry calls it makes so the view can mount and focus.
beforeAll(() => {
document.elementFromPoint = () => null;
const emptyRect = { top: 0, left: 0, bottom: 0, right: 0, width: 0, height: 0 };
for (const proto of [Text.prototype, Element.prototype, Range.prototype]) {
// defineProperty rather than assignment: jsdom's real signatures return DOMRect(List),
// and a stub that shape-matches them is more code than the measurement it stands in for.
Object.defineProperty(proto, 'getClientRects', { value: () => [], configurable: true });
Object.defineProperty(proto, 'getBoundingClientRect', {
value: () => emptyRect,
configurable: true,
});
}
});
function Composer() {
const { editor } = useCedarEditor();
return <EditorContent editor={editor} />;
}
describe('useCedarEditor — override content takes focus', () => {
beforeEach(() => {
useCedarStore.getState().setOverrideInputContent(null);
});
it('focuses the composer when a prompt is dropped into it', async () => {
render(<Composer />);
const trigger = document.createElement('button');
document.body.appendChild(trigger);
trigger.focus();
act(() => {
useCedarStore.getState().setOverrideInputContent('I need to reschedule "Kickoff".');
});
await waitFor(() => {
expect(document.activeElement).toHaveClass('ProseMirror');
});
});
it('takes focus back from an overlay that restores it to its trigger on close', async () => {
render(<Composer />);
const trigger = document.createElement('button');
document.body.appendChild(trigger);
trigger.focus();
jest.useFakeTimers();
try {
act(() => {
// Exactly what @radix-ui/react-focus-scope does on unmount: restore focus to the
// element that was focused before the overlay opened, one macrotask later.
setTimeout(() => trigger.focus(), 0);
useCedarStore.getState().setOverrideInputContent('I need to reschedule "Kickoff".');
});
act(() => {
jest.runOnlyPendingTimers();
});
} finally {
jest.useRealTimers();
}
expect(document.activeElement).toHaveClass('ProseMirror');
});
});