import { useRef, type ReactNode } from "react"; import { AnimatePresence, motion } from "motion/react"; import { cn } from "../../lib/utils"; import { SOURCE_EXIT_FADE, SOURCE_COLLAPSE_MOVE, TAG_SWAP_ENTER, TAG_SWAP_EXIT, TAG_SWAP_TRAVEL, } from "./onboarding-motion"; /** * The connect step's row of model sources, and the tag under each one saying * which credential the source would be reached with. * * Presentational only — the caller owns which source is picked and which * credential mode is in force, because both outlive this row: the mode is set * by a checkbox that sits below it, and the selection drives the step's CTA. */ /** How a source gets authenticated. Every tile is in the same mode at once. */ export type CredentialMode = "subscription" | "api"; export type ModelConnectionMode = CredentialMode | "advanced"; export type ModelSource = { id: string; label: string; /** The brand mark, rendered into a 30px square. */ icon: ReactNode; /** Override the row's credential mode; null leaves the tag slot empty. */ credentialMode?: ModelConnectionMode | null; }; const CREDENTIAL_TAG_LABEL: Record = { subscription: "Subscription", api: "API key", advanced: "Custom Gateway", }; /** * The credential tag, swapping in a fixed-height slot. * * The slot has to hold its height whatever is in it: the tag is the last line * of the tile, and a label that measured itself would resize the tile mid-swap * and nudge the two beside it. `overflow-hidden` is doing real work too — it is * what makes the outgoing label fall out of frame rather than slide past the * tile's padding and over the row below. */ export function CredentialTag({ mode }: { mode: ModelConnectionMode }) { return ( {CREDENTIAL_TAG_LABEL[mode]} ); } function ModelSourceTile({ source, mode, selected, onSelect, buttonRef, settling, }: { source: ModelSource; mode: ModelConnectionMode | null; selected: boolean; onSelect: () => void; buttonRef: (node: HTMLButtonElement | null) => void; settling: boolean; }) { return ( ); } export function ModelSourceTiles({ sources, mode, selectedId, onSelect, label, collapsed = false, settling = false, }: { sources: ModelSource[]; mode: ModelConnectionMode; /** `null` before anything has been picked — the step opens this way. */ selectedId: string | null; onSelect: (id: string) => void; label: string; /** * Show only the chosen source, centred. * * The row is a question, and once a sign-in is running it has been answered — * leaving the alternative on screen invites a press that would have to cancel * a live server session to honour. Collapsing says the choice is made without * disabling anything, which reads better than a greyed-out tile. */ collapsed?: boolean; /** * The row is returning to its default. Only changes how long the selected * styling takes to leave — see the tile's own note. */ settling?: boolean; }) { const tiles = useRef(new Map()); /** * Arrow keys move the selection and the focus together, which is what a * radio group is expected to do — without it the role would be announced and * then not behave, which is worse than plain buttons. Selection wraps at both * ends; three tiles is short enough that stopping at the edges just reads as * the key having failed. */ const moveSelection = (delta: number) => { if (sources.length === 0) return; const current = sources.findIndex((source) => source.id === selectedId); // Nothing picked yet: either arrow enters the row from the near end. const from = current === -1 ? (delta > 0 ? -1 : 0) : current; const next = (from + delta + sources.length) % sources.length; const target = sources[next]!; onSelect(target.id); tiles.current.get(target.id)?.focus(); }; const shown = collapsed ? sources.filter((source) => source.id === selectedId) : sources; return (
{ // Collapsed, the row is a statement rather than a choice; arrow keys // would move a selection that is no longer being asked for. if (collapsed) return; if (event.key === "ArrowRight" || event.key === "ArrowDown") { event.preventDefault(); moveSelection(1); } else if (event.key === "ArrowLeft" || event.key === "ArrowUp") { event.preventDefault(); moveSelection(-1); } }} > {/* `popLayout` takes the leaving tile out of flow at once, so the survivor's `layout` animation targets its final centred position rather than chasing a gap that is still closing. The wrapper carries the width, not the tile: held at the width it had with two in the row, so the kept tile travels without also growing. */} {shown.map((source) => ( onSelect(source.id)} settling={settling} buttonRef={(node) => { if (node) tiles.current.set(source.id, node); else tiles.current.delete(source.id); }} /> ))}
); }