mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-03 19:20:07 +02:00
feat(projects): add search and max-height to the project picker (MUL-5344) (#5979)
* feat(projects): add search and max-height to the project picker (MUL-5344) Migrate ProjectPicker off the bare DropdownMenu onto the shared PropertyPicker (the same primitive assignee/label/status pickers use), so the project dropdown now caps its height with a scrollable list and gains a client-side search box. Search matches on title substring and pinyin, so Chinese project names are reachable by latin input. Preserves the full existing contract: controlled/uncontrolled open with the Base UI open-latch normalization, the disabled read-only lock, the inline hover/keyboard clear button, and every caller's custom trigger. Co-authored-by: multica-agent <github@multica.ai> * fix(pickers): reset picker search state on programmatic close (MUL-5344) PropertyPicker cleared its search query inside the popover's open-change handler. Every picker closes itself after a selection by calling its own setOpen(false), which flips the `open` prop directly and never routes through that handler — so the stale query survived into the next open and kept the rest of the list filtered out. Move the reset onto the open -> closed transition so it covers programmatic closes too. This also fixes the same latent staleness in the assignee and label pickers, which close on selection the same way. Adds a regression test: search -> select -> reopen must show an empty input and the full list. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -104,17 +104,20 @@ export function PropertyPicker({
|
||||
}
|
||||
}, [highlightedIndex, getItems, children]); // re-run when children change (filtered list updates)
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(v: boolean) => {
|
||||
onOpenChange(v);
|
||||
if (!v) {
|
||||
setQuery("");
|
||||
setHighlightedIndex(-1);
|
||||
onSearchChange?.("");
|
||||
}
|
||||
},
|
||||
[onOpenChange, onSearchChange],
|
||||
);
|
||||
// Reset the search state on the open -> closed transition rather than inside
|
||||
// an open-change handler. Every picker closes itself after a selection by
|
||||
// calling its own `setOpen(false)`, which flips this `open` prop directly and
|
||||
// never routes through the popover's `onOpenChange` — so a handler-only reset
|
||||
// left the stale query (and the filtered list) in place on the next open.
|
||||
const wasOpen = useRef(open);
|
||||
useEffect(() => {
|
||||
if (wasOpen.current && !open) {
|
||||
setQuery("");
|
||||
setHighlightedIndex(-1);
|
||||
onSearchChange?.("");
|
||||
}
|
||||
wasOpen.current = open;
|
||||
}, [open, onSearchChange]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
@@ -161,7 +164,7 @@ export function PropertyPicker({
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<Popover open={open} onOpenChange={onOpenChange}>
|
||||
{tooltip ? (
|
||||
<Tooltip open={tooltipOpen} onOpenChange={setTooltipHover}>
|
||||
<TooltipTrigger render={popoverTrigger} />
|
||||
|
||||
@@ -127,7 +127,8 @@
|
||||
"picker": {
|
||||
"no_project": "No project",
|
||||
"remove": "Remove from project",
|
||||
"empty": "No projects yet"
|
||||
"empty": "No projects yet",
|
||||
"search_placeholder": "Search projects..."
|
||||
},
|
||||
"chip": {
|
||||
"fallback_label": "Project"
|
||||
|
||||
@@ -126,7 +126,8 @@
|
||||
"picker": {
|
||||
"no_project": "プロジェクトなし",
|
||||
"remove": "プロジェクトから削除",
|
||||
"empty": "プロジェクトはまだありません"
|
||||
"empty": "プロジェクトはまだありません",
|
||||
"search_placeholder": "プロジェクトを検索..."
|
||||
},
|
||||
"chip": {
|
||||
"fallback_label": "プロジェクト"
|
||||
|
||||
@@ -126,7 +126,8 @@
|
||||
"picker": {
|
||||
"no_project": "프로젝트 없음",
|
||||
"remove": "프로젝트에서 제거",
|
||||
"empty": "아직 프로젝트가 없습니다"
|
||||
"empty": "아직 프로젝트가 없습니다",
|
||||
"search_placeholder": "프로젝트 검색..."
|
||||
},
|
||||
"chip": {
|
||||
"fallback_label": "프로젝트"
|
||||
|
||||
@@ -126,7 +126,8 @@
|
||||
"picker": {
|
||||
"no_project": "无项目",
|
||||
"remove": "从项目移除",
|
||||
"empty": "还没有项目"
|
||||
"empty": "还没有项目",
|
||||
"search_placeholder": "搜索项目..."
|
||||
},
|
||||
"chip": {
|
||||
"fallback_label": "项目"
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
// Regression test: selecting a project in the create-issue
|
||||
// dialog left the dropdown stuck open. The dialog wires the picker with
|
||||
// `open={cond ? true : undefined}`; Base UI's Menu latches a controlled
|
||||
// `open={true}` and does NOT treat a later `undefined` as "close", so the
|
||||
// picker must normalize to an always-boolean controlled value. This test
|
||||
// uses the REAL dropdown-menu (Base UI) — do not mock it here.
|
||||
// Behavioural tests for the Popover-backed ProjectPicker. These use the REAL
|
||||
// PropertyPicker / Base UI Popover — do not mock them here.
|
||||
//
|
||||
// Open-state regression: selecting a project in the create-issue dialog left
|
||||
// the dropdown stuck open. The dialog wires the picker with
|
||||
// `open={cond ? true : undefined}`; Base UI latches a controlled `open={true}`
|
||||
// and does NOT treat a later `undefined` as "close", so the picker normalizes
|
||||
// to an always-boolean controlled value.
|
||||
import { useState } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { I18nProvider } from "@multica/core/i18n/react";
|
||||
import enProjects from "../../locales/en/projects.json";
|
||||
import enIssues from "../../locales/en/issues.json";
|
||||
import { ProjectPicker } from "./project-picker";
|
||||
import { PillButton } from "../../common/pill-button";
|
||||
|
||||
@@ -18,6 +21,7 @@ vi.mock("@tanstack/react-query", () => ({
|
||||
data: [
|
||||
{ id: "project-1", title: "Launch Command Center", icon: null },
|
||||
{ id: "project-2", title: "Mobile Web", icon: null },
|
||||
{ id: "project-3", title: "数据透明化", icon: null },
|
||||
],
|
||||
}),
|
||||
}));
|
||||
@@ -34,27 +38,42 @@ vi.mock("./project-icon", () => ({
|
||||
ProjectIcon: () => <span data-testid="project-icon" />,
|
||||
}));
|
||||
|
||||
function withI18n(children: React.ReactNode) {
|
||||
return (
|
||||
<I18nProvider locale="en" resources={{ en: { projects: enProjects, issues: enIssues } }}>
|
||||
{children}
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/** Mirrors the create-issue dialog wiring from packages/views/modals/create-issue.tsx. */
|
||||
function CreateDialogHarness({ onUpdate }: { onUpdate: (u: object) => void }) {
|
||||
const [fieldPickerOpen, setFieldPickerOpen] = useState<"project" | null>(null);
|
||||
const [projectId, setProjectId] = useState<string | null>(null);
|
||||
return (
|
||||
<I18nProvider locale="en" resources={{ en: { projects: enProjects } }}>
|
||||
<ProjectPicker
|
||||
projectId={projectId}
|
||||
onUpdate={(u) => {
|
||||
onUpdate(u);
|
||||
setProjectId(u.project_id ?? null);
|
||||
}}
|
||||
triggerRender={<PillButton />}
|
||||
align="start"
|
||||
open={fieldPickerOpen === "project" ? true : undefined}
|
||||
onOpenChange={(open) => setFieldPickerOpen(open ? "project" : null)}
|
||||
/>
|
||||
</I18nProvider>
|
||||
return withI18n(
|
||||
<ProjectPicker
|
||||
projectId={projectId}
|
||||
onUpdate={(u) => {
|
||||
onUpdate(u);
|
||||
setProjectId((u as { project_id?: string | null }).project_id ?? null);
|
||||
}}
|
||||
triggerRender={<PillButton />}
|
||||
align="start"
|
||||
open={fieldPickerOpen === "project" ? true : undefined}
|
||||
onOpenChange={(open) => setFieldPickerOpen(open ? "project" : null)}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
// The picker is closed iff its search input is unmounted. A closed selection
|
||||
// can't be detected by the item's name because the trigger adopts the selected
|
||||
// project's title, so a name query would keep matching the trigger.
|
||||
function expectClosed() {
|
||||
return waitFor(() => {
|
||||
expect(screen.queryByPlaceholderText("Search projects...")).not.toBeInTheDocument();
|
||||
});
|
||||
}
|
||||
|
||||
describe("ProjectPicker open state under create-dialog wiring", () => {
|
||||
it("closes the dropdown after selecting a project", async () => {
|
||||
const user = userEvent.setup();
|
||||
@@ -62,16 +81,14 @@ describe("ProjectPicker open state under create-dialog wiring", () => {
|
||||
|
||||
render(<CreateDialogHarness onUpdate={onUpdate} />);
|
||||
|
||||
// Open the picker via its trigger.
|
||||
// Open the picker via its trigger (unselected → trigger reads "No project").
|
||||
await user.click(screen.getByRole("button", { name: /no project/i }));
|
||||
const item = await screen.findByRole("menuitem", { name: /mobile web/i });
|
||||
const item = await screen.findByRole("button", { name: /mobile web/i });
|
||||
|
||||
// Select a project — the selection must register AND the popup must close.
|
||||
await user.click(item);
|
||||
expect(onUpdate).toHaveBeenCalledWith({ project_id: "project-2" });
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("menuitem", { name: /mobile web/i })).not.toBeInTheDocument();
|
||||
});
|
||||
await expectClosed();
|
||||
});
|
||||
|
||||
it("can be reopened and closed again after a selection", async () => {
|
||||
@@ -80,16 +97,76 @@ describe("ProjectPicker open state under create-dialog wiring", () => {
|
||||
render(<CreateDialogHarness onUpdate={vi.fn()} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /no project/i }));
|
||||
await user.click(await screen.findByRole("menuitem", { name: /launch command center/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("menuitem", { name: /launch command center/i })).not.toBeInTheDocument();
|
||||
});
|
||||
await user.click(await screen.findByRole("button", { name: /launch command center/i }));
|
||||
await expectClosed();
|
||||
|
||||
// Reopen from the (now selected) trigger and close by selecting again.
|
||||
await user.click(screen.getByRole("button", { name: /launch command center/i }));
|
||||
await user.click(await screen.findByRole("menuitem", { name: /mobile web/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("menuitem", { name: /mobile web/i })).not.toBeInTheDocument();
|
||||
});
|
||||
await user.click(await screen.findByRole("button", { name: /mobile web/i }));
|
||||
await expectClosed();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProjectPicker search", () => {
|
||||
it("filters the project list by title substring", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(withI18n(<ProjectPicker projectId={null} onUpdate={vi.fn()} triggerRender={<PillButton />} />));
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /no project/i }));
|
||||
const search = await screen.findByPlaceholderText("Search projects...");
|
||||
|
||||
await user.type(search, "mobile");
|
||||
expect(screen.getByRole("button", { name: /mobile web/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /launch command center/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("matches Chinese project names by pinyin", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(withI18n(<ProjectPicker projectId={null} onUpdate={vi.fn()} triggerRender={<PillButton />} />));
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /no project/i }));
|
||||
const search = await screen.findByPlaceholderText("Search projects...");
|
||||
|
||||
// "数据透明化" → full pinyin "shujutouminghua"; a prefix must match.
|
||||
await user.type(search, "shuju");
|
||||
expect(screen.getByText("数据透明化")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /mobile web/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows an empty state when no project matches", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(withI18n(<ProjectPicker projectId={null} onUpdate={vi.fn()} triggerRender={<PillButton />} />));
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /no project/i }));
|
||||
const search = await screen.findByPlaceholderText("Search projects...");
|
||||
|
||||
await user.type(search, "zzzznomatch");
|
||||
expect(screen.getByText("No results")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /mobile web/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Regression: selecting a row closes the popover by calling `setOpen(false)`
|
||||
// directly, which never routes through PropertyPicker's own open-change
|
||||
// handler — the only place that used to reset the query. The stale search
|
||||
// term survived into the next open and kept the rest of the list hidden.
|
||||
it("resets the search term after selecting a match and reopening", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<CreateDialogHarness onUpdate={vi.fn()} />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /no project/i }));
|
||||
await user.type(await screen.findByPlaceholderText("Search projects..."), "mobile");
|
||||
await user.click(await screen.findByRole("button", { name: /mobile web/i }));
|
||||
await expectClosed();
|
||||
|
||||
// Reopen: the input must be empty and the full list restored.
|
||||
await user.click(screen.getByRole("button", { name: /mobile web/i }));
|
||||
const reopened = await screen.findByPlaceholderText("Search projects...");
|
||||
expect(reopened).toHaveValue("");
|
||||
expect(screen.getByRole("button", { name: /launch command center/i })).toBeInTheDocument();
|
||||
expect(screen.getByText("数据透明化")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { cloneElement, isValidElement, type ReactElement, type ReactNode } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { I18nProvider } from "@multica/core/i18n/react";
|
||||
import enProjects from "../../locales/en/projects.json";
|
||||
import enIssues from "../../locales/en/issues.json";
|
||||
import { ProjectPicker } from "./project-picker";
|
||||
import { PillButton } from "../../common/pill-button";
|
||||
|
||||
@@ -25,37 +25,36 @@ vi.mock("./project-icon", () => ({
|
||||
ProjectIcon: () => <span data-testid="project-icon" />,
|
||||
}));
|
||||
|
||||
vi.mock("@multica/ui/components/ui/dropdown-menu", () => ({
|
||||
DropdownMenu: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
DropdownMenuTrigger: ({ render: trigger, children }: { render?: ReactElement; children: ReactNode }) =>
|
||||
isValidElement(trigger)
|
||||
? cloneElement(trigger, {}, children)
|
||||
: <button type="button">{children}</button>,
|
||||
DropdownMenuContent: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
DropdownMenuItem: ({ children, onClick }: { children: ReactNode; onClick?: () => void }) => (
|
||||
<button type="button" onClick={onClick}>{children}</button>
|
||||
),
|
||||
DropdownMenuSeparator: () => null,
|
||||
}));
|
||||
// Real PropertyPicker (Popover) — do not mock it: the inline clear control is
|
||||
// rendered by ProjectPicker outside the popover, so it is present without
|
||||
// opening the picker.
|
||||
function renderPicker(props: Partial<React.ComponentProps<typeof ProjectPicker>> = {}) {
|
||||
return render(
|
||||
<I18nProvider locale="en" resources={{ en: { projects: enProjects, issues: enIssues } }}>
|
||||
<ProjectPicker
|
||||
projectId="project-1"
|
||||
onUpdate={props.onUpdate ?? vi.fn()}
|
||||
triggerRender={<PillButton />}
|
||||
{...props}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function findInlineClear() {
|
||||
return screen
|
||||
.getAllByRole("button", { name: "Remove from project" })
|
||||
.find((button) => button.className.includes("group-hover/project:opacity-100"));
|
||||
}
|
||||
|
||||
describe("ProjectPicker", () => {
|
||||
it("shows a hover clear action for the selected project", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onUpdate = vi.fn();
|
||||
|
||||
render(
|
||||
<I18nProvider locale="en" resources={{ en: { projects: enProjects } }}>
|
||||
<ProjectPicker
|
||||
projectId="project-1"
|
||||
onUpdate={onUpdate}
|
||||
triggerRender={<PillButton />}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
renderPicker({ onUpdate });
|
||||
|
||||
const clear = screen
|
||||
.getAllByRole("button", { name: "Remove from project" })
|
||||
.find((button) => button.className.includes("group-hover/project:opacity-100"));
|
||||
const clear = findInlineClear();
|
||||
expect(clear).toBeDefined();
|
||||
expect(clear!.className).toContain("group-hover/project:opacity-100");
|
||||
expect(clear!.className).toContain("size-3.5");
|
||||
@@ -74,19 +73,9 @@ describe("ProjectPicker", () => {
|
||||
const user = userEvent.setup();
|
||||
const onUpdate = vi.fn();
|
||||
|
||||
render(
|
||||
<I18nProvider locale="en" resources={{ en: { projects: enProjects } }}>
|
||||
<ProjectPicker
|
||||
projectId="project-1"
|
||||
onUpdate={onUpdate}
|
||||
triggerRender={<PillButton />}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
renderPicker({ onUpdate });
|
||||
|
||||
const clear = screen
|
||||
.getAllByRole("button", { name: "Remove from project" })
|
||||
.find((button) => button.className.includes("group-hover/project:opacity-100"));
|
||||
const clear = findInlineClear();
|
||||
expect(clear).toBeDefined();
|
||||
expect(clear).not.toBeDisabled();
|
||||
|
||||
@@ -105,20 +94,9 @@ describe("ProjectPicker", () => {
|
||||
// both pointer and keyboard activation inert.
|
||||
const onUpdate = vi.fn();
|
||||
|
||||
render(
|
||||
<I18nProvider locale="en" resources={{ en: { projects: enProjects } }}>
|
||||
<ProjectPicker
|
||||
projectId="project-1"
|
||||
onUpdate={onUpdate}
|
||||
disabled
|
||||
triggerRender={<PillButton />}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
renderPicker({ onUpdate, disabled: true });
|
||||
|
||||
const clear = screen
|
||||
.getAllByRole("button", { name: "Remove from project" })
|
||||
.find((button) => button.className.includes("group-hover/project:opacity-100"));
|
||||
const clear = findInlineClear();
|
||||
expect(clear).toBeDefined();
|
||||
expect(clear).toBeDisabled();
|
||||
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Check, FolderKanban, X } from "lucide-react";
|
||||
import { FolderKanban, X } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { projectListOptions } from "@multica/core/projects/queries";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import type { UpdateIssueRequest } from "@multica/core/types";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
} from "@multica/ui/components/ui/dropdown-menu";
|
||||
import { ProjectIcon } from "./project-icon";
|
||||
import {
|
||||
PropertyPicker,
|
||||
PickerItem,
|
||||
PickerEmpty,
|
||||
PICKER_TRIGGER_CLASS,
|
||||
} from "../../issues/components/pickers/property-picker";
|
||||
import { matchesPinyin } from "../../editor/extensions/pinyin-match";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
export function ProjectPicker({
|
||||
@@ -48,73 +48,110 @@ export function ProjectPicker({
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: projects = [] } = useQuery(projectListOptions(wsId));
|
||||
const current = projects.find((p) => p.id === projectId);
|
||||
const [filter, setFilter] = useState("");
|
||||
// Normalize to an always-boolean controlled `open`, matching the other
|
||||
// pickers (status/priority/assignee/labels). Base UI's Menu latches a
|
||||
// controlled `open={true}` — a later `undefined` does NOT close it — so
|
||||
// callers wiring `open={cond ? true : undefined}` (create-issue dialog)
|
||||
// would leave the popup stuck open after selecting a project.
|
||||
// pickers (status/priority/assignee/labels). Base UI latches a controlled
|
||||
// `open={true}` — a later `undefined` does NOT close it — so callers wiring
|
||||
// `open={cond ? true : undefined}` (create-issue dialog) would otherwise
|
||||
// leave the popup stuck open after selecting a project.
|
||||
const [internalOpen, setInternalOpen] = useState(defaultOpen);
|
||||
// A disabled picker can never be open, and no interaction may reopen it.
|
||||
const open = disabled ? false : controlledOpen ?? internalOpen;
|
||||
const setOpen = disabled ? () => {} : onOpenChange ?? setInternalOpen;
|
||||
|
||||
// Client-side filter: substring match plus pinyin so Chinese project names
|
||||
// are reachable by latin input (e.g. "sjtmh" → "数据透明化").
|
||||
const query = filter.trim().toLowerCase();
|
||||
const filtered = projects.filter(
|
||||
(p) => p.title.toLowerCase().includes(query) || matchesPinyin(p.title, query),
|
||||
);
|
||||
|
||||
// Default trigger built as a `triggerRender` so it can reserve right padding
|
||||
// for the inline clear button. Callers that bring their own trigger (chat
|
||||
// pill, autopilot card, table cell) take over the trigger entirely.
|
||||
const resolvedTriggerRender = triggerRender ?? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className={cn(PICKER_TRIGGER_CLASS, current && "pr-5")}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<div className="group/project relative inline-flex min-w-0">
|
||||
<DropdownMenuTrigger
|
||||
disabled={disabled}
|
||||
className={
|
||||
triggerRender
|
||||
? undefined
|
||||
: cn(
|
||||
"flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors overflow-hidden",
|
||||
current && "pr-5",
|
||||
)
|
||||
}
|
||||
render={triggerRender}
|
||||
>
|
||||
{current ? (
|
||||
<ProjectIcon project={current} size="sm" />
|
||||
<div className="group/project relative inline-flex min-w-0">
|
||||
<PropertyPicker
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
width="w-52"
|
||||
align={align}
|
||||
searchable
|
||||
searchPlaceholder={t(($) => $.picker.search_placeholder)}
|
||||
onSearchChange={setFilter}
|
||||
triggerRender={resolvedTriggerRender}
|
||||
trigger={
|
||||
current ? (
|
||||
<>
|
||||
<ProjectIcon project={current} size="sm" />
|
||||
<span className="truncate">{current.title}</span>
|
||||
</>
|
||||
) : (
|
||||
<FolderKanban className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="truncate">{current ? current.title : t(($) => $.picker.no_project)}</span>
|
||||
</DropdownMenuTrigger>
|
||||
{current && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-label={t(($) => $.picker.remove)}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
<>
|
||||
<FolderKanban className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{t(($) => $.picker.no_project)}</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
{/* "No project" clear row — hidden while searching, mirrors the
|
||||
unassigned row in the assignee picker. */}
|
||||
{!query && projects.length > 0 && (
|
||||
<PickerItem
|
||||
selected={!projectId}
|
||||
onClick={() => {
|
||||
onUpdate({ project_id: null });
|
||||
setOpen(false);
|
||||
}}
|
||||
className="pointer-events-none absolute right-1 top-1/2 flex size-3.5 -translate-y-1/2 items-center justify-center rounded-sm text-muted-foreground opacity-0 transition-[background-color,color,opacity] hover:bg-muted-foreground/20 hover:text-foreground focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none group-hover/project:pointer-events-auto group-hover/project:opacity-100 focus-visible:pointer-events-auto focus-visible:opacity-100 disabled:pointer-events-none disabled:opacity-0 disabled:group-hover/project:opacity-0"
|
||||
>
|
||||
<X className="size-2.5" />
|
||||
</button>
|
||||
<FolderKanban className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">{t(($) => $.picker.no_project)}</span>
|
||||
</PickerItem>
|
||||
)}
|
||||
</div>
|
||||
<DropdownMenuContent align={align} className="w-52">
|
||||
{projects.map((p) => (
|
||||
<DropdownMenuItem key={p.id} onClick={() => onUpdate({ project_id: p.id })}>
|
||||
<ProjectIcon project={p} size="md" className="mr-1" />
|
||||
|
||||
{filtered.map((p) => (
|
||||
<PickerItem
|
||||
key={p.id}
|
||||
selected={p.id === projectId}
|
||||
onClick={() => {
|
||||
onUpdate({ project_id: p.id });
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<ProjectIcon project={p} size="sm" />
|
||||
<span className="truncate">{p.title}</span>
|
||||
{p.id === projectId && <Check className="ml-auto h-3.5 w-3.5 shrink-0" />}
|
||||
</DropdownMenuItem>
|
||||
</PickerItem>
|
||||
))}
|
||||
{projects.length > 0 && projectId && <DropdownMenuSeparator />}
|
||||
{projectId && (
|
||||
<DropdownMenuItem onClick={() => onUpdate({ project_id: null })}>
|
||||
<X className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{t(($) => $.picker.remove)}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{projects.length === 0 && (
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">{t(($) => $.picker.empty)}</div>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{projects.length > 0 && filtered.length === 0 && query && <PickerEmpty />}
|
||||
</PropertyPicker>
|
||||
|
||||
{current && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-label={t(($) => $.picker.remove)}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onUpdate({ project_id: null });
|
||||
}}
|
||||
className="pointer-events-none absolute right-1 top-1/2 flex size-3.5 -translate-y-1/2 items-center justify-center rounded-sm text-muted-foreground opacity-0 transition-[background-color,color,opacity] hover:bg-muted-foreground/20 hover:text-foreground focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none group-hover/project:pointer-events-auto group-hover/project:opacity-100 focus-visible:pointer-events-auto focus-visible:opacity-100 disabled:pointer-events-none disabled:opacity-0 disabled:group-hover/project:opacity-0"
|
||||
>
|
||||
<X className="size-2.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user