392 lines
15 KiB
TypeScript
392 lines
15 KiB
TypeScript
import * as React from "react";
|
|
import { Link, useRouter } from "@tanstack/react-router";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import {
|
|
Check,
|
|
ChevronsUpDown,
|
|
FolderCog,
|
|
Plus,
|
|
Search,
|
|
Settings,
|
|
} from "lucide-react";
|
|
import { getProjects } from "@/serverFunctions/projects";
|
|
import { setLastProjectId } from "@/client/lib/active-project";
|
|
import { CreateProjectModal } from "@/client/features/projects/CreateProjectModal";
|
|
import type { ProjectSummary } from "./types";
|
|
|
|
// Below this many projects the plain list is faster to scan than a search box.
|
|
const SEARCH_THRESHOLD = 8;
|
|
|
|
export function ProjectSwitcher({
|
|
activeProjectId,
|
|
onCloseDrawer,
|
|
}: {
|
|
activeProjectId: string | null;
|
|
// Mobile sidebar passes this so switching / navigating away also closes the
|
|
// drawer overlay.
|
|
onCloseDrawer?: () => void;
|
|
}) {
|
|
// Matches are read off router.state at click time; subscribing via
|
|
// useMatches() would re-render the whole sidebar on every route change for
|
|
// a value only a click needs.
|
|
const router = useRouter();
|
|
const [creating, setCreating] = React.useState(false);
|
|
// Controlled open state rather than daisyUI's CSS focus-within dropdown:
|
|
// focus-within can't guarantee the search input ends up focused on open
|
|
// (Safari never focuses buttons on click, and moving focus into the panel
|
|
// is exactly what a combobox needs).
|
|
const [open, setOpen] = React.useState(false);
|
|
const [query, setQuery] = React.useState("");
|
|
const [highlightIndex, setHighlightIndex] = React.useState(0);
|
|
const rootRef = React.useRef<HTMLDivElement>(null);
|
|
const triggerRef = React.useRef<HTMLButtonElement>(null);
|
|
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
|
const listRef = React.useRef<HTMLUListElement>(null);
|
|
|
|
const projectsQuery = useQuery({
|
|
queryKey: ["projects"],
|
|
queryFn: () => getProjects(),
|
|
});
|
|
const projects = projectsQuery.data ?? [];
|
|
const activeProject =
|
|
projects.find((project) => project.id === activeProjectId) ?? null;
|
|
|
|
const showSearch = projects.length >= SEARCH_THRESHOLD;
|
|
const normalizedQuery = query.trim().toLowerCase();
|
|
const filteredProjects = normalizedQuery
|
|
? projects.filter(
|
|
(project) =>
|
|
project.name.toLowerCase().includes(normalizedQuery) ||
|
|
project.domain?.toLowerCase().includes(normalizedQuery),
|
|
)
|
|
: projects;
|
|
|
|
const openPanel = () => {
|
|
setQuery("");
|
|
setHighlightIndex(0);
|
|
setOpen(true);
|
|
};
|
|
|
|
const closePanel = () => {
|
|
setOpen(false);
|
|
setQuery("");
|
|
};
|
|
|
|
// Opening the panel puts the caret straight in the search box, so
|
|
// click → type → Enter selects a project with no extra step. Touch devices
|
|
// are skipped: autofocus would pop the keyboard over the list.
|
|
React.useEffect(() => {
|
|
if (!open || !showSearch) return;
|
|
if (window.matchMedia("(pointer: coarse)").matches) return;
|
|
searchInputRef.current?.focus();
|
|
}, [open, showSearch]);
|
|
|
|
const handleSelect = (project: ProjectSummary) => {
|
|
closePanel();
|
|
onCloseDrawer?.();
|
|
if (project.id === activeProjectId) return;
|
|
setLastProjectId(project.id);
|
|
// Stay on the current page in the new project: the deepest matched route
|
|
// whose path's only dynamic segment is the project id. Deeper routes (a
|
|
// rank tracker, an audit result) reference entities owned by the old
|
|
// project, so they fall back to their section. Filtering on the path
|
|
// template rather than match.params matters: the router gives every match
|
|
// the location's full param set, so params can't tell layers apart.
|
|
const stayable = router.state.matches.findLast(
|
|
(match) =>
|
|
match.fullPath.includes("$projectId") &&
|
|
match.fullPath
|
|
.split("/")
|
|
.every(
|
|
(segment) => !segment.startsWith("$") || segment === "$projectId",
|
|
),
|
|
);
|
|
// Navigating by href keeps typed-route generics out of a dynamic target
|
|
// while still running search validation; search params are deliberately
|
|
// not carried over — filters and session ids belong to the old project.
|
|
const template = stayable?.fullPath ?? "/p/$projectId";
|
|
void router.navigate({
|
|
href: template.split("$projectId").join(project.id).replace(/\/$/, ""),
|
|
});
|
|
};
|
|
|
|
const moveHighlight = (delta: number) => {
|
|
setHighlightIndex((index) => {
|
|
const next = index + delta;
|
|
if (next < 0) return 0;
|
|
if (next > filteredProjects.length - 1)
|
|
return filteredProjects.length - 1;
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const handleSearchKeyDown = (event: React.KeyboardEvent) => {
|
|
if (event.key === "ArrowDown") {
|
|
event.preventDefault();
|
|
moveHighlight(1);
|
|
} else if (event.key === "ArrowUp") {
|
|
event.preventDefault();
|
|
moveHighlight(-1);
|
|
} else if (event.key === "Enter") {
|
|
event.preventDefault();
|
|
const project = filteredProjects[highlightIndex] ?? filteredProjects[0];
|
|
if (project) handleSelect(project);
|
|
}
|
|
// Escape is handled once at the root so it also works from the project
|
|
// list and footer buttons.
|
|
};
|
|
|
|
// Type-ahead fallback on the trigger: if the user types while focus is
|
|
// still on the trigger (touch skips autofocus; focus can also stay here
|
|
// between open and the focus effect), open the panel and route the
|
|
// keystroke into the search box instead of dropping it. Deliberately not
|
|
// on the wrapper — that would also swallow keystrokes bubbling from the
|
|
// menu items and the create-project modal rendered inside it.
|
|
const handleTriggerKeyDown = (event: React.KeyboardEvent) => {
|
|
if (!showSearch) return;
|
|
const isCharacter =
|
|
event.key.length === 1 &&
|
|
!event.metaKey &&
|
|
!event.ctrlKey &&
|
|
!event.altKey;
|
|
if (isCharacter || event.key === "Backspace") {
|
|
event.preventDefault();
|
|
if (!open) setOpen(true);
|
|
setQuery((current) =>
|
|
isCharacter ? current + event.key : current.slice(0, -1),
|
|
);
|
|
setHighlightIndex(0);
|
|
searchInputRef.current?.focus();
|
|
} else if (!open && event.key === "ArrowDown") {
|
|
event.preventDefault();
|
|
openPanel();
|
|
} else if (open && ["ArrowDown", "ArrowUp", "Enter"].includes(event.key)) {
|
|
handleSearchKeyDown(event);
|
|
}
|
|
};
|
|
|
|
// Escape closes the panel from anywhere inside the switcher — trigger,
|
|
// search box, project list, or footer. When the create-project modal is up
|
|
// the panel is already closed, so this never swallows the modal's own
|
|
// Escape handling.
|
|
const handleRootKeyDown = (event: React.KeyboardEvent) => {
|
|
if (event.key !== "Escape" || !open) return;
|
|
event.preventDefault();
|
|
closePanel();
|
|
triggerRef.current?.focus();
|
|
};
|
|
|
|
// Close on clicks outside the switcher. Focus loss alone isn't used for
|
|
// this (clicking a non-focusable spot inside the panel blurs to <body>),
|
|
// so pointerdown containment is the single source of truth for "outside".
|
|
React.useEffect(() => {
|
|
if (!open) return;
|
|
const handlePointerDown = (event: PointerEvent) => {
|
|
const target = event.target;
|
|
if (!(target instanceof Node) || !rootRef.current?.contains(target)) {
|
|
closePanel();
|
|
}
|
|
};
|
|
document.addEventListener("pointerdown", handlePointerDown);
|
|
return () => document.removeEventListener("pointerdown", handlePointerDown);
|
|
}, [open]);
|
|
|
|
// Also close when keyboard focus tabs out of the switcher entirely.
|
|
const handleRootBlur = (event: React.FocusEvent) => {
|
|
if (!open) return;
|
|
const next = event.relatedTarget;
|
|
if (next instanceof Node && !rootRef.current?.contains(next)) closePanel();
|
|
};
|
|
|
|
// Keep the keyboard highlight visible while arrowing through a scrolled
|
|
// list.
|
|
React.useEffect(() => {
|
|
const highlighted = listRef.current?.querySelector(
|
|
'[data-highlighted="true"]',
|
|
);
|
|
highlighted?.scrollIntoView({ block: "nearest" });
|
|
}, [highlightIndex]);
|
|
|
|
return (
|
|
<div
|
|
ref={rootRef}
|
|
onBlur={handleRootBlur}
|
|
onKeyDown={handleRootKeyDown}
|
|
// Hand-rolled positioning instead of daisyUI's .dropdown: its CSS also
|
|
// shows the panel on :focus-within, which fights the controlled `open`
|
|
// state (e.g. the panel would stay visible after closing while the
|
|
// trigger still has focus).
|
|
className="relative w-full"
|
|
>
|
|
<div className="flex items-stretch rounded-lg border border-base-300 bg-base-100">
|
|
<button
|
|
ref={triggerRef}
|
|
type="button"
|
|
aria-label="Switch project"
|
|
aria-expanded={open}
|
|
aria-haspopup="listbox"
|
|
onClick={() => (open ? closePanel() : openPanel())}
|
|
onKeyDown={handleTriggerKeyDown}
|
|
className="flex min-w-0 flex-1 items-center justify-between gap-2 rounded-l-lg px-3 py-1.5 text-left transition-colors hover:bg-base-200"
|
|
>
|
|
<span className="flex min-w-0 flex-col">
|
|
<span className="truncate text-sm font-medium text-base-content">
|
|
{activeProject?.name ?? "Select project"}
|
|
</span>
|
|
{activeProject?.domain ? (
|
|
<span className="truncate text-xs font-normal text-base-content/50">
|
|
{activeProject.domain}
|
|
</span>
|
|
) : null}
|
|
</span>
|
|
<ChevronsUpDown className="size-3.5 shrink-0 text-base-content/40" />
|
|
</button>
|
|
{activeProject ? (
|
|
<Link
|
|
to="/p/$projectId/settings"
|
|
params={{ projectId: activeProject.id }}
|
|
aria-label="Project settings"
|
|
title="Project settings"
|
|
onClick={() => {
|
|
closePanel();
|
|
onCloseDrawer?.();
|
|
}}
|
|
className="flex shrink-0 items-center justify-center rounded-r-lg border-l border-base-300 px-2.5 text-base-content/60 transition-colors hover:bg-base-200 hover:text-base-content"
|
|
>
|
|
<Settings className="size-4" />
|
|
</Link>
|
|
) : null}
|
|
</div>
|
|
|
|
{open ? (
|
|
<div className="absolute left-0 right-0 top-full z-30 mt-1 overflow-hidden rounded-box border border-base-300 bg-base-100 shadow-lg">
|
|
{showSearch ? (
|
|
<div className="border-b border-base-300 p-2">
|
|
<label className="input input-sm w-full">
|
|
<Search className="size-3.5 shrink-0 text-base-content/40" />
|
|
<input
|
|
ref={searchInputRef}
|
|
type="text"
|
|
value={query}
|
|
placeholder="Find project…"
|
|
aria-label="Filter projects"
|
|
aria-controls="project-switcher-listbox"
|
|
aria-activedescendant={
|
|
filteredProjects[highlightIndex]
|
|
? `project-option-${filteredProjects[highlightIndex].id}`
|
|
: undefined
|
|
}
|
|
className="grow"
|
|
onChange={(event) => {
|
|
setQuery(event.target.value);
|
|
setHighlightIndex(0);
|
|
}}
|
|
onKeyDown={handleSearchKeyDown}
|
|
/>
|
|
</label>
|
|
</div>
|
|
) : null}
|
|
|
|
{projects.length > 0 ? (
|
|
// Long project lists scroll inside the dropdown; without the cap the
|
|
// menu grows past the viewport and the footer becomes unreachable.
|
|
// flex-nowrap because daisyUI menus wrap into columns by default.
|
|
<ul
|
|
ref={listRef}
|
|
id="project-switcher-listbox"
|
|
role="listbox"
|
|
aria-label="Projects"
|
|
className="menu max-h-[min(60vh,21rem)] w-full flex-nowrap overflow-y-auto p-2"
|
|
>
|
|
{filteredProjects.map((project, index) => {
|
|
const isActive = project.id === activeProjectId;
|
|
const isHighlighted = showSearch && index === highlightIndex;
|
|
return (
|
|
<li key={project.id} role="presentation">
|
|
<button
|
|
type="button"
|
|
id={`project-option-${project.id}`}
|
|
role="option"
|
|
aria-selected={isActive}
|
|
data-highlighted={isHighlighted || undefined}
|
|
onClick={() => handleSelect(project)}
|
|
onMouseEnter={
|
|
showSearch ? () => setHighlightIndex(index) : undefined
|
|
}
|
|
className={
|
|
isActive ? "active" : isHighlighted ? "bg-base-200" : ""
|
|
}
|
|
>
|
|
<span className="flex min-w-0 flex-1 flex-col">
|
|
<span className="truncate">{project.name}</span>
|
|
{project.domain ? (
|
|
<span className="truncate text-xs text-base-content/50">
|
|
{project.domain}
|
|
</span>
|
|
) : null}
|
|
</span>
|
|
{isActive ? (
|
|
<Check className="size-4 shrink-0 text-primary" />
|
|
) : null}
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
{filteredProjects.length === 0 ? (
|
|
<li className="menu-disabled">
|
|
<span className="text-base-content/50">
|
|
No projects match “{query.trim()}”
|
|
</span>
|
|
</li>
|
|
) : null}
|
|
</ul>
|
|
) : null}
|
|
|
|
<ul
|
|
className={`menu w-full shrink-0 p-2 ${
|
|
projects.length > 0 ? "border-t border-base-300" : ""
|
|
}`}
|
|
>
|
|
<li>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
closePanel();
|
|
// Deliberately leave the mobile drawer open: the modal is
|
|
// rendered inside it, so closing the drawer would unmount the
|
|
// modal. The drawer closes when the modal does.
|
|
setCreating(true);
|
|
}}
|
|
>
|
|
<Plus className="size-4" />
|
|
New project
|
|
</button>
|
|
</li>
|
|
<li>
|
|
<Link
|
|
to="/projects"
|
|
onClick={() => {
|
|
closePanel();
|
|
onCloseDrawer?.();
|
|
}}
|
|
>
|
|
<FolderCog className="size-4" />
|
|
Manage projects
|
|
</Link>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
) : null}
|
|
|
|
{creating ? (
|
|
<CreateProjectModal
|
|
onClose={() => {
|
|
setCreating(false);
|
|
onCloseDrawer?.();
|
|
}}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|