fix(client): keep space switcher dropdown inside its popover

This commit is contained in:
Philipinho
2026-08-14 02:01:04 +01:00
parent ea59912c7e
commit 30e5f42b3a
3 changed files with 137 additions and 5 deletions
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { useDebouncedValue } from "@mantine/hooks"; import { useDebouncedValue } from "@mantine/hooks";
import { Group, Select, SelectProps, Text } from "@mantine/core"; import { Group, Select, SelectProps, Text } from "@mantine/core";
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts"; import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
@@ -14,6 +14,7 @@ interface SpaceSelectProps {
width?: number; width?: number;
opened?: boolean; opened?: boolean;
clearable?: boolean; clearable?: boolean;
withinPortal?: boolean;
} }
const renderSelectOption: SelectProps["renderOption"] = ({ option }) => ( const renderSelectOption: SelectProps["renderOption"] = ({ option }) => (
@@ -41,6 +42,7 @@ export function SpaceSelect({
width, width,
opened, opened,
clearable, clearable,
withinPortal = true,
}: SpaceSelectProps) { }: SpaceSelectProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [searchValue, setSearchValue] = useState(""); const [searchValue, setSearchValue] = useState("");
@@ -50,9 +52,13 @@ export function SpaceSelect({
limit: 50, limit: 50,
}); });
const [data, setData] = useState([]); const [data, setData] = useState([]);
const fetchedSpaces = useRef(new Map<string, ISpace>());
useEffect(() => { useEffect(() => {
if (spaces) { if (spaces) {
spaces.items.forEach((space: ISpace) =>
fetchedSpaces.current.set(space.slug, space),
);
const spaceData = spaces?.items const spaceData = spaces?.items
.filter((space: ISpace) => space.slug !== value) .filter((space: ISpace) => space.slug !== value)
.map((space: ISpace) => { .map((space: ISpace) => {
@@ -83,14 +89,19 @@ export function SpaceSelect({
onSearchChange={setSearchValue} onSearchChange={setSearchValue}
clearable={clearable} clearable={clearable}
variant="filled" variant="filled"
onChange={(slug) => onChange={(slug) => {
onChange(spaces.items?.find((item) => item.slug === slug)) // options accumulate across fetches; resolve against everything
} // fetched, not just the latest query result
const space = slug && fetchedSpaces.current.get(slug);
if (space) {
onChange(space);
}
}}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
nothingFoundMessage={t("No space found")} nothingFoundMessage={t("No space found")}
limit={50} limit={50}
checkIconPosition="right" checkIconPosition="right"
comboboxProps={{ width, withinPortal: true, position: "bottom", keepMounted: false, dropdownPadding: 0 }} comboboxProps={{ width, withinPortal, position: "bottom", keepMounted: false, dropdownPadding: 0 }}
dropdownOpened={opened} dropdownOpened={opened}
/> />
); );
@@ -0,0 +1,118 @@
import { beforeAll, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter, useLocation } from "react-router-dom";
import { SwitchSpace } from "./switch-space.tsx";
vi.mock("@/main.tsx", async () => {
const { QueryClient } = await import("@tanstack/react-query");
return { queryClient: new QueryClient() };
});
const SPACES = [
{ id: "1", name: "General", slug: "general", logo: null },
{ id: "2", name: "Engineering", slug: "engineering", logo: null },
{ id: "3", name: "Marketing", slug: "marketing", logo: null },
];
vi.mock("@/features/space/services/space-service.ts", () => ({
getSpaces: vi.fn(async () => ({ items: SPACES, meta: {} })),
getSpaceById: vi.fn(),
getSpaceMembers: vi.fn(),
addSpaceMember: vi.fn(),
changeMemberRole: vi.fn(),
removeSpaceMember: vi.fn(),
createSpace: vi.fn(),
updateSpace: vi.fn(),
deleteSpace: vi.fn(),
}));
beforeAll(() => {
window.matchMedia = ((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
})) as unknown as typeof window.matchMedia;
window.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof window.ResizeObserver;
Element.prototype.scrollIntoView = () => {};
});
function LocationProbe() {
const location = useLocation();
return <div data-testid="location">{location.pathname}</div>;
}
function renderSwitcher() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<MantineProvider>
<QueryClientProvider client={client}>
<MemoryRouter initialEntries={["/s/general"]}>
<SwitchSpace spaceName="General" spaceSlug="general" />
<LocationProbe />
</MemoryRouter>
</QueryClientProvider>
</MantineProvider>,
);
}
function getOption(name: string) {
const options = screen.getAllByRole("option", { hidden: true });
const match = options.find((option) => option.textContent?.includes(name));
if (!match) {
throw new Error(`option ${name} not found`);
}
return match;
}
async function openSwitcher() {
fireEvent.click(screen.getByRole("button", { name: /general/i }));
const dialog = await screen.findByRole("dialog", { hidden: true });
await screen.findAllByRole("option", { hidden: true });
return dialog;
}
describe("SwitchSpace", () => {
it("renders the space options inside the popover so option clicks are not outside clicks", async () => {
renderSwitcher();
const dialog = await openSwitcher();
const listbox = screen.getByRole("listbox", { hidden: true });
expect(dialog.contains(listbox)).toBe(true);
});
it("keeps the popover open when an option is pressed with the mouse", async () => {
renderSwitcher();
const dialog = await openSwitcher();
fireEvent.mouseDown(getOption("Marketing"));
await new Promise((resolve) => setTimeout(resolve, 400));
expect(document.body.contains(dialog)).toBe(true);
});
it("navigates to the space picked from search results", async () => {
renderSwitcher();
await openSwitcher();
fireEvent.change(screen.getByPlaceholderText("Search for spaces"), {
target: { value: "mark" },
});
await waitFor(() => getOption("Marketing"));
const option = getOption("Marketing");
fireEvent.mouseDown(option);
fireEvent.mouseUp(option);
fireEvent.click(option);
await waitFor(() =>
expect(screen.getByTestId("location").textContent).toBe("/s/marketing"),
);
});
});
@@ -64,12 +64,15 @@ export function SwitchSpace({
</Button> </Button>
</Popover.Target> </Popover.Target>
<Popover.Dropdown> <Popover.Dropdown>
{/* keep the options dropdown inside the popover; a portaled dropdown
registers as an outside click and closes the popover mid-selection */}
<SpaceSelect <SpaceSelect
label={spaceName} label={spaceName}
value={spaceSlug} value={spaceSlug}
onChange={(space) => handleSelect(space.slug)} onChange={(space) => handleSelect(space.slug)}
width={300} width={300}
opened={true} opened={true}
withinPortal={false}
/> />
</Popover.Dropdown> </Popover.Dropdown>
</Popover> </Popover>