Project switcher: fix viewport overflow + searchable combobox (#427)
This commit is contained in:
parent
c81fd7a130
commit
86cbba3cdb
@ -49,6 +49,7 @@
|
|||||||
"billing:usage": "tsx scripts/dataforseo-account-usage.ts",
|
"billing:usage": "tsx scripts/dataforseo-account-usage.ts",
|
||||||
"cleanup:default-projects:d1": "tsx scripts/d1-default-project-cleanup.ts",
|
"cleanup:default-projects:d1": "tsx scripts/d1-default-project-cleanup.ts",
|
||||||
"seed:rank-tracking": "tsx scripts/seed-rank-tracking.ts",
|
"seed:rank-tracking": "tsx scripts/seed-rank-tracking.ts",
|
||||||
|
"seed:projects": "tsx scripts/seed-projects.ts",
|
||||||
"ci:check": "prettier --check . && knip && tsc --noEmit && tsc --noEmit -p badseo/tsconfig.json && oxlint . --type-aware"
|
"ci:check": "prettier --check . && knip && tsc --noEmit && tsc --noEmit -p badseo/tsconfig.json && oxlint . --type-aware"
|
||||||
},
|
},
|
||||||
"cloudflare": {
|
"cloudflare": {
|
||||||
|
|||||||
194
scripts/seed-projects.ts
Normal file
194
scripts/seed-projects.ts
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
/**
|
||||||
|
* Seed the local D1 database with demo projects so the project switcher and
|
||||||
|
* /projects page can be exercised with a long list (agency-style account).
|
||||||
|
* Fully offline — no DataForSEO key or network needed.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* pnpm db:migrate:local # once — creates the local D1
|
||||||
|
* pnpm seed:projects # seed 18 demo projects
|
||||||
|
* pnpm seed:projects --count=30
|
||||||
|
* pnpm seed:projects --clean # remove previously seeded demo projects
|
||||||
|
*
|
||||||
|
* Then view them:
|
||||||
|
* env AUTH_MODE=local_noauth pnpm dev
|
||||||
|
*
|
||||||
|
* Bootstraps the same local_noauth user/org that `AUTH_MODE=local_noauth`
|
||||||
|
* resolves, so the projects are immediately visible without signing up.
|
||||||
|
* Re-running skips projects that already exist (matched by name), so it is
|
||||||
|
* safe to run repeatedly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import process from "node:process";
|
||||||
|
import { getPlatformProxy } from "wrangler";
|
||||||
|
import { drizzle } from "drizzle-orm/d1";
|
||||||
|
import { and, eq, inArray, isNull, like } from "drizzle-orm";
|
||||||
|
// Import the sqlite schema files directly: the "../src/db/schema" barrel pulls
|
||||||
|
// in provider.ts, whose `cloudflare:workers` import Node's loader can't
|
||||||
|
// resolve outside the Workers runtime.
|
||||||
|
import * as appSchema from "../src/db/app.schema";
|
||||||
|
import { organization, user } from "../src/db/better-auth-schema";
|
||||||
|
import { parseArgs } from "./cli-utils";
|
||||||
|
|
||||||
|
const schema = { ...appSchema, organization, user };
|
||||||
|
|
||||||
|
const LOCAL_ADMIN_USER_ID = "local-admin";
|
||||||
|
const LOCAL_ADMIN_EMAIL = "admin@localhost";
|
||||||
|
const LOCAL_ORG_ID = `delegated-${LOCAL_ADMIN_USER_ID}`;
|
||||||
|
|
||||||
|
// Suffix marks rows as seeded demo data so --clean can find them without
|
||||||
|
// touching projects the user created by hand.
|
||||||
|
const DEMO_DOMAIN_SUFFIX = ".demo-seed.test";
|
||||||
|
|
||||||
|
const DEMO_PROJECTS = [
|
||||||
|
"Flytedesk",
|
||||||
|
"Steadfast Paving",
|
||||||
|
"Greenhow",
|
||||||
|
"Oliver Realty",
|
||||||
|
"Intl. Designers",
|
||||||
|
"Zabella",
|
||||||
|
"Collis Roofing",
|
||||||
|
"We Lend",
|
||||||
|
"EarlyBird Electricians",
|
||||||
|
"Paul Bunyan Plumbing",
|
||||||
|
"Blue Ox HVAC",
|
||||||
|
"Yearli",
|
||||||
|
"Spakinect",
|
||||||
|
"MVP Logistics",
|
||||||
|
"Northside Dental",
|
||||||
|
"Harbor & Vine",
|
||||||
|
"Summit Physio",
|
||||||
|
"Lakeview Storage",
|
||||||
|
"Copperline Coffee",
|
||||||
|
"Trailhead Outfitters",
|
||||||
|
"Bellamy Law",
|
||||||
|
"Quartz Analytics",
|
||||||
|
"Fernwood Nursery",
|
||||||
|
"Ironclad Fencing",
|
||||||
|
"Beacon Tutoring",
|
||||||
|
"Riverbend Vet",
|
||||||
|
"Solstice Yoga",
|
||||||
|
"Granite Countertops Co",
|
||||||
|
"Pinewood Cabinets",
|
||||||
|
"Atlas Moving",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type SeedDb = ReturnType<typeof drizzle<typeof schema>>;
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
const count = clampInt(args.count, 18, 1, DEMO_PROJECTS.length);
|
||||||
|
|
||||||
|
console.log("Setting up local D1 connection...");
|
||||||
|
const { env, dispose } = await getPlatformProxy<{ DB: D1Database }>();
|
||||||
|
const db = drizzle(env.DB, { schema });
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (args.clean === "true") {
|
||||||
|
await clean(db);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await bootstrapLocalIdentity(db);
|
||||||
|
|
||||||
|
const existing = await db.query.projects.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(schema.projects.organizationId, LOCAL_ORG_ID),
|
||||||
|
isNull(schema.projects.archivedAt),
|
||||||
|
),
|
||||||
|
columns: { name: true },
|
||||||
|
});
|
||||||
|
const existingNames = new Set(existing.map((project) => project.name));
|
||||||
|
|
||||||
|
let created = 0;
|
||||||
|
for (const name of DEMO_PROJECTS.slice(0, count)) {
|
||||||
|
if (existingNames.has(name)) continue;
|
||||||
|
await db.insert(schema.projects).values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
organizationId: LOCAL_ORG_ID,
|
||||||
|
name,
|
||||||
|
domain: toDomain(name),
|
||||||
|
});
|
||||||
|
created += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Done. Created ${created} project(s); ${count - created} already existed.`,
|
||||||
|
);
|
||||||
|
console.log("View them with: env AUTH_MODE=local_noauth pnpm dev");
|
||||||
|
} finally {
|
||||||
|
await dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clean(db: SeedDb) {
|
||||||
|
const seeded = await db.query.projects.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(schema.projects.organizationId, LOCAL_ORG_ID),
|
||||||
|
like(schema.projects.domain, `%${DEMO_DOMAIN_SUFFIX}`),
|
||||||
|
),
|
||||||
|
columns: { id: true },
|
||||||
|
});
|
||||||
|
if (seeded.length === 0) {
|
||||||
|
console.log("No seeded demo projects found.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await db.delete(schema.projects).where(
|
||||||
|
inArray(
|
||||||
|
schema.projects.id,
|
||||||
|
seeded.map((project) => project.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
console.log(`Removed ${seeded.length} seeded demo project(s).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootstrapLocalIdentity(db: SeedDb) {
|
||||||
|
await db
|
||||||
|
.insert(schema.user)
|
||||||
|
.values({
|
||||||
|
id: LOCAL_ADMIN_USER_ID,
|
||||||
|
name: "admin",
|
||||||
|
email: LOCAL_ADMIN_EMAIL,
|
||||||
|
emailVerified: true,
|
||||||
|
})
|
||||||
|
.onConflictDoNothing({ target: schema.user.id });
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insert(schema.organization)
|
||||||
|
.values({
|
||||||
|
id: LOCAL_ORG_ID,
|
||||||
|
name: "admin workspace",
|
||||||
|
slug: `delegated-admin-${toHex(LOCAL_ADMIN_USER_ID)}`,
|
||||||
|
createdAt: new Date(),
|
||||||
|
})
|
||||||
|
.onConflictDoNothing({ target: schema.organization.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDomain(name: string) {
|
||||||
|
const slug = name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "")
|
||||||
|
.slice(0, 40);
|
||||||
|
return `${slug}${DEMO_DOMAIN_SUFFIX}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toHex(value: string) {
|
||||||
|
return Array.from(new TextEncoder().encode(value))
|
||||||
|
.map((byte) => byte.toString(16).padStart(2, "0"))
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampInt(
|
||||||
|
raw: string | undefined,
|
||||||
|
fallback: number,
|
||||||
|
min: number,
|
||||||
|
max: number,
|
||||||
|
) {
|
||||||
|
const parsed = Number.parseInt(raw ?? "", 10);
|
||||||
|
if (Number.isNaN(parsed)) return fallback;
|
||||||
|
return Math.min(max, Math.max(min, parsed));
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@ -1,11 +1,15 @@
|
|||||||
|
import * as React from "react";
|
||||||
import { Link, useNavigate } from "@tanstack/react-router";
|
import { Link, useNavigate } from "@tanstack/react-router";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Check, ChevronsUpDown, FolderCog } from "lucide-react";
|
import { Check, ChevronsUpDown, FolderCog, Plus, Search } from "lucide-react";
|
||||||
import { getProjects } from "@/serverFunctions/projects";
|
import { getProjects } from "@/serverFunctions/projects";
|
||||||
import { setLastProjectId } from "@/client/lib/active-project";
|
import { setLastProjectId } from "@/client/lib/active-project";
|
||||||
import { closeDropdown } from "@/client/lib/dropdown";
|
import { CreateProjectModal } from "@/client/features/projects/CreateProjectModal";
|
||||||
import type { ProjectSummary } from "./types";
|
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({
|
export function ProjectSwitcher({
|
||||||
activeProjectId,
|
activeProjectId,
|
||||||
onCloseDrawer,
|
onCloseDrawer,
|
||||||
@ -16,6 +20,19 @@ export function ProjectSwitcher({
|
|||||||
onCloseDrawer?: () => void;
|
onCloseDrawer?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
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({
|
const projectsQuery = useQuery({
|
||||||
queryKey: ["projects"],
|
queryKey: ["projects"],
|
||||||
queryFn: () => getProjects(),
|
queryFn: () => getProjects(),
|
||||||
@ -24,8 +41,38 @@ export function ProjectSwitcher({
|
|||||||
const activeProject =
|
const activeProject =
|
||||||
projects.find((project) => project.id === activeProjectId) ?? null;
|
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) => {
|
const handleSelect = (project: ProjectSummary) => {
|
||||||
closeDropdown();
|
closePanel();
|
||||||
onCloseDrawer?.();
|
onCloseDrawer?.();
|
||||||
if (project.id === activeProjectId) return;
|
if (project.id === activeProjectId) return;
|
||||||
setLastProjectId(project.id);
|
setLastProjectId(project.id);
|
||||||
@ -35,12 +82,122 @@ export function ProjectSwitcher({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div className="dropdown w-full">
|
<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"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
tabIndex={0}
|
|
||||||
aria-label="Switch project"
|
aria-label="Switch project"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
onClick={() => (open ? closePanel() : openPanel())}
|
||||||
|
onKeyDown={handleTriggerKeyDown}
|
||||||
className="flex w-full items-center justify-between gap-2 rounded-lg border border-base-300 bg-base-100 px-3 py-1.5 text-left transition-colors hover:border-base-content/25"
|
className="flex w-full items-center justify-between gap-2 rounded-lg border border-base-300 bg-base-100 px-3 py-1.5 text-left transition-colors hover:border-base-content/25"
|
||||||
>
|
>
|
||||||
<span className="flex min-w-0 flex-col">
|
<span className="flex min-w-0 flex-col">
|
||||||
@ -56,18 +213,64 @@ export function ProjectSwitcher({
|
|||||||
<ChevronsUpDown className="size-3.5 shrink-0 text-base-content/40" />
|
<ChevronsUpDown className="size-3.5 shrink-0 text-base-content/40" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{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
|
<ul
|
||||||
tabIndex={0}
|
ref={listRef}
|
||||||
className="dropdown-content z-30 menu w-full rounded-box border border-base-300 bg-base-100 p-2 shadow-lg"
|
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"
|
||||||
>
|
>
|
||||||
{projects.map((project) => {
|
{filteredProjects.map((project, index) => {
|
||||||
const isActive = project.id === activeProjectId;
|
const isActive = project.id === activeProjectId;
|
||||||
|
const isHighlighted = showSearch && index === highlightIndex;
|
||||||
return (
|
return (
|
||||||
<li key={project.id}>
|
<li key={project.id} role="presentation">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
id={`project-option-${project.id}`}
|
||||||
|
role="option"
|
||||||
|
aria-selected={isActive}
|
||||||
|
data-highlighted={isHighlighted || undefined}
|
||||||
onClick={() => handleSelect(project)}
|
onClick={() => handleSelect(project)}
|
||||||
className={isActive ? "active" : ""}
|
onMouseEnter={
|
||||||
|
showSearch ? () => setHighlightIndex(index) : undefined
|
||||||
|
}
|
||||||
|
className={
|
||||||
|
isActive ? "active" : isHighlighted ? "bg-base-200" : ""
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<span className="flex min-w-0 flex-1 flex-col">
|
<span className="flex min-w-0 flex-1 flex-col">
|
||||||
<span className="truncate">{project.name}</span>
|
<span className="truncate">{project.name}</span>
|
||||||
@ -84,19 +287,41 @@ export function ProjectSwitcher({
|
|||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{filteredProjects.length === 0 ? (
|
||||||
{projects.length > 0 ? (
|
<li className="menu-disabled">
|
||||||
<li
|
<span className="text-base-content/50">
|
||||||
aria-hidden="true"
|
No projects match “{query.trim()}”
|
||||||
className="pointer-events-none my-1 h-px bg-base-300 p-0"
|
</span>
|
||||||
/>
|
</li>
|
||||||
|
) : null}
|
||||||
|
</ul>
|
||||||
) : null}
|
) : 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>
|
<li>
|
||||||
<Link
|
<Link
|
||||||
to="/projects"
|
to="/projects"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
closeDropdown();
|
closePanel();
|
||||||
onCloseDrawer?.();
|
onCloseDrawer?.();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -106,5 +331,16 @@ export function ProjectSwitcher({
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{creating ? (
|
||||||
|
<CreateProjectModal
|
||||||
|
onClose={() => {
|
||||||
|
setCreating(false);
|
||||||
|
onCloseDrawer?.();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user