HomeDocsComponentsBlocksChartsThemes
Introduction
  • Introduction
  • Installation
  • Themes
  • llms.txt
Components
  • Accordion
  • Alert
  • Alert Dialog
  • Amount
  • Aspect Ratio
  • Audio Player
  • Avatar
  • Avatar Upload
  • Badge
  • Banner
  • Breadcrumb
  • Button
  • Button Group
  • Calendar
  • Card
  • Carousel
  • Chart
  • Checkbox
  • Code Block
  • Collapsible
  • Color Picker
  • Combobox
  • Command
  • Context Menu
  • Copy Button
  • Countdown
  • Data Grid
  • Data Table
  • Date Picker
  • Date Range Picker
  • Description List
  • Device Mockup
  • Dialog
  • Direction
  • Dot Loader
  • Drawer
  • Dropdown Menu
  • Empty
  • Field
  • File Upload
  • Hover Card
  • Input
  • Input Group
  • Input OTP
  • Item
  • Kanban
  • Kbd
  • Label
  • Menubar
  • Meter
  • Multi Select
  • Native Select
  • Navigation Menu
  • Number Field
  • Pagination
  • Password Strength
  • Phone Input
  • Popover
  • Progress
  • Progress Circle
  • Radio Group
  • Rating
  • Resizable
  • Responsive Dialog
  • Rich Text Editor
  • Scroll Area
  • Segmented Control
  • Select
  • Separator
  • Sheet
  • Sidebar
  • Signature
  • Skeleton
  • Slider
  • Sortable
  • Spinner
  • Stat
  • Stepper
  • Switch
  • Table
  • Tabs
  • Tag Input
  • Terminal
  • Textarea
  • Time Field
  • Timeline
  • Toast
  • Toggle
  • Toggle Group
  • Tooltip
  • Tree
  • Typography
  • Video Player
  • Window Mockup
AI
  • Message Scroller
  • Message
  • Bubble
  • Marker
  • Attachment
  • Questionnaire
  • Composer
  • Reasoning
  • Tool Call
  • Approval Card
  • Citations
Blocks
  • Airtable
  • Airtable Kanban
  • ClickUp List
  • ClickUp Kanban
  • Repeatable Group
  • Command Palette
  • Sign In and Sign Up
  • Availability Scheduler
  • AI Chat
  • Action Bar
  • Notion
  • Banking
  • Lineup Builder
  • Sports Score
  • Strava
  • Sheets
  • Notices
  • Settings
  • Billing
Utilities
  • motion
  • sounds
  • scroll-fade
  • shimmer
Another UI. Built on shadcn/ui with Phosphor icons.

Repeatable Group

PreviousNext

Collapsible cards for forms that need an unknown number of the same thing, built from Another UI primitives.

Guests

Up to 6 people. Submit with a blank field to see errors.

2 of 6guests
1 / 2
2 / 2

Line items

No limit. Each summary shows the line total.

2line items
1 / 2
$
2 / 2
Total$2,700.00

Installation#

pnpm dlx shadcn@latest add https://anotherui.dev/r/repeatable-group.json

Features#

  • Any content. Each card renders whatever you pass it: fields, a table, an upload, another repeatable group.
  • Header. A drag handle, an icon, a title and a live summary built from the card's values, its position, a menu and a collapse button.
  • Add as many as you need. Set a min and a max, or leave both off. A new card opens, slides in and focuses its first field.
  • Collapse. Each card opens and closes with the transitions.dev accordion, and the chevron flips. Expand all and Collapse all sit in the toolbar.
  • Reorder. Drag the handle, use the keyboard, or Move up and Move down in the menu.
  • Duplicate and remove. Remove uses the destructive variant and offers Undo.
  • Validation. Pass validate and showErrors. Cards with problems open, turn red, show the message in the header, and their fields shake with Cuelume's error sound.
  • Sounds. Opening, closing, adding, dropping and removing all play Cuelume cues.

Usage#

<RepeatableGroup
  items={guests}
  onChange={setGuests}
  create={() => ({ id: uid(), name: "" })}
  label="Guest"
  icon={UserIcon}
  min={1}
  max={6}
  title={(g, i) => g.name || `Guest ${i + 1}`}
>
  {(guest, { update }) => (
    <Input value={guest.name} onChange={(e) => update({ name: e.target.value })} />
  )}
</RepeatableGroup>

Built with#

Card, Collapsible, Item, Badge, Button, Dropdown Menu and Sonner, with dnd-kit for reordering. The demo adds Field, Input, Input Group, Select, Switch and Textarea.

Code#

components/blocks/repeatable-group.tsx
"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>
  )
}
ClickUp KanbanCommand Palette

On This Page

InstallationFeaturesUsageBuilt withCode