{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "transaction-list",
  "title": "Transaction list",
  "description": "Payments grouped by day with the day's net, signed amounts in tabular mono, incoming, pending and failed states that never rely on colour alone, and a show-more limit.",
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "@manniche/chart-kit"
  ],
  "files": [
    {
      "path": "registry/manniche/transaction-list/transaction-list.tsx",
      "content": "// A list of payments grouped by day: \"Today\", \"Yesterday\", then the date, each with the day's net on the right.\n// A row is a category glyph, the name, the category and time, and the signed amount in tabular mono with a real\n// minus. Money coming in gets a plus, an arrow glyph and the success colour; a pending payment gets a dashed chip\n// with a clock, a failed one a filled chip with a cross, a struck-through amount and its reason. None of these\n// depends on colour alone. A limit hides the older rows behind a \"Show more\" button.\n// With `onSelect` every row is a button; without it the rows are plain list items.\n// Screen readers: each day is a heading followed by a list. Each row is read as one sentence, \"Harbour Market,\n// −€48.20, pending, Groceries, 12:58\", and the visual pieces are hidden so nothing is read twice. Showing more\n// rows says how many came in.\n// Reduced motion: new rows appear at once instead of rising in.\nimport { ArrowDown, ChevronDown, Clock3, X } from 'lucide-react'\nimport { motion } from 'motion/react'\nimport { useEffect, useId, useState, type CSSProperties, type HTMLAttributes, type ReactNode } from 'react'\nimport { EASE, formatValue, seriesColor, valueParts, type ValueFormat } from '@/registry/manniche/chart-kit/chart-utils'\nimport { useAnnounce } from '@/registry/manniche/chart-kit/use-chart'\nimport { useReducedMotion } from '@/registry/manniche/hooks/use-reduced-motion'\nimport { cn } from '@/lib/utils'\n\nexport type Transaction = {\n  /** Unique and stable. */\n  id: string\n  /** Who was paid, or who paid you. */\n  name: string\n  /** Negative for money out, positive for money in. */\n  amount: number\n  /** \"YYYY-MM-DD\", or \"YYYY-MM-DDTHH:MM\" to show a time. Read as written, without time zone maths. */\n  date: string\n  /** Shown under the name and used to pick the glyph's colour. */\n  category?: string\n  /** The glyph's colour. Default from `categories`, else a series colour per category; money in uses --success. */\n  color?: string\n  /** Replaces the initials (or the arrow for money in) in the glyph. */\n  glyph?: ReactNode\n  /** \"pending\" for a payment that has not cleared, \"failed\" for one that did not go through. */\n  status?: 'pending' | 'failed'\n  /** A short reason under a failed amount, such as \"Card limit reached\". */\n  note?: string\n}\n\nexport type TransactionListLabels = {\n  /** The name of a day group. `offset` is days before `today`, or null without `today`. */\n  day?: (date: string, offset: number | null, locale: string) => string\n  pending?: string\n  failed?: string\n  /** Read after the amount of money that came in. */\n  incoming?: string\n  /** Read before a day's total. */\n  net?: string\n  more?: (hidden: number) => string\n  less?: string\n  /** Read when more rows are shown. */\n  shown?: (count: number) => string\n  /** The one-line sentence a row is read as. */\n  row?: (item: Transaction, parts: { amount: string; status: string; time: string }) => string\n  empty?: string\n  loading?: string\n}\n\nexport type TransactionListProps = Omit<HTMLAttributes<HTMLDivElement>, 'onSelect'> & {\n  /** The payments. Days are sorted newest first; rows keep your order within a day. */\n  data: Transaction[]\n  /** The accessible name of the list, such as \"Recent transactions\". */\n  label: string\n  /** \"YYYY-MM-DD\" of today, so the first days read \"Today\" and \"Yesterday\". Without it every day shows its date. */\n  today?: string\n  /** The amounts' format. Default two decimals; pass a currency for money. */\n  format?: ValueFormat\n  /** Colours per category name, such as { Groceries: 'var(--chart-1)' }. */\n  categories?: Record<string, string>\n  /** Show this many rows and put the rest behind \"Show more\". Default all. */\n  limit?: number\n  /** Makes each row a button and is called with its payment. */\n  onSelect?: (item: Transaction) => void\n  /** The id of the row that is open elsewhere, marked with the signal colour. Needs `onSelect`. */\n  selectedId?: string\n  /** The level of the day headings. Default 4. */\n  headingLevel?: 2 | 3 | 4 | 5 | 6\n  /** Show placeholder rows while the data loads. */\n  loading?: boolean\n  /** What to show when there are no payments. Default a short line. */\n  empty?: ReactNode\n  /** \"compact\" tightens the rows. Default follows the tile. */\n  density?: 'comfortable' | 'compact'\n  labels?: TransactionListLabels\n}\n\nconst dayName = (date: string, offset: number | null, locale: string) => {\n  if (offset === 0) return 'Today'\n  if (offset === 1) return 'Yesterday'\n  const [y, m, d] = date.split('-').map(Number)\n  return new Intl.DateTimeFormat(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })\n    .format(new Date(Date.UTC(y, m - 1, d)))\n    .replace(',', '')\n}\n\nconst DEFAULT_LABELS: Required<TransactionListLabels> = {\n  day: dayName,\n  pending: 'Pending',\n  failed: 'Failed',\n  incoming: 'in',\n  net: 'Net',\n  more: (n) => `Show ${n} more`,\n  less: 'Show fewer',\n  shown: (n) => `${n} more transactions shown`,\n  row: (t, p) => [t.name, p.amount, p.status, t.category, p.time].filter(Boolean).join(', '),\n  empty: 'No transactions yet.',\n  loading: 'Loading transactions',\n}\n\n// Row metrics as variables, so a compact tile (or the prop) can swap them in one place.\nconst METRICS =\n  '[--tx-g:44px] [--tx-gr:14px] [--tx-gt:14px] [--tx-ic:18px] [--tx-row:62px] [--tx-gap:14px] [--tx-nm:15px] [--tx-dt:16px] [--tx-db:6px] ' +\n  'group-data-[density=compact]/tile:[--tx-g:36px] group-data-[density=compact]/tile:[--tx-gr:11px] group-data-[density=compact]/tile:[--tx-gt:12.5px] ' +\n  'group-data-[density=compact]/tile:[--tx-ic:16px] group-data-[density=compact]/tile:[--tx-row:52px] group-data-[density=compact]/tile:[--tx-gap:12px] ' +\n  'group-data-[density=compact]/tile:[--tx-nm:14px] group-data-[density=compact]/tile:[--tx-dt:10px] group-data-[density=compact]/tile:[--tx-db:4px]'\nconst DENSITY_VARS = {\n  comfortable: { '--tx-g': '44px', '--tx-gr': '14px', '--tx-gt': '14px', '--tx-ic': '18px', '--tx-row': '62px', '--tx-gap': '14px', '--tx-nm': '15px', '--tx-dt': '16px', '--tx-db': '6px' },\n  compact: { '--tx-g': '36px', '--tx-gr': '11px', '--tx-gt': '12.5px', '--tx-ic': '16px', '--tx-row': '52px', '--tx-gap': '12px', '--tx-nm': '14px', '--tx-dt': '10px', '--tx-db': '4px' },\n} as Record<string, CSSProperties>\n\nconst dayNumber = (iso: string) => {\n  const [y, m, d] = iso.slice(0, 10).split('-').map(Number)\n  return Math.round(Date.UTC(y, m - 1, d) / 86400000)\n}\n\n/** Two letters for the glyph: the first letters of the first two words, or the first two of one word. */\nfunction initials(name: string) {\n  const words = name.replace(/^refund,\\s*/i, '').match(/[\\p{L}\\p{N}]+/gu) ?? []\n  if (!words.length) return '·'\n  const [a = '', b = ''] = words\n  return (b ? a.slice(0, 1) + b.slice(0, 1) : a.slice(0, 2)).toUpperCase()\n}\n\nexport function TransactionList({\n  data,\n  label,\n  today,\n  format = { decimals: 2 },\n  categories,\n  limit,\n  onSelect,\n  selectedId,\n  headingLevel = 4,\n  loading = false,\n  empty,\n  density,\n  labels: labelsProp,\n  className,\n  style: styleProp,\n  ...rest\n}: TransactionListProps) {\n  const labels = { ...DEFAULT_LABELS, ...labelsProp }\n  const locale = format.locale ?? 'en-GB'\n  const reduced = useReducedMotion()\n  const { say, region } = useAnnounce()\n  const baseId = useId()\n  const [expanded, setExpanded] = useState(false)\n  // Rows that mount after the first paint (a filter, new data, \"Show more\") rise in; the first paint is still.\n  const [settled, setSettled] = useState(false)\n  useEffect(() => {\n    const r = requestAnimationFrame(() => setSettled(true))\n    return () => cancelAnimationFrame(r)\n  }, [])\n\n  const H = `h${headingLevel}` as 'h4'\n  const style = { ...(density ? DENSITY_VARS[density] : null), ...styleProp }\n  const autoColors = new Map<string, string>()\n  for (const t of data) {\n    if (t.category && !categories?.[t.category] && !autoColors.has(t.category) && t.amount <= 0)\n      autoColors.set(t.category, seriesColor(autoColors.size))\n  }\n  const colorOf = (t: Transaction) =>\n    t.color ??\n    (t.amount > 0 && t.status !== 'failed' ? 'var(--success)' : undefined) ??\n    (t.category ? (categories?.[t.category] ?? autoColors.get(t.category)) : undefined) ??\n    'var(--muted-foreground)'\n\n  if (loading) {\n    return (\n      <div role=\"status\" aria-label={labels.loading} className={cn(METRICS, 'grid gap-1', className)} style={style} {...rest}>\n        {[0, 1, 2, 3].map((i) => (\n          <div key={i} aria-hidden className={cn('grid grid-cols-[var(--tx-g)_minmax(0,1fr)_auto] items-center gap-(--tx-gap) p-2', 'min-h-(--tx-row)')}>\n            <span className={cn('rounded-[14px] bg-muted motion-safe:animate-pulse', 'size-(--tx-g)')} />\n            <span className=\"grid gap-2\">\n              <span className=\"h-3.5 w-2/3 max-w-40 rounded-full bg-muted motion-safe:animate-pulse\" />\n              <span className=\"h-3 w-1/3 max-w-24 rounded-full bg-muted motion-safe:animate-pulse\" />\n            </span>\n            <span className=\"h-3.5 w-16 rounded-full bg-muted motion-safe:animate-pulse\" />\n          </div>\n        ))}\n      </div>\n    )\n  }\n\n  if (!data.length) {\n    return (\n      <div role=\"group\" aria-label={label} className={cn('grid min-h-[124px] place-items-center px-4 py-8 text-center', className)} style={styleProp} {...rest}>\n        {empty ?? <p className=\"text-[13.5px] text-muted-foreground\">{labels.empty}</p>}\n      </div>\n    )\n  }\n\n  // Group by day, newest day first; keep the given order inside a day.\n  const groups: { date: string; items: Transaction[] }[] = []\n  const byDay = new Map<string, Transaction[]>()\n  for (const t of data) {\n    const d = t.date.slice(0, 10)\n    if (!byDay.has(d)) {\n      const items: Transaction[] = []\n      byDay.set(d, items)\n      groups.push({ date: d, items })\n    }\n    byDay.get(d)!.push(t)\n  }\n  groups.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0))\n\n  const total = data.length\n  const cap = limit !== undefined && limit < total && !expanded ? limit : total\n  const hidden = total - cap\n  const todayN = today ? dayNumber(today) : null\n\n  let shown = 0\n  let fresh = 0\n  const sections = groups.flatMap((g) => {\n    if (shown >= cap) return []\n    const items = g.items.slice(0, cap - shown)\n    shown += items.length\n    return [{ ...g, items, all: g.items }]\n  })\n\n  const amountSign = { ...format, sign: true }\n\n  return (\n    <div role=\"group\" aria-label={label} className={cn(METRICS, 'grid min-w-0', className)} style={style} {...rest}>\n      {sections.map((g, gi) => {\n        const net = g.all.filter((t) => t.status !== 'failed').reduce((s, t) => s + t.amount, 0)\n        const offset = todayN === null ? null : todayN - dayNumber(g.date)\n        const headId = `${baseId}-d${gi}`\n        return (\n          <section key={g.date} aria-labelledby={headId} className=\"min-w-0\">\n            <H\n              id={headId}\n              className={cn(\n                'flex items-baseline justify-between gap-3 px-2 text-[13px] leading-[1.3] font-medium text-muted-foreground',\n                gi === 0 ? 'pt-0.5' : 'pt-(--tx-dt)',\n                'pb-(--tx-db)',\n              )}\n            >\n              <span className=\"min-w-0 truncate\">{labels.day(g.date, offset, locale)}</span>\n              <span className=\"flex-none font-mono text-[11.5px] tabular-nums\">\n                <span className=\"sr-only\">, {labels.net} </span>\n                {formatValue(net, amountSign)}\n              </span>\n            </H>\n            <ul className=\"grid\">\n              {g.items.map((t) => {\n                const k = fresh\n                const animate = settled && !reduced\n                if (animate) fresh++\n                return (\n                  <motion.li\n                    key={t.id}\n                    initial={animate ? { opacity: 0, y: 6 } : false}\n                    animate={{ opacity: 1, y: 0 }}\n                    transition={{ duration: 0.22, ease: EASE, delay: Math.min(k * 0.03, 0.36) }}\n                    className=\"min-w-0\"\n                  >\n                    <Row\n                      item={t}\n                      color={colorOf(t)}\n                      format={format}\n                      labels={labels}\n                      onSelect={onSelect}\n                      selected={onSelect !== undefined && selectedId === t.id}\n                    />\n                  </motion.li>\n                )\n              })}\n            </ul>\n          </section>\n        )\n      })}\n      {limit !== undefined && limit < total && (\n        <button\n          type=\"button\"\n          aria-expanded={expanded}\n          onClick={() => {\n            if (!expanded) say(labels.shown(hidden))\n            setExpanded(!expanded)\n          }}\n          className={cn(\n            'relative isolate mt-1 inline-flex min-h-11 cursor-pointer items-center justify-center gap-1.5 rounded-[14px] px-3 text-[13.5px] font-medium text-foreground',\n            'before:absolute before:inset-0 before:-z-10 before:rounded-[inherit] before:bg-muted before:opacity-60 before:transition-opacity before:duration-150 hover:before:opacity-100',\n            'transition-transform duration-150 ease-out-quint active:scale-[0.98] motion-reduce:transition-none motion-reduce:before:transition-none',\n          )}\n        >\n          {expanded ? labels.less : labels.more(hidden)}\n          <ChevronDown\n            aria-hidden\n            className={cn('size-4 transition-transform duration-200 ease-out-quint motion-reduce:transition-none', expanded && 'rotate-180')}\n          />\n        </button>\n      )}\n      {region}\n    </div>\n  )\n}\n\nfunction Row({\n  item: t,\n  color,\n  format,\n  labels,\n  onSelect,\n  selected,\n}: {\n  item: Transaction\n  color: string\n  format: ValueFormat\n  labels: Required<TransactionListLabels>\n  onSelect?: (item: Transaction) => void\n  selected: boolean\n}) {\n  const failed = t.status === 'failed'\n  const pending = t.status === 'pending'\n  const incoming = t.amount > 0 && !failed\n  const p = valueParts(t.amount, { ...format, sign: true })\n  const time = t.date.length > 10 ? t.date.slice(11, 16) : ''\n  const amount = formatValue(t.amount, { ...format, sign: true })\n  const status = failed ? [labels.failed, t.note].filter(Boolean).join(': ') : pending ? labels.pending : incoming ? labels.incoming : ''\n  const sentence = labels.row(t, { amount, status, time })\n  const unit = p.unit && <span className=\"text-muted-foreground\">{p.unit}</span>\n\n  const body = (\n    <>\n      <span className=\"sr-only\">{sentence}</span>\n      <span\n        aria-hidden\n        className={cn(\n          'grid flex-none place-items-center text-foreground',\n          'size-(--tx-g) rounded-(--tx-gr) text-(length:--tx-gt)',\n        )}\n        style={{ background: `color-mix(in oklab, ${color} 18%, var(--card))`, fontFamily: 'var(--font-display, inherit)', fontWeight: 700 }}\n      >\n        {t.glyph ?? (incoming ? <ArrowDown className=\"size-(--tx-ic)\" strokeWidth={2} /> : initials(t.name))}\n      </span>\n      <span aria-hidden className=\"grid min-w-0 gap-0.5\">\n        <span className={cn('truncate leading-[1.25] font-medium text-foreground', 'text-(length:--tx-nm)')}>{t.name}</span>\n        <span className=\"flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-[13px] leading-[1.3] text-muted-foreground\">\n          <span className=\"min-w-0 truncate\">{[t.category, time].filter(Boolean).join(' · ')}</span>\n          {pending && (\n            <span className=\"inline-flex h-[22px] flex-none items-center gap-1 rounded-full px-2 text-[11.5px] font-medium outline-1 -outline-offset-1 outline-dashed outline-[color-mix(in_oklab,var(--foreground)_35%,transparent)]\">\n              <Clock3 className=\"size-3\" strokeWidth={2.2} />\n              {labels.pending}\n            </span>\n          )}\n          {failed && (\n            <span\n              className=\"inline-flex h-[22px] flex-none items-center gap-1 rounded-full px-2 text-[11.5px] font-medium\"\n              style={{\n                background: 'color-mix(in oklab, var(--destructive) 13%, var(--card))',\n                color: 'color-mix(in oklab, var(--destructive) 85%, var(--foreground))',\n              }}\n            >\n              <X className=\"size-3\" strokeWidth={2.6} />\n              {labels.failed}\n            </span>\n          )}\n        </span>\n      </span>\n      <span aria-hidden className=\"grid justify-items-end gap-0.5 text-right\">\n        <span\n          className={cn(\n            'font-mono leading-[1.2] whitespace-nowrap tabular-nums',\n            'text-(length:--tx-nm)',\n            failed ? 'font-medium text-muted-foreground line-through decoration-[1.5px]' : 'font-semibold',\n          )}\n          style={incoming ? { color: 'color-mix(in oklab, var(--success) 85%, var(--foreground))' } : failed ? undefined : { color: 'var(--foreground)' }}\n        >\n          {p.sign}\n          {!p.unitAfter && unit}\n          {p.whole}\n          {p.fraction}\n          {p.unitAfter && <> {unit}</>}\n          {p.suffix}\n        </span>\n        {failed && t.note && <span className=\"max-w-[24ch] font-mono text-[11px] leading-[1.3] text-muted-foreground\">{t.note}</span>}\n      </span>\n    </>\n  )\n\n  const grid = cn(\n    'relative grid w-full min-w-0 items-center rounded-[16px] p-2 text-left',\n    'min-h-(--tx-row) grid-cols-[var(--tx-g)_minmax(0,1fr)_auto] gap-(--tx-gap)',\n  )\n  if (!onSelect) return <div className={grid}>{body}</div>\n  return (\n    <button\n      type=\"button\"\n      aria-current={selected || undefined}\n      onClick={() => onSelect(t)}\n      className={cn(\n        grid,\n        'isolate cursor-pointer before:absolute before:inset-0 before:-z-10 before:rounded-[inherit] before:bg-muted before:opacity-0 before:transition-opacity before:duration-150 hover:before:opacity-100',\n        'transition-transform duration-150 ease-out-quint active:scale-[0.98] motion-reduce:transition-none motion-reduce:before:transition-none',\n        selected && 'before:opacity-100',\n      )}\n    >\n      {selected && <span aria-hidden className=\"absolute top-3 bottom-3 -left-0.5 w-[3px] rounded-full bg-primary\" />}\n      {body}\n    </button>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "categories": [
    "widgets"
  ],
  "type": "registry:component"
}