350 lines
12 KiB
TypeScript
350 lines
12 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import * as React from "react";
|
||
|
|
import { Clock, Loader2, Mail, UserCheck, UserPlus } from "lucide-react";
|
||
|
|
|
||
|
|
import { Input } from "@/components/ui/input";
|
||
|
|
import { api } from "@/lib/trpc";
|
||
|
|
import { cn } from "@/lib/utils";
|
||
|
|
import type { InviteSuggestion } from "@tasks/shared";
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Standalone combobox for the invite recipient field. Fully controlled —
|
||
|
|
* the parent owns the raw email string via `value`/`onChange` and may
|
||
|
|
* react to a non-`new_email` pick (existing member, pending invite,
|
||
|
|
* known user from a sibling workspace) via `onSelectSuggestion`.
|
||
|
|
*
|
||
|
|
* Behavior:
|
||
|
|
* - Debounces the query 200ms before hitting `invites.suggestRecipient`.
|
||
|
|
* - Shows nothing for queries shorter than 2 characters.
|
||
|
|
* - Distinct row styling per `kind` so the inviter can tell at a glance
|
||
|
|
* whether they're about to send a fresh invite vs. re-discover someone
|
||
|
|
* who's already in the workspace.
|
||
|
|
* - Keyboard: ArrowUp/Down to highlight, Enter to pick, Esc to close.
|
||
|
|
* - For a `new_email` pick we just fill the input via `onChange` and let
|
||
|
|
* the parent submit the form. For every other kind we also call
|
||
|
|
* `onSelectSuggestion` so the parent can e.g. close the dialog and
|
||
|
|
* scroll to the matching member row.
|
||
|
|
*/
|
||
|
|
export interface InviteRecipientComboboxProps {
|
||
|
|
workspaceSlug: string;
|
||
|
|
value: string;
|
||
|
|
onChange: (email: string) => void;
|
||
|
|
/**
|
||
|
|
* Called when the user picks an existing-member / pending-invite /
|
||
|
|
* known-user row. NOT called for `new_email`; for that kind, only
|
||
|
|
* `onChange(suggestion.email)` runs.
|
||
|
|
*/
|
||
|
|
onSelectSuggestion?: (suggestion: InviteSuggestion) => void;
|
||
|
|
placeholder?: string;
|
||
|
|
disabled?: boolean;
|
||
|
|
/** Optional id so an external `<label htmlFor>` can target the input. */
|
||
|
|
inputId?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
function useDebounced<T>(value: T, delayMs: number): T {
|
||
|
|
const [debounced, setDebounced] = React.useState(value);
|
||
|
|
React.useEffect(() => {
|
||
|
|
const id = window.setTimeout(() => setDebounced(value), delayMs);
|
||
|
|
return () => window.clearTimeout(id);
|
||
|
|
}, [value, delayMs]);
|
||
|
|
return debounced;
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatExpires(expiresAt: Date): string {
|
||
|
|
const ms = expiresAt.getTime() - Date.now();
|
||
|
|
if (ms <= 0) return "expired";
|
||
|
|
const minutes = Math.round(ms / (1000 * 60));
|
||
|
|
if (minutes < 60) return minutes <= 1 ? "in 1 minute" : `in ${minutes} minutes`;
|
||
|
|
const hours = Math.round(minutes / 60);
|
||
|
|
if (hours < 24) return hours === 1 ? "in 1 hour" : `in ${hours} hours`;
|
||
|
|
const days = Math.round(hours / 24);
|
||
|
|
return days === 1 ? "in 1 day" : `in ${days} days`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function suggestionKey(s: InviteSuggestion): string {
|
||
|
|
switch (s.kind) {
|
||
|
|
case "member":
|
||
|
|
return `member:${s.userId}`;
|
||
|
|
case "pending_invite":
|
||
|
|
return `invite:${s.inviteId}`;
|
||
|
|
case "known_user":
|
||
|
|
return `known:${s.userId}`;
|
||
|
|
case "new_email":
|
||
|
|
return `new:${s.email}`;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export function InviteRecipientCombobox({
|
||
|
|
workspaceSlug,
|
||
|
|
value,
|
||
|
|
onChange,
|
||
|
|
onSelectSuggestion,
|
||
|
|
placeholder = "alex@example.com",
|
||
|
|
disabled,
|
||
|
|
inputId,
|
||
|
|
}: InviteRecipientComboboxProps) {
|
||
|
|
const [open, setOpen] = React.useState(false);
|
||
|
|
const [activeIdx, setActiveIdx] = React.useState(0);
|
||
|
|
const listboxId = React.useId();
|
||
|
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
||
|
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
||
|
|
|
||
|
|
const debouncedQuery = useDebounced(value.trim(), 200);
|
||
|
|
const enabled = debouncedQuery.length >= 2;
|
||
|
|
|
||
|
|
const suggestQuery = api.invites.suggestRecipient.useQuery(
|
||
|
|
{ workspace: workspaceSlug, query: debouncedQuery },
|
||
|
|
{ enabled, staleTime: 10_000 },
|
||
|
|
);
|
||
|
|
|
||
|
|
const suggestions = React.useMemo<InviteSuggestion[]>(
|
||
|
|
() => (enabled ? (suggestQuery.data ?? []) : []),
|
||
|
|
[enabled, suggestQuery.data],
|
||
|
|
);
|
||
|
|
|
||
|
|
React.useEffect(() => {
|
||
|
|
setActiveIdx(0);
|
||
|
|
}, [suggestions]);
|
||
|
|
|
||
|
|
React.useEffect(() => {
|
||
|
|
if (!enabled) setOpen(false);
|
||
|
|
}, [enabled]);
|
||
|
|
|
||
|
|
// Close when focus leaves the entire combobox (input + popup). Defer to
|
||
|
|
// the next tick so a click inside the popup registers before we close.
|
||
|
|
React.useEffect(() => {
|
||
|
|
if (!open) return;
|
||
|
|
const handleClick = (event: MouseEvent) => {
|
||
|
|
const target = event.target as Node | null;
|
||
|
|
if (!target) return;
|
||
|
|
if (containerRef.current && !containerRef.current.contains(target)) {
|
||
|
|
setOpen(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
document.addEventListener("mousedown", handleClick);
|
||
|
|
return () => document.removeEventListener("mousedown", handleClick);
|
||
|
|
}, [open]);
|
||
|
|
|
||
|
|
const handleSelect = (suggestion: InviteSuggestion) => {
|
||
|
|
onChange(suggestion.email);
|
||
|
|
if (suggestion.kind !== "new_email") {
|
||
|
|
onSelectSuggestion?.(suggestion);
|
||
|
|
}
|
||
|
|
setOpen(false);
|
||
|
|
inputRef.current?.focus();
|
||
|
|
};
|
||
|
|
|
||
|
|
const popupOpen = open && enabled && (suggestQuery.isFetching || suggestions.length > 0);
|
||
|
|
|
||
|
|
const onKeyDown: React.KeyboardEventHandler<HTMLInputElement> = (event) => {
|
||
|
|
if (event.key === "ArrowDown") {
|
||
|
|
if (suggestions.length === 0) return;
|
||
|
|
event.preventDefault();
|
||
|
|
setOpen(true);
|
||
|
|
setActiveIdx((i) => (i + 1) % suggestions.length);
|
||
|
|
} else if (event.key === "ArrowUp") {
|
||
|
|
if (suggestions.length === 0) return;
|
||
|
|
event.preventDefault();
|
||
|
|
setOpen(true);
|
||
|
|
setActiveIdx((i) => (i - 1 + suggestions.length) % suggestions.length);
|
||
|
|
} else if (event.key === "Enter") {
|
||
|
|
if (!popupOpen || suggestions.length === 0) return;
|
||
|
|
event.preventDefault();
|
||
|
|
const pick = suggestions[activeIdx];
|
||
|
|
if (pick) handleSelect(pick);
|
||
|
|
} else if (event.key === "Escape") {
|
||
|
|
if (popupOpen) {
|
||
|
|
event.preventDefault();
|
||
|
|
setOpen(false);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const activeId = popupOpen && suggestions[activeIdx]
|
||
|
|
? `${listboxId}-${suggestionKey(suggestions[activeIdx])}`
|
||
|
|
: undefined;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div ref={containerRef} className="relative">
|
||
|
|
<Input
|
||
|
|
ref={inputRef}
|
||
|
|
id={inputId}
|
||
|
|
type="email"
|
||
|
|
autoComplete="off"
|
||
|
|
spellCheck={false}
|
||
|
|
placeholder={placeholder}
|
||
|
|
value={value}
|
||
|
|
disabled={disabled}
|
||
|
|
onChange={(event) => {
|
||
|
|
onChange(event.target.value);
|
||
|
|
setOpen(true);
|
||
|
|
}}
|
||
|
|
onFocus={() => {
|
||
|
|
if (enabled) setOpen(true);
|
||
|
|
}}
|
||
|
|
onKeyDown={onKeyDown}
|
||
|
|
role="combobox"
|
||
|
|
aria-expanded={popupOpen}
|
||
|
|
aria-controls={listboxId}
|
||
|
|
aria-autocomplete="list"
|
||
|
|
aria-activedescendant={activeId}
|
||
|
|
/>
|
||
|
|
{popupOpen ? (
|
||
|
|
<div
|
||
|
|
className={cn(
|
||
|
|
"absolute left-0 right-0 top-full z-50 mt-2 rounded-lg",
|
||
|
|
"border border-border bg-popover p-1 shadow-lg outline-none",
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
<ul
|
||
|
|
id={listboxId}
|
||
|
|
role="listbox"
|
||
|
|
aria-label="Invite recipient suggestions"
|
||
|
|
className="max-h-72 overflow-y-auto"
|
||
|
|
>
|
||
|
|
{suggestQuery.isFetching && suggestions.length === 0 ? (
|
||
|
|
<li className="flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground">
|
||
|
|
<Loader2 className="size-3 animate-spin" aria-hidden />
|
||
|
|
Searching…
|
||
|
|
</li>
|
||
|
|
) : null}
|
||
|
|
{suggestions.map((suggestion, idx) => (
|
||
|
|
<SuggestionRow
|
||
|
|
key={suggestionKey(suggestion)}
|
||
|
|
id={`${listboxId}-${suggestionKey(suggestion)}`}
|
||
|
|
suggestion={suggestion}
|
||
|
|
active={idx === activeIdx}
|
||
|
|
onMouseEnter={() => setActiveIdx(idx)}
|
||
|
|
onClick={() => handleSelect(suggestion)}
|
||
|
|
/>
|
||
|
|
))}
|
||
|
|
</ul>
|
||
|
|
</div>
|
||
|
|
) : null}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
interface SuggestionRowProps {
|
||
|
|
id: string;
|
||
|
|
suggestion: InviteSuggestion;
|
||
|
|
active: boolean;
|
||
|
|
onMouseEnter: () => void;
|
||
|
|
onClick: () => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
function SuggestionRow({
|
||
|
|
id,
|
||
|
|
suggestion,
|
||
|
|
active,
|
||
|
|
onMouseEnter,
|
||
|
|
onClick,
|
||
|
|
}: SuggestionRowProps) {
|
||
|
|
const buttonBase = cn(
|
||
|
|
"flex w-full items-center gap-3 rounded-md px-2 py-2 text-left text-xs transition-colors",
|
||
|
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||
|
|
active && "bg-accent",
|
||
|
|
);
|
||
|
|
|
||
|
|
switch (suggestion.kind) {
|
||
|
|
case "member":
|
||
|
|
return (
|
||
|
|
<li id={id} role="option" aria-selected={active}>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
// Prevent input from losing focus on mousedown so the click
|
||
|
|
// still fires before the outside-click handler closes the popup.
|
||
|
|
onMouseDown={(event) => event.preventDefault()}
|
||
|
|
onMouseEnter={onMouseEnter}
|
||
|
|
onClick={onClick}
|
||
|
|
className={cn(buttonBase, "opacity-70 hover:opacity-100")}
|
||
|
|
>
|
||
|
|
<UserCheck className="size-4 shrink-0 text-muted-foreground" aria-hidden />
|
||
|
|
<div className="min-w-0 flex-1">
|
||
|
|
<div className="truncate font-medium text-foreground">
|
||
|
|
{suggestion.name ?? suggestion.email}
|
||
|
|
</div>
|
||
|
|
<div className="truncate text-[11px] text-muted-foreground">
|
||
|
|
Already a member · click to focus their row
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<span className="shrink-0 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||
|
|
{suggestion.role}
|
||
|
|
</span>
|
||
|
|
</button>
|
||
|
|
</li>
|
||
|
|
);
|
||
|
|
case "pending_invite":
|
||
|
|
return (
|
||
|
|
<li id={id} role="option" aria-selected={active}>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onMouseDown={(event) => event.preventDefault()}
|
||
|
|
onMouseEnter={onMouseEnter}
|
||
|
|
onClick={onClick}
|
||
|
|
className={buttonBase}
|
||
|
|
>
|
||
|
|
<Clock className="size-4 shrink-0 text-amber-600" aria-hidden />
|
||
|
|
<div className="min-w-0 flex-1">
|
||
|
|
<div className="truncate font-medium text-foreground">
|
||
|
|
{suggestion.email}
|
||
|
|
</div>
|
||
|
|
<div className="truncate text-[11px] text-muted-foreground">
|
||
|
|
Invite already sent · expires {formatExpires(suggestion.expiresAt)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<span className="shrink-0 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||
|
|
{suggestion.role}
|
||
|
|
</span>
|
||
|
|
</button>
|
||
|
|
</li>
|
||
|
|
);
|
||
|
|
case "known_user":
|
||
|
|
return (
|
||
|
|
<li id={id} role="option" aria-selected={active}>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onMouseDown={(event) => event.preventDefault()}
|
||
|
|
onMouseEnter={onMouseEnter}
|
||
|
|
onClick={onClick}
|
||
|
|
className={buttonBase}
|
||
|
|
>
|
||
|
|
<UserPlus className="size-4 shrink-0 text-emerald-600" aria-hidden />
|
||
|
|
<div className="min-w-0 flex-1">
|
||
|
|
<div className="truncate font-medium text-foreground">
|
||
|
|
{suggestion.name ?? suggestion.email}
|
||
|
|
</div>
|
||
|
|
<div className="truncate text-[11px] text-muted-foreground">
|
||
|
|
Invite as member · in {suggestion.sharedWorkspaceCount} of your
|
||
|
|
{" "}workspace{suggestion.sharedWorkspaceCount === 1 ? "" : "s"}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</button>
|
||
|
|
</li>
|
||
|
|
);
|
||
|
|
case "new_email":
|
||
|
|
return (
|
||
|
|
<li id={id} role="option" aria-selected={active}>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onMouseDown={(event) => event.preventDefault()}
|
||
|
|
onMouseEnter={onMouseEnter}
|
||
|
|
onClick={onClick}
|
||
|
|
className={buttonBase}
|
||
|
|
>
|
||
|
|
<Mail className="size-4 shrink-0 text-primary" aria-hidden />
|
||
|
|
<div className="min-w-0 flex-1">
|
||
|
|
<div className="truncate font-medium text-foreground">
|
||
|
|
Send invite to {suggestion.email}
|
||
|
|
</div>
|
||
|
|
<div className="truncate text-[11px] text-muted-foreground">
|
||
|
|
No existing account · a fresh invite link will be created
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</button>
|
||
|
|
</li>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|