Presentation
The one input of every human-readable formatter. A language tag, or a context composed from language, notation profile and time zone.
Every human-readable function takes a presentation. There are two forms:
A bare language tag (
Locale), such asCode 'en','nl'or'pt-. Notation follows the language: its own date order, separators and clock.BR' A composed context (
Presentation), fromContext compose. Use it when a reader has preferences that differ from their language, or when instants must be shown in a specific zone.Presentation Context(language, profile, time Zone)
compose is the only place language, profile and zone meet. Compose once,
where you know the reader, and pass the result down unchanged.
Change the presentation, see every formatter follow#
| formatDateOnly | |
| formatDateTime | |
| formatNumber | |
| formatCurrency | |
| formatFractionAsPercent | |
| formatByteSize | |
| formatList |
// apps/docs/examples/format/presentation-switcher.tsx'use client';
import {useId, useState} from 'react';
import { composePresentationContext, formatByteSize, formatCurrency, formatDateOnly, formatDateTime, formatFractionAsPercent, formatList, formatNumber, toIanaTimeZone, type DateNotationPreset, type NumberNotationPreset, type PresentationProfileConfig, type TimeNotationPreset,} from '@workspace/active-format';import { Field, FieldLabel,} from '@workspace/active-ui/components/primitives/field';import { NativeSelect, NativeSelectOption,} from '@workspace/active-ui/components/primitives/native-select';import { Table, TableBody, TableCell, TableRow,} from '@workspace/active-ui/components/primitives/table';
// Fixed values, so every reader sees the same input.const CALENDAR_DAY = '2026-03-09';const INSTANT = '2026-03-09T20:18:05Z';const LANGUAGES = ['en', 'nl', 'de', 'fr', 'es', 'ja', 'ar-EG'];const CURRENCIES = ['EUR', 'USD', 'GBP', 'JPY', 'KWD'];const TIME_ZONES = [ 'Europe/Amsterdam', 'America/New_York', 'Asia/Tokyo', 'UTC',];
type Choice<T> = T | 'language';
// What each notation choice shows. The value stays the preset name the// profile takes; the label says what it does, short enough to read in full// in a field on a narrow screen.const CHOICE_LABELS: Record<string, string> = { language: 'From the language', DMY_NUMERIC: 'Day, month, year', MDY_NUMERIC: 'Month, day, year', ISO_NUMERIC: 'Year, month, day (ISO)', H24: '24-hour clock', H12: '12-hour clock', DOT_DECIMAL_COMMA_GROUP: 'Decimal point, comma groups', COMMA_DECIMAL_DOT_GROUP: 'Decimal comma, point groups', COMMA_DECIMAL_SPACE_GROUP: 'Decimal comma, space groups',};
/** One presentation, composed from language, profile and zone, feeding every formatter. */export default function PresentationSwitcher() { const [language, setLanguage] = useState('en'); const [currency, setCurrency] = useState('EUR'); const [timeZone, setTimeZone] = useState('Europe/Amsterdam'); const [dateFormat, setDateFormat] = useState<Choice<DateNotationPreset>>('language'); const [timeFormat, setTimeFormat] = useState<Choice<TimeNotationPreset>>('language'); const [numberFormat, setNumberFormat] = useState<Choice<NumberNotationPreset>>('language');
// A profile field that is absent means "not configured": the language's own // convention applies. const profile: PresentationProfileConfig = { ...(dateFormat === 'language' ? {} : {dateFormat}), ...(timeFormat === 'language' ? {} : {timeFormat}), ...(numberFormat === 'language' ? {} : {numberFormat}), }; const presentation = composePresentationContext( language, profile, toIanaTimeZone(timeZone), );
const rows: [string, string][] = [ ['formatDateOnly', formatDateOnly(CALENDAR_DAY, 'long', presentation)], [ 'formatDateTime', formatDateTime(INSTANT, 'numeric-datetime', presentation), ], ['formatNumber', formatNumber(1234567.891, 'decimal-2', presentation)], [ 'formatCurrency', formatCurrency({amount: 1234.5, currency}, presentation), ], [ 'formatFractionAsPercent', formatFractionAsPercent(0.125, 'decimal-1', presentation), ], [ 'formatByteSize', formatByteSize(1_536_000, presentation, {system: 'decimal'}), ], ['formatList', formatList(['Amsterdam', 'Utrecht', 'Delft'], presentation)], ];
// The stage centres its content, which sizes a plain root to its widest // child: `w-full min-w-0` keeps the demo to the width there is, so the // fields wrap and only the result table scrolls, in its own named region. return ( <div className="flex w-full min-w-0 flex-col gap-4"> {/* Columns follow the width the demo has, not the screen: beside the sidebar a three-column row at `sm` left each field too narrow for its longest choice. */} <div className="grid grid-cols-[repeat(auto-fill,minmax(min(100%,15rem),1fr))] gap-3"> <Select label="Language" value={language} onChange={setLanguage} options={LANGUAGES} /> <Select label="Currency" value={currency} onChange={setCurrency} options={CURRENCIES} /> <Select label="Time zone" value={timeZone} onChange={setTimeZone} options={TIME_ZONES} /> <Select label="Date notation" value={dateFormat} onChange={(value) => setDateFormat(value as Choice<DateNotationPreset>) } options={['language', 'DMY_NUMERIC', 'MDY_NUMERIC', 'ISO_NUMERIC']} /> <Select label="Clock" value={timeFormat} onChange={(value) => setTimeFormat(value as Choice<TimeNotationPreset>) } options={['language', 'H24', 'H12']} /> <Select label="Number notation" value={numberFormat} onChange={(value) => setNumberFormat(value as Choice<NumberNotationPreset>) } options={[ 'language', 'DOT_DECIMAL_COMMA_GROUP', 'COMMA_DECIMAL_DOT_GROUP', 'COMMA_DECIMAL_SPACE_GROUP', ]} /> </div> <Table scrollLabel="Results, scrolls sideways"> <TableBody> {rows.map(([name, text]) => ( <TableRow key={name}> <TableCell className="text-muted-foreground font-mono text-xs"> {name} </TableCell> <TableCell> <output suppressHydrationWarning>{text}</output> </TableCell> </TableRow> ))} </TableBody> </Table> </div> );}
function Select({ label, value, onChange, options,}: { label: string; value: string; onChange: (value: string) => void; options: readonly string[];}) { const id = useId(); return ( <Field className="min-w-0"> <FieldLabel htmlFor={id}>{label}</FieldLabel> <NativeSelect id={id} className="w-full" value={value} onChange={(event) => onChange(event.target.value)} > {options.map((option) => ( <NativeSelectOption key={option} value={option}> {CHOICE_LABELS[option] ?? option} </NativeSelectOption> ))} </NativeSelect> </Field> );}The profile#
A profile (Presentation) is a set of optional notation choices. An absent field
means "not configured": the language's own convention applies, never a hidden default.
Fields
- Type
"LONG_TEXTUAL" "NUMERIC""SHORT_TEXTUAL"
The normal human date shape: the shape an ordinary human date consumer inherits. Subordinate to it, and not the other way around, is date.
- Type
"DMY_NUMERIC" "ISO_NUMERIC" "MDY_NUMERIC"
Structural date notation within the numeric shape.
- Type
"H12""H24"
Clock notation, independent of the date order.
- Type
"COMMA_DECIMAL_ DOT_ GROUP" "COMMA_DECIMAL_ SPACE_ GROUP" "DOT_DECIMAL_ COMMA_ GROUP"
Number notation family.
- Type
"CODE""SYMBOL"
Currency display style.
The values come from the package's vocabulary, exported as runtime lists so a settings screen can offer them:
| Field | Values | List |
|---|---|---|
date | NUMERIC, SHORT_, LONG_ | DATE_ |
date | DMY_, MDY_, ISO_ | DATE_ |
time | H24, H12 | TIME_ |
number | DOT_, COMMA_, COMMA_ | NUMBER_ |
currency | SYMBOL, CODE | CURRENCY_ |
Which value applies to which reader is your policy: the package only names the choices.
Passing it through your own components#
A component or package that formats values should not choose a language itself. Give it a
presentation (or locale) prop of type Value and pass the value on to every
formatter it calls. Replace it as a whole; never merge single fields into it.
import {formatCurrency, type ValuePresentation} from '@workspace/active-format';
export function Price({ amount, presentation,}: { amount: number; presentation: ValuePresentation;}) { return <span>{formatCurrency({amount, currency: 'EUR'}, presentation)}</span>;}Words versus notation#
Words (month names, "and", unit names) follow the reader's language. Numbers inside them follow
the presentation's notation: a number choice rewrites separators and digits, never the
words.