"use client"
import * as React from "react"
import {
ArrowsInSimpleIcon,
ArrowsOutSimpleIcon,
CaretDownIcon,
CopyIcon,
DotsSixVerticalIcon,
DotsThreeIcon,
PlusIcon,
ReceiptIcon,
TrashIcon,
UserIcon,
WarningCircleIcon,
} from "@phosphor-icons/react"
import {
closestCenter,
DndContext,
type DragEndEvent,
KeyboardSensor,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
} from "@dnd-kit/core"
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import { toast } from "sonner"
import { cue } from "@/lib/sounds"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from "@/components/ui/input-group"
import { ItemContent, ItemDescription, ItemMedia, ItemTitle } from "@/components/ui/item"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"
import { cn } from "cn"
/*
* Repeatable group: a list of collapsible cards for forms that need an
* unknown number of the same thing (guests, line items, addresses). Each card
* has a header with a drag handle, an icon, a title, a live summary and a
* menu, and its body is whatever you render. Built only from Another UI
* primitives. transitions.dev: accordion expand on each card, the chevron
* flip, the text swap reveal on a new card and the error shake on invalid
* fields. Cuelume: open and close on collapse, tap on add, a strong close on
* remove and a tap when a card is dropped.
*/
export const uid = () => Math.random().toString(36).slice(2, 10)
export type GroupContext<T> = {
index: number
update: (patch: Partial<T>) => void
/** True once the form has been submitted, so fields can show errors. */
showErrors: boolean
}
export type RepeatableGroupProps<T extends { id: string }> = {
items: T[]
onChange: (items: T[]) => void
/** Makes a new, empty item. */
create: () => T
/** The body of each card. It can be anything. */
children: (item: T, ctx: GroupContext<T>) => React.ReactNode
/** The singular name, used for the title, the add button and toasts. */
label?: string
icon?: React.ElementType
title?: (item: T, index: number) => React.ReactNode
/** A short live summary shown under the title. */
description?: (item: T, index: number) => React.ReactNode
/** Returns a message when the item is incomplete. */
validate?: (item: T) => string | undefined | false
showErrors?: boolean
min?: number
max?: number
sortable?: boolean
className?: string
}
export function RepeatableGroup<T extends { id: string }>({
items,
onChange,
create,
children,
label = "Item",
icon: Icon = UserIcon,
title,
description,
validate,
showErrors = false,
min = 0,
max = Infinity,
sortable = true,
className,
}: RepeatableGroupProps<T>) {
const [open, setOpen] = React.useState<Set<string>>(() => new Set(items.slice(0, 1).map((i) => i.id)))
const [fresh, setFresh] = React.useState<string | null>(null)
const listRef = React.useRef<HTMLDivElement>(null)
const lower = label.toLowerCase()
const full = items.length >= max
const atMin = items.length <= min
const setItemOpen = (id: string, next: boolean) =>
setOpen((cur) => {
const s = new Set(cur)
if (next) s.add(id)
else s.delete(id)
return s
})
// A submit with errors opens every card that needs attention.
const invalidKey = showErrors && validate ? items.filter((i) => validate(i)).map((i) => i.id).join() : ""
React.useEffect(() => {
if (!invalidKey) return
setOpen((cur) => new Set([...cur, ...invalidKey.split(",")]))
}, [invalidKey])
// A new card scrolls into view and focuses its first field.
React.useEffect(() => {
if (!fresh) return
const card = listRef.current?.querySelector<HTMLElement>(`[data-group-id="${fresh}"]`)
card?.scrollIntoView({ block: "nearest", behavior: "smooth" })
const field = card?.querySelector<HTMLElement>("input, textarea, [data-slot=select-trigger]")
const t = window.setTimeout(() => field?.focus({ preventScroll: true }), 60)
return () => window.clearTimeout(t)
}, [fresh])
const insert = (item: T, at = items.length) => {
if (full) return
const next = [...items]
next.splice(at, 0, item)
onChange(next)
setItemOpen(item.id, true)
setFresh(item.id)
}
const add = () => insert(create())
const duplicate = (item: T, index: number) =>
insert({ ...structuredClone(item), id: uid() }, index + 1)
const remove = (item: T, index: number) => {
if (atMin) return
const before = items
onChange(items.filter((i) => i.id !== item.id))
toast(`${label} ${index + 1} removed`, {
action: {
label: "Undo",
onClick: () => {
onChange(before)
cue("tap", { force: true })
},
},
})
}
const move = (index: number, to: number) => {
if (to < 0 || to >= items.length) return
onChange(arrayMove(items, index, to))
cue("select", { force: true })
}
const update = (id: string, patch: Partial<T>) =>
onChange(items.map((i) => (i.id === id ? { ...i, ...patch } : i)))
const allOpen = items.length > 0 && items.every((i) => open.has(i.id))
const sensors = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 5 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 180, tolerance: 6 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
)
const onDragEnd = ({ active, over }: DragEndEvent) => {
if (!over || active.id === over.id) return cue("close", { emphasis: "subtle", force: true })
const from = items.findIndex((i) => i.id === active.id)
const to = items.findIndex((i) => i.id === over.id)
onChange(arrayMove(items, from, to))
cue("tap", { emphasis: "strong", force: true })
}
return (
<div data-slot="repeatable-group" className={cn("flex flex-col gap-3", className)}>
{/* One toolbar row: count, expand or collapse all, add. */}
<div className="flex items-center gap-2">
<span className="flex min-w-0 items-center gap-1.5">
<Badge variant="secondary" className="tabular-nums">
{items.length}
{Number.isFinite(max) ? ` of ${max}` : ""}
</Badge>
<span className="truncate text-sm text-muted-foreground">
{items.length === 1 ? lower : `${lower}s`}
</span>
</span>
<div className="ml-auto flex shrink-0 items-center gap-1">
<Button
type="button"
variant="ghost"
size="sm"
disabled={!items.length}
aria-label={allOpen ? "Collapse all" : "Expand all"}
onClick={() => setOpen(allOpen ? new Set() : new Set(items.map((i) => i.id)))}
>
{allOpen ? <ArrowsInSimpleIcon weight="bold" /> : <ArrowsOutSimpleIcon weight="bold" />}
<span className="hidden sm:inline">{allOpen ? "Collapse all" : "Expand all"}</span>
</Button>
<Button type="button" variant="outline" size="sm" disabled={full} onClick={add}>
<PlusIcon weight="bold" />
Add {lower}
</Button>
</div>
</div>
<DndContext
id={React.useId()}
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
onDragStart={() => cue("tap", { emphasis: "subtle", force: true })}
onDragEnd={onDragEnd}
>
<SortableContext items={items.map((i) => i.id)} strategy={verticalListSortingStrategy}>
<div ref={listRef} className="flex flex-col gap-2">
{items.map((item, index) => {
const error = showErrors && validate ? validate(item) : undefined
return (
<GroupCard
key={item.id}
id={item.id}
fresh={fresh === item.id}
open={open.has(item.id)}
onOpenChange={(o) => setItemOpen(item.id, o)}
sortable={sortable && items.length > 1}
icon={Icon}
index={index}
count={items.length}
title={title ? title(item, index) : `${label} ${index + 1}`}
description={description?.(item, index)}
error={error || undefined}
canAdd={!full}
canRemove={!atMin}
onDuplicate={() => duplicate(item, index)}
onMove={(to) => move(index, to)}
onRemove={() => remove(item, index)}
>
{children(item, { index, update: (patch) => update(item.id, patch), showErrors })}
</GroupCard>
)
})}
</div>
</SortableContext>
</DndContext>
{items.length === 0 && (
<p className="t-swap-in py-2 text-center text-sm text-muted-foreground">
No {lower}s yet.
</p>
)}
<Button
type="button"
variant="outline"
className="w-full border-dashed text-muted-foreground hover:text-foreground"
disabled={full}
onClick={add}
>
<PlusIcon weight="bold" />
{full ? `Limit of ${max} reached` : `Add ${lower}`}
</Button>
</div>
)
}
function GroupCard({
id,
fresh,
open,
onOpenChange,
sortable,
icon: Icon,
index,
count,
title,
description,
error,
canAdd,
canRemove,
onDuplicate,
onMove,
onRemove,
children,
}: {
id: string
fresh: boolean
open: boolean
onOpenChange: (open: boolean) => void
sortable: boolean
icon: React.ElementType
index: number
count: number
title: React.ReactNode
description?: React.ReactNode
error?: string
canAdd: boolean
canRemove: boolean
onDuplicate: () => void
onMove: (to: number) => void
onRemove: () => void
children: React.ReactNode
}) {
const { setNodeRef, setActivatorNodeRef, attributes, listeners, transform, transition, isDragging } = useSortable({
id,
disabled: !sortable,
})
return (
<Collapsible
ref={setNodeRef}
open={open}
onOpenChange={onOpenChange}
data-group-id={id}
data-invalid={error ? "" : undefined}
style={{ transform: CSS.Translate.toString(transform), transition }}
className={cn("group/rg", isDragging && "relative z-10")}
>
<Card
className={cn(
"gap-0 py-0",
fresh && "t-swap-in",
isDragging && "shadow-lg",
error && "border-destructive/60"
)}
>
<div className="flex items-center gap-1 py-2 pr-2 pl-1">
{sortable ? (
<Button
type="button"
ref={setActivatorNodeRef}
variant="ghost"
size="icon-sm"
aria-label={`Reorder ${index + 1}`}
className="cursor-grab touch-none text-muted-foreground active:cursor-grabbing"
{...attributes}
{...listeners}
>
<DotsSixVerticalIcon weight="bold" />
</Button>
) : (
<span className="w-1" />
)}
<CollapsibleTrigger className="flex min-w-0 flex-1 items-center gap-3 rounded-md py-1 text-left outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50">
<ItemMedia
variant="icon"
className={cn(
"size-8 rounded-md border bg-muted text-foreground",
error && "border-destructive/40 bg-destructive/10 text-destructive"
)}
>
{error ? <WarningCircleIcon weight="duotone" /> : <Icon weight="duotone" />}
</ItemMedia>
<ItemContent className="min-w-0 gap-0.5">
<ItemTitle className="w-full truncate">{title}</ItemTitle>
{(error || description) && (
<ItemDescription className={cn("truncate", error && "text-destructive")}>
{error ?? description}
</ItemDescription>
)}
</ItemContent>
</CollapsibleTrigger>
<Badge variant="outline" className="hidden tabular-nums sm:inline-flex">
{index + 1} / {count}
</Badge>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="ghost" size="icon-sm" aria-label={`Actions for ${index + 1}`}>
<DotsThreeIcon weight="bold" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem disabled={!canAdd} onSelect={onDuplicate}>
<CopyIcon weight="duotone" />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem disabled={index === 0} onSelect={() => onMove(index - 1)}>
<CaretDownIcon weight="bold" className="rotate-180" />
Move up
</DropdownMenuItem>
<DropdownMenuItem disabled={index === count - 1} onSelect={() => onMove(index + 1)}>
<CaretDownIcon weight="bold" />
Move down
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" disabled={!canRemove} onSelect={onRemove}>
<TrashIcon weight="duotone" />
Remove
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<CollapsibleTrigger asChild>
<Button type="button" variant="ghost" size="icon-sm" aria-label={open ? "Collapse" : "Expand"}>
{/* transitions.dev accordion: the chevron flips instead of rotating. */}
<CaretDownIcon
weight="bold"
className="transition-transform duration-(--acc-chevron) ease-(--acc-ease) group-data-[state=open]/rg:-scale-y-100"
/>
</Button>
</CollapsibleTrigger>
</div>
<CollapsibleContent className="overflow-hidden duration-(--acc-expand) ease-(--acc-ease) data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down">
<div className="border-t px-4 pt-4 pb-4">{children}</div>
</CollapsibleContent>
</Card>
</Collapsible>
)
}
/* Demo ------------------------------------------------------------------ */
type Guest = { id: string; name: string; email: string; meal: string; plusOne: boolean; notes: string }
type Line = { id: string; description: string; qty: string; price: string }
const MEALS = ["Chicken", "Salmon", "Vegetarian", "Vegan"]
const newGuest = (): Guest => ({ id: uid(), name: "", email: "", meal: "", plusOne: false, notes: "" })
const newLine = (): Line => ({ id: uid(), description: "", qty: "1", price: "" })
const lineTotal = (l: Line) => (Number(l.qty) || 0) * (Number(l.price) || 0)
const money = (n: number) => n.toLocaleString("en-CA", { style: "currency", currency: "CAD" })
const isEmail = (s: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s)
function guestError(g: Guest) {
if (!g.name.trim()) return "Add a name"
if (!isEmail(g.email)) return "Add a valid email"
if (!g.meal) return "Pick a meal"
}
export function RepeatableGroupDemo() {
const [guests, setGuests] = React.useState<Guest[]>([
{ id: "g1", name: "Evita Vendramin", email: "evita@example.com", meal: "Salmon", plusOne: false, notes: "" },
{ id: "g2", name: "", email: "", meal: "", plusOne: false, notes: "" },
])
const [lines, setLines] = React.useState<Line[]>([
{ id: "l1", description: "Website design", qty: "1", price: "2400" },
{ id: "l2", description: "Hosting, monthly", qty: "12", price: "25" },
])
const [submitted, setSubmitted] = React.useState(false)
const total = lines.reduce((s, l) => s + lineTotal(l), 0)
const submit = (e: React.FormEvent) => {
e.preventDefault()
setSubmitted(true)
if (guests.some(guestError)) return
cue("success", { force: true })
toast.success(`RSVP sent for ${guests.length} ${guests.length === 1 ? "guest" : "guests"}`)
}
return (
<div className="grid gap-8 lg:grid-cols-2">
<form noValidate onSubmit={submit} className="flex flex-col gap-4">
<div>
<h3 className="font-semibold">Guests</h3>
<p className="text-sm text-muted-foreground">Up to 6 people. Submit with a blank field to see errors.</p>
</div>
<RepeatableGroup
items={guests}
onChange={setGuests}
create={newGuest}
label="Guest"
icon={UserIcon}
min={1}
max={6}
showErrors={submitted}
validate={guestError}
title={(g, i) => g.name.trim() || `Guest ${i + 1}`}
description={(g) =>
[g.email, g.meal, g.plusOne && "Plus one"].filter(Boolean).join(" · ") || "Not filled in yet"
}
>
{(g, { update, showErrors }) => (
<FieldGroup className="gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<Field data-invalid={showErrors && !g.name.trim()}>
<FieldLabel htmlFor={`${g.id}-name`}>Name</FieldLabel>
<Input
id={`${g.id}-name`}
value={g.name}
aria-invalid={showErrors && !g.name.trim()}
onChange={(e) => update({ name: e.target.value })}
/>
{showErrors && !g.name.trim() && <FieldError>Enter a name.</FieldError>}
</Field>
<Field data-invalid={showErrors && !isEmail(g.email)}>
<FieldLabel htmlFor={`${g.id}-email`}>Email</FieldLabel>
<Input
id={`${g.id}-email`}
type="email"
value={g.email}
aria-invalid={showErrors && !isEmail(g.email)}
onChange={(e) => update({ email: e.target.value })}
/>
{showErrors && !isEmail(g.email) && <FieldError>Enter a valid email.</FieldError>}
</Field>
</div>
<Field data-invalid={showErrors && !g.meal}>
<FieldLabel>Meal</FieldLabel>
<Select value={g.meal} onValueChange={(meal) => update({ meal })}>
<SelectTrigger className="w-full" aria-invalid={showErrors && !g.meal}>
<SelectValue placeholder="Choose a meal" />
</SelectTrigger>
<SelectContent>
{MEALS.map((m) => (
<SelectItem key={m} value={m}>
{m}
</SelectItem>
))}
</SelectContent>
</Select>
{showErrors && !g.meal && <FieldError>Pick a meal.</FieldError>}
</Field>
<Field orientation="horizontal">
<Switch
id={`${g.id}-plus`}
checked={g.plusOne}
onCheckedChange={(plusOne) => update({ plusOne })}
/>
<Label htmlFor={`${g.id}-plus`}>Bringing a plus one</Label>
</Field>
<Field>
<FieldLabel htmlFor={`${g.id}-notes`}>Notes</FieldLabel>
<Textarea
id={`${g.id}-notes`}
value={g.notes}
placeholder="Allergies, accessibility, anything else"
onChange={(e) => update({ notes: e.target.value })}
/>
</Field>
</FieldGroup>
)}
</RepeatableGroup>
<Button type="submit" className="self-start">
Send RSVP
</Button>
</form>
<div className="flex flex-col gap-4">
<div>
<h3 className="font-semibold">Line items</h3>
<p className="text-sm text-muted-foreground">No limit. Each summary shows the line total.</p>
</div>
<RepeatableGroup
items={lines}
onChange={setLines}
create={newLine}
label="Line item"
icon={ReceiptIcon}
title={(l, i) => l.description.trim() || `Line item ${i + 1}`}
description={(l) => `${Number(l.qty) || 0} × ${money(Number(l.price) || 0)} = ${money(lineTotal(l))}`}
>
{(l, { update }) => (
<FieldGroup className="gap-4">
<Field>
<FieldLabel htmlFor={`${l.id}-desc`}>Description</FieldLabel>
<Input
id={`${l.id}-desc`}
value={l.description}
onChange={(e) => update({ description: e.target.value })}
/>
</Field>
<div className="grid grid-cols-2 gap-4">
<Field>
<FieldLabel htmlFor={`${l.id}-qty`}>Quantity</FieldLabel>
<Input
id={`${l.id}-qty`}
inputMode="numeric"
value={l.qty}
onChange={(e) => update({ qty: e.target.value.replace(/[^\d.]/g, "") })}
/>
</Field>
<Field>
<FieldLabel htmlFor={`${l.id}-price`}>Unit price</FieldLabel>
<InputGroup>
<InputGroupAddon>
<InputGroupText>$</InputGroupText>
</InputGroupAddon>
<InputGroupInput
id={`${l.id}-price`}
inputMode="decimal"
value={l.price}
onChange={(e) => update({ price: e.target.value.replace(/[^\d.]/g, "") })}
/>
</InputGroup>
</Field>
</div>
</FieldGroup>
)}
</RepeatableGroup>
<div className="flex items-center justify-between border-t pt-3 text-sm">
<span className="text-muted-foreground">Total</span>
<span className="text-base font-semibold tabular-nums">{money(total)}</span>
</div>
</div>
</div>
)
}