{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "exchange",
  "type": "registry:component",
  "title": "Exchange",
  "description": "An amount in one currency and what it comes to in another, in two cards that swap places with one press.",
  "dependencies": [
    "tailwind-variants"
  ],
  "registryDependencies": [
    "https://panelui.dev/r/web/scroll-area.json"
  ],
  "cssVars": {
    "light": {
      "success": "oklch(0.696 0.17 162.48)",
      "warning": "oklch(0.769 0.188 70.08)",
      "info": "oklch(0.623 0.214 259.815)"
    },
    "dark": {
      "success": "oklch(0.696 0.17 162.48)",
      "warning": "oklch(0.769 0.188 70.08)",
      "info": "oklch(0.623 0.214 259.815)"
    }
  },
  "files": [
    {
      "path": "components/panelui/exchange/index.tsx",
      "type": "registry:component",
      "target": "@components/panelui/exchange/index.tsx",
      "content": "'use client';\n\n/**\n * Exchange — an amount in one currency and what it comes to in another, with\n * the two sides swapped by one press.\n *\n * ```tsx\n * <Exchange currencies={CURRENCIES} rates={RATES} defaultValue={{ from: 'USD', to: 'EUR', amount: 100 }}>\n *   <Exchange.Field side=\"from\" label=\"You send\" />\n *   <Exchange.Swap />\n *   <Exchange.Field side=\"to\" label=\"They receive\" />\n * </Exchange>\n * ```\n *\n * ## Two cards and a seam\n *\n * Each side is a card of its own: what it is, the amount in large figures, the\n * currency it is in, and the rate in a quieter line under it. The swap button\n * sits on the seam between them, so it reads as belonging to both. Pressing it\n * exchanges the cards themselves, figures and all, rather than rewriting the\n * numbers in place, so the reader can see that nothing was recalculated.\n *\n * ## Either side can be typed in\n *\n * The amount belongs to the side it was typed in, and the other side follows\n * from the rate. Typing in the lower card makes it the fixed one, which is how\n * \"I need exactly 500 euros there\" is asked.\n *\n * ## The list opens in place\n *\n * A currency is chosen from a list that takes over the whole control, not from\n * a menu hanging off the pill: the list has room for names as well as codes,\n * and nothing else on the control means anything while the choice is being\n * made. Choosing the currency already on the other side swaps the two.\n *\n * ## Rates come from you\n *\n * It fetches nothing. Pass `rates`, units of each currency to one unit of any\n * common base, or `convert` for a rule of your own, such as a fee.\n */\nimport {\n  createContext,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n  type ChangeEvent,\n  type ComponentProps,\n  type KeyboardEvent,\n  type ReactNode,\n} from 'react';\nimport { tv } from 'tailwind-variants';\nimport { ScrollArea } from '@/components/panelui/scroll-area';\nimport { parseAmount } from './parse';\n\n/** Which of the two cards: what is paid, or what it comes to. */\nexport type ExchangeSide = 'from' | 'to';\n\n/** A theme role for a currency's mark. */\nexport type ExchangeColor =\n  | 'neutral'\n  | 'primary'\n  | 'info'\n  | 'success'\n  | 'warning'\n  | 'destructive'\n  | 'chart-1'\n  | 'chart-2'\n  | 'chart-3'\n  | 'chart-4'\n  | 'chart-5';\n\nconst COLOR_VAR: Record<Exclude<ExchangeColor, 'neutral'>, string> = {\n  primary: 'var(--primary)',\n  info: 'var(--info)',\n  success: 'var(--success)',\n  warning: 'var(--warning)',\n  destructive: 'var(--destructive)',\n  'chart-1': 'var(--chart-1)',\n  'chart-2': 'var(--chart-2)',\n  'chart-3': 'var(--chart-3)',\n  'chart-4': 'var(--chart-4)',\n  'chart-5': 'var(--chart-5)',\n};\n\nexport interface ExchangeCurrency {\n  /** The code it is known by, such as `USD`. Unique in the list. */\n  code: string;\n  /** Its name, shown beside the code in the list: \"US dollar\". */\n  name: string;\n  /** Drawn in its mark: `$`, `€`. Defaults to the code's first letter. */\n  symbol?: string;\n  /** Digits after the point. Defaults to `2`; `0` for a currency with no minor unit. */\n  decimals?: number;\n  /** The mark's colour, as a theme role. Default `neutral`. */\n  color?: ExchangeColor;\n}\n\nexport interface ExchangeValue {\n  /** Code of the currency paid. */\n  from: string;\n  /** Code of the currency it comes to. */\n  to: string;\n  /** The amount typed, or `null` while the field is empty. */\n  amount: number | null;\n  /** Which card the amount belongs to. The other is worked out from it. Default `from`. */\n  side?: ExchangeSide;\n}\n\nconst exchangeVariants = tv({\n  slots: {\n    // A zero minimum, so the amount field's own width never pushes the cards wider than the space they are given.\n    root: 'relative grid w-full max-w-sm grid-cols-[minmax(0,1fr)] text-foreground',\n    stack: [\n      'col-start-1 row-start-1 flex flex-col gap-1 transition-[opacity,scale] duration-200 ease-out',\n      'data-hidden:pointer-events-none data-hidden:scale-[0.98] data-hidden:opacity-0 motion-reduce:transition-none',\n    ],\n    card: [\n      'relative flex flex-col rounded-[22px] bg-card px-5 pb-4 pt-3.5 text-card-foreground',\n      'shadow-[0_1px_2px_rgb(0_0_0/0.04),0_8px_24px_-12px_rgb(0_0_0/0.18)] ring-1 ring-foreground/[0.06] dark:ring-foreground/10',\n      'focus-within:ring-foreground/15',\n    ],\n    label: 'text-sm text-muted-foreground',\n    row: 'mt-1.5 flex items-center gap-3',\n    amount: [\n      'w-full min-w-0 flex-1 bg-transparent text-[32px] font-medium leading-10 tracking-tight tabular-nums outline-none',\n      'placeholder:text-muted-foreground/50 disabled:cursor-not-allowed',\n    ],\n    pill: [\n      'inline-flex h-9 shrink-0 cursor-pointer items-center gap-1.5 rounded-full bg-muted pe-3 ps-1.5 text-sm font-semibold outline-none',\n      'transition-[background-color,scale] duration-150 hover:bg-foreground/10 active:scale-[0.97]',\n      'focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none',\n    ],\n    mark: 'inline-flex size-6 shrink-0 items-center justify-center rounded-full text-[13px] font-bold leading-none',\n    rate: 'mt-1 truncate text-sm tabular-nums text-muted-foreground',\n    // An opaque chip raised off the seam, so it reads on a page, a card or a\n    // Frame alike: no ring in the page's colour that shows as a halo elsewhere.\n    swap: [\n      'relative z-10 -my-[20px] inline-flex size-10 cursor-pointer items-center justify-center self-center rounded-full',\n      'bg-[color-mix(in_oklab,var(--card),var(--foreground)_7%)] text-foreground outline-none',\n      'shadow-[0_1px_2px_rgb(0_0_0/0.12),0_4px_12px_-4px_rgb(0_0_0/0.25)] ring-1 ring-foreground/10',\n      'transition-[background-color,scale] duration-150 hover:bg-[color-mix(in_oklab,var(--card),var(--foreground)_12%)] active:scale-95',\n      'focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none [&_svg]:size-[18px]',\n    ],\n    swapIcon: 'transition-transform duration-300 ease-[cubic-bezier(0.3,1.3,0.4,1)] motion-reduce:transition-none',\n    list: [\n      'col-start-1 row-start-1 flex flex-col self-start rounded-[22px] bg-card p-2 text-card-foreground',\n      'shadow-[0_1px_2px_rgb(0_0_0/0.04),0_8px_24px_-12px_rgb(0_0_0/0.18)] ring-1 ring-foreground/[0.06] dark:ring-foreground/10',\n      'origin-top transition-[opacity,scale] duration-200 ease-out data-hidden:pointer-events-none data-hidden:scale-[0.98] data-hidden:opacity-0',\n      'motion-reduce:transition-none',\n    ],\n    listHeader: 'flex items-center justify-between px-3 pb-1 pt-1.5 text-sm text-muted-foreground',\n    listClose: [\n      'inline-flex size-7 cursor-pointer items-center justify-center rounded-full text-muted-foreground outline-none',\n      'hover:bg-foreground/[0.06] hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring [&_svg]:size-4',\n    ],\n    listScroll: 'rounded-[14px]',\n    listViewport: 'max-h-72',\n    listItems: 'flex flex-col',\n    item: [\n      'flex h-11 shrink-0 cursor-pointer select-none items-center gap-3 rounded-[14px] px-3 text-sm outline-none',\n      'transition-colors duration-100 hover:bg-foreground/[0.05] focus-visible:bg-foreground/[0.07]',\n      'data-selected:bg-foreground/[0.04] motion-reduce:transition-none',\n    ],\n    itemCode: 'font-semibold',\n    itemName: 'min-w-0 flex-1 truncate text-muted-foreground',\n    itemCheck: 'size-4 shrink-0 text-foreground',\n  },\n});\n\n/** Relative luminance of a resolved CSS colour, read back through a one-pixel canvas. */\nfunction luminanceOf(color: string, context: CanvasRenderingContext2D): number | null {\n  context.clearRect(0, 0, 1, 1);\n  context.fillStyle = '#000';\n  context.fillStyle = color;\n  context.fillRect(0, 0, 1, 1);\n  const [r, g, b, a] = context.getImageData(0, 0, 1, 1).data;\n  if (a === 0) return null;\n  const linear = (channel: number) => {\n    const value = channel / 255;\n    return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;\n  };\n  return 0.2126 * linear(r!) + 0.7152 * linear(g!) + 0.0722 * linear(b!);\n}\n\n/**\n * Which of the theme's foreground and background reads better on each mark's\n * colour. The roles are light in one theme and dark in the other, so the\n * symbol's ink is measured per colour, and again when the theme changes.\n */\nfunction useMarkInk(probe: { current: HTMLElement | null }, colors: readonly string[]) {\n  const [ink, setInk] = useState<Record<string, string>>({});\n  const key = colors.join('|');\n  useEffect(() => {\n    const host = probe.current;\n    if (!host || typeof document === 'undefined') return;\n    const canvas = document.createElement('canvas');\n    canvas.width = 1;\n    canvas.height = 1;\n    const context = canvas.getContext('2d', { willReadFrequently: true });\n    if (!context) return;\n    const measure = () => {\n      const style = getComputedStyle(host);\n      const resolve = (value: string) => {\n        host.style.color = value;\n        return getComputedStyle(host).color;\n      };\n      const foreground = luminanceOf(resolve('var(--foreground)'), context) ?? 0;\n      const background = luminanceOf(resolve('var(--background)'), context) ?? 1;\n      const contrast = (a: number, b: number) => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);\n      const next: Record<string, string> = {};\n      for (const color of key.split('|')) {\n        if (!color) continue;\n        const fill = luminanceOf(resolve(color), context);\n        next[color] =\n          fill === null || contrast(fill, foreground) >= contrast(fill, background)\n            ? 'var(--foreground)'\n            : 'var(--background)';\n      }\n      host.style.color = style.color;\n      setInk((current) => (JSON.stringify(current) === JSON.stringify(next) ? current : next));\n    };\n    measure();\n    const observer = new MutationObserver(measure);\n    observer.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: ['class', 'style', 'data-theme'],\n    });\n    const scheme = window.matchMedia('(prefers-color-scheme: dark)');\n    scheme.addEventListener('change', measure);\n    return () => {\n      observer.disconnect();\n      scheme.removeEventListener('change', measure);\n    };\n  }, [probe, key]);\n  return ink;\n}\n\ninterface ExchangeContextValue {\n  /** The ink a mark's symbol is drawn in, by its fill. */\n  inkOf: (fill: string) => string;\n  currencies: readonly ExchangeCurrency[];\n  byCode: ReadonlyMap<string, ExchangeCurrency>;\n  value: Required<ExchangeValue>;\n  /** The amount on each side, worked out from the one typed. */\n  amounts: Record<ExchangeSide, number | null>;\n  /** Units of `to` per unit of `from`, and back, or `null` without a rate. */\n  rate: Record<ExchangeSide, number | null>;\n  locale: string;\n  disabled: boolean;\n  picking: ExchangeSide | null;\n  setPicking: (side: ExchangeSide | null) => void;\n  setAmount: (side: ExchangeSide, amount: number | null) => void;\n  choose: (side: ExchangeSide, code: string) => void;\n  swap: () => void;\n  /** How many times the sides have been swapped, for the arrow's turn. */\n  turns: number;\n  register: (side: ExchangeSide, node: HTMLElement | null) => void;\n  registerPill: (side: ExchangeSide, node: HTMLButtonElement | null) => void;\n}\n\nconst ExchangeContext = createContext<ExchangeContextValue | null>(null);\n\nfunction useExchange(component: string): ExchangeContextValue {\n  const context = useContext(ExchangeContext);\n  if (!context) throw new Error(`${component} must be used within an <Exchange>`);\n  return context;\n}\n\n/** Sets `inert` as a property, which every React version passes through. */\nconst setInert = (node: HTMLElement | null, inert: boolean) => {\n  if (node) node.inert = inert;\n};\n\nconst decimalsOf = (currency: ExchangeCurrency | undefined) => Math.max(0, Math.min(8, currency?.decimals ?? 2));\n\n/** Rounds to a currency's minor unit, so a figure carried across a swap is one that could be paid. */\nconst roundTo = (amount: number, decimals: number) => {\n  const scale = 10 ** decimals;\n  return Math.round(amount * scale) / scale;\n};\n\n/** A rate with four significant figures, so a small one is not rounded to nothing. */\nconst formatRate = (rate: number, locale: string) =>\n  new Intl.NumberFormat(locale, { maximumSignificantDigits: 4 }).format(rate);\n\nexport interface ExchangeProps extends Omit<ComponentProps<'div'>, 'defaultValue' | 'onChange'> {\n  /** Every currency that can be chosen, in the order the list shows them. */\n  currencies: readonly ExchangeCurrency[];\n  /**\n   * How many units of each currency one unit of a common base is worth, keyed\n   * by code: `{ USD: 1, EUR: 0.92, JPY: 151.4 }`. Any base works, since only\n   * the ratio between two entries is used.\n   */\n  rates?: Readonly<Record<string, number>>;\n  /**\n   * Works out an amount in another currency, in place of `rates`: for a fee, a\n   * spread, or a rate you fetch per pair. Return `NaN` when there is no rate.\n   */\n  convert?: (amount: number, from: string, to: string) => number;\n  /** The currencies and the amount, when you hold them. */\n  value?: ExchangeValue;\n  /** The currencies and the amount to start with, when the control holds them. */\n  defaultValue?: ExchangeValue;\n  /** Called with every change: a figure typed, a currency chosen, a swap. */\n  onValueChange?: (value: Required<ExchangeValue>) => void;\n  /** The locale figures are written in. Default `en-US`. */\n  locale?: string;\n  /** Shows the figures but takes no input. */\n  disabled?: boolean;\n  children?: ReactNode;\n}\n\n/** The control: both cards, the swap between them, and the list that opens over them. */\nfunction ExchangeRoot({\n  currencies,\n  rates,\n  convert,\n  value: valueProp,\n  defaultValue,\n  onValueChange,\n  locale = 'en-US',\n  disabled = false,\n  className,\n  children,\n  ...props\n}: ExchangeProps) {\n  const slots = exchangeVariants();\n  const first = currencies[0]?.code ?? '';\n  const second = currencies[1]?.code ?? first;\n  const [inner, setInner] = useState<Required<ExchangeValue>>(() => ({\n    from: defaultValue?.from ?? first,\n    to: defaultValue?.to ?? second,\n    amount: defaultValue?.amount ?? null,\n    side: defaultValue?.side ?? 'from',\n  }));\n  const controlled = valueProp !== undefined;\n  const value: Required<ExchangeValue> = controlled ? { side: 'from', ...valueProp } : inner;\n\n  const latest = useRef({ value, controlled, onValueChange });\n  latest.current = { value, controlled, onValueChange };\n  const commit = useCallback((next: Required<ExchangeValue>) => {\n    if (!latest.current.controlled) setInner(next);\n    latest.current.onValueChange?.(next);\n  }, []);\n\n  const byCode = useMemo(() => new Map(currencies.map((currency) => [currency.code, currency])), [currencies]);\n\n  const convertWith = useCallback(\n    (amount: number, from: string, to: string) => {\n      if (from === to) return amount;\n      if (convert) return convert(amount, from, to);\n      const a = rates?.[from];\n      const b = rates?.[to];\n      return a && b ? (amount / a) * b : Number.NaN;\n    },\n    [convert, rates]\n  );\n\n  const amounts = useMemo(() => {\n    const other: ExchangeSide = value.side === 'from' ? 'to' : 'from';\n    const result: Record<ExchangeSide, number | null> = {\n      from: null,\n      to: null,\n    };\n    result[value.side] = value.amount;\n    if (value.amount !== null) {\n      const worked = convertWith(value.amount, value[value.side], value[other]);\n      result[other] = Number.isFinite(worked) ? roundTo(worked, decimalsOf(byCode.get(value[other]))) : null;\n    }\n    return result;\n  }, [value, convertWith, byCode]);\n\n  const rate = useMemo(() => {\n    const forward = convertWith(1, value.from, value.to);\n    const back = convertWith(1, value.to, value.from);\n    return {\n      from: Number.isFinite(forward) ? forward : null,\n      to: Number.isFinite(back) ? back : null,\n    };\n  }, [value.from, value.to, convertWith]);\n\n  const [picking, setPickingState] = useState<ExchangeSide | null>(null);\n  const [turns, setTurns] = useState(0);\n  const cards = useRef<Record<ExchangeSide, HTMLElement | null>>({\n    from: null,\n    to: null,\n  });\n  const pills = useRef<Record<ExchangeSide, HTMLButtonElement | null>>({\n    from: null,\n    to: null,\n  });\n\n  const setPicking = useCallback((side: ExchangeSide | null) => {\n    setPickingState((current) => {\n      // Focus goes back to the pill the list was opened from.\n      if (side === null && current) requestAnimationFrame(() => pills.current[current]?.focus());\n      return side;\n    });\n  }, []);\n\n  const setAmount = useCallback(\n    (side: ExchangeSide, amount: number | null) => commit({ ...latest.current.value, amount, side }),\n    [commit]\n  );\n\n  /*\n   * The cards trade places. Their contents have already been exchanged by the\n   * time this runs, so each is started from where the other one was and moved\n   * home: the figures are seen to travel with their card.\n   */\n  const animateSwap = useCallback(() => {\n    const top = cards.current.from;\n    const bottom = cards.current.to;\n    if (!top || !bottom || typeof top.animate !== 'function') return;\n    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;\n    const distance = bottom.offsetTop - top.offsetTop;\n    const timing = { duration: 420, easing: 'cubic-bezier(0.3, 1.15, 0.4, 1)' };\n    top.animate([{ transform: `translateY(${distance}px)` }, { transform: 'none' }], timing);\n    bottom.animate([{ transform: `translateY(${-distance}px)` }, { transform: 'none' }], timing);\n  }, []);\n\n  const swap = useCallback(() => {\n    const current = latest.current.value;\n    // The amount stays with its card, so the card that was below arrives on top\n    // with the figure it showed there.\n    commit({\n      from: current.to,\n      to: current.from,\n      amount: current.amount,\n      side: current.side === 'from' ? 'to' : 'from',\n    });\n    setTurns((count) => count + 1);\n    requestAnimationFrame(animateSwap);\n  }, [commit, animateSwap]);\n\n  const choose = useCallback(\n    (side: ExchangeSide, code: string) => {\n      const current = latest.current.value;\n      const other: ExchangeSide = side === 'from' ? 'to' : 'from';\n      // The currency already on the other side: the two trade places.\n      if (code === current[other]) commit({ ...current, from: current.to, to: current.from });\n      else commit({ ...current, [side]: code });\n      setPicking(null);\n    },\n    [commit, setPicking]\n  );\n\n  const register = useCallback((side: ExchangeSide, node: HTMLElement | null) => {\n    cards.current[side] = node;\n  }, []);\n  const registerPill = useCallback((side: ExchangeSide, node: HTMLButtonElement | null) => {\n    pills.current[side] = node;\n  }, []);\n\n  const probe = useRef<HTMLSpanElement>(null);\n  const fills = useMemo(() => [...new Set(currencies.map((currency) => fillOf(currency)))], [currencies]);\n  const ink = useMarkInk(probe, fills);\n  const inkOf = useCallback((fill: string) => ink[fill] ?? 'var(--background)', [ink]);\n\n  const context: ExchangeContextValue = {\n    inkOf,\n    currencies,\n    byCode,\n    value,\n    amounts,\n    rate,\n    locale,\n    disabled,\n    picking,\n    setPicking,\n    setAmount,\n    choose,\n    swap,\n    turns,\n    register,\n    registerPill,\n  };\n\n  return (\n    <ExchangeContext.Provider value={context}>\n      <div data-slot=\"exchange\" role=\"group\" className={slots.root({ className })} {...props}>\n        <div\n          // Out of the tab order and the reading order while the list covers it.\n          ref={(node) => setInert(node, picking !== null)}\n          data-slot=\"exchange-stack\"\n          className={slots.stack()}\n          data-hidden={picking ? '' : undefined}\n        >\n          {children}\n        </div>\n        <ExchangeList />\n        {/* Resolves the theme's colours for measuring the marks' ink. */}\n        <span ref={probe} aria-hidden=\"true\" className=\"pointer-events-none absolute size-0 overflow-hidden\" />\n      </div>\n    </ExchangeContext.Provider>\n  );\n}\nExchangeRoot.displayName = 'Exchange';\n\n/** The disc colour of a currency's mark. */\nconst fillOf = (currency: ExchangeCurrency | undefined) =>\n  currency?.color && currency.color !== 'neutral' ? COLOR_VAR[currency.color] : 'var(--foreground)';\n\n/** A currency's mark: its symbol in a disc of its colour. */\nfunction CurrencyMark({ currency }: { currency: ExchangeCurrency | undefined }) {\n  const slots = exchangeVariants();\n  const { inkOf } = useExchange('Exchange');\n  const fill = fillOf(currency);\n  return (\n    <span aria-hidden=\"true\" className={slots.mark()} style={{ background: fill, color: inkOf(fill) }}>\n      {currency?.symbol ?? currency?.code.slice(0, 1) ?? '?'}\n    </span>\n  );\n}\n\nexport interface ExchangeFieldProps extends Omit<ComponentProps<'div'>, 'children'> {\n  /** Which card this is. */\n  side: ExchangeSide;\n  /** What the card is, above its amount: \"You pay\", \"They receive\". */\n  label: ReactNode;\n  /** Shown in an empty field. Default `0`. */\n  placeholder?: string;\n  /**\n   * The quieter line under the amount. Defaults to the rate from this card's\n   * currency to the other one; pass `null` for none.\n   */\n  detail?: ReactNode;\n}\n\n/** One side: its label, the amount, the currency it is in and the rate under it. */\nfunction ExchangeField({ side, label, placeholder = '0', detail, className, ...props }: ExchangeFieldProps) {\n  const slots = exchangeVariants();\n  const exchange = useExchange('Exchange.Field');\n  const { value, amounts, byCode, locale, disabled, rate } = exchange;\n  const code = value[side];\n  const currency = byCode.get(code);\n  const other = byCode.get(value[side === 'from' ? 'to' : 'from']);\n  const decimals = decimalsOf(currency);\n  const labelId = useId();\n\n  // While a figure is being typed it is shown as typed; formatted once the field is left.\n  const [draft, setDraft] = useState<string | null>(null);\n  const amount = amounts[side];\n  // The worked-out side shows the currency's whole minor unit, 216.50 not 216.5;\n  // the typed side shows what was typed.\n  const worked = value.side !== side;\n  const formatted =\n    amount === null\n      ? ''\n      : new Intl.NumberFormat(locale, {\n          minimumFractionDigits: worked ? decimals : 0,\n          maximumFractionDigits: decimals,\n          useGrouping: true,\n        }).format(amount);\n\n  const onChange = (event: ChangeEvent<HTMLInputElement>) => {\n    const text = event.target.value;\n    // Holds back anything that is not a figure, rather than clearing what was typed.\n    const typed = text.replace(/,/g, '');\n    if (typed !== '' && !/^\\d*\\.?\\d*$/.test(typed)) return;\n    const [, fraction = ''] = typed.split('.');\n    if (fraction.length > decimals) return;\n    setDraft(text);\n    exchange.setAmount(side, parseAmount(text));\n  };\n\n  const perUnit = rate[side];\n  const line =\n    detail !== undefined\n      ? detail\n      : perUnit !== null && other\n        ? `1 ${code} = ${formatRate(perUnit, locale)} ${other.code}`\n        : null;\n\n  return (\n    <div\n      ref={(node) => exchange.register(side, node)}\n      data-slot=\"exchange-field\"\n      data-side={side}\n      role=\"group\"\n      aria-labelledby={labelId}\n      className={slots.card({ className })}\n      {...props}\n    >\n      <span id={labelId} className={slots.label()}>\n        {label}\n      </span>\n      <div className={slots.row()}>\n        <input\n          data-slot=\"exchange-amount\"\n          inputMode=\"decimal\"\n          autoComplete=\"off\"\n          spellCheck={false}\n          aria-label={`${typeof label === 'string' ? label : 'Amount'} in ${currency?.name ?? code}`}\n          placeholder={placeholder}\n          disabled={disabled}\n          value={draft ?? formatted}\n          onChange={onChange}\n          onFocus={(event) => {\n            setDraft(amount === null ? '' : String(amount));\n            const input = event.currentTarget;\n            requestAnimationFrame(() => input.select());\n          }}\n          onBlur={() => setDraft(null)}\n          className={slots.amount()}\n        />\n        <button\n          ref={(node) => exchange.registerPill(side, node)}\n          type=\"button\"\n          data-slot=\"exchange-currency\"\n          disabled={disabled}\n          aria-haspopup=\"listbox\"\n          aria-expanded={exchange.picking === side}\n          aria-label={`${currency?.name ?? code}, change currency`}\n          onClick={() => exchange.setPicking(side)}\n          className={slots.pill()}\n        >\n          <CurrencyMark currency={currency} />\n          {code}\n        </button>\n      </div>\n      {line !== null ? (\n        <span data-slot=\"exchange-detail\" className={slots.rate()}>\n          {line}\n        </span>\n      ) : null}\n    </div>\n  );\n}\nExchangeField.displayName = 'Exchange.Field';\n\nexport interface ExchangeSwapProps extends Omit<ComponentProps<'button'>, 'children'> {\n  /** What a screen reader calls the button. Default \"Swap currencies\". */\n  'aria-label'?: string;\n}\n\n/** The round button on the seam: the two cards trade places. */\nfunction ExchangeSwap({\n  className,\n  onClick,\n  'aria-label': ariaLabel = 'Swap currencies',\n  ...props\n}: ExchangeSwapProps) {\n  const slots = exchangeVariants();\n  const exchange = useExchange('Exchange.Swap');\n  return (\n    <button\n      type=\"button\"\n      data-slot=\"exchange-swap\"\n      aria-label={ariaLabel}\n      disabled={exchange.disabled}\n      onClick={(event) => {\n        onClick?.(event);\n        if (!event.defaultPrevented) exchange.swap();\n      }}\n      className={slots.swap({ className })}\n      {...props}\n    >\n      <svg\n        viewBox=\"0 0 24 24\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth=\"2.2\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        aria-hidden=\"true\"\n        className={slots.swapIcon()}\n        // Half a turn per swap, always the same way round.\n        style={{ transform: `rotate(${exchange.turns * 180}deg)` }}\n      >\n        <path d=\"M12 5v14\" />\n        <path d=\"m6 13 6 6 6-6\" />\n      </svg>\n    </button>\n  );\n}\nExchangeSwap.displayName = 'Exchange.Swap';\n\n/** The currency list, over both cards while a side is being chosen. */\nfunction ExchangeList() {\n  const slots = exchangeVariants();\n  const { picking, setPicking, choose, currencies, value } = useExchange('Exchange');\n  const listRef = useRef<HTMLDivElement>(null);\n  const headingId = useId();\n  const open = picking !== null;\n  const selected = picking ? value[picking] : undefined;\n\n  // Opens on the chosen currency, so Enter keeps it and the arrows move from it.\n  useEffect(() => {\n    if (!open) return;\n    const items = [...(listRef.current?.querySelectorAll<HTMLElement>('[role=option]') ?? [])];\n    const current = items.find((item) => item.dataset.selected !== undefined) ?? items[0];\n    current?.scrollIntoView({ block: 'nearest' });\n    current?.focus({ preventScroll: true });\n  }, [open]);\n\n  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    if (event.key === 'Escape') {\n      event.preventDefault();\n      setPicking(null);\n      return;\n    }\n    const items = [...(listRef.current?.querySelectorAll<HTMLElement>('[role=option]') ?? [])];\n    const index = items.indexOf(document.activeElement as HTMLElement);\n    const next =\n      event.key === 'ArrowDown'\n        ? items[Math.min(index + 1, items.length - 1)]\n        : event.key === 'ArrowUp'\n          ? items[Math.max(index - 1, 0)]\n          : event.key === 'Home'\n            ? items[0]\n            : event.key === 'End'\n              ? items[items.length - 1]\n              : undefined;\n    if (!next) return;\n    event.preventDefault();\n    next.focus();\n  };\n\n  return (\n    <div\n      ref={(node) => setInert(node, !open)}\n      data-slot=\"exchange-list\"\n      className={slots.list()}\n      data-hidden={open ? undefined : ''}\n      onKeyDown={onKeyDown}\n    >\n      <div className={slots.listHeader()}>\n        <span id={headingId}>{picking === 'to' ? 'Convert to' : 'Convert from'}</span>\n        <button type=\"button\" aria-label=\"Close\" className={slots.listClose()} onClick={() => setPicking(null)}>\n          <svg\n            viewBox=\"0 0 24 24\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth=\"2.2\"\n            strokeLinecap=\"round\"\n            aria-hidden=\"true\"\n          >\n            <path d=\"M18 6 6 18M6 6l12 12\" />\n          </svg>\n        </button>\n      </div>\n      {/* A drawn bar over the rows rather than the browser's, with the rows faded where they run on. */}\n      <ScrollArea fade className={slots.listScroll()} viewportClassName={slots.listViewport()}>\n        <div ref={listRef} role=\"listbox\" aria-labelledby={headingId} className={slots.listItems()}>\n          {currencies.map((currency) => {\n            const chosen = currency.code === selected;\n            return (\n              <div\n                key={currency.code}\n                role=\"option\"\n                aria-selected={chosen}\n                tabIndex={chosen ? 0 : -1}\n                data-selected={chosen ? '' : undefined}\n                className={slots.item()}\n                onClick={() => picking && choose(picking, currency.code)}\n                onKeyDown={(event) => {\n                  if ((event.key === 'Enter' || event.key === ' ') && picking) {\n                    event.preventDefault();\n                    choose(picking, currency.code);\n                  }\n                }}\n              >\n                <CurrencyMark currency={currency} />\n                <span className={slots.itemCode()}>{currency.code}</span>\n                <span className={slots.itemName()}>{currency.name}</span>\n                {chosen ? (\n                  <svg\n                    viewBox=\"0 0 24 24\"\n                    fill=\"none\"\n                    stroke=\"currentColor\"\n                    strokeWidth=\"2.4\"\n                    strokeLinecap=\"round\"\n                    strokeLinejoin=\"round\"\n                    aria-hidden=\"true\"\n                    className={slots.itemCheck()}\n                  >\n                    <path d=\"m5 12.5 4.5 4.5L19 7.5\" />\n                  </svg>\n                ) : null}\n              </div>\n            );\n          })}\n        </div>\n      </ScrollArea>\n    </div>\n  );\n}\n\nexport const Exchange = Object.assign(ExchangeRoot, {\n  Field: ExchangeField,\n  Swap: ExchangeSwap,\n});\n\nexport { ExchangeField, ExchangeSwap, exchangeVariants, parseAmount };\n"
    },
    {
      "path": "components/panelui/exchange/parse.ts",
      "type": "registry:component",
      "target": "@components/panelui/exchange/parse.ts",
      "content": "/**\n * Reading a typed amount, apart from anything drawn, so a test can run it directly.\n */\n\n/** The figure as it is typed: digits and one point, with grouping commas dropped. */\nexport function parseAmount(text: string): number | null {\n  const cleaned = text.replace(/,/g, '').trim();\n  if (cleaned === '' || cleaned === '.') return null;\n  if (!/^\\d*\\.?\\d*$/.test(cleaned)) return null;\n  const parsed = Number(cleaned);\n  return Number.isFinite(parsed) ? parsed : null;\n}\n"
    }
  ]
}
