qwen-code/packages/web-shell/client/components/PaneHeaderActions.test.tsx
samuelhsin 26600896d5
feat(web-shell): add split pane header action slot with overflow (#7808)
* feat(web-shell): add split pane header action slot with overflow

Let hosts render per-session actions in each split pane header, collapsing them into a … menu when the pane is too narrow.

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs(web-shell): add pane header actions PR screenshots

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web-shell): tighten pane header overflow measurement

Drop the per-render children effect dependency that rebuilt ResizeObserver during streaming, and reserve workspace-tag width when computing available header space.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web-shell): address pane header overflow review blockers

Mount host actions in only one tree, and wrap overflow entries as DropdownMenuItems so Radix selection and keyboard navigation work.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web-shell): keep pane header actions alive across overflow

Flatten Fragment host actions before building the overflow menu, and keep the same host instances mounted when collapsing so stateful actions are not reset.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web-shell): address pane header overflow review suggestions (#7808)

* fix(web-shell): proxy overflow clicks via action slots

Wrap host pane actions in stable slots so the overflow menu can activate interactive descendants without requiring opaque custom components to forward internal data attributes.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web-shell): address overflow menu review suggestions (#7808)

* fix(web-shell): harden pane header overflow actions (#7808)

Restore the 8px gap between the built-in maximize/close controls, ignore
aria-hidden glyphs when labelling overflow items, omit non-interactive
children from the overflow menu, and document the popover constraint on
renderHeaderActions. Refreshes the design doc to match the mount-once
implementation.

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com>
Co-authored-by: Qwen Code Bot <qwen-code-bot@users.noreply.github.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
Co-authored-by: Qwen Code Autofix <qwen-code-autofix@users.noreply.github.com>
2026-07-29 13:06:48 +00:00

587 lines
15 KiB
TypeScript

// @vitest-environment jsdom
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, useEffect, useState, type ReactNode } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { I18nProvider } from '../i18n';
import { WebShellPortalRootContext } from '../portalRoot';
import { PaneHeaderActions } from './PaneHeaderActions';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
let root: Root | null = null;
let container: HTMLDivElement | null = null;
let portalRoot: HTMLDivElement | null = null;
let resizeCallback: ResizeObserverCallback | null = null;
beforeEach(() => {
resizeCallback = null;
vi.stubGlobal(
'ResizeObserver',
class {
constructor(cb: ResizeObserverCallback) {
resizeCallback = cb;
}
observe() {}
unobserve() {}
disconnect() {}
},
);
});
afterEach(() => {
act(() => root?.unmount());
container?.remove();
portalRoot?.remove();
root = null;
container = null;
portalRoot = null;
vi.unstubAllGlobals();
});
function render(ui: ReactNode): void {
container = document.createElement('div');
portalRoot = document.createElement('div');
portalRoot.dataset.webShellPortalRoot = '';
document.body.appendChild(container);
document.body.appendChild(portalRoot);
root = createRoot(container);
act(() =>
root!.render(
<WebShellPortalRootContext.Provider value={portalRoot}>
<I18nProvider language="en">{ui}</I18nProvider>
</WebShellPortalRootContext.Provider>,
),
);
}
function hostEl(): HTMLElement | null {
return (
container!.querySelector('[data-testid="pane-header-actions-inline"]') ??
container!.querySelector('[data-testid="pane-header-actions-host"]')
);
}
function stubWidths(opts: {
header: number;
hostActions: number;
trailing?: number;
workspaceTag?: number;
}): void {
const header = container!.querySelector('header') as HTMLElement;
const host = hostEl();
Object.defineProperty(header, 'clientWidth', {
configurable: true,
value: opts.header,
});
if (host) {
Object.defineProperty(host, 'scrollWidth', {
configurable: true,
value: opts.hostActions,
});
}
const trailingEl = container!.querySelector(
'[data-testid="pane-close"]',
)?.parentElement;
if (trailingEl) {
Object.defineProperty(trailingEl, 'offsetWidth', {
configurable: true,
value: opts.trailing ?? 26,
});
}
if (opts.workspaceTag != null) {
const tag = container!.querySelector(
'[data-web-shell-pane-workspace]',
) as HTMLElement;
if (tag) {
Object.defineProperty(tag, 'offsetWidth', {
configurable: true,
value: opts.workspaceTag,
});
}
}
}
function collapse(): void {
stubWidths({ header: 200, hostActions: 180, trailing: 26 });
act(() => {
resizeCallback?.([], {} as ResizeObserver);
});
}
describe('PaneHeaderActions', () => {
it('shows host actions inline when they fit', () => {
render(
<header>
<span>Title</span>
<PaneHeaderActions
trailing={
<button type="button" data-testid="pane-close">
x
</button>
}
>
<button type="button" data-testid="host-action">
Share
</button>
</PaneHeaderActions>
</header>,
);
stubWidths({ header: 400, hostActions: 80, trailing: 26 });
act(() => {
resizeCallback?.([], {} as ResizeObserver);
});
expect(
container!.querySelector('[data-testid="pane-header-actions-inline"]'),
).not.toBeNull();
expect(
container!.querySelector('[data-testid="pane-header-overflow"]'),
).toBeNull();
expect(
container!.querySelector('[data-testid="host-action"]')?.textContent,
).toBe('Share');
});
it('collapses host actions into an overflow menu with menuitems', async () => {
const onShare = vi.fn();
render(
<header>
<span>Title</span>
<PaneHeaderActions
trailing={
<button type="button" data-testid="pane-close">
x
</button>
}
>
<button type="button" data-testid="host-action" onClick={onShare}>
Share
</button>
</PaneHeaderActions>
</header>,
);
collapse();
expect(
container!.querySelector('[data-testid="pane-header-actions-inline"]'),
).toBeNull();
expect(
container!.querySelector('[data-testid="pane-header-actions-host"]'),
).not.toBeNull();
const overflow = container!.querySelector(
'[data-testid="pane-header-overflow"]',
) as HTMLButtonElement;
expect(overflow).not.toBeNull();
await act(async () => {
overflow.dispatchEvent(
new MouseEvent('pointerdown', { bubbles: true, button: 0 }),
);
});
const menu = document.querySelector(
'[data-testid="pane-header-overflow-menu"]',
);
expect(menu).not.toBeNull();
const items = menu!.querySelectorAll('[role="menuitem"]');
expect(items).toHaveLength(1);
expect(items[0]?.textContent).toBe('Share');
await act(async () => {
items[0]!.dispatchEvent(
new MouseEvent('click', { bubbles: true, button: 0 }),
);
});
expect(onShare).toHaveBeenCalledTimes(1);
});
it('flattens Fragment children into overflow menuitems', async () => {
render(
<header>
<span>Title</span>
<PaneHeaderActions
trailing={
<button type="button" data-testid="pane-close">
x
</button>
}
>
<>
<button type="button" aria-label="Env">
Env
</button>
<button type="button" aria-label="Share">
Share
</button>
</>
</PaneHeaderActions>
</header>,
);
collapse();
const overflow = container!.querySelector(
'[data-testid="pane-header-overflow"]',
) as HTMLButtonElement;
await act(async () => {
overflow.dispatchEvent(
new MouseEvent('pointerdown', { bubbles: true, button: 0 }),
);
});
const menu = document.querySelector(
'[data-testid="pane-header-overflow-menu"]',
);
const items = menu!.querySelectorAll('[role="menuitem"]');
expect(items).toHaveLength(2);
expect(items[0]?.textContent).toBe('Env');
expect(items[1]?.textContent).toBe('Share');
});
it('activates opaque custom host components from the overflow menu', async () => {
const onShare = vi.fn();
function ShareButton() {
return (
<button type="button" data-testid="host-action" onClick={onShare}>
Share
</button>
);
}
render(
<header>
<span>Title</span>
<PaneHeaderActions
trailing={
<button type="button" data-testid="pane-close">
x
</button>
}
>
<ShareButton />
</PaneHeaderActions>
</header>,
);
collapse();
const overflow = container!.querySelector(
'[data-testid="pane-header-overflow"]',
) as HTMLButtonElement;
await act(async () => {
overflow.dispatchEvent(
new MouseEvent('pointerdown', { bubbles: true, button: 0 }),
);
});
const menu = document.querySelector(
'[data-testid="pane-header-overflow-menu"]',
);
const items = menu!.querySelectorAll('[role="menuitem"]');
expect(items).toHaveLength(1);
expect(items[0]?.textContent).toBe('Share');
await act(async () => {
items[0]!.dispatchEvent(
new MouseEvent('click', { bubbles: true, button: 0 }),
);
});
expect(onShare).toHaveBeenCalledTimes(1);
});
it('uses the title attribute as overflow label when no text children', async () => {
render(
<header>
<span>Title</span>
<PaneHeaderActions
trailing={
<button type="button" data-testid="pane-close">
x
</button>
}
>
<button type="button" title="Export">
<span aria-hidden="true"></span>
</button>
</PaneHeaderActions>
</header>,
);
collapse();
const overflow = container!.querySelector(
'[data-testid="pane-header-overflow"]',
) as HTMLButtonElement;
await act(async () => {
overflow.dispatchEvent(
new MouseEvent('pointerdown', { bubbles: true, button: 0 }),
);
});
const menu = document.querySelector(
'[data-testid="pane-header-overflow-menu"]',
);
const items = menu!.querySelectorAll('[role="menuitem"]');
expect(items).toHaveLength(1);
expect(items[0]?.textContent).toBe('Export');
});
it('falls back to the default label when no label source exists', async () => {
render(
<header>
<span>Title</span>
<PaneHeaderActions
trailing={
<button type="button" data-testid="pane-close">
x
</button>
}
>
<button type="button" />
</PaneHeaderActions>
</header>,
);
collapse();
const overflow = container!.querySelector(
'[data-testid="pane-header-overflow"]',
) as HTMLButtonElement;
await act(async () => {
overflow.dispatchEvent(
new MouseEvent('pointerdown', { bubbles: true, button: 0 }),
);
});
const menu = document.querySelector(
'[data-testid="pane-header-overflow-menu"]',
);
const items = menu!.querySelectorAll('[role="menuitem"]');
expect(items).toHaveLength(1);
expect(items[0]?.textContent).toBe('Action');
});
it('keeps host action instances mounted across collapse', () => {
let mounts = 0;
let unmounts = 0;
function HostAction() {
const [clicks, setClicks] = useState(0);
useEffect(() => {
mounts += 1;
return () => {
unmounts += 1;
};
}, []);
return (
<button
type="button"
data-testid="host-action"
data-clicks={clicks}
onClick={() => setClicks((value) => value + 1)}
>
Share
</button>
);
}
render(
<header>
<span>Title</span>
<PaneHeaderActions
trailing={
<button type="button" data-testid="pane-close">
x
</button>
}
>
<HostAction />
</PaneHeaderActions>
</header>,
);
act(() => {
container!
.querySelector('[data-testid="host-action"]')!
.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(
container!
.querySelector('[data-testid="host-action"]')
?.getAttribute('data-clicks'),
).toBe('1');
collapse();
expect(mounts).toBe(1);
expect(unmounts).toBe(0);
expect(
container!
.querySelector('[data-testid="host-action"]')
?.getAttribute('data-clicks'),
).toBe('1');
});
it('reserves width for the workspace tag when measuring', () => {
render(
<header>
<span data-web-shell-pane-workspace>ws</span>
<span>Title</span>
<PaneHeaderActions
trailing={
<button type="button" data-testid="pane-close">
x
</button>
}
>
<button type="button" data-testid="host-action">
Share
</button>
</PaneHeaderActions>
</header>,
);
// Header 300px, actions 140px, tag 80px → available ≈ 122 → collapse
stubWidths({
header: 300,
hostActions: 140,
trailing: 26,
workspaceTag: 80,
});
act(() => {
resizeCallback?.([], {} as ResizeObserver);
});
expect(
container!.querySelector('[data-testid="pane-header-overflow"]'),
).not.toBeNull();
});
it('expands host actions back inline when the pane widens', () => {
render(
<header>
<span>Title</span>
<PaneHeaderActions
trailing={
<button type="button" data-testid="pane-close">
x
</button>
}
>
<button type="button" data-testid="host-action">
Share
</button>
</PaneHeaderActions>
</header>,
);
collapse();
expect(
container!.querySelector('[data-testid="pane-header-overflow"]'),
).not.toBeNull();
expect(
container!.querySelector('[data-testid="pane-header-actions-inline"]'),
).toBeNull();
stubWidths({ header: 400, hostActions: 80, trailing: 26 });
act(() => {
resizeCallback?.([], {} as ResizeObserver);
});
expect(
container!.querySelector('[data-testid="pane-header-actions-inline"]'),
).not.toBeNull();
expect(
container!.querySelector('[data-testid="pane-header-overflow"]'),
).toBeNull();
});
it('ignores aria-hidden glyphs when labelling overflow items', async () => {
render(
<header>
<span>Title</span>
<PaneHeaderActions
trailing={
<button type="button" data-testid="pane-close">
x
</button>
}
>
<button type="button">
<span aria-hidden="true"></span>
</button>
</PaneHeaderActions>
</header>,
);
collapse();
const overflow = container!.querySelector(
'[data-testid="pane-header-overflow"]',
) as HTMLButtonElement;
await act(async () => {
overflow.dispatchEvent(
new MouseEvent('pointerdown', { bubbles: true, button: 0 }),
);
});
const menu = document.querySelector(
'[data-testid="pane-header-overflow-menu"]',
);
const items = menu!.querySelectorAll('[role="menuitem"]');
expect(items).toHaveLength(1);
expect(items[0]?.textContent).toBe('Action');
});
it('omits non-interactive children from the overflow menu', async () => {
const onEnv = vi.fn();
const onShare = vi.fn();
render(
<header>
<span>Title</span>
<PaneHeaderActions
trailing={
<button type="button" data-testid="pane-close">
x
</button>
}
>
<button type="button" aria-label="Env" onClick={onEnv}>
Env
</button>
<span aria-hidden="true">|</span>
<button type="button" aria-label="Share" onClick={onShare}>
Share
</button>
</PaneHeaderActions>
</header>,
);
collapse();
const overflow = container!.querySelector(
'[data-testid="pane-header-overflow"]',
) as HTMLButtonElement;
await act(async () => {
overflow.dispatchEvent(
new MouseEvent('pointerdown', { bubbles: true, button: 0 }),
);
});
const menu = document.querySelector(
'[data-testid="pane-header-overflow-menu"]',
);
const items = menu!.querySelectorAll('[role="menuitem"]');
expect(items).toHaveLength(2);
expect(items[0]?.textContent).toBe('Env');
expect(items[1]?.textContent).toBe('Share');
await act(async () => {
items[1]!.dispatchEvent(
new MouseEvent('click', { bubbles: true, button: 0 }),
);
});
expect(onShare).toHaveBeenCalledTimes(1);
expect(onEnv).not.toHaveBeenCalled();
});
});