Skip to main content
astrocraft-ui/ components · 101

Currency Input

Free · MIT

Headless, accessible, and styled entirely through the attribute surface below — bring your own CSS system, or start from the reference theme.

Install

Without a flag you get no styling at all — plain HTML carrying the attribute surface below. --theme copies the same theme in two dialects;--bridge lumos re-points either one onto Lumos for Astro's tokens (it needs a theme to re-point, and Lumos itself, already in your project).

Unstyled — behavior only, you write the CSS

Install command
npx astrocraft-ui add forms/currency-input

Plain-CSS theme — no build step

Install command
npx astrocraft-ui add forms/currency-input --theme css

Tailwind theme — needs Tailwind v4

Install command
npx astrocraft-ui add forms/currency-input --theme tailwind

Plain-CSS theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add forms/currency-input --theme css --bridge lumos

Tailwind theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add forms/currency-input --theme tailwind --bridge lumos

Live demo

PhoneInput & CurrencyInput

The phone field groups digits the way the chosen country's data says to, and submits E.164 from a hidden input; switch country and the same digits regroup. The money field formats on BLUR, not while you type — a field that regroups mid-keystroke moves the caret out from under you — and reads both 1,234.56 and1.234,56 correctly, whichparseFloat does not.

Phone

Attribute surface

With no class-merging machinery, these attributes are the whole extension point. State is a data-attribute or a native attribute; visibility is the hiddenattribute. Generated from source by pnpm slots.

ComponentSlotsVariantsRuntime stateNative state
`CurrencyInput.astro``currency-input` `currency-input-field` `currency-input-value``data-size`: `sm` · `md` · `lg`
`data-state`: `default` · `error` · `success`
`data-value`—

Source

What the command copies — 3 files, alias-free so the copy lands standing alone. This is the whole component; there is nothing else.

src/components/ui/forms/currency-input/CurrencyInput.astro
---
// src/components/ui/forms/currency-input/CurrencyInput.astro — headless primitive (see ../../README.md).
// A money field: type freely, and on blur it formats to the locale's own notation while a hidden
// input carries the plain number the server should receive.
//
// FORMAT ON BLUR, NOT ON EVERY KEYSTROKE. A field that regroups while you type moves the caret out
// from under you — type "1234" and the separator lands mid-number, and the next digit goes in the
// wrong place. Waiting until focus leaves is both simpler and better, and it is why this needs no
// caret arithmetic at all.
//
// It is `type="text"` with `inputmode="decimal"`, not `type="number"`: a number input refuses to
// hold "1,234.56", silently empties itself on an unparseable keystroke in some browsers, and comes
// with spinners nobody wants on a price. The value that submits is the hidden input's.
//
// The reading and writing is `Intl` in `amount.ts` — including the part that catches people out,
// which is that "1.234,56" and "1,234.56" are the same amount and `parseFloat` gets one of them
// wrong. That module is checked by `amount.test.ts`.
import type { HTMLAttributes } from "astro/types";

type Props = HTMLAttributes<"input"> & {
  /** The amount to start with, as a plain number. */
  value?: number;
  /** The hidden input's name — the one carrying the plain number. */
  name?: string;
  /** ISO 4217 code. Omit it to format as a plain grouped number, with your own symbol beside it. */
  currency?: string;
  locale?: string;
  label?: string;
  size?: "sm" | "md" | "lg";
  state?: "default" | "error" | "success";
};

const {
  value,
  name,
  currency,
  locale,
  label = "Amount",
  size = "md",
  state = "default",
  class: className,
  ...rest
} = Astro.props;

const formatted =
  value === undefined
    ? ""
    : new Intl.NumberFormat(
        locale,
        currency
          ? { style: "currency", currency }
          : { minimumFractionDigits: 2, maximumFractionDigits: 2 },
      ).format(value);
---

<div data-slot="currency-input" data-value={value} data-locale={locale} data-currency={currency}>
  <input
    type="text"
    inputmode="decimal"
    autocomplete="off"
    value={formatted}
    aria-label={label}
    data-slot="currency-input-field"
    data-size={size}
    data-state={state}
    class={className}
    {...rest}
  />
  {name && <input type="hidden" name={name} value={value} data-slot="currency-input-value" />}
</div>

<script>
  import { onReadyOnce } from "../../_once";
  import { formatAmount, parseAmount } from "./amount";

  function wire(root: HTMLElement) {
    const field = root.querySelector<HTMLInputElement>('[data-slot="currency-input-field"]');
    if (!field) return;
    const hidden = root.querySelector<HTMLInputElement>('[data-slot="currency-input-value"]');
    const locale = root.dataset.locale || undefined;
    const currency = root.dataset.currency || null;

    const publish = (amount: number | null) => {
      const plain = amount === null ? "" : String(amount);
      root.dataset.value = plain;
      if (hidden) hidden.value = plain;
    };

    field.addEventListener("blur", () => {
      const amount = parseAmount(field.value, locale);
      // Nothing parseable: leave what they typed alone and publish nothing. Replacing it with "0.00"
      // would invent a price, and clearing it would throw away the correction they were making.
      if (amount === null) {
        publish(null);
        return;
      }
      field.value = formatAmount(amount, locale, currency);
      publish(amount);
      field.dispatchEvent(new Event("change", { bubbles: true }));
    });

    // Focusing gives back the bare number, so the first keystroke is not fighting a currency symbol
    // and a group separator that the browser would otherwise have to be persuaded to accept.
    field.addEventListener("focus", () => {
      const amount = parseAmount(field.value, locale);
      if (amount !== null) field.value = String(amount);
      field.select();
    });

    publish(parseAmount(field.value, locale));
  }

  onReadyOnce('[data-slot="currency-input"]', wire);
</script>
src/components/ui/forms/currency-input/amount.ts
// src/components/ui/forms/currency-input/amount.ts — reading and writing a money amount in the user's own
// notation. Pure, so it is checkable without a DOM (see amount.test.ts).
//
// THE PROBLEM THIS SOLVES: `Number("1.234,56")` is `NaN`, and `Number("1,234.56")` is 1. Those are
// the same amount written by a German and an American, and a field that ran `parseFloat` on either
// one has already lost — silently, as a thousand-times-too-small number. The separators are not
// guessable from the language either (Swiss German uses an apostrophe), so they are ASKED OF `Intl`
// with `formatToParts`, which is the same source that will format the value back out.

export interface Separators {
  readonly group: string;
  readonly decimal: string;
}

/** What `locale` puts between thousands and before the decimals. */
export function separatorsFor(locale?: string): Separators {
  const parts = new Intl.NumberFormat(locale).formatToParts(12345.6);
  return {
    group: parts.find((part) => part.type === "group")?.value ?? ",",
    decimal: parts.find((part) => part.type === "decimal")?.value ?? ".",
  };
}

/**
 * Read what someone typed as a number — their separators, their currency symbol, their spaces.
 *
 * @returns the amount, or `null` when there is no number in the text at all (which is what an empty
 *   field is, and what must NOT become `0` — a zero is a price, and an empty field is not)
 * @example parseAmount("1.234,56 €", "de-DE") // => 1234.56
 */
export function parseAmount(text: string, locale?: string): number | null {
  const { group, decimal } = separatorsFor(locale);
  const cleaned = text
    .split(group)
    .join("")
    // Non-breaking and narrow spaces are what `Intl` itself groups with in several locales, so they
    // arrive back in the field on the next edit and have to be accepted.
    .replace(/[\s\u00a0\u202f]/g, "")
    .replace(decimal, ".")
    .replace(/[^0-9.-]/g, "");
  if (!/\d/.test(cleaned)) return null;
  const value = Number(cleaned);
  return Number.isFinite(value) ? value : null;
}

/**
 * Write an amount the way `locale` writes money. With no `currency` it is a plain grouped number,
 * which is the right thing for a field whose symbol is drawn beside it by an InputGroup addon.
 *
 * @example formatAmount(1234.5, "en-GB", "GBP") // => "£1,234.50"
 */
export function formatAmount(value: number, locale?: string, currency?: string | null): string {
  return new Intl.NumberFormat(
    locale,
    currency
      ? { style: "currency", currency }
      : { minimumFractionDigits: 2, maximumFractionDigits: 2 },
  ).format(value);
}
src/components/ui/forms/currency-input/index.ts
import CurrencyInput from "./CurrencyInput.astro";

export { CurrencyInput };
export default CurrencyInput;

What you get

The component source, copied into your project by npx astrocraft-ui add forms/currency-input — no package dependency, no CSS to fight. Whatever the paid blocks compose, this is it.