diff --git a/frontend/src/components/chat/MessageComposer/CommandPopoverButton.tsx b/frontend/src/components/chat/MessageComposer/CommandPopoverButton.tsx index 9d35d4834d..fd6338ed0a 100644 --- a/frontend/src/components/chat/MessageComposer/CommandPopoverButton.tsx +++ b/frontend/src/components/chat/MessageComposer/CommandPopoverButton.tsx @@ -2,6 +2,7 @@ import { cn } from '@/lib/utils'; import { Popover, PopoverContent, + PopoverPortal, PopoverTrigger } from '@radix-ui/react-popover'; import { every } from 'lodash'; @@ -191,51 +192,53 @@ export const CommandPopoverButton = ({ - - - - - {nonButtonCommands.map((command, index) => ( - handleMouseMove(index)} - onSelect={() => handleCommandSelect(command)} // Direct call for mouse clicks - className="space-x-2" - > - -
-
{command.id}
-
- {command.description} + + + + + + {nonButtonCommands.map((command, index) => ( + handleMouseMove(index)} + onSelect={() => handleCommandSelect(command)} // Direct call for mouse clicks + className="space-x-2" + > + +
+
{command.id}
+
+ {command.description} +
-
- - ))} - - - - + + ))} + + + + +
); diff --git a/frontend/src/components/chat/MessageComposer/FavoriteButton.tsx b/frontend/src/components/chat/MessageComposer/FavoriteButton.tsx index 6e80a29617..3d498ca4e1 100644 --- a/frontend/src/components/chat/MessageComposer/FavoriteButton.tsx +++ b/frontend/src/components/chat/MessageComposer/FavoriteButton.tsx @@ -2,6 +2,7 @@ import { cn } from '@/lib/utils'; import { Popover, PopoverContent, + PopoverPortal, PopoverTrigger } from '@radix-ui/react-popover'; import { Star, Trash } from 'lucide-react'; @@ -117,69 +118,71 @@ export const FavoriteButton = ({ disabled = false, onSelect }: Props) => { - - - - {favorites.length === 0 ? ( - -
-

- {t('chat.favorites.empty.title')} -

-

- {t('chat.favorites.empty.description')} -

-
-
- ) : ( - - {favorites.map((step) => ( - { - onSelect(step.output); - setOpen(false); - cancelTooltipOpen(); - }} - className="cursor-pointer group" - > -
-
- - {step.output} - - - {new Date(step.createdAt).toLocaleDateString()} - + + + + + {favorites.length === 0 ? ( + +
+

+ {t('chat.favorites.empty.title')} +

+

+ {t('chat.favorites.empty.description')} +

+
+
+ ) : ( + + {favorites.map((step) => ( + { + onSelect(step.output); + setOpen(false); + cancelTooltipOpen(); + }} + className="cursor-pointer group" + > +
+
+ + {step.output} + + + {new Date(step.createdAt).toLocaleDateString()} + +
+
- -
- - ))} - - )} - - - + + ))} + + )} + + + +
); diff --git a/frontend/src/components/chat/MessageComposer/ModePicker.tsx b/frontend/src/components/chat/MessageComposer/ModePicker.tsx index cef142ce1a..bab792a201 100644 --- a/frontend/src/components/chat/MessageComposer/ModePicker.tsx +++ b/frontend/src/components/chat/MessageComposer/ModePicker.tsx @@ -2,6 +2,7 @@ import { cn } from '@/lib/utils'; import { Popover, PopoverContent, + PopoverPortal, PopoverTrigger } from '@radix-ui/react-popover'; import { ChevronDown, ChevronUp } from 'lucide-react'; @@ -155,57 +156,59 @@ export const ModePicker = ({ - - - - - {options.map((option, index) => ( - handleMouseMove(index)} - onSelect={() => handleOptionSelect(option)} - className={cn( - 'flex items-start gap-2 px-2 py-2 cursor-pointer', - selectedOptionId === option.id && 'bg-accent' - )} - > - {renderIcon( - option.icon, - cn( - '!size-5 mt-0.5 text-muted-foreground flex-shrink-0', - index === selectedIndex && 'text-foreground' - ) - )} -
-
- {option.name} -
- {option.description && ( -
- {option.description} -
+ + + + + + {options.map((option, index) => ( + handleMouseMove(index)} + onSelect={() => handleOptionSelect(option)} + className={cn( + 'flex items-start gap-2 px-2 py-2 cursor-pointer', + selectedOptionId === option.id && 'bg-accent' + )} + > + {renderIcon( + option.icon, + cn( + '!size-5 mt-0.5 text-muted-foreground flex-shrink-0', + index === selectedIndex && 'text-foreground' + ) )} -
-
- ))} -
-
-
-
+
+
+ {option.name} +
+ {option.description && ( +
+ {option.description} +
+ )} +
+
+ ))} +
+
+
+
+ ); diff --git a/frontend/tests/CommandPopoverButton.spec.tsx b/frontend/tests/CommandPopoverButton.spec.tsx new file mode 100644 index 0000000000..e4d808bda2 --- /dev/null +++ b/frontend/tests/CommandPopoverButton.spec.tsx @@ -0,0 +1,65 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { RecoilRoot } from 'recoil'; +import { describe, expect, it, vi } from 'vitest'; + +import { commandsState } from '@chainlit/react-client'; + +import { CommandPopoverButton } from '@/components/chat/MessageComposer/CommandPopoverButton'; + +import { mountShadowHost } from './testUtils'; + +vi.mock('components/i18n/Translator', () => ({ + useTranslation: () => ({ t: (key: string) => key }) +})); + +vi.mock('@chainlit/react-client', async () => { + const { atom } = await import('recoil'); + return { + commandsState: atom({ key: 'commandsState', default: [] }) + }; +}); + +const commands = [ + { id: 'Search', description: 'Search the web', icon: 'search', button: false } +]; + +const POPOVER_ID = '#command-popover'; + +const renderComponent = () => + render( + set(commandsState, commands as any)} + > + + + ); + +describe('CommandPopoverButton — shadow DOM popover positioning', () => { + it('portals the popover into the widget shadow root', () => { + const shadowRoot = mountShadowHost(); + + renderComponent(); + fireEvent.click(screen.getByRole('button')); + + // Content lives inside the encapsulated shadow tree, not the light DOM. + expect(shadowRoot.querySelector(POPOVER_ID)).not.toBeNull(); + expect(document.body.querySelector(POPOVER_ID)).toBeNull(); + }); + + it('falls back to document.body when no shadow root is set (standalone app)', () => { + renderComponent(); + fireEvent.click(screen.getByRole('button')); + + expect(document.querySelector(POPOVER_ID)).not.toBeNull(); + }); + + it('gives the popover content a stacking z-index above the chat', () => { + renderComponent(); + fireEvent.click(screen.getByRole('button')); + + // jsdom has no layout engine, so we assert the stacking class Radix copies + // onto the popper wrapper rather than computed geometry (covered by e2e). + const content = document.querySelector(POPOVER_ID); + expect(content?.classList.contains('z-[51]')).toBe(true); + }); +}); diff --git a/frontend/tests/FavoriteButton.spec.tsx b/frontend/tests/FavoriteButton.spec.tsx index c84c846e8e..6964554d99 100644 --- a/frontend/tests/FavoriteButton.spec.tsx +++ b/frontend/tests/FavoriteButton.spec.tsx @@ -10,6 +10,8 @@ import { import { FavoriteButton } from '@/components/chat/MessageComposer/FavoriteButton'; +import { mountShadowHost } from './testUtils'; + const toggleMessageFavoriteMock = vi.fn(); vi.mock('@/components/i18n/Translator', () => ({ @@ -42,14 +44,6 @@ vi.mock('@chainlit/react-client', async () => { }; }); -global.ResizeObserver = class ResizeObserver { - observe() {} - unobserve() {} - disconnect() {} -}; - -window.HTMLElement.prototype.scrollIntoView = vi.fn(); - describe('FavoriteButton', () => { const mockOnSelect = vi.fn(); @@ -304,3 +298,58 @@ describe('FavoriteButton', () => { expect(screen.getByText('Favorites List')).toBeInTheDocument(); }); }); + +describe('FavoriteButton — shadow DOM popover positioning', () => { + const favorites: IStep[] = [ + { + id: 'msg_1', + output: 'How do I center a div?', + createdAt: new Date('2023-10-01').getTime(), + type: 'assistant_message', + name: 'Assistant' + } + ]; + + const renderComponent = () => + render( + set(favoriteMessagesState, favorites)} + > + + + ); + + beforeEach(() => { + (useConfig as any).mockReturnValue({ + config: { features: { favorites: true } } + }); + }); + + it('portals the popover into the widget shadow root', () => { + const shadowRoot = mountShadowHost(); + + renderComponent(); + fireEvent.click(screen.getByRole('button')); + + // Content lives inside the encapsulated shadow tree, not the light DOM. + expect(shadowRoot.textContent).toContain('Favorites List'); + expect(document.body.textContent).not.toContain('Favorites List'); + }); + + it('falls back to document.body when no shadow root is set (standalone app)', () => { + renderComponent(); + fireEvent.click(screen.getByRole('button')); + + expect(screen.getByText('Favorites List')).toBeInTheDocument(); + }); + + it('gives the popover content a stacking z-index above the chat', () => { + renderComponent(); + fireEvent.click(screen.getByRole('button')); + + // jsdom has no layout engine, so we assert the stacking class Radix copies + // onto the popper wrapper rather than computed geometry (covered by e2e). + const content = document.querySelector('.z-\\[51\\]'); + expect(content).toHaveTextContent('Favorites List'); + }); +}); diff --git a/frontend/tests/ModePicker.spec.tsx b/frontend/tests/ModePicker.spec.tsx new file mode 100644 index 0000000000..7ba0f4757a --- /dev/null +++ b/frontend/tests/ModePicker.spec.tsx @@ -0,0 +1,57 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { ModePicker } from '@/components/chat/MessageComposer/ModePicker'; + +import { mountShadowHost } from './testUtils'; + +vi.mock('@chainlit/react-client', async () => { + const React = await import('react'); + return { + ChainlitContext: React.createContext(undefined) + }; +}); + +const mode = { + id: 'model', + name: 'Model', + options: [ + { id: 'fast', name: 'Fast' }, + { id: 'smart', name: 'Smart' } + ] +}; + +const POPOVER_ID = '#mode-picker-popover-model'; + +const renderComponent = () => + render(); + +describe('ModePicker — shadow DOM popover positioning', () => { + it('portals the popover into the widget shadow root', () => { + const shadowRoot = mountShadowHost(); + + renderComponent(); + fireEvent.click(screen.getByRole('button')); + + // Content lives inside the encapsulated shadow tree, not the light DOM. + expect(shadowRoot.querySelector(POPOVER_ID)).not.toBeNull(); + expect(document.body.querySelector(POPOVER_ID)).toBeNull(); + }); + + it('falls back to document.body when no shadow root is set (standalone app)', () => { + renderComponent(); + fireEvent.click(screen.getByRole('button')); + + expect(document.querySelector(POPOVER_ID)).not.toBeNull(); + }); + + it('gives the popover content a stacking z-index above the chat', () => { + renderComponent(); + fireEvent.click(screen.getByRole('button')); + + // jsdom has no layout engine, so we assert the stacking class Radix copies + // onto the popper wrapper rather than computed geometry (covered by e2e). + const content = document.querySelector(POPOVER_ID); + expect(content?.classList.contains('z-[51]')).toBe(true); + }); +}); diff --git a/frontend/tests/setup-tests.ts b/frontend/tests/setup-tests.ts index 154169172a..eac3e7368d 100644 --- a/frontend/tests/setup-tests.ts +++ b/frontend/tests/setup-tests.ts @@ -2,11 +2,21 @@ import matchers from '@testing-library/jest-dom/matchers'; import { cleanup } from '@testing-library/react'; import { afterEach, expect, vi } from 'vitest'; +import { cleanupShadowHosts } from './testUtils'; + expect.extend(matchers); // Mock URL.createObjectURL global.URL.createObjectURL = vi.fn(); +// Radix popovers/commands rely on these DOM APIs that jsdom doesn't implement. +global.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +}; +window.HTMLElement.prototype.scrollIntoView = vi.fn(); + // Polyfill DOMMatrix for pdfjs-dist which requires it at import time in JSDOM if (typeof globalThis.DOMMatrix === 'undefined') { globalThis.DOMMatrix = class DOMMatrix { @@ -49,4 +59,5 @@ if (typeof globalThis.DOMMatrix === 'undefined') { afterEach(() => { cleanup(); + cleanupShadowHosts(); }); diff --git a/frontend/tests/testUtils.ts b/frontend/tests/testUtils.ts new file mode 100644 index 0000000000..b9a85c5edb --- /dev/null +++ b/frontend/tests/testUtils.ts @@ -0,0 +1,27 @@ +const shadowHosts: HTMLElement[] = []; + +/** + * Mounts a real (open) shadow root and points window.cl_shadowRootElement at a + * div inside it, mirroring how the Copilot widget mounts. Returns the shadow + * root so tests can assert popovers are portaled into the encapsulated tree. + * Cleanup is handled globally by setup-tests' afterEach — no per-spec wiring needed. + */ +export function mountShadowHost(): ShadowRoot { + const host = document.createElement('div'); + document.body.appendChild(host); + shadowHosts.push(host); + + const shadowRoot = host.attachShadow({ mode: 'open' }); + const container = document.createElement('div'); + shadowRoot.appendChild(container); + window.cl_shadowRootElement = container as HTMLDivElement; + + return shadowRoot; +} + +/** Removes any mounted shadow hosts and clears the global. Run once from setup-tests' afterEach. */ +export function cleanupShadowHosts(): void { + shadowHosts.forEach((host) => host.remove()); + shadowHosts.length = 0; + window.cl_shadowRootElement = undefined; +}