diff --git a/apps/web/src/components/chat/ComposerPermissionsSection.browser.test.tsx b/apps/web/src/components/chat/ComposerPermissionsSection.browser.test.tsx index 9dadb999da85..bb03e19dd130 100644 --- a/apps/web/src/components/chat/ComposerPermissionsSection.browser.test.tsx +++ b/apps/web/src/components/chat/ComposerPermissionsSection.browser.test.tsx @@ -7,12 +7,14 @@ import { render } from "../../../test/render"; import { ComposerHandleContext, type ComposerHandleRef } from "../../composerHandleContext"; import { ComposerContextPermissions } from "../ComposerContextPermissions"; import { Menu, MenuPopup, MenuTrigger } from "../ui/menu"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { focusRequestedPermission, markOpenToPermissions, useComposerMenuProps, } from "./composerEventScope"; import { ComposerFooterModeControls, ComposerPermissionsSection } from "./ComposerFooterControls"; +import { ComposerPickerFooter } from "./ComposerModelEffort"; import { runtimeModeConfig, runtimeModeOptions } from "./runtimeModeConfig"; const options = runtimeModeOptions.map((mode) => ({ mode, ...runtimeModeConfig[mode] })); @@ -95,6 +97,49 @@ it("returns focus to the editor after a pick", async () => { await vi.waitFor(() => expect(handle.focusAtEnd).toHaveBeenCalled()); }); +// Mirrors the model picker: a composer popover whose footer nests the Permissions menu. +function PickerWithPermissions() { + const [mode, setMode] = useState("full-access"); + const composerMenuProps = useComposerMenuProps(); + return ( + <> +

Timeline

+ + Model + + + + + + ); +} + +it("on a touch tablet, tapping outside the Permissions menu leaves the editor unfocused", async () => { + const realMatchMedia = window.matchMedia.bind(window); + using _touch = vi + .spyOn(window, "matchMedia") + .mockImplementation((query) => realMatchMedia(query === "(pointer: coarse)" ? "all" : query)); + const { handle, ui } = withComposer(); + await using screen = await render(ui, { router: false }); // no routes involved + await userEvent.click(screen.getByRole("button", { name: "Model" })); + await userEvent.click(page.getByRole("button", { name: "Permissions: Full access" })); + const fullAccess = page.getByRole("menuitemradio", { name: "Full access" }); + await expect.element(fullAccess).toBeInTheDocument(); + const permissionsTrigger = page.getByRole("button", { name: "Permissions: Full access" }); + // The first outside tap closes the Permissions menu, the second the picker around it. + await userEvent.click(document.body, { position: { x: 1, y: 1 } }); + await expect.element(fullAccess).not.toBeInTheDocument(); + await userEvent.click(screen.getByText("Timeline")); + await expect.element(permissionsTrigger).not.toBeInTheDocument(); + // Focusing the editor after a tap opens the on-screen keyboard on Android tablets. + expect(handle.focusAtEnd).not.toHaveBeenCalled(); +}); + it("opens on the checked permission when composer.mode marked the trigger", async () => { await using screen = await render(, { router: false }); // no routes involved const trigger = screen.getByRole("button", { name: "More" }); diff --git a/apps/web/src/components/chat/composerEventScope.ts b/apps/web/src/components/chat/composerEventScope.ts index a0271b4a8257..b71c338e61ec 100644 --- a/apps/web/src/components/chat/composerEventScope.ts +++ b/apps/web/src/components/chat/composerEventScope.ts @@ -1,4 +1,5 @@ import { useComposerHandleContext } from "../../composerHandleContext"; +import { useMediaQuery } from "../../hooks/useMediaQuery"; const COMPOSER_FLOATING_LAYER_SELECTOR = [ '[data-composer-drawer-layer="true"]', @@ -11,19 +12,22 @@ export const composerFloatingLayerProps = { export function useComposerMenuProps() { const composerRef = useComposerHandleContext(); + const isCoarsePointer = useMediaQuery({ pointer: "coarse" }); return { ...composerFloatingLayerProps, - finalFocus: composerRef - ? () => { - const activeElement = document.activeElement; - if (activeElement !== document.body && !isInsideComposerFloatingLayer(activeElement)) { + // Focusing the editor on a touch device opens the on-screen keyboard, so the menu keeps its own focus return. + finalFocus: + composerRef && !isCoarsePointer + ? () => { + const activeElement = document.activeElement; + if (activeElement !== document.body && !isInsideComposerFloatingLayer(activeElement)) { + return false; + } + composerRef.current?.focusAtEnd(); return false; } - composerRef.current?.focusAtEnd(); - return false; - } - : undefined, + : undefined, }; }