{ "name": "fmt", "type": "registry:ui", "title": "Fmt", "description": "One component for every formatted value — dates, numbers, currency, bytes, durations, relative times — so the locale is a single decision rather than three developers reaching for toLocaleString, toFixed and padStart on one screen.", "category": "Display", "dependencies": [], "registryDependencies": [ "lib-motion" ], "files": [ { "path": "components/ui/fmt.tsx", "type": "registry:ui", "content": "import type { ComponentProps } from 'react'\nimport { useCountUp } from '@/lib/motion'\n\n/**\n * One component for every formatted value: dates, numbers, currency, bytes,\n * durations, relative times.\n *\n * Formatting is where dashboards drift. Three developers reach for\n * `toLocaleString`, `toFixed` and a hand-rolled `padStart` on the same screen,\n * and the result is 1,234.5 beside 1234.50 beside 1 234,5. Routing all of it\n * through one component makes the locale a single decision.\n *\n * Numbers, currency and percentages go through `Intl`, which already knows\n * every locale's grouping and symbol placement. Dates support an `Intl` style\n * *and* a token pattern, because \"DD.MM.YYYY\" is a real requirement that\n * `Intl` deliberately cannot express — it formats by locale convention, not by\n * an exact layout someone specified.\n *\n * Note the token casing, which follows the moment/dayjs convention rather than\n * strftime: **MM is the month and mm is the minute**. `HH:MM:SS` would print\n * the month where you wanted minutes, so it is worth reading twice.\n */\nexport type FmtType =\n | 'date'\n | 'number'\n | 'currency'\n | 'percent'\n | 'bytes'\n | 'duration'\n | 'relative'\n\nconst PAD = (value: number, length = 2) => String(value).padStart(length, '0')\n\n/**\n * Token pattern for dates.\n *\n * Ordered longest-first and applied in a single pass. Replacing token by token\n * would corrupt the output — substituting `MMMM` with \"September\" leaves a \"D\"\n * and an \"S\" behind for a later rule to hit.\n */\nconst DATE_TOKENS = /YYYY|YY|MMMM|MMM|MM|M|DDDD|DDD|DD|D|dddd|ddd|HH|H|hh|h|mm|m|ss|s|SSS|A|a/g\n\nfunction formatDate(value: Date, pattern: string, locale: string) {\n const month = (style: 'long' | 'short') =>\n new Intl.DateTimeFormat(locale, { month: style }).format(value)\n const weekday = (style: 'long' | 'short') =>\n new Intl.DateTimeFormat(locale, { weekday: style }).format(value)\n\n const hours12 = value.getHours() % 12 || 12\n\n const map: Record = {\n YYYY: String(value.getFullYear()),\n YY: PAD(value.getFullYear() % 100),\n MMMM: month('long'),\n MMM: month('short'),\n MM: PAD(value.getMonth() + 1),\n M: String(value.getMonth() + 1),\n DDDD: weekday('long'),\n DDD: weekday('short'),\n DD: PAD(value.getDate()),\n D: String(value.getDate()),\n dddd: weekday('long'),\n ddd: weekday('short'),\n HH: PAD(value.getHours()),\n H: String(value.getHours()),\n hh: PAD(hours12),\n h: String(hours12),\n mm: PAD(value.getMinutes()),\n m: String(value.getMinutes()),\n ss: PAD(value.getSeconds()),\n s: String(value.getSeconds()),\n SSS: PAD(value.getMilliseconds(), 3),\n A: value.getHours() < 12 ? 'AM' : 'PM',\n a: value.getHours() < 12 ? 'am' : 'pm',\n }\n\n return pattern.replace(DATE_TOKENS, (token) => map[token] ?? token)\n}\n\n/** Binary-prefixed size. `1000` is a kilobyte to marketing, `1024` to a disk. */\nfunction formatBytes(bytes: number, decimals: number | undefined, locale: string) {\n const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']\n let value = Math.abs(bytes)\n let unit = 0\n while (value >= 1024 && unit < units.length - 1) {\n value /= 1024\n unit++\n }\n const places = decimals ?? (unit === 0 ? 0 : value < 10 ? 1 : 0)\n const text = new Intl.NumberFormat(locale, {\n minimumFractionDigits: places,\n maximumFractionDigits: places,\n }).format(bytes < 0 ? -value : value)\n return `${text} ${units[unit]}`\n}\n\n/** Seconds as `1h 04m 12s`, dropping leading units that are zero. */\nfunction formatDuration(totalSeconds: number) {\n const sign = totalSeconds < 0 ? '-' : ''\n const seconds = Math.floor(Math.abs(totalSeconds))\n const h = Math.floor(seconds / 3600)\n const m = Math.floor((seconds % 3600) / 60)\n const s = seconds % 60\n\n if (h) return `${sign}${h}h ${PAD(m)}m ${PAD(s)}s`\n if (m) return `${sign}${m}m ${PAD(s)}s`\n return `${sign}${s}s`\n}\n\nconst RELATIVE_STEPS: [Intl.RelativeTimeFormatUnit, number][] = [\n ['second', 60],\n ['minute', 60],\n ['hour', 24],\n ['day', 7],\n ['week', 4.34524],\n ['month', 12],\n ['year', Infinity],\n]\n\n/** Largest unit that keeps the number under its own threshold. */\nfunction formatRelative(value: Date, locale: string, now: Date) {\n let delta = (value.getTime() - now.getTime()) / 1000\n const formatter = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' })\n\n for (const [unit, size] of RELATIVE_STEPS) {\n if (Math.abs(delta) < size) {\n return formatter.format(Math.round(delta), unit)\n }\n delta /= size\n }\n return formatter.format(Math.round(delta), 'year')\n}\n\ntype FmtProps = Omit, 'children'> & {\n type: FmtType\n value: number | string | Date\n /**\n * Date: a token pattern (`'DD.MM.YYYY HH:mm'`) or an `Intl` style\n * (`'short' | 'medium' | 'long' | 'full'`).\n * Currency: `'symbol' | 'code' | 'name'`.\n */\n format?: string\n /** ISO 4217 code. Required for `type=\"currency\"`. */\n currency?: string\n /** Fraction digits. Defaults per type. */\n decimals?: number\n locale?: string\n /** Thousands separators. Numbers and currency only. */\n grouping?: boolean\n /** Reference point for `type=\"relative\"`. Defaults to now. */\n now?: Date\n /** Line up in a column. On by default for every numeric type. */\n tabular?: boolean\n /**\n * Count to the value instead of printing it.\n *\n * Off by default, and meant to stay that way for most call sites: a table of\n * forty figures that all count at once is noise, and the reader is scanning\n * the column rather than watching any one cell. Turn it on where the number\n * *is* the content — the headline on a card, the total on a summary, a figure\n * that changes while you watch it.\n *\n * Ignored for `date` and `relative`, which have nothing to count through, and\n * for a value that is not a finite number.\n */\n animate?: boolean\n}\n\nconst DATE_STYLES = new Set(['short', 'medium', 'long', 'full'])\n\nfunction Fmt({\n type,\n value,\n format,\n currency,\n decimals,\n locale = 'en-GB',\n grouping = true,\n now,\n tabular,\n animate = false,\n className,\n ...props\n}: FmtProps) {\n // Every type except a formatted date is a number in a column.\n const numeric = type !== 'date'\n\n // The tween runs on the raw value and the formatter runs on the tween's\n // output, so grouping, currency symbols and unit suffixes are re-derived\n // every frame. Formatting the final value and interpolating the string\n // instead would give you \"1,2Y4\" somewhere in the middle.\n //\n // Percentages ask for more precision than they print: `0.42` written with no\n // decimals still has to pass through 0.13 and 0.28 on the way, and rounding\n // the tween to the *displayed* two places would make it climb in visible\n // steps of one per cent.\n const raw = typeof value === 'number' ? value : Number(value)\n const counting = animate && numeric && type !== 'relative' && Number.isFinite(raw)\n const counted = useCountUp(raw, {\n disabled: !counting,\n ...(type === 'percent' && { decimals: 6 }),\n })\n const shown = counting ? counted : value\n const date =\n value instanceof Date ? value : type === 'date' || type === 'relative' ? new Date(value) : undefined\n\n let text: string\n let dateTime: string | undefined\n\n switch (type) {\n case 'date': {\n if (!date || Number.isNaN(date.getTime())) {\n text = String(value)\n break\n }\n dateTime = date.toISOString()\n text =\n format && !DATE_STYLES.has(format)\n ? formatDate(date, format, locale)\n : new Intl.DateTimeFormat(locale, {\n dateStyle: (format as Intl.DateTimeFormatOptions['dateStyle']) ?? 'medium',\n }).format(date)\n break\n }\n\n case 'relative': {\n if (!date || Number.isNaN(date.getTime())) {\n text = String(value)\n break\n }\n dateTime = date.toISOString()\n text = formatRelative(date, locale, now ?? new Date())\n break\n }\n\n case 'currency': {\n text = new Intl.NumberFormat(locale, {\n style: 'currency',\n currency: currency ?? 'USD',\n currencyDisplay: (format as Intl.NumberFormatOptions['currencyDisplay']) ?? 'symbol',\n useGrouping: grouping,\n ...(decimals !== undefined && {\n minimumFractionDigits: decimals,\n maximumFractionDigits: decimals,\n }),\n }).format(Number(shown))\n break\n }\n\n case 'percent': {\n text = new Intl.NumberFormat(locale, {\n style: 'percent',\n useGrouping: grouping,\n minimumFractionDigits: decimals ?? 0,\n maximumFractionDigits: decimals ?? 0,\n }).format(Number(shown))\n break\n }\n\n case 'bytes':\n text = formatBytes(Number(shown), decimals, locale)\n break\n\n case 'duration':\n text = formatDuration(Number(shown))\n break\n\n default: {\n text = new Intl.NumberFormat(locale, {\n useGrouping: grouping,\n ...(decimals !== undefined && {\n minimumFractionDigits: decimals,\n maximumFractionDigits: decimals,\n }),\n }).format(Number(shown))\n }\n }\n\n const classes = [(tabular ?? numeric) && 'tabular-nums', className]\n .filter(Boolean)\n .join(' ')\n\n // A real \n )\n }\n\n return (\n \n {text}\n \n )\n}\n\nexport { Fmt, Fmt as FMT, formatBytes, formatDate, formatDuration, formatRelative }\nexport type { FmtProps }\n" } ] }