ubiquitous-invention/apps/web/components/panels/assignee-picker.tsx

157 lines
5 KiB
TypeScript
Raw Normal View History

"use client";
import * as React from "react";
import { useMemo } from "react";
import * as Popover from "@radix-ui/react-popover";
import { Check, Search } from "lucide-react";
import { cn } from "@/lib/utils";
import { api } from "@/lib/trpc";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar";
import { Input } from "@/components/ui/input";
export interface WorkspaceUser {
id: string;
name: string;
avatarUrl?: string | null;
}
/** Internal default for prop comparison only; list comes from `listMembers` when `workspaceId` is set */
const WORKSPACE_USERS: WorkspaceUser[] = [
{ id: "u1", name: "Alice", avatarUrl: null },
{ id: "u2", name: "Bob", avatarUrl: null },
{ id: "u3", name: "Charlie", avatarUrl: null },
{ id: "u4", name: "Diana", avatarUrl: null },
];
function initials(name: string) {
return name
.split(/\s+/)
.map((p) => p[0])
.join("")
.slice(0, 2)
.toUpperCase();
}
export interface AssigneePickerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
assignedIds: string[];
onToggle: (userId: string) => void;
users?: WorkspaceUser[];
workspaceId?: string;
children: React.ReactNode;
side?: "top" | "right" | "bottom" | "left";
align?: "start" | "center" | "end";
}
export function AssigneePicker({
open,
onOpenChange,
assignedIds,
onToggle,
users = WORKSPACE_USERS,
workspaceId,
children,
side = "bottom",
align = "start",
}: AssigneePickerProps) {
const { data: members } = api.workspaces.listMembers.useQuery(
{ workspaceId: workspaceId! },
{ enabled: Boolean(workspaceId) },
);
const resolvedUsers = useMemo(() => {
if (users && users !== WORKSPACE_USERS) return users;
if (!members) return [];
return members.map((m) => ({
id: m.id,
name: m.name ?? m.email,
avatarUrl: m.avatarUrl,
}));
}, [users, members]);
const [q, setQ] = React.useState("");
const filtered = React.useMemo(() => {
const s = q.trim().toLowerCase();
if (!s) return resolvedUsers;
return resolvedUsers.filter((u) => u.name.toLowerCase().includes(s));
}, [q, resolvedUsers]);
React.useEffect(() => {
if (!open) setQ("");
}, [open]);
return (
<Popover.Root open={open} onOpenChange={onOpenChange}>
<Popover.Trigger asChild>{children}</Popover.Trigger>
<Popover.Portal>
<Popover.Content
side={side}
align={align}
sideOffset={6}
className={cn(
"z-50 w-[min(100vw-2rem,280px)] rounded-lg border border-border bg-popover p-0 shadow-lg outline-none",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2",
)}
>
<div className="border-b border-border p-2">
<div className="relative">
<Search className="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Search people…"
className="h-8 border-0 bg-muted/50 pl-8 text-xs focus-visible:ring-1"
/>
</div>
</div>
<div className="max-h-56 overflow-y-auto p-1">
{filtered.length === 0 ? (
<p className="px-2 py-4 text-center text-xs text-muted-foreground">
No matches
</p>
) : (
filtered.map((user) => {
const assigned = assignedIds.includes(user.id);
return (
<button
key={user.id}
type="button"
onClick={() => onToggle(user.id)}
className={cn(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors",
"hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
assigned && "bg-primary/5",
)}
>
<Avatar className="size-7 border border-border">
<AvatarImage src={user.avatarUrl ?? undefined} alt="" />
<AvatarFallback className="text-[10px] font-medium">
{initials(user.name)}
</AvatarFallback>
</Avatar>
<span className="min-w-0 flex-1 truncate font-medium text-foreground">
{user.name}
</span>
{assigned ? (
<Check className="size-4 shrink-0 text-primary" aria-hidden />
) : (
<span className="size-4 shrink-0" />
)}
</button>
);
})
)}
</div>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
);
}