diff --git a/apps/web/components/teams/invite-dialog.tsx b/apps/web/components/teams/invite-dialog.tsx index 7db637d..b054097 100644 --- a/apps/web/components/teams/invite-dialog.tsx +++ b/apps/web/components/teams/invite-dialog.tsx @@ -13,6 +13,7 @@ import { SheetTitle, SheetTrigger, } from "@/components/ui/sheet"; +import { InviteRecipientCombobox } from "@/components/teams/invite-recipient-combobox"; import { api } from "@/lib/trpc"; import { cn } from "@/lib/utils"; @@ -22,6 +23,13 @@ interface InviteDialogProps { workspaceSlug: string; /** Children render as the trigger; default is a primary "Invite" button. */ children?: React.ReactNode; + /** + * Optional. Called when the inviter picks an "already a member" suggestion + * from the autocomplete. Lets the teams page close the dialog and scroll + * the existing row into view. If omitted, the combobox just closes its + * dropdown and the user has to clear the input manually. + */ + onFocusExistingMember?: (userId: string) => void; } /** @@ -29,11 +37,15 @@ interface InviteDialogProps { * accept URL on success so the inviter can copy/paste it into chat / DM * until the transactional email send is wired up (filed follow-up). * - * The email input is a plain `` in this task; the smart recipient - * autocomplete from `Task-invite-recipient-autocomplete` will swap it for - * a combobox in a separate commit. + * The email input is the `InviteRecipientCombobox` from Task 3 — typing + * surfaces existing members, pending invites, and known users from the + * inviter's other workspaces before the "Send invite to " fallback. */ -export function InviteDialog({ workspaceSlug, children }: InviteDialogProps) { +export function InviteDialog({ + workspaceSlug, + children, + onFocusExistingMember, +}: InviteDialogProps) { const utils = api.useUtils(); const [open, setOpen] = React.useState(false); const [email, setEmail] = React.useState(""); @@ -175,17 +187,21 @@ export function InviteDialog({ workspaceSlug, children }: InviteDialogProps) {
- setEmail(e.target.value)} - required + onChange={setEmail} + placeholder="alex@example.com" disabled={createMut.isPending} + onSelectSuggestion={(s) => { + if (s.kind === "member") { + setOpen(false); + onFocusExistingMember?.(s.userId); + } + }} />
diff --git a/apps/web/components/teams/invite-recipient-combobox.tsx b/apps/web/components/teams/invite-recipient-combobox.tsx new file mode 100644 index 0000000..aada459 --- /dev/null +++ b/apps/web/components/teams/invite-recipient-combobox.tsx @@ -0,0 +1,349 @@ +"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 `