From 9bc3bc0eba53e576e18c655a032b144b3cce7658 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Thu, 22 Oct 2026 00:24:22 +0530 Subject: [PATCH 1/7] feat(prompt-input): add the PromptInput toolbar parts Add PromptInputToolbar with an add menu, icon buttons, model and mode pickers, a float above the box, a context chip and a note. The pickers are select-only comboboxes and the add menu is a menu button; both keep or return focus to their trigger and need no optional peer. A toolbar placed last in PromptInput takes its own row. --- e2e/fixtures.tsx | 112 ++++ e2e/prompt-input-toolbar.spec.ts | 181 ++++++ src/index.ts | 1 + src/ui/prompt-input-toolbar.test.tsx | 563 +++++++++++++++++++ src/ui/prompt-input-toolbar.tsx | 772 ++++++++++++++++++++++++++ src/ui/prompt-input.tsx | 2 + stories/chat/prompt-input.stories.tsx | 207 +++++++ 7 files changed, 1838 insertions(+) create mode 100644 e2e/prompt-input-toolbar.spec.ts create mode 100644 src/ui/prompt-input-toolbar.test.tsx create mode 100644 src/ui/prompt-input-toolbar.tsx diff --git a/e2e/fixtures.tsx b/e2e/fixtures.tsx index f8da0c5b..42931838 100644 --- a/e2e/fixtures.tsx +++ b/e2e/fixtures.tsx @@ -35,6 +35,14 @@ import { PanelHandle, PanelHeader, PanelPane, + PromptInputToolbar, + PromptInputAddMenu, + PromptInputButton, + PromptInputModelSelect, + PromptInputModeSelect, + PromptInputFloat, + PromptInputContext, + PromptInputNote, } from "@corbits/react-ui"; import { CommandPalette, @@ -683,6 +691,109 @@ function PanelScenario() { ); } +/** Every toolbar part, with each choice and action recorded. "Toggle + * toolbar" takes the toolbar out, to compare layouts; clearing the reply + * chip removes it and "Reply to Ada" brings it back. */ +function PromptToolbarScenario() { + const [value, setValue] = useState(""); + const [model, setModel] = useState("deep"); + const [mode, setMode] = useState("ask"); + const [toolbar, setToolbar] = useState(true); + const [replying, setReplying] = useState(true); + const { log, record } = useEventLog(); + return ( +
+ record(`submit:${value}`)} + tokens={PROMPT_TOKENS} + > + {replying ? ( + + setReplying(false)} + /> + + ) : null} + + + {toolbar ? ( + + record("add:files"), + }, + { + id: "link", + label: "Add a link", + disabled: true, + onSelect: () => record("add:link"), + }, + { + id: "mention", + label: "Mention an agent", + onSelect: () => record("add:mention"), + }, + ]} + /> + { + record(`mode:${id}`); + setMode(id); + }} + /> + { + record(`model:${id}`); + setModel(id); + }} + /> + record("voice")} + > + V + + + + ) : ( + + )} + + Agents can make mistakes. + + + {log} +
+ ); +} + export const scenarios: Record ReactNode> = { smoke: () => , "approval-card": () => , @@ -722,4 +833,5 @@ export const scenarios: Record ReactNode> = { "prompt-input-chat": () => , "prompt-input-chat-no-stop": () => , panel: () => , + "prompt-input-toolbar": () => , }; diff --git a/e2e/prompt-input-toolbar.spec.ts b/e2e/prompt-input-toolbar.spec.ts new file mode 100644 index 00000000..caf31785 --- /dev/null +++ b/e2e/prompt-input-toolbar.spec.ts @@ -0,0 +1,181 @@ +import { expect, test, type Page } from "@playwright/test"; + +const events = (page: Page) => page.getByRole("status", { name: "Events" }); +const message = (page: Page) => page.getByRole("textbox", { name: "Message" }); +const model = (page: Page) => page.getByRole("combobox", { name: "Model" }); +const mode = (page: Page) => + page.getByRole("combobox", { name: "Mode", exact: true }); +const add = (page: Page) => + page.getByRole("button", { name: "Add", exact: true }); + +function box(page: Page, selector: string) { + return page.locator(selector).evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { top: rect.top, bottom: rect.bottom, height: rect.height }; + }); +} + +test.beforeEach(async ({ page }) => { + await page.goto("/prompt-input-toolbar"); +}); + +test("the model picker opens, moves and chooses by keyboard, keeping focus on its trigger", async ({ + page, +}) => { + await model(page).focus(); + await page.keyboard.press("Enter"); + const list = page.getByRole("listbox", { name: "Model" }); + await expect(list).toBeVisible(); + await expect(model(page)).toBeFocused(); + // Opens on the chosen model, then walks the grouped order upward. + const current = page.locator( + "[data-slot='prompt-input-select-option'][data-active]", + ); + await expect(current).toHaveText("Deep 4"); + await page.keyboard.press("ArrowUp"); + await expect(current).toHaveText("Lite 1"); + await expect(model(page)).toHaveAttribute( + "aria-activedescendant", + (await current.getAttribute("id")) ?? "", + ); + await page.keyboard.press("Enter"); + await expect(list).toBeHidden(); + await expect(events(page)).toHaveText("model:lite"); + await expect(model(page)).toHaveText("Lite"); + await expect(model(page)).toBeFocused(); +}); + +test("Escape closes a picker without choosing and leaves focus on its trigger", async ({ + page, +}) => { + await mode(page).focus(); + await page.keyboard.press("ArrowDown"); + await expect( + page.getByRole("listbox", { name: "Mode", exact: true }), + ).toBeVisible(); + await page.keyboard.press("End"); + await page.keyboard.press("Escape"); + await expect( + page.getByRole("listbox", { name: "Mode", exact: true }), + ).toBeHidden(); + await expect(mode(page)).toBeFocused(); + await expect(mode(page)).toHaveText("Ask"); + await expect(events(page)).toHaveText(""); + + // Space opens and chooses too. + await page.keyboard.press(" "); + await page.keyboard.press("ArrowDown"); + await page.keyboard.press(" "); + await expect(events(page)).toHaveText("mode:plan"); + await expect( + page.getByRole("listbox", { name: "Mode", exact: true }), + ).toBeHidden(); + await expect(mode(page)).toBeFocused(); +}); + +test("a picker chooses by pointer and closes on a press outside", async ({ + page, +}) => { + await model(page).click(); + await page.getByRole("option", { name: "Swift 2" }).click(); + await expect(events(page)).toHaveText("model:swift"); + await expect(model(page)).toHaveText("Swift"); + + await model(page).click(); + await expect(page.getByRole("listbox", { name: "Model" })).toBeVisible(); + await page.getByRole("button", { name: "Reply to Ada" }).click(); + await expect(page.getByRole("listbox", { name: "Model" })).toBeHidden(); +}); + +test("the add menu opens, moves past a disabled item and chooses by keyboard", async ({ + page, +}) => { + await add(page).focus(); + await page.keyboard.press("Enter"); + const menu = page.getByRole("menu", { name: "Add" }); + await expect(menu).toBeVisible(); + await expect(page.getByRole("menuitem", { name: "Add files" })).toBeFocused(); + await page.keyboard.press("ArrowDown"); + await expect( + page.getByRole("menuitem", { name: "Mention an agent" }), + ).toBeFocused(); + await page.keyboard.press("ArrowDown"); + await expect(page.getByRole("menuitem", { name: "Add files" })).toBeFocused(); + await page.keyboard.press("ArrowUp"); + await page.keyboard.press("Enter"); + await expect(menu).toBeHidden(); + await expect(events(page)).toHaveText("add:mention"); + await expect(add(page)).toBeFocused(); +}); + +test("Escape closes the add menu and returns focus; Tab closes it and moves on", async ({ + page, +}) => { + await add(page).focus(); + await page.keyboard.press("ArrowUp"); + await expect( + page.getByRole("menuitem", { name: "Mention an agent" }), + ).toBeFocused(); + await page.keyboard.press("Escape"); + await expect(page.getByRole("menu", { name: "Add" })).toBeHidden(); + await expect(add(page)).toBeFocused(); + + await page.keyboard.press("ArrowDown"); + await expect(page.getByRole("menuitem", { name: "Add files" })).toBeFocused(); + await page.keyboard.press("Tab"); + await expect(page.getByRole("menu", { name: "Add" })).toBeHidden(); + await expect(mode(page)).toBeFocused(); + await expect(events(page)).toHaveText(""); +}); + +test("the toolbar takes its own row, and without it the box is one row", async ({ + page, +}) => { + const field = await box(page, "[data-slot='prompt-input-field']"); + const toolbar = await box(page, "[data-slot='prompt-input-toolbar']"); + expect(toolbar.top).toBeGreaterThanOrEqual(field.bottom); + + await page.getByRole("button", { name: "Toggle toolbar" }).click(); + await expect(page.locator("[data-slot='prompt-input-toolbar']")).toHaveCount( + 0, + ); + const form = await box(page, "[data-slot='prompt-input']"); + const single = await box(page, "[data-slot='prompt-input-field']"); + const submit = await box(page, "[data-slot='prompt-input-submit']"); + // One row: the send button sits beside the text, not under it. + expect(submit.top).toBeLessThan(single.bottom); + expect(form.height).toBeLessThan(field.height + toolbar.height); +}); + +test("the float holds the reply chip above the box and steps aside for a popup", async ({ + page, +}) => { + const float = page.locator("[data-slot='prompt-input-float']"); + const chip = page.locator("[data-slot='prompt-input-context']"); + await expect(chip).toContainText("Replying to Ada"); + const floatBox = await box(page, "[data-slot='prompt-input-float']"); + const form = await box(page, "[data-slot='prompt-input']"); + expect(floatBox.bottom).toBeLessThanOrEqual(form.top); + await expect(float).toHaveCSS("visibility", "visible"); + + await model(page).click(); + await expect(float).toHaveCSS("visibility", "hidden"); + await page.keyboard.press("Escape"); + await expect(float).toHaveCSS("visibility", "visible"); + + await message(page).pressSequentially("@a"); + await expect(page.getByRole("listbox", { name: "Mentions" })).toBeVisible(); + await expect(float).toHaveCSS("visibility", "hidden"); + await page.keyboard.press("Escape"); + await expect(float).toHaveCSS("visibility", "visible"); + + await page.getByRole("button", { name: "Clear" }).click(); + await expect(chip).toHaveCount(0); +}); + +test("toolbar buttons never submit the prompt", async ({ page }) => { + await message(page).fill("draft"); + await page.getByRole("button", { name: "Voice" }).click(); + await expect(events(page)).toHaveText("voice"); + await expect(message(page)).toHaveValue("draft"); +}); diff --git a/src/index.ts b/src/index.ts index dc769c66..283451cd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -133,3 +133,4 @@ export * from "./ui/conversation.js"; export * from "./ui/conversation-divider.js"; export * from "./ui/prompt-input.js"; export * from "./ui/panel.js"; +export * from "./ui/prompt-input-toolbar.js"; diff --git a/src/ui/prompt-input-toolbar.test.tsx b/src/ui/prompt-input-toolbar.test.tsx new file mode 100644 index 00000000..bd9cd7ab --- /dev/null +++ b/src/ui/prompt-input-toolbar.test.tsx @@ -0,0 +1,563 @@ +import { describe, expect, spyOn, test } from "bun:test"; +import { act, type ReactNode, useState } from "react"; +import { createRoot, type Root } from "react-dom/client"; + +import { + PromptInputAddMenu, + PromptInputButton, + PromptInputContext, + PromptInputModeSelect, + PromptInputModelSelect, + type PromptInputAddMenuItem, + type PromptInputModelOption, + type PromptInputModeOption, +} from "./prompt-input-toolbar.js"; + +const MODELS: readonly PromptInputModelOption[] = [ + { id: "swift", name: "Swift 2", short: "Swift", group: "Fast" }, + { id: "deep", name: "Deep 4", short: "Deep", group: "Thorough" }, + { id: "lite", name: "Lite 1", group: "Fast" }, +]; + +const MODES: readonly PromptInputModeOption[] = [ + { id: "ask", label: "Ask", detail: "Answers without acting" }, + { id: "agent", label: "Agent", detail: "Acts on your behalf" }, +]; + +function mount(node: ReactNode) { + const container = document.createElement("div"); + document.body.appendChild(container); + let root!: Root; + act(() => { + root = createRoot(container); + root.render(node); + }); + return { + container, + query(selector: string): T { + const element = container.querySelector(selector); + if (element === null) throw new Error(`no ${selector}`); + return element; + }, + all(selector: string): HTMLElement[] { + return [...container.querySelectorAll(selector)]; + }, + unmount() { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +function press(target: Element, key: string): KeyboardEvent { + const event = new KeyboardEvent("keydown", { + key, + bubbles: true, + cancelable: true, + }); + act(() => { + target.dispatchEvent(event); + }); + return event; +} + +function click(target: HTMLElement) { + act(() => target.click()); +} + +function ControlledModel({ + initial, + events, +}: { + readonly initial: string; + readonly events: string[]; +}) { + const [value, setValue] = useState(initial); + return ( + { + events.push(id); + setValue(id); + }} + /> + ); +} + +describe("PromptInputModelSelect", () => { + test("shows the first model's short name by default and lists models by group", () => { + const view = mount(); + const trigger = view.query("[role='combobox']"); + expect(trigger.getAttribute("aria-label")).toBe("Model"); + expect(trigger.textContent).toBe("Swift"); + expect(trigger.getAttribute("aria-expanded")).toBe("false"); + expect(trigger.hasAttribute("aria-controls")).toBe(false); + + click(trigger); + const list = view.query("[role='listbox']"); + expect(trigger.getAttribute("aria-controls")).toBe(list.id); + const groups = view.all("[role='group']"); + expect( + groups.map((group) => { + const labelId = group.getAttribute("aria-labelledby") ?? ""; + return document.getElementById(labelId)?.textContent; + }), + ).toEqual(["Fast", "Thorough"]); + // Grouped order, which is also the order the arrows walk. + expect( + view.all("[role='option']").map((option) => option.textContent), + ).toEqual(["Swift 2", "Lite 1", "Deep 4"]); + view.unmount(); + }); + + test("calls onValueChange with the chosen id and reflects the controlled value", () => { + const events: string[] = []; + const view = mount(); + const trigger = view.query("[role='combobox']"); + expect(trigger.textContent).toBe("Deep"); + + click(trigger); + const chosen = view + .all("[role='option']") + .find((option) => option.getAttribute("aria-selected") === "true"); + expect(chosen?.textContent).toBe("Deep 4"); + const lite = view.all("[role='option']")[1]; + if (lite === undefined) throw new Error("no option"); + click(lite); + expect(events).toEqual(["lite"]); + expect(trigger.textContent).toBe("Lite 1"); + expect(view.container.querySelector("[role='listbox']")).toBeNull(); + view.unmount(); + }); + + test("a controlled value the host does not change stays put", () => { + const events: string[] = []; + const view = mount( + events.push(id)} + />, + ); + const trigger = view.query("[role='combobox']"); + click(trigger); + const deep = view.all("[role='option']")[2]; + if (deep === undefined) throw new Error("no option"); + click(deep); + expect(events).toEqual(["deep"]); + expect(trigger.textContent).toBe("Swift"); + view.unmount(); + }); + + test("works by keyboard with focus kept on the trigger", () => { + const events: string[] = []; + const view = mount(); + const trigger = view.query("[role='combobox']"); + act(() => trigger.focus()); + + const opening = press(trigger, "ArrowDown"); + expect(opening.defaultPrevented).toBe(true); + const options = view.all("[role='option']"); + // Opens on the chosen model. + expect(trigger.getAttribute("aria-activedescendant")).toBe( + options[1]?.id ?? "", + ); + press(trigger, "ArrowDown"); + expect(trigger.getAttribute("aria-activedescendant")).toBe( + options[2]?.id ?? "", + ); + // Stops at the end rather than wrapping. + press(trigger, "ArrowDown"); + expect(trigger.getAttribute("aria-activedescendant")).toBe( + options[2]?.id ?? "", + ); + press(trigger, "Home"); + expect(trigger.getAttribute("aria-activedescendant")).toBe( + options[0]?.id ?? "", + ); + press(trigger, "Enter"); + expect(events).toEqual(["swift"]); + expect(trigger.getAttribute("aria-expanded")).toBe("false"); + expect(trigger.hasAttribute("aria-activedescendant")).toBe(false); + expect(document.activeElement).toBe(trigger); + view.unmount(); + }); + + test("Escape closes without choosing and does not reach an enclosing handler", () => { + const events: string[] = []; + const outer: string[] = []; + const view = mount( +
outer.push(event.key)}> + events.push(id)} + /> +
, + ); + const trigger = view.query("[role='combobox']"); + act(() => trigger.focus()); + press(trigger, "Enter"); + press(trigger, "ArrowDown"); + press(trigger, "Escape"); + expect(view.container.querySelector("[role='listbox']")).toBeNull(); + expect(events).toEqual([]); + expect(outer).toEqual(["Enter", "ArrowDown"]); + expect(document.activeElement).toBe(trigger); + view.unmount(); + }); + + test("a value that matches no model shows the label", () => { + const view = mount( + undefined} + />, + ); + expect(view.query("[role='combobox']").textContent).toBe("Model"); + view.unmount(); + }); + + test("renders nothing without models", () => { + const view = mount(); + expect(view.container.innerHTML).toBe(""); + view.unmount(); + }); + + test("an uncontrolled select whose models arrive after mount defaults to the first", () => { + const events: string[] = []; + let setModels!: (models: readonly PromptInputModelOption[]) => void; + function Host() { + const [models, set] = useState([]); + setModels = set; + return ( + events.push(id)} + /> + ); + } + const view = mount(); + act(() => setModels(MODELS)); + const trigger = view.query("[role='combobox']"); + expect(trigger.textContent).toBe("Swift"); + click(trigger); + expect( + view + .all("[role='option']") + .filter((option) => option.getAttribute("aria-selected") === "true") + .map((option) => option.textContent), + ).toEqual(["Swift 2"]); + // The late default is not a choice, so the host hears nothing. + expect(events).toEqual([]); + + // A choice the user made survives a new list. + const deep = view.all("[role='option']")[2]; + if (deep === undefined) throw new Error("no option"); + click(deep); + act(() => setModels([...MODELS].reverse())); + expect(trigger.textContent).toBe("Deep"); + expect(events).toEqual(["deep"]); + view.unmount(); + }); + + test("every open starts on the chosen model, even one the host drives", () => { + let setOpen!: (open: boolean) => void; + function Host() { + const [open, set] = useState(false); + setOpen = set; + return ( + + ); + } + const view = mount(); + const trigger = view.query("[role='combobox']"); + act(() => trigger.focus()); + press(trigger, "ArrowDown"); + press(trigger, "ArrowDown"); + press(trigger, "ArrowDown"); + expect(view.query("[data-active]").textContent).toBe("Deep 4"); + act(() => { + document.body.dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true }), + ); + }); + expect(view.container.querySelector("[role='listbox']")).toBeNull(); + + act(() => setOpen(true)); + const active = view.query("[data-active]"); + expect(active.textContent).toBe("Swift 2"); + expect(trigger.getAttribute("aria-activedescendant")).toBe(active.id); + view.unmount(); + }); + + test("ids are unique across instances", () => { + const view = mount( + <> + + + , + ); + const ids = view.all("[id]").map((element) => element.id); + expect(ids.length).toBeGreaterThan(0); + expect(new Set(ids).size).toBe(ids.length); + view.unmount(); + }); +}); + +describe("PromptInputModeSelect", () => { + test("calls onValueChange with the chosen id and reflects the controlled value", () => { + function Host({ events }: { readonly events: string[] }) { + const [mode, setMode] = useState("agent"); + return ( + { + events.push(id); + setMode(id); + }} + /> + ); + } + const events: string[] = []; + const view = mount(); + const trigger = view.query("[role='combobox']"); + expect(trigger.getAttribute("aria-label")).toBe("Mode"); + expect(trigger.textContent).toBe("Agent"); + + act(() => trigger.focus()); + press(trigger, " "); + expect( + view.all("[role='option']").map((option) => option.textContent), + ).toEqual(["AskAnswers without acting", "AgentActs on your behalf"]); + press(trigger, "ArrowUp"); + press(trigger, " "); + expect(events).toEqual(["ask"]); + expect(trigger.textContent).toBe("Ask"); + view.unmount(); + }); + + test("open state can be owned by the host", () => { + const changes: boolean[] = []; + const view = mount( + changes.push(open)} + />, + ); + expect(view.container.querySelector("[role='listbox']")).not.toBeNull(); + const option = view.all("[role='option']")[1]; + if (option === undefined) throw new Error("no option"); + click(option); + expect(changes).toEqual([false]); + // Still open: the host has not closed it. + expect(view.container.querySelector("[role='listbox']")).not.toBeNull(); + view.unmount(); + }); +}); + +function menuItems(events: string[]): PromptInputAddMenuItem[] { + return [ + { + id: "files", + label: "Add files", + onSelect: () => events.push(`files:${document.activeElement?.tagName}`), + }, + { + id: "mention", + label: "Mention an agent", + disabled: true, + onSelect: () => events.push("mention"), + }, + { + id: "command", + label: "Use a command", + detail: "Starts a /", + onSelect: () => events.push("command"), + }, + ]; +} + +describe("PromptInputAddMenu", () => { + test("opens on the first item, moves past disabled items and wraps", () => { + const view = mount(); + const trigger = view.query("[aria-haspopup='menu']"); + expect(trigger.getAttribute("aria-label")).toBe("Add"); + click(trigger); + const menu = view.query("[role='menu']"); + expect(trigger.getAttribute("aria-controls")).toBe(menu.id); + const items = view.all("[role='menuitem']"); + expect(items[1]?.getAttribute("aria-disabled")).toBe("true"); + expect(document.activeElement).toBe(items[0] ?? null); + + press(menu, "ArrowDown"); + expect(document.activeElement).toBe(items[2] ?? null); + press(menu, "ArrowDown"); + expect(document.activeElement).toBe(items[0] ?? null); + press(menu, "ArrowUp"); + expect(document.activeElement).toBe(items[2] ?? null); + press(menu, "Home"); + expect(document.activeElement).toBe(items[0] ?? null); + view.unmount(); + }); + + test("ArrowUp on the trigger opens on the last item", () => { + const view = mount(); + const trigger = view.query("[aria-haspopup='menu']"); + act(() => trigger.focus()); + press(trigger, "ArrowUp"); + expect(document.activeElement?.textContent).toBe("Use a commandStarts a /"); + view.unmount(); + }); + + test("choosing an item closes the menu, returns focus, then runs it", () => { + const events: string[] = []; + const view = mount(); + const trigger = view.query("[aria-haspopup='menu']"); + click(trigger); + const files = view.all("[role='menuitem']")[0]; + if (files === undefined) throw new Error("no item"); + click(files); + // Focus was back on the trigger when the action ran. + expect(events).toEqual(["files:BUTTON"]); + expect(document.activeElement).toBe(trigger); + expect(view.container.querySelector("[role='menu']")).toBeNull(); + view.unmount(); + }); + + test("a disabled item does nothing", () => { + const events: string[] = []; + const view = mount(); + click(view.query("[aria-haspopup='menu']")); + const mention = view.all("[role='menuitem']")[1]; + if (mention === undefined) throw new Error("no item"); + click(mention); + expect(events).toEqual([]); + expect(view.container.querySelector("[role='menu']")).not.toBeNull(); + view.unmount(); + }); + + test("Escape closes, returns focus and stays inside the menu", () => { + const outer: string[] = []; + const view = mount( +
outer.push(event.key)}> + +
, + ); + const trigger = view.query("[aria-haspopup='menu']"); + click(trigger); + press(view.query("[role='menu']"), "Escape"); + expect(view.container.querySelector("[role='menu']")).toBeNull(); + expect(document.activeElement).toBe(trigger); + expect(outer).toEqual([]); + view.unmount(); + }); + + test("a menu open from the start does not take focus", () => { + const outside = document.createElement("button"); + document.body.appendChild(outside); + outside.focus(); + const view = mount( + , + ); + expect(view.container.querySelector("[role='menu']")).not.toBeNull(); + expect(document.activeElement).toBe(outside); + view.unmount(); + outside.remove(); + }); + + test("a menu whose items are all disabled opens, keeps focus and closes on Escape", () => { + const view = mount( + undefined }, + ]} + />, + ); + const trigger = view.query("[aria-haspopup='menu']"); + act(() => trigger.focus()); + press(trigger, "ArrowDown"); + expect(view.container.querySelector("[role='menu']")).not.toBeNull(); + expect(document.activeElement).toBe(trigger); + press(trigger, "Escape"); + expect(view.container.querySelector("[role='menu']")).toBeNull(); + view.unmount(); + }); + + test("unmounting while open removes its document listeners", () => { + const added = spyOn(document, "addEventListener"); + const removed = spyOn(document, "removeEventListener"); + try { + const view = mount( + , + ); + view.unmount(); + const count = (calls: readonly (readonly unknown[])[], type: string) => + calls.filter(([name]) => name === type).length; + expect(count(added.mock.calls, "pointerdown")).toBeGreaterThan(0); + expect(count(removed.mock.calls, "pointerdown")).toBe( + count(added.mock.calls, "pointerdown"), + ); + } finally { + added.mockRestore(); + removed.mockRestore(); + } + }); +}); + +describe("PromptInputButton", () => { + test("never submits the form it sits in", () => { + const submits: string[] = []; + const view = mount( +
{ + event.preventDefault(); + submits.push("submit"); + }} + > + v +
, + ); + const button = view.query( + "[data-slot='prompt-input-button']", + ); + expect(button.type).toBe("button"); + click(button); + expect(submits).toEqual([]); + view.unmount(); + }); +}); + +describe("PromptInputContext", () => { + test("offers a clear button only when it can be cleared", () => { + const cleared: string[] = []; + const plain = mount( + , + ); + expect(plain.container.querySelector("button")).toBeNull(); + expect(plain.container.textContent).toBe("Replying to Ada"); + plain.unmount(); + + const clearable = mount( + cleared.push("clear")} + />, + ); + const button = clearable.query("button"); + expect(button.getAttribute("aria-label")).toBe("Stop replying"); + click(button); + expect(cleared).toEqual(["clear"]); + clearable.unmount(); + }); +}); diff --git a/src/ui/prompt-input-toolbar.tsx b/src/ui/prompt-input-toolbar.tsx new file mode 100644 index 00000000..41e8befb --- /dev/null +++ b/src/ui/prompt-input-toolbar.tsx @@ -0,0 +1,772 @@ +"use client"; + +import { Check, ChevronDown, Plus, Reply, X } from "lucide-react"; +import { useEffect, useId, useRef, useState } from "react"; +import type * as React from "react"; + +import { useControllableState } from "../hooks/use-controllable-state.js"; +import { useDismissablePopover } from "../hooks/use-dismissable-popover.js"; +import { useScrollCurrentIntoView } from "../hooks/use-scroll-current-into-view.js"; +import { cn } from "../lib/utils.js"; + +function assignRef(ref: React.Ref | undefined, node: T | null) { + if (typeof ref === "function") ref(node); + else if (ref !== null && ref !== undefined) ref.current = node; +} + +/** Where a key moves the current item in a list of `length`: arrows step + * (wrapping when `wrap`, stopping at the ends otherwise), Home and End jump. + * `undefined` for any other key. */ +function stepIndex( + key: string, + current: number, + length: number, + wrap: boolean, +): number | undefined { + if (length === 0) return undefined; + switch (key) { + case "ArrowDown": + return wrap ? (current + 1) % length : Math.min(current + 1, length - 1); + case "ArrowUp": + if (current < 0) return length - 1; + return wrap ? (current - 1 + length) % length : Math.max(current - 1, 0); + case "Home": + return 0; + case "End": + return length - 1; + default: + return undefined; + } +} + +/** Open state a parent may own, as on every popup here. */ +type PopupStateProps = { + readonly open?: boolean; + readonly defaultOpen?: boolean; + readonly onOpenChange?: (open: boolean) => void; +}; + +/** Which edge of its trigger a popup lines up with. */ +export type PromptInputPopupAlign = "start" | "end"; + +// Above the textarea (z-10) and the suggestion list (z-20); the popups open +// upward, over the box, since the box sits at the bottom of the screen. +const POPUP = + "absolute bottom-full z-30 mb-2 flex max-h-72 w-max max-w-80 min-w-48 flex-col gap-0.5 overflow-y-auto rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-lg"; + +const ICON_BUTTON = + "grid size-8 shrink-0 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-50"; + +export type PromptInputToolbarProps = React.ComponentProps<"div">; + +/** + * The row of controls under the text. Rendered last inside `PromptInput`, it + * takes a row of its own; without it the box stays a single row. Put `ms-auto` + * on the first control that belongs at the far end (a model select, say, or + * `PromptInputSubmit`). + */ +export function PromptInputToolbar({ + className, + ...props +}: PromptInputToolbarProps) { + return ( +
+ ); +} + +export type PromptInputButtonProps = Omit< + React.ComponentProps<"button">, + "type" | "aria-label" +> & { + /** Toolbar buttons show only an icon, so the name is required. */ + readonly "aria-label": string; +}; + +/** An icon button sized for the toolbar: voice, attach, anything the host + * adds. Never submits the form it sits in. */ +export function PromptInputButton({ + className, + ...props +}: PromptInputButtonProps) { + return ( + + {open ? ( + + ) : null} +
+ ); +} + +/** An option as the shared select draws it. */ +type SelectOption = { + readonly id: string; + /** What the trigger shows while this option is chosen. */ + readonly short: string; + readonly name: string; + readonly detail?: string | undefined; + readonly group?: string | undefined; +}; + +type SelectGroup = { + readonly name: string | undefined; + readonly options: readonly SelectOption[]; +}; + +/** Options grouped in order of each group's first appearance. */ +function groupOptions(options: readonly SelectOption[]): SelectGroup[] { + const groups: { name: string | undefined; options: SelectOption[] }[] = []; + for (const option of options) { + const group = groups.find((entry) => entry.name === option.group); + if (group === undefined) { + groups.push({ name: option.group, options: [option] }); + } else { + group.options.push(option); + } + } + return groups; +} + +/** The value, open-state and layout props both selects share. */ +type SelectStateProps = Omit< + React.ComponentProps<"div">, + "children" | "defaultValue" | "onChange" +> & + PopupStateProps & { + /** The chosen option's `id`. Supply it with `onValueChange` to own it. */ + readonly value?: string; + /** Seeds the select's own state when `value` is not given. Without it, + * the select shows the first option until one is chosen, including + * options that arrive after mount; that default is not a choice, so it + * does not call `onValueChange`. */ + readonly defaultValue?: string; + readonly onValueChange?: (id: string) => void; + readonly disabled?: boolean; + readonly align?: PromptInputPopupAlign; + }; + +type OptionSelectProps = SelectStateProps & { + readonly slot: string; + /** The trigger's and the list's accessible name; also what the trigger + * shows while the value matches no option. */ + readonly label: string; + readonly options: readonly SelectOption[]; +}; + +/** + * A select-only combobox: focus stays on the trigger, which points at the + * current option with `aria-activedescendant` while the list is open, so + * focus is already home when the list closes. Enter, Space or the arrows + * open it on the chosen option; the arrows move, Home and End jump, Enter + * or Space chooses, Escape and Tab close without choosing. + */ +function OptionSelect({ + slot, + label, + options, + value: valueProp, + defaultValue, + onValueChange, + open: openProp, + defaultOpen = false, + onOpenChange, + disabled = false, + align = "start", + className, + ref, + ...props +}: OptionSelectProps) { + // Undefined until something is chosen: the first option then stands in, + // read from the current list so options that load late still get it. + const [picked, setPicked] = useControllableState({ + value: valueProp, + defaultValue, + onChange: + onValueChange === undefined + ? undefined + : (id) => { + if (id !== undefined) onValueChange(id); + }, + name: slot, + }); + const { open, setOpen, rootRef, triggerRef, close } = useDismissablePopover< + HTMLDivElement, + HTMLButtonElement + >({ open: openProp, defaultOpen, onOpenChange, closeOnEscape: false }); + const listId = useId(); + const [activeId, setActiveId] = useState(undefined); + // However the list closed (a press outside, the host), the next open + // starts on the chosen option, so the highlight is dropped on close. + const [wasOpen, setWasOpen] = useState(open); + if (wasOpen !== open) { + setWasOpen(open); + if (!open) setActiveId(undefined); + } + + const groups = groupOptions(options); + // The order the arrows walk is the order on screen, groups and all. + const ordered = groups.flatMap((group) => group.options); + const value = picked ?? ordered[0]?.id; + const chosen = ordered.find((option) => option.id === value); + const current = + ordered.find((option) => option.id === activeId) ?? chosen ?? ordered[0]; + const currentRef = useScrollCurrentIntoView( + open ? (current?.id ?? "") : "", + ); + + const optionId = (option: SelectOption) => + `${listId}-${ordered.indexOf(option)}`; + + function show(option: SelectOption | undefined) { + setActiveId(option?.id); + setOpen(true); + } + + function hide() { + setActiveId(undefined); + close(); + } + + function choose(option: SelectOption) { + setPicked(option.id); + hide(); + } + + if (ordered.length === 0) return null; + + const renderOption = (option: SelectOption) => { + const active = option.id === current?.id; + const selected = option.id === chosen?.id; + return ( +
event.preventDefault()} + onPointerMove={() => { + if (!active) setActiveId(option.id); + }} + onClick={() => choose(option)} + > + + {option.name} + {option.detail === undefined ? null : ( + + {option.detail} + + )} + + +
+ ); + }; + + return ( +
{ + rootRef.current = node; + assignRef(ref, node); + }} + data-slot={slot} + className={cn("relative inline-flex min-w-0", className)} + > + + {open ? ( +
+ {groups.map((group, groupIndex) => + group.name === undefined ? ( + group.options.map(renderOption) + ) : ( +
+
+ {group.name} +
+ {group.options.map(renderOption)} +
+ ), + )} +
+ ) : null} +
+ ); +} + +/** A model to choose from in `PromptInputModelSelect`. */ +export type PromptInputModelOption = { + readonly id: string; + readonly name: string; + /** What the trigger shows while it is chosen; defaults to `name`. */ + readonly short?: string; + readonly detail?: string; + /** Models with the same group (a provider, say) are listed together + * under it. */ + readonly group?: string; +}; + +export type PromptInputModelSelectProps = SelectStateProps & { + readonly models: readonly PromptInputModelOption[]; + /** The accessible name; also shown when `value` matches no model. */ + readonly label?: string; +}; + +/** + * Which model answers. The trigger shows the chosen model's short name; the + * list groups models by `group`. A model only: anything finer, such as a + * reasoning level, is a separate control, so `onValueChange` stays a single + * model id. + */ +export function PromptInputModelSelect({ + models, + label = "Model", + align = "end", + ...props +}: PromptInputModelSelectProps) { + return ( + ({ + id: model.id, + short: model.short ?? model.name, + name: model.name, + detail: model.detail, + group: model.group, + }))} + /> + ); +} + +/** A way of working, offered by `PromptInputModeSelect`. */ +export type PromptInputModeOption = { + readonly id: string; + readonly label: string; + readonly detail?: string; +}; + +export type PromptInputModeSelectProps = SelectStateProps & { + readonly modes: readonly PromptInputModeOption[]; + /** The accessible name; also shown when `value` matches no mode. */ + readonly label?: string; +}; + +/** How the agent works this turn. The modes' labels and details are the + * host's own strings; the trigger shows the chosen mode's label. */ +export function PromptInputModeSelect({ + modes, + label = "Mode", + ...props +}: PromptInputModeSelectProps) { + return ( + ({ + id: mode.id, + short: mode.label, + name: mode.label, + detail: mode.detail, + }))} + /> + ); +} + +export type PromptInputFloatProps = React.ComponentProps<"div">; + +/** + * What floats above the box: the reply context, attachments, a notice. + * Rendered inside `PromptInput`, it sits over the content above the box + * rather than pushing it. It steps aside while the suggestion list or a + * toolbar popup is open, since those open over the same spot. + */ +export function PromptInputFloat({ + className, + ...props +}: PromptInputFloatProps) { + return ( +
+ ); +} + +export type PromptInputContextProps = Omit< + React.ComponentProps<"div">, + "children" +> & { + /** What the next message answers, e.g. "Replying to". */ + readonly label: string; + readonly detail?: string; + /** Replaces the reply arrow. Decorative. */ + readonly icon?: React.ReactNode; + /** Offered as a clear button when given. */ + readonly onClear?: () => void; + /** The clear button's accessible name. */ + readonly clearLabel?: string; +}; + +/** A chip saying what the next message is about: the message it replies + * to, the item it refers to. Usually inside `PromptInputFloat`. */ +export function PromptInputContext({ + label, + detail, + icon, + onClear, + clearLabel = "Clear", + className, + ...props +}: PromptInputContextProps) { + return ( +
+ + {icon ?? } + + + {label} + {detail === undefined ? null : <> {detail}} + + {onClear === undefined ? null : ( + + )} +
+ ); +} + +export type PromptInputNoteProps = React.ComponentProps<"p">; + +/** Small print under the box, such as a reminder that answers can be + * wrong. Rendered after `PromptInput`, not inside it. */ +export function PromptInputNote({ className, ...props }: PromptInputNoteProps) { + return ( +

+ ); +} diff --git a/src/ui/prompt-input.tsx b/src/ui/prompt-input.tsx index cd2cd1e6..757b593b 100644 --- a/src/ui/prompt-input.tsx +++ b/src/ui/prompt-input.tsx @@ -147,6 +147,8 @@ export function PromptInput({ data-disabled={disabled ? "" : undefined} className={cn( "relative flex items-end gap-2 rounded-lg border border-input bg-background p-1.5", + // A `PromptInputToolbar` placed last takes a row of its own. + "has-[>[data-slot=prompt-input-toolbar]]:flex-wrap", // The textarea gives up its own outline for one round the box. "has-[textarea:focus-visible]:outline-2 has-[textarea:focus-visible]:outline-offset-2 has-[textarea:focus-visible]:outline-ring", className, diff --git a/stories/chat/prompt-input.stories.tsx b/stories/chat/prompt-input.stories.tsx index 13dd10a3..1331d57e 100644 --- a/stories/chat/prompt-input.stories.tsx +++ b/stories/chat/prompt-input.stories.tsx @@ -1,3 +1,4 @@ +import { AtSign, AudioLines, Paperclip, SquareSlash } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import type { ReactNode } from "react"; @@ -9,6 +10,18 @@ import { PromptInputTextarea, type PromptInputStatus, } from "../../src/ui/prompt-input.js"; +import { + PromptInputAddMenu, + PromptInputButton, + PromptInputContext, + PromptInputFloat, + PromptInputModeSelect, + PromptInputModelSelect, + PromptInputNote, + PromptInputToolbar, + type PromptInputModelOption, + type PromptInputModeOption, +} from "../../src/ui/prompt-input-toolbar.js"; export default { title: "Chat / Prompt input" }; @@ -115,3 +128,197 @@ export const Replying = () => ( export const LongDraft = () => ; export const Disabled = () => ; + +const MODELS: readonly PromptInputModelOption[] = [ + { + id: "swift", + name: "Swift 2", + short: "Swift", + detail: "Quick answers", + group: "Fast", + }, + { + id: "lite", + name: "Lite 1", + short: "Lite", + detail: "Lowest cost", + group: "Fast", + }, + { + id: "deep", + name: "Deep 4", + short: "Deep", + detail: "Careful, slower reasoning", + group: "Thorough", + }, +]; + +const MODES: readonly PromptInputModeOption[] = [ + { id: "ask", label: "Ask", detail: "Answers without taking action" }, + { id: "plan", label: "Plan", detail: "Drafts steps for you to approve" }, + { id: "agent", label: "Agent", detail: "Acts on your behalf" }, +]; + +type ToolbarPromptProps = { + readonly addMenu?: boolean; + readonly mode?: boolean; + readonly model?: boolean; + readonly voice?: boolean; + readonly replying?: boolean; + readonly note?: boolean; + readonly openPicker?: "model" | "mode" | "add"; + readonly disabled?: boolean; +}; + +/** A prompt with whichever toolbar parts are switched on. */ +function ToolbarPrompt({ + addMenu = false, + mode = false, + model = false, + voice = false, + replying = false, + note = false, + openPicker, + disabled = false, +}: ToolbarPromptProps) { + const [value, setValue] = useState(""); + const [modelId, setModelId] = useState("deep"); + const [modeId, setModeId] = useState("agent"); + const [context, setContext] = useState(replying); + const textareaRef = useRef(null); + const startToken = (prefix: string) => { + setValue( + (draft) => + (draft === "" || draft.endsWith(" ") ? draft : `${draft} `) + prefix, + ); + textareaRef.current?.focus(); + }; + const toolbar = addMenu || mode || model || voice; + return ( + + setValue("")} + tokens={TOKENS} + disabled={disabled} + > + {context ? ( + + setContext(false)} + /> + + ) : null} + + + {toolbar ? ( + + {addMenu ? ( + , + onSelect: () => undefined, + }, + { + id: "mention", + label: "Mention an agent", + icon: , + onSelect: () => startToken("@"), + }, + { + id: "command", + label: "Use a command", + icon: , + onSelect: () => startToken("/"), + }, + ]} + /> + ) : null} + {mode ? ( + + ) : null} +

+ {model ? ( + + ) : null} + {voice ? ( + + + + ) : null} + undefined} /> +
+ + ) : ( + undefined} /> + )} + + {note ? ( + + Agents can make mistakes. Check important work. + + ) : null} + + ); +} + +/** Every optional part on: add menu, mode and model pickers, voice, the + * reply chip floating above and the note below. */ +export const EverythingOn = () => ( + +); + +/** The model picker open, grouped, with the chosen model checked. */ +export const ModelPickerOpen = () => ( + +); + +/** The mode picker open on the current mode. */ +export const ModePickerOpen = () => ( + +); + +/** The `+` menu open: files, a mention, a command. */ +export const AddMenuOpen = () => ( + +); + +/** Small print under the box; nothing else added. */ +export const WithNote = () => ; + +/** The chip says what the next message answers; its button clears it. */ +export const ReplyingChip = () => ; + +/** Disabled: the host passes `disabled` to the toolbar parts as well as + * to the prompt. */ +export const ToolbarDisabled = () => ( + +); From d446ee5e94b02c4847c927ff31c0d4d9394c5315 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Tue, 27 Oct 2026 19:26:07 +0545 Subject: [PATCH 2/7] fix(prompt-input): close host-opened popups on escape; follow disabled A toolbar popup the host opened stayed open when Escape was pressed in the textarea, because the popups opt out of the shared hook's Escape handling to run their own. Listen for Escape on the document in the bubble phase while open and close when focus is outside the popup, leaving focus where it is. Handled Escapes are skipped, so the suggestion list still closes first. The toolbar parts each took their own disabled flag and ignored the one on PromptInput, so a disabled prompt still offered a live add menu, selects and buttons. Expose usePromptInputDisabled and default each part's disabled to it; a part's own disabled still wins. Also say that Home and End open a select on the first or last option, and cover the toolbar, float and note parts with a test. --- e2e/fixtures.tsx | 7 +- e2e/prompt-input-toolbar.spec.ts | 22 +++ src/ui/prompt-input-toolbar.test.tsx | 197 ++++++++++++++++++++++++++ src/ui/prompt-input-toolbar.tsx | 56 +++++++- src/ui/prompt-input.tsx | 9 ++ stories/chat/prompt-input.stories.tsx | 9 +- 6 files changed, 287 insertions(+), 13 deletions(-) diff --git a/e2e/fixtures.tsx b/e2e/fixtures.tsx index 42931838..e9eb5590 100644 --- a/e2e/fixtures.tsx +++ b/e2e/fixtures.tsx @@ -693,10 +693,12 @@ function PanelScenario() { /** Every toolbar part, with each choice and action recorded. "Toggle * toolbar" takes the toolbar out, to compare layouts; clearing the reply - * chip removes it and "Reply to Ada" brings it back. */ + * chip removes it and "Reply to Ada" brings it back. "Open models" opens the + * model list the way a host would, without focusing it. */ function PromptToolbarScenario() { const [value, setValue] = useState(""); const [model, setModel] = useState("deep"); + const [modelOpen, setModelOpen] = useState(false); const [mode, setMode] = useState("ask"); const [toolbar, setToolbar] = useState(true); const [replying, setReplying] = useState(true); @@ -771,6 +773,8 @@ function PromptToolbarScenario() { record(`model:${id}`); setModel(id); }} + open={modelOpen} + onOpenChange={setModelOpen} /> + {log}
); diff --git a/e2e/prompt-input-toolbar.spec.ts b/e2e/prompt-input-toolbar.spec.ts index caf31785..2acb916d 100644 --- a/e2e/prompt-input-toolbar.spec.ts +++ b/e2e/prompt-input-toolbar.spec.ts @@ -128,6 +128,28 @@ test("Escape closes the add menu and returns focus; Tab closes it and moves on", await expect(events(page)).toHaveText(""); }); +test("Escape in the textarea closes a list the host opened, after the suggestion list", async ({ + page, +}) => { + await page.getByRole("button", { name: "Open models" }).click(); + const models = page.getByRole("listbox", { name: "Model" }); + await expect(models).toBeVisible(); + await message(page).focus(); + await message(page).pressSequentially("@a"); + const mentions = page.getByRole("listbox", { name: "Mentions" }); + await expect(mentions).toBeVisible(); + + await page.keyboard.press("Escape"); + await expect(mentions).toBeHidden(); + await expect(models).toBeVisible(); + + await page.keyboard.press("Escape"); + await expect(models).toBeHidden(); + await expect(model(page)).toHaveAttribute("aria-expanded", "false"); + await expect(message(page)).toBeFocused(); + await expect(events(page)).toHaveText(""); +}); + test("the toolbar takes its own row, and without it the box is one row", async ({ page, }) => { diff --git a/src/ui/prompt-input-toolbar.test.tsx b/src/ui/prompt-input-toolbar.test.tsx index bd9cd7ab..0ac29782 100644 --- a/src/ui/prompt-input-toolbar.test.tsx +++ b/src/ui/prompt-input-toolbar.test.tsx @@ -2,12 +2,22 @@ import { describe, expect, spyOn, test } from "bun:test"; import { act, type ReactNode, useState } from "react"; import { createRoot, type Root } from "react-dom/client"; +import type { PromptToken } from "../lib/prompt-tokens.js"; +import { + PromptInput, + PromptInputSubmit, + PromptInputSuggestions, + PromptInputTextarea, +} from "./prompt-input.js"; import { PromptInputAddMenu, PromptInputButton, PromptInputContext, + PromptInputFloat, PromptInputModeSelect, PromptInputModelSelect, + PromptInputNote, + PromptInputToolbar, type PromptInputAddMenuItem, type PromptInputModelOption, type PromptInputModeOption, @@ -561,3 +571,190 @@ describe("PromptInputContext", () => { clearable.unmount(); }); }); + +describe("PromptInputToolbar, PromptInputFloat and PromptInputNote", () => { + test("each renders its slot, keeps the host's props and merges its class", () => { + const view = mount( + <> + + + + + Floating + + Small print + , + ); + const toolbar = view.query("[data-slot='prompt-input-toolbar']"); + expect(toolbar.tagName).toBe("DIV"); + expect(toolbar.getAttribute("aria-label")).toBe("Message tools"); + expect(toolbar.classList.contains("basis-full")).toBe(true); + expect(toolbar.classList.contains("host-a")).toBe(true); + expect(toolbar.textContent).toBe("Tool"); + + const float = view.query("[data-slot='prompt-input-float']"); + expect(float.id).toBe("float"); + expect(float.classList.contains("absolute")).toBe(true); + expect(float.classList.contains("host-b")).toBe(true); + expect(float.textContent).toBe("Floating"); + + const note = view.query("[data-slot='prompt-input-note']"); + expect(note.tagName).toBe("P"); + expect(note.classList.contains("host-c")).toBe(true); + expect(note.textContent).toBe("Small print"); + view.unmount(); + }); +}); + +const TOKENS: readonly PromptToken[] = [ + { id: "ada", kind: "mention", token: "@Ada" }, +]; + +/** A prompt with a toolbar, as a host would put one together. */ +function Prompt({ + open, + disabled = false, + partsDisabled, +}: { + readonly open?: "add" | "model"; + readonly disabled?: boolean; + readonly partsDisabled?: boolean; +}) { + const [value, setValue] = useState(""); + const own = partsDisabled === undefined ? {} : { disabled: partsDisabled }; + return ( + undefined} + tokens={TOKENS} + disabled={disabled} + > + + + + undefined }]} + /> + + + + v + + + + + ); +} + +/** A boolean rather than the element, so a failure prints a line, not a + * React tree. */ +function shown(view: { readonly container: HTMLElement }, selector: string) { + return view.container.querySelector(selector) !== null; +} + +const POPUPS = { + add: { popup: "[role='menu']", trigger: "[aria-haspopup='menu']" }, + model: { + popup: "[role='listbox']", + trigger: "[data-slot='prompt-input-model-select'] [role='combobox']", + }, +} as const; + +describe("toolbar parts inside PromptInput", () => { + for (const which of ["add", "model"] as const) { + test(`the ${which} popup, opened by the host, closes on Escape from the textarea and focus stays there`, () => { + const view = mount(); + const textarea = view.query("textarea"); + act(() => textarea.focus()); + expect(shown(view, POPUPS[which].popup)).toBe(true); + press(textarea, "Escape"); + expect(shown(view, POPUPS[which].popup)).toBe(false); + expect( + view.query(POPUPS[which].trigger).getAttribute("aria-expanded"), + ).toBe("false"); + expect(document.activeElement).toBe(textarea); + view.unmount(); + }); + } + + test("with the suggestion list open too, the first Escape closes only the list", () => { + const view = mount(); + const textarea = view.query("textarea"); + const setValue = Object.getOwnPropertyDescriptor( + HTMLTextAreaElement.prototype, + "value", + )?.set; + act(() => { + textarea.focus(); + setValue?.call(textarea, "@"); + textarea.setSelectionRange(1, 1); + textarea.dispatchEvent(new Event("input", { bubbles: true })); + }); + const list = "[data-slot='prompt-input-suggestions']"; + expect(shown(view, list)).toBe(true); + expect(shown(view, "[role='menu']")).toBe(true); + + press(textarea, "Escape"); + expect(shown(view, list)).toBe(false); + expect(shown(view, "[role='menu']")).toBe(true); + + press(textarea, "Escape"); + expect(shown(view, "[role='menu']")).toBe(false); + expect(document.activeElement).toBe(textarea); + view.unmount(); + }); + + test("a popup's own Escape is not handled twice", () => { + const changes: boolean[] = []; + const view = mount( + changes.push(open)} + />, + ); + const trigger = view.query("[aria-haspopup='menu']"); + click(trigger); + press(view.query("[role='menu']"), "Escape"); + expect(changes).toEqual([true, false]); + expect(document.activeElement).toBe(trigger); + view.unmount(); + }); + + test("the parts follow PromptInput's disabled", () => { + const view = mount(); + expect(view.query("textarea").disabled).toBe(true); + const parts = view.all( + "[aria-haspopup='menu'], [role='combobox'], [data-slot='prompt-input-button']", + ); + expect(parts).toHaveLength(4); + for (const part of parts) { + expect((part as HTMLButtonElement).disabled).toBe(true); + } + view.unmount(); + }); + + test("a part's own disabled wins over PromptInput's", () => { + const enabled = mount(); + for (const part of enabled.all( + "[aria-haspopup='menu'], [role='combobox'], [data-slot='prompt-input-button']", + )) { + expect((part as HTMLButtonElement).disabled).toBe(false); + } + enabled.unmount(); + + const disabled = mount(); + for (const part of disabled.all( + "[aria-haspopup='menu'], [role='combobox'], [data-slot='prompt-input-button']", + )) { + expect((part as HTMLButtonElement).disabled).toBe(true); + } + disabled.unmount(); + }); +}); diff --git a/src/ui/prompt-input-toolbar.tsx b/src/ui/prompt-input-toolbar.tsx index 41e8befb..b0bc704e 100644 --- a/src/ui/prompt-input-toolbar.tsx +++ b/src/ui/prompt-input-toolbar.tsx @@ -8,6 +8,7 @@ import { useControllableState } from "../hooks/use-controllable-state.js"; import { useDismissablePopover } from "../hooks/use-dismissable-popover.js"; import { useScrollCurrentIntoView } from "../hooks/use-scroll-current-into-view.js"; import { cn } from "../lib/utils.js"; +import { usePromptInputDisabled } from "./prompt-input.js"; function assignRef(ref: React.Ref | undefined, node: T | null) { if (typeof ref === "function") ref(node); @@ -39,6 +40,31 @@ function stepIndex( } } +/** + * While `open`, Escape pressed with focus outside `rootRef` (in the textarea, + * say, beside a popup the host opened) closes the popup and leaves focus + * where it is. It listens in the bubble phase and skips a handled Escape, so + * the suggestion list, and the popup's own keys, claim theirs first. + */ +function useCloseOnEscapeOutside( + open: boolean, + rootRef: React.RefObject, + setOpen: (open: boolean) => void, +) { + useEffect(() => { + if (!open) return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape" || event.defaultPrevented) return; + const inside = + event.target instanceof Node && + rootRef.current?.contains(event.target) === true; + if (!inside) setOpen(false); + }; + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [open, rootRef, setOpen]); +} + /** Open state a parent may own, as on every popup here. */ type PopupStateProps = { readonly open?: boolean; @@ -64,6 +90,9 @@ export type PromptInputToolbarProps = React.ComponentProps<"div">; * takes a row of its own; without it the box stays a single row. Put `ms-auto` * on the first control that belongs at the far end (a model select, say, or * `PromptInputSubmit`). + * + * Inside a disabled `PromptInput`, the add menu, the selects and + * `PromptInputButton` are disabled too unless given their own `disabled`. */ export function PromptInputToolbar({ className, @@ -87,15 +116,19 @@ export type PromptInputButtonProps = Omit< }; /** An icon button sized for the toolbar: voice, attach, anything the host - * adds. Never submits the form it sits in. */ + * adds. Never submits the form it sits in. `disabled` defaults to the + * enclosing `PromptInput`'s. */ export function PromptInputButton({ + disabled, className, ...props }: PromptInputButtonProps) { + const boxDisabled = usePromptInputDisabled(); return ( + + + {log} + + ); +} + export const scenarios: Record ReactNode> = { smoke: () => , "approval-card": () => , @@ -839,4 +894,5 @@ export const scenarios: Record ReactNode> = { "prompt-input-chat-no-stop": () => , panel: () => , "prompt-input-toolbar": () => , + "prompt-input-toolbar-dialog": () => , }; diff --git a/e2e/prompt-input-toolbar.spec.ts b/e2e/prompt-input-toolbar.spec.ts index 2acb916d..d74886be 100644 --- a/e2e/prompt-input-toolbar.spec.ts +++ b/e2e/prompt-input-toolbar.spec.ts @@ -201,3 +201,59 @@ test("toolbar buttons never submit the prompt", async ({ page }) => { await expect(events(page)).toHaveText("voice"); await expect(message(page)).toHaveValue("draft"); }); + +test.describe("inside a dialog", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/prompt-input-toolbar-dialog"); + }); + + test("Escape in the add menu closes the menu, not the dialog", async ({ + page, + }) => { + const dialog = page.getByRole("dialog", { name: "Ask" }); + await add(page).click(); + const menu = page.getByRole("menu", { name: "Add" }); + await expect( + page.getByRole("menuitem", { name: "Add files" }), + ).toBeFocused(); + await page.keyboard.press("Escape"); + await expect(menu).toBeHidden(); + await expect(add(page)).toBeFocused(); + await expect(dialog).toBeVisible(); + + await model(page).focus(); + await page.keyboard.press("Enter"); + const list = page.getByRole("listbox", { name: "Model" }); + await expect(list).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(list).toBeHidden(); + await expect(model(page)).toBeFocused(); + await expect(dialog).toBeVisible(); + }); + + test("beside the suggestion list, Escape closes the list, then a host-opened popup, then the dialog", async ({ + page, + }) => { + const dialog = page.getByRole("dialog", { name: "Ask" }); + const models = page.getByRole("listbox", { name: "Model" }); + const mentions = page.getByRole("listbox", { name: "Mentions" }); + await page.getByRole("button", { name: "Open models" }).click(); + await expect(models).toBeVisible(); + await message(page).pressSequentially("@a"); + await expect(mentions).toBeVisible(); + + await page.keyboard.press("Escape"); + await expect(mentions).toBeHidden(); + await expect(models).toBeVisible(); + await expect(dialog).toBeVisible(); + + await page.keyboard.press("Escape"); + await expect(models).toBeHidden(); + await expect(message(page)).toBeFocused(); + await expect(dialog).toBeVisible(); + + await page.keyboard.press("Escape"); + await expect(dialog).toBeHidden(); + await expect(events(page)).toHaveText("dialog-closed"); + }); +}); diff --git a/src/ui/prompt-input-toolbar.test.tsx b/src/ui/prompt-input-toolbar.test.tsx index 0ac29782..072e4d6c 100644 --- a/src/ui/prompt-input-toolbar.test.tsx +++ b/src/ui/prompt-input-toolbar.test.tsx @@ -3,6 +3,7 @@ import { act, type ReactNode, useState } from "react"; import { createRoot, type Root } from "react-dom/client"; import type { PromptToken } from "../lib/prompt-tokens.js"; +import { Dialog, DialogContent, DialogTitle } from "./dialog.js"; import { PromptInput, PromptInputSubmit, @@ -52,6 +53,9 @@ function mount(node: ReactNode) { all(selector: string): HTMLElement[] { return [...container.querySelectorAll(selector)]; }, + rerender(next: ReactNode) { + act(() => root.render(next)); + }, unmount() { act(() => root.unmount()); container.remove(); @@ -428,6 +432,35 @@ describe("PromptInputAddMenu", () => { view.unmount(); }); + test("an ArrowUp the host declined does not make its later open take focus", () => { + const asks: boolean[] = []; + function Host() { + const [open, setOpen] = useState(false); + return ( + <> + asks.push(next)} + /> + + + ); + } + const view = mount(); + const trigger = view.query("[aria-haspopup='menu']"); + act(() => trigger.focus()); + press(trigger, "ArrowUp"); + expect(asks).toEqual([true]); + expect(view.container.querySelector("[role='menu']")).toBeNull(); + click(view.query("[data-host]")); + expect(view.container.querySelector("[role='menu']")).not.toBeNull(); + expect(document.activeElement?.getAttribute("aria-haspopup")).toBe("menu"); + view.unmount(); + }); + test("choosing an item closes the menu, returns focus, then runs it", () => { const events: string[] = []; const view = mount(); @@ -615,10 +648,13 @@ function Prompt({ open, disabled = false, partsDisabled, + events, }: { readonly open?: "add" | "model"; readonly disabled?: boolean; readonly partsDisabled?: boolean; + /** Records the popups' open changes and choices. */ + readonly events?: string[]; }) { const [value, setValue] = useState(""); const own = partsDisabled === undefined ? {} : { disabled: partsDisabled }; @@ -636,13 +672,18 @@ function Prompt({ undefined }]} + onOpenChange={(next) => events?.push(`add:${next}`)} + items={[ + { id: "f", label: "Add files", onSelect: () => events?.push("f") }, + ]} /> events?.push(`model:${next}`)} + onValueChange={(id) => events?.push(`model=${id}`)} /> v @@ -667,6 +708,83 @@ const POPUPS = { }, } as const; +/** `Prompt` in a dialog that records its own open changes. The dialog + * renders in a portal, so its contents are queried from `document`. */ +function InDialog({ + dialog, + open, +}: { + readonly dialog: boolean[]; + readonly open?: "add" | "model"; +}) { + const [isOpen, setOpen] = useState(true); + return ( + { + dialog.push(next); + setOpen(next); + }} + > + + Chat + + + + ); +} + +/** A prompt in a dialog whose model list the host opens with a button. */ +function HostModelInDialog({ dialog }: { readonly dialog: boolean[] }) { + const [value, setValue] = useState(""); + const [modelOpen, setModelOpen] = useState(false); + return ( + dialog.push(next)}> + + Chat + undefined} + tokens={TOKENS} + > + + + + + + + + + + ); +} + +function typeMention(textarea: HTMLTextAreaElement) { + const setValue = Object.getOwnPropertyDescriptor( + HTMLTextAreaElement.prototype, + "value", + )?.set; + act(() => { + textarea.focus(); + setValue?.call(textarea, "@"); + textarea.setSelectionRange(1, 1); + textarea.dispatchEvent(new Event("input", { bubbles: true })); + }); +} + +function inDialog(selector: string): T { + const element = document.querySelector(`[role='dialog'] ${selector}`); + if (element === null) throw new Error(`no ${selector}`); + return element; +} + describe("toolbar parts inside PromptInput", () => { for (const which of ["add", "model"] as const) { test(`the ${which} popup, opened by the host, closes on Escape from the textarea and focus stays there`, () => { @@ -727,6 +845,106 @@ describe("toolbar parts inside PromptInput", () => { view.unmount(); }); + test("Escape inside the add menu closes the menu, not an enclosing dialog", () => { + const dialog: boolean[] = []; + const view = mount(); + const trigger = inDialog("[aria-haspopup='menu']"); + click(trigger); + const item = inDialog("[role='menuitem']"); + expect(document.activeElement).toBe(item); + press(item, "Escape"); + expect(dialog).toEqual([]); + expect(document.querySelector("[role='dialog'] [role='menu']")).toBeNull(); + expect(document.activeElement).toBe(trigger); + view.unmount(); + }); + + test("Escape on an open select closes the list, not an enclosing dialog", () => { + const dialog: boolean[] = []; + const view = mount(); + const trigger = inDialog(POPUPS.model.trigger); + act(() => trigger.focus()); + click(trigger); + expect( + document.querySelector("[role='dialog'] [role='listbox']"), + ).not.toBeNull(); + press(trigger, "Escape"); + expect(dialog).toEqual([]); + expect( + document.querySelector("[role='dialog'] [role='listbox']"), + ).toBeNull(); + expect(document.activeElement).toBe(trigger); + view.unmount(); + }); + + for (const which of ["add", "model"] as const) { + test(`Escape from the textarea closes the host-opened ${which} popup, not an enclosing dialog`, () => { + const dialog: boolean[] = []; + const view = mount(); + const textarea = inDialog("textarea"); + act(() => textarea.focus()); + const popup = `[role='dialog'] ${POPUPS[which].popup}`; + expect(document.querySelector(popup)).not.toBeNull(); + press(textarea, "Escape"); + expect(dialog).toEqual([]); + expect(document.querySelector(popup)).toBeNull(); + expect(document.activeElement).toBe(textarea); + view.unmount(); + }); + } + + for (const first of ["popup", "list"] as const) { + test(`in a dialog, with the ${first} opened first, Escape closes the list, then the popup, and never the dialog`, () => { + const dialog: boolean[] = []; + const view = mount(); + const textarea = inDialog("textarea"); + const openPopup = () => click(inDialog("[data-host]")); + if (first === "popup") openPopup(); + typeMention(textarea); + if (first === "list") openPopup(); + const list = "[role='dialog'] [data-slot='prompt-input-suggestions']"; + const popup = "[role='dialog'] [role='listbox'][aria-label='Model']"; + expect(document.querySelector(list)).not.toBeNull(); + expect(document.querySelector(popup)).not.toBeNull(); + + press(textarea, "Escape"); + expect(dialog).toEqual([]); + expect(document.querySelector(list)).toBeNull(); + expect(document.querySelector(popup)).not.toBeNull(); + + press(textarea, "Escape"); + expect(dialog).toEqual([]); + expect(document.querySelector(popup)).toBeNull(); + expect(document.activeElement).toBe(textarea); + view.unmount(); + }); + } + + for (const which of ["add", "model"] as const) { + test(`the ${which} popup, open from the start in a disabled prompt, is not shown`, () => { + const events: string[] = []; + const view = mount(); + expect(shown(view, POPUPS[which].popup)).toBe(false); + expect( + view.query(POPUPS[which].trigger).getAttribute("aria-expanded"), + ).toBe("false"); + expect(events.filter((event) => !event.includes(":"))).toEqual([]); + view.unmount(); + }); + + test(`disabling the prompt closes the open ${which} popup for good`, () => { + const events: string[] = []; + const view = mount(); + expect(shown(view, POPUPS[which].popup)).toBe(true); + view.rerender(); + expect(shown(view, POPUPS[which].popup)).toBe(false); + expect(events).toEqual([`${which}:false`]); + view.rerender(); + expect(shown(view, POPUPS[which].popup)).toBe(false); + view.unmount(); + }); + } + test("the parts follow PromptInput's disabled", () => { const view = mount(); expect(view.query("textarea").disabled).toBe(true); diff --git a/src/ui/prompt-input-toolbar.tsx b/src/ui/prompt-input-toolbar.tsx index 8eb0f6a2..3793ce8c 100644 --- a/src/ui/prompt-input-toolbar.tsx +++ b/src/ui/prompt-input-toolbar.tsx @@ -1,7 +1,7 @@ "use client"; import { Check, ChevronDown, Plus, Reply, X } from "lucide-react"; -import { useEffect, useId, useRef, useState } from "react"; +import { useEffect, useId, useLayoutEffect, useRef, useState } from "react"; import type * as React from "react"; import { useControllableState } from "../hooks/use-controllable-state.js"; @@ -41,12 +41,17 @@ function stepIndex( } /** - * While `open`, Escape pressed with focus outside `rootRef` (in the textarea, - * say, beside a popup the host opened) closes the popup and leaves focus - * where it is. It listens in the bubble phase and skips a handled Escape, so - * the suggestion list, and the popup's own keys, claim theirs first. + * While `open`, claims Escape for the popup so that one Escape closes only + * the popup, not an enclosing dialog as well. A dialog dismisses on Escape + * from a capture listener on the document unless the event is already + * prevented, so this prevents it earlier, in the window's capture phase. + * Escape pressed inside `rootRef` is then the popup's own keys' to close. + * Escape pressed outside it (in the textarea, say, beside a popup the host + * opened) closes the popup here and leaves focus where it is, unless the + * enclosing `PromptInput`'s suggestion list is showing: that Escape is the + * list's, so it is left alone. */ -function useCloseOnEscapeOutside( +function useClaimEscape( open: boolean, rootRef: React.RefObject, setOpen: (open: boolean) => void, @@ -54,17 +59,49 @@ function useCloseOnEscapeOutside( useEffect(() => { if (!open) return; const onKeyDown = (event: KeyboardEvent) => { - if (event.key !== "Escape" || event.defaultPrevented) return; + const root = rootRef.current; + if (event.key !== "Escape" || event.defaultPrevented || root === null) { + return; + } const inside = - event.target instanceof Node && - rootRef.current?.contains(event.target) === true; + event.target instanceof Node && root.contains(event.target); + if (!inside) { + const list = root + .closest("[data-slot=prompt-input]") + ?.querySelector("[data-slot=prompt-input-suggestions]"); + if (list !== null && list !== undefined) return; + } + event.preventDefault(); if (!inside) setOpen(false); }; - document.addEventListener("keydown", onKeyDown); - return () => document.removeEventListener("keydown", onKeyDown); + window.addEventListener("keydown", onKeyDown, true); + return () => window.removeEventListener("keydown", onKeyDown, true); }, [open, rootRef, setOpen]); } +/** + * What a popup shows of its `open` state: closed while `disabled`, whatever + * its owner holds, so a disabled part offers nothing to choose. Turning + * disabled while open closes it for the owner too, so it stays closed once + * the part is enabled again. + */ +function useClosedWhileDisabled( + open: boolean, + disabled: boolean, + setOpen: (open: boolean) => void, +): boolean { + // The latest `setOpen`, which is new on every render of a popup the owner + // controls, so the owner is asked once per change rather than each render. + const setOpenRef = useRef(setOpen); + useLayoutEffect(() => { + setOpenRef.current = setOpen; + }); + useEffect(() => { + if (disabled && open) setOpenRef.current(false); + }, [disabled, open]); + return open && !disabled; +} + /** Open state a parent may own, as on every popup here. */ type PopupStateProps = { readonly open?: boolean; @@ -190,15 +227,19 @@ export function PromptInputAddMenu({ onOpenChange, closeOnEscape: false, }); - const { open, setOpen, rootRef, triggerRef, close } = popover; - useCloseOnEscapeOutside(open, rootRef, setOpen); + const { setOpen, rootRef, triggerRef, close } = popover; const boxDisabled = usePromptInputDisabled(); const disabled = disabledProp ?? boxDisabled; + const open = useClosedWhileDisabled(popover.open, disabled, setOpen); + useClaimEscape(open, rootRef, setOpen); const menuRef = useRef(null); const menuId = useId(); // Set only when the person opens the menu from its trigger, so a menu that // is open from the start (or opened by the host) does not take focus. - const focusOnOpen = useRef<"first" | "last" | null>(null); + // Dropped when the owner does not open the menu at once, so the owner's + // own later open does not take focus either. + const [focusOnOpen, setFocusOnOpen] = useState<"first" | "last" | null>(null); + if (focusOnOpen !== null && !open) setFocusOnOpen(null); function enabledItems(): HTMLElement[] { const menu = menuRef.current; @@ -216,18 +257,15 @@ export function PromptInputAddMenu({ } useEffect(() => { - const which = focusOnOpen.current; - if (!open || which === null) return; - focusOnOpen.current = null; - focusItem(which); - }, [open]); + if (open && focusOnOpen !== null) focusItem(focusOnOpen); + }, [open, focusOnOpen]); function openOn(which: "first" | "last") { if (open) { focusItem(which); return; } - focusOnOpen.current = which; + setFocusOnOpen(which); setOpen(true); } @@ -434,13 +472,17 @@ function OptionSelect({ }, name: slot, }); - const { open, setOpen, rootRef, triggerRef, close } = useDismissablePopover< - HTMLDivElement, - HTMLButtonElement - >({ open: openProp, defaultOpen, onOpenChange, closeOnEscape: false }); - useCloseOnEscapeOutside(open, rootRef, setOpen); + const popover = useDismissablePopover({ + open: openProp, + defaultOpen, + onOpenChange, + closeOnEscape: false, + }); + const { setOpen, rootRef, triggerRef, close } = popover; const boxDisabled = usePromptInputDisabled(); const disabled = disabledProp ?? boxDisabled; + const open = useClosedWhileDisabled(popover.open, disabled, setOpen); + useClaimEscape(open, rootRef, setOpen); const listId = useId(); const [activeId, setActiveId] = useState(undefined); // However the list closed (a press outside, the host), the next open From 889d780271db7908b2f8f7cf8e5995c1ca23b810 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Wed, 28 Oct 2026 19:39:37 +0545 Subject: [PATCH 5/7] fix(prompt-input): claim only escapes from the prompt's own box While a toolbar popup was open it took every Escape in the window whose target lay outside the popup, closing itself and preventing the event. A dialog opened elsewhere on the page then saw its Escape as handled and stayed open while the popup behind it closed, and an Escape inside the add menu also closed a model list the host had opened beside it. A popup now closes on an outside Escape only when it comes from the same PromptInput, such as its textarea, and not from inside another open toolbar popup, which handles its own. Each popup's root carries data-state="open" while open, so that check reads the DOM. Escapes from anywhere else are left alone for the layer they were pressed in. --- src/ui/prompt-input-toolbar.test.tsx | 52 ++++++++++++++++++++++++++ src/ui/prompt-input-toolbar.tsx | 56 +++++++++++++++++++++------- 2 files changed, 94 insertions(+), 14 deletions(-) diff --git a/src/ui/prompt-input-toolbar.test.tsx b/src/ui/prompt-input-toolbar.test.tsx index 072e4d6c..ec454941 100644 --- a/src/ui/prompt-input-toolbar.test.tsx +++ b/src/ui/prompt-input-toolbar.test.tsx @@ -79,6 +79,7 @@ function click(target: HTMLElement) { act(() => target.click()); } + function ControlledModel({ initial, events, @@ -893,6 +894,57 @@ describe("toolbar parts inside PromptInput", () => { }); } + test("Escape in an unrelated dialog closes the dialog, not a host-opened list", () => { + const events: string[] = []; + const dialog: boolean[] = []; + function Page() { + const [isOpen, setOpen] = useState(true); + return ( + <> + + { + dialog.push(next); + setOpen(next); + }} + > + + Other + + + + + ); + } + const view = mount(); + const other = inDialog("[data-other]"); + act(() => other.focus()); + press(other, "Escape"); + expect(dialog).toEqual([false]); + expect(events).toEqual([]); + expect(shown(view, POPUPS.model.popup)).toBe(true); + view.unmount(); + }); + + test("Escape inside the add menu leaves a host-opened list open", () => { + const events: string[] = []; + const view = mount(); + const trigger = view.query(POPUPS.add.trigger); + act(() => trigger.focus()); + press(trigger, "ArrowDown"); + const item = view.query("[role='menuitem']"); + expect(document.activeElement).toBe(item); + press(item, "Escape"); + expect(events).toEqual(["add:true", "add:false"]); + expect(shown(view, POPUPS.add.popup)).toBe(false); + expect(shown(view, POPUPS.model.popup)).toBe(true); + expect(document.activeElement).toBe(trigger); + view.unmount(); + }); + for (const first of ["popup", "list"] as const) { test(`in a dialog, with the ${first} opened first, Escape closes the list, then the popup, and never the dialog`, () => { const dialog: boolean[] = []; diff --git a/src/ui/prompt-input-toolbar.tsx b/src/ui/prompt-input-toolbar.tsx index 3793ce8c..de16fa53 100644 --- a/src/ui/prompt-input-toolbar.tsx +++ b/src/ui/prompt-input-toolbar.tsx @@ -40,16 +40,28 @@ function stepIndex( } } +/** The root of a toolbar popup while it is open; each root carries + * `data-state="open"` only then. */ +const OPEN_POPUP = [ + "prompt-input-add-menu", + "prompt-input-model-select", + "prompt-input-mode-select", +] + .map((slot) => `[data-slot=${slot}][data-state=open]`) + .join(","); + /** * While `open`, claims Escape for the popup so that one Escape closes only * the popup, not an enclosing dialog as well. A dialog dismisses on Escape * from a capture listener on the document unless the event is already * prevented, so this prevents it earlier, in the window's capture phase. * Escape pressed inside `rootRef` is then the popup's own keys' to close. - * Escape pressed outside it (in the textarea, say, beside a popup the host - * opened) closes the popup here and leaves focus where it is, unless the - * enclosing `PromptInput`'s suggestion list is showing: that Escape is the - * list's, so it is left alone. + * Escape pressed elsewhere in the enclosing `PromptInput` (in the textarea, + * say, beside a popup the host opened) closes the popup here and leaves + * focus where it is. Left alone are an Escape from outside the + * `PromptInput`, which belongs to whatever layer it was pressed in, one + * inside another open toolbar popup, which that popup closes, and any while + * the `PromptInput`'s suggestion list is showing, which the list takes. */ function useClaimEscape( open: boolean, @@ -60,16 +72,28 @@ function useClaimEscape( if (!open) return; const onKeyDown = (event: KeyboardEvent) => { const root = rootRef.current; - if (event.key !== "Escape" || event.defaultPrevented || root === null) { + const { target } = event; + if ( + event.key !== "Escape" || + event.defaultPrevented || + root === null || + !(target instanceof Element) + ) { return; } - const inside = - event.target instanceof Node && root.contains(event.target); + const inside = root.contains(target); if (!inside) { - const list = root - .closest("[data-slot=prompt-input]") - ?.querySelector("[data-slot=prompt-input-suggestions]"); - if (list !== null && list !== undefined) return; + const box = root.closest("[data-slot=prompt-input]"); + if ( + box === null || + target.closest("[data-slot=prompt-input]") !== box + ) { + return; + } + const listShowing = + box.querySelector("[data-slot=prompt-input-suggestions]") !== null; + const inOther = target.closest(OPEN_POPUP) !== null; + if (listShowing || inOther) return; } event.preventDefault(); if (!inside) setOpen(false); @@ -207,7 +231,8 @@ export type PromptInputAddMenuProps = Omit< * ArrowUp on the last; the arrows move round the items, Home and End jump, * Escape closes and returns focus to the trigger, Tab closes and moves on. * A press outside closes it without taking focus back, as does Escape from - * outside it (from the textarea, beside a menu the host opened). + * elsewhere in the enclosing `PromptInput` (the textarea, beside a menu the + * host opened). */ export function PromptInputAddMenu({ items, @@ -278,6 +303,7 @@ export function PromptInputAddMenu({ assignRef(ref, node); }} data-slot="prompt-input-add-menu" + data-state={open ? "open" : undefined} className={cn("relative inline-flex", className)} >