Skip to content

Commit 31a12c9

Browse files
committed
Carry the menu popup contract onto the composer quick pills
The 插件 / 设计百宝箱 pills open the standalone `role="menu"` popovers in ChatComposer, but they arrived without the contract of the ComposerPlusMenu rows they replaced: no `aria-haspopup` / `aria-expanded`, no Escape handler, and no focus return. Keyboard and screen-reader users could not tell a popup was open, and Escape pressed while focus sat in the plugin search did nothing. The pills live in ChatPane while the popovers live in ChatComposer, so the state is plumbed both ways rather than duplicated: - `onStandalonePanelChange` reports which popover is open, and each pill carries `aria-haspopup="menu"` plus its own `aria-expanded` — the same pair `ComposerPlusMenu` already puts on its trigger. No `aria-controls`: that surface does not use one, and this is not the place to invent a second convention. - `openDesignToolbox` / `openPluginsPanel` take the opening pill as the return-focus target. Both popovers move focus inside themselves, so a dismissal has to hand it back, and the pill is the control the user came from. - Escape closes through a document-level handler, mirroring ComposerPlusMenu's. `dismissStandalonePanels` is deliberately only wired to the dismissal paths (Escape, backdrop). Picking a plugin or an action keeps the plain setters, because those hand focus to the composer input and pulling it back to the pill would fight that. No interaction changes: hover-open, the shared close timer, pill-to-popup pointer travel and every selection path behave exactly as before. Red first: `tests/components/ChatPane.quick-pill-popup-contract.test.tsx` runs both pills through advertise / expand / Escape-from-inside / backdrop-dismiss and asserts focus lands back on the pill. All six cases failed before this change.
1 parent e48b871 commit 31a12c9

3 files changed

Lines changed: 191 additions & 10 deletions

File tree

apps/web/src/components/ChatComposer.tsx

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,10 @@ interface Props {
273273
// ChatPane → ProjectView → App. Omitted → the add rows are hidden.
274274
onBrowsePlugins?: () => void;
275275
onOpenConnectors?: () => void;
276+
/** Reports which standalone quick-pill popover is open (null when none), so
277+
* the host that renders the pills can carry `aria-expanded` on them. The
278+
* popovers live here but their triggers do not. */
279+
onStandalonePanelChange?: (panel: ComposerStandalonePanel) => void;
276280
// Optional pet wiring. The composer no longer renders a visible pet
277281
// entry, but existing manual `/pet` commands still route here.
278282
petConfig?: AppConfig['pet'];
@@ -347,6 +351,9 @@ export interface ChatComposerDraftOptions {
347351
sessionMode?: ChatSessionMode;
348352
}
349353

354+
/** Which of the two standalone quick-pill popovers is open, if either. */
355+
export type ComposerStandalonePanel = 'plugins' | 'toolbox' | null;
356+
350357
export interface ChatComposerHandle {
351358
setDraft: (text: string, options?: ChatComposerDraftOptions) => void;
352359
restoreDraft: (draft: {
@@ -377,11 +384,13 @@ export interface ChatComposerHandle {
377384
*/
378385
applyDesignToolboxSkill: (skillId: string) => void;
379386
/** Open the standalone toolbox popover (the 设计百宝箱 quick pill above the
380-
* composer input; the "+" menu no longer carries a toolbox row). */
381-
openDesignToolbox: () => void;
387+
* composer input; the "+" menu no longer carries a toolbox row). `opener` is
388+
* the control focus returns to when the popover is dismissed. */
389+
openDesignToolbox: (opener?: HTMLElement | null) => void;
382390
/** Open the standalone plugins popover (the 插件 quick pill above the
383-
* composer input; the "+" menu no longer carries a plugins row). */
384-
openPluginsPanel: () => void;
391+
* composer input; the "+" menu no longer carries a plugins row). `opener` is
392+
* the control focus returns to when the popover is dismissed. */
393+
openPluginsPanel: (opener?: HTMLElement | null) => void;
385394
/** Schedule closing whichever standalone popover is open (hover-leave from
386395
* a quick pill); re-opening or hovering the popup cancels it. */
387396
scheduleComposerPanelClose: () => void;
@@ -447,6 +456,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
447456
onStop,
448457
onOpenMcpSettings,
449458
onBrowsePlugins,
459+
onStandalonePanelChange,
450460
onOpenConnectors,
451461
petConfig,
452462
onAdoptPet,
@@ -557,6 +567,43 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
557567
useEffect(() => () => {
558568
if (panelCloseTimerRef.current) clearTimeout(panelCloseTimerRef.current);
559569
}, []);
570+
// The quick pill a standalone popover was opened from. Both popovers move
571+
// focus inside themselves (the plugins pane autofocuses its search box), so
572+
// a dismissal has to hand focus back — the pill lives in the host above the
573+
// composer and is the only control still mounted afterwards.
574+
const panelOpenerRef = useRef<HTMLElement | null>(null);
575+
/** Close whichever standalone popover is open BECAUSE THE USER DISMISSED IT
576+
* (Escape, backdrop) and return focus to the pill that opened it. Paths
577+
* where the user picked something keep the plain setters: the composer
578+
* takes focus there, and pulling it back to the pill would fight that. */
579+
function dismissStandalonePanels() {
580+
cancelComposerPanelClose();
581+
setPluginsPanelOpen(false);
582+
setDesignToolboxOpen(false);
583+
const opener = panelOpenerRef.current;
584+
panelOpenerRef.current = null;
585+
opener?.focus();
586+
}
587+
const openStandalonePanel: ComposerStandalonePanel = designToolboxOpen
588+
? 'toolbox'
589+
: pluginsPanelOpen
590+
? 'plugins'
591+
: null;
592+
useEffect(() => {
593+
onStandalonePanelChange?.(openStandalonePanel);
594+
}, [onStandalonePanelChange, openStandalonePanel]);
595+
// Escape closes the popover, matching ComposerPlusMenu's own document-level
596+
// handler. Without it, Escape pressed while focus sat in the plugin search
597+
// did nothing at all.
598+
useEffect(() => {
599+
if (openStandalonePanel == null) return;
600+
function onKey(event: KeyboardEvent) {
601+
if (event.key !== 'Escape') return;
602+
dismissStandalonePanels();
603+
}
604+
document.addEventListener('keydown', onKey);
605+
return () => document.removeEventListener('keydown', onKey);
606+
}, [openStandalonePanel]);
560607
// External "+"-menu open request (next-step quick pills) — nonce-keyed so
561608
// every pill click re-opens even after the menu was dismissed.
562609
const [plusMenuOpenRequest, setPlusMenuOpenRequest] = useState<
@@ -1157,17 +1204,19 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
11571204
pendingEntryFromRef.current = 'next_step';
11581205
applyDesignToolboxSkillByIdRef.current(skillId);
11591206
},
1160-
openDesignToolbox: () => {
1207+
openDesignToolbox: (opener?: HTMLElement | null) => {
11611208
cancelComposerPanelClose();
11621209
setComposerEngaged(true);
1210+
panelOpenerRef.current = opener ?? null;
11631211
// The two popovers share one anchor spot — opening one closes the
11641212
// other so hover-switching between the pills swaps panels.
11651213
setPluginsPanelOpen(false);
11661214
setDesignToolboxOpen(true);
11671215
},
1168-
openPluginsPanel: () => {
1216+
openPluginsPanel: (opener?: HTMLElement | null) => {
11691217
cancelComposerPanelClose();
11701218
setComposerEngaged(true);
1219+
panelOpenerRef.current = opener ?? null;
11711220
setDesignToolboxOpen(false);
11721221
setPluginsPanelOpen(true);
11731222
},
@@ -2761,7 +2810,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
27612810
<div
27622811
className="composer-toolbox-standalone-backdrop"
27632812
aria-hidden="true"
2764-
onClick={() => setDesignToolboxOpen(false)}
2813+
onClick={dismissStandalonePanels}
27652814
/>
27662815
<div
27672816
className="plus-menu__popup composer-toolbox-standalone-popup"
@@ -2820,7 +2869,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
28202869
<div
28212870
className="composer-toolbox-standalone-backdrop"
28222871
aria-hidden="true"
2823-
onClick={() => setPluginsPanelOpen(false)}
2872+
onClick={dismissStandalonePanels}
28242873
/>
28252874
<div
28262875
className="plus-menu__popup composer-toolbox-standalone-popup composer-plugins-standalone-popup"

apps/web/src/components/ChatPane.tsx

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ import {
8888
type ChatComposerHandle,
8989
type ChatSendOutcome,
9090
type ChatSendMeta,
91+
type ComposerStandalonePanel,
9192
} from './ChatComposer';
9293
import type { PlaceholderScenario } from './home-hero/placeholderScenarios';
9394
import { listDesignArtifactCandidates } from './design-files/designArtifacts';
@@ -966,6 +967,14 @@ export function ChatPane({
966967
const chatLogScrollIdleTimerRef = useRef<number | null>(null);
967968
const historyWrapRef = useRef<HTMLDivElement | null>(null);
968969
const composerRef = useRef<ChatComposerHandle | null>(null);
970+
// The 插件 / 设计百宝箱 quick pills. The popovers they open live inside
971+
// ChatComposer, so the pills need both a way to report their expanded state
972+
// and a stable identity for the popover to return focus to.
973+
const quickPillRefs = {
974+
plugins: useRef<HTMLButtonElement | null>(null),
975+
toolbox: useRef<HTMLButtonElement | null>(null),
976+
};
977+
const [openComposerPanel, setOpenComposerPanel] = useState<ComposerStandalonePanel>(null);
969978
const composerSlotRef = useRef<HTMLDivElement | null>(null);
970979
const composerLayerRef = useRef<HTMLDivElement | null>(null);
971980
const pinnedTodoRef = useRef<HTMLDivElement | null>(null);
@@ -1079,8 +1088,11 @@ export function ChatPane({
10791088
clearTimeout(pillHoverTimerRef.current);
10801089
pillHoverTimerRef.current = null;
10811090
}
1082-
if (which === 'toolbox') composerRef.current?.openDesignToolbox();
1083-
else composerRef.current?.openPluginsPanel();
1091+
// Hand the pill down as the popover's return-focus target: it opens a
1092+
// surface that takes focus, and it is the control the user came from.
1093+
const opener = quickPillRefs[which].current;
1094+
if (which === 'toolbox') composerRef.current?.openDesignToolbox(opener);
1095+
else composerRef.current?.openPluginsPanel(opener);
10841096
}, []);
10851097
const handleQuickPillHoverEnter = useCallback((which: 'plugins' | 'toolbox') => {
10861098
if (pillHoverTimerRef.current) clearTimeout(pillHoverTimerRef.current);
@@ -2152,9 +2164,12 @@ export function ChatPane({
21522164
data-testid="composer-quick-pills"
21532165
>
21542166
<button
2167+
ref={quickPillRefs.plugins}
21552168
type="button"
21562169
className={nextStepStyles.quickPill}
21572170
data-testid="next-step-quick-pill-plugins"
2171+
aria-haspopup="menu"
2172+
aria-expanded={openComposerPanel === 'plugins'}
21582173
onClick={() => handleNextStepOpenComposerPanel('plugins')}
21592174
onMouseEnter={() => handleQuickPillHoverEnter('plugins')}
21602175
onMouseLeave={handleQuickPillHoverLeave}
@@ -2163,9 +2178,12 @@ export function ChatPane({
21632178
<span>{t('entry.navPlugins')}</span>
21642179
</button>
21652180
<button
2181+
ref={quickPillRefs.toolbox}
21662182
type="button"
21672183
className={nextStepStyles.quickPill}
21682184
data-testid="next-step-quick-pill-toolbox"
2185+
aria-haspopup="menu"
2186+
aria-expanded={openComposerPanel === 'toolbox'}
21692187
onClick={() => handleNextStepOpenComposerPanel('toolbox')}
21702188
onMouseEnter={() => handleQuickPillHoverEnter('toolbox')}
21712189
onMouseLeave={handleQuickPillHoverLeave}
@@ -2231,6 +2249,7 @@ export function ChatPane({
22312249
onOpenSettings={onOpenSettings}
22322250
onOpenMcpSettings={onOpenMcpSettings}
22332251
onBrowsePlugins={onBrowsePlugins}
2252+
onStandalonePanelChange={setOpenComposerPanel}
22342253
onOpenConnectors={onOpenConnectors}
22352254
petConfig={petConfig}
22362255
onAdoptPet={onAdoptPet}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
// @vitest-environment jsdom
2+
//
3+
// The 插件 / 设计百宝箱 quick pills above the composer input open the standalone
4+
// `role="menu"` popovers in ChatComposer. They replaced rows that used to live
5+
// in ComposerPlusMenu, but arrived without that surface's keyboard/AT contract:
6+
// no `aria-haspopup` / `aria-expanded` on the pill, no Escape handler, and no
7+
// focus return — so Escape pressed inside the plugin search did nothing and
8+
// keyboard users could not tell the popup was open.
9+
//
10+
// This pins the contract ComposerPlusMenu already ships (`aria-haspopup="menu"`
11+
// plus `aria-expanded`, Escape closes) and the focus return the pills need on
12+
// top of it: the popovers move focus inside themselves, so closing has to hand
13+
// it back to the pill that opened them.
14+
15+
// jsdom has no HTMLElement.scrollTo; ChatPane's log calls it on mount.
16+
if (typeof HTMLElement.prototype.scrollTo !== 'function') {
17+
HTMLElement.prototype.scrollTo = function () {};
18+
}
19+
20+
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
21+
import { afterEach, describe, expect, it } from 'vitest';
22+
23+
import { ChatPane } from '../../src/components/ChatPane';
24+
25+
function renderChatPane() {
26+
return render(
27+
<ChatPane
28+
messages={[]}
29+
streaming={false}
30+
error={null}
31+
projectId="project-1"
32+
projectFiles={[]}
33+
onEnsureProject={async () => 'project-1'}
34+
onSend={() => {}}
35+
onStop={() => {}}
36+
conversations={[]}
37+
activeConversationId={null}
38+
onSelectConversation={() => {}}
39+
onDeleteConversation={() => {}}
40+
/>,
41+
);
42+
}
43+
44+
function pill(which: 'plugins' | 'toolbox'): HTMLElement {
45+
return screen.getByTestId(`next-step-quick-pill-${which}`);
46+
}
47+
48+
function standalonePopup(): HTMLElement | null {
49+
return document.querySelector<HTMLElement>('.composer-toolbox-standalone-popup');
50+
}
51+
52+
afterEach(() => {
53+
cleanup();
54+
});
55+
56+
describe.each([
57+
{ which: 'plugins' as const, label: '插件' },
58+
{ which: 'toolbox' as const, label: '设计百宝箱' },
59+
])('composer quick pill popup contract — $label', ({ which }) => {
60+
it('advertises the menu it owns and flips expanded as it opens', () => {
61+
renderChatPane();
62+
const trigger = pill(which);
63+
64+
expect(trigger.getAttribute('aria-haspopup')).toBe('menu');
65+
expect(trigger.getAttribute('aria-expanded')).toBe('false');
66+
67+
act(() => {
68+
fireEvent.click(trigger);
69+
});
70+
71+
expect(standalonePopup()).not.toBeNull();
72+
expect(pill(which).getAttribute('aria-expanded')).toBe('true');
73+
});
74+
75+
it('closes on Escape from inside the menu and returns focus to the pill', () => {
76+
renderChatPane();
77+
const trigger = pill(which);
78+
79+
act(() => {
80+
fireEvent.click(trigger);
81+
});
82+
const popup = standalonePopup();
83+
expect(popup).not.toBeNull();
84+
85+
// Focus starts inside the popover (the plugin pane autofocuses its search
86+
// box), which is exactly why the close path has to restore it.
87+
act(() => {
88+
popup!.focus();
89+
fireEvent.keyDown(document, { key: 'Escape' });
90+
});
91+
92+
expect(standalonePopup()).toBeNull();
93+
expect(pill(which).getAttribute('aria-expanded')).toBe('false');
94+
expect(document.activeElement).toBe(pill(which));
95+
});
96+
97+
it('returns focus to the pill when the backdrop dismisses the menu', () => {
98+
renderChatPane();
99+
100+
act(() => {
101+
fireEvent.click(pill(which));
102+
});
103+
const backdrop = document.querySelector<HTMLElement>('.composer-toolbox-standalone-backdrop');
104+
expect(backdrop).not.toBeNull();
105+
106+
act(() => {
107+
fireEvent.click(backdrop!);
108+
});
109+
110+
expect(standalonePopup()).toBeNull();
111+
expect(document.activeElement).toBe(pill(which));
112+
});
113+
});

0 commit comments

Comments
 (0)