Trang này chưa có bản tiếng Việt. Nội dung dưới đây là bản tiếng Anh.
Composites
Mask Input
A single text field that formats as the user types — money in the workspace’s currency, percentages, IDs with a fixed shape — and hands you two values every time: the formatted text the user sees, and the raw value you actually store.
- Import
import { MaskInput } from '@comitor/ui'— the main entry, with no optional peer to install.MaskInputis a client component; the pattern table and the six helpers beside it (MASK_PATTERNS,applyCurrencyMask…) are plain functions a Server Component can call.- Origin
- Added in
2.1.0, ported from Dice UI (MIT, © 2024 Sadman Sakib) onto the package’s own Input styling and tokens. Where it behaves differently from upstream is listed under What the port guarantees. - Changed in 3.0.0
currencydefaults to USD (2.1.0: VND) and the number format follows the provider’s locale (2.1.0: alwaysvi-VN). A VND workspace passescurrency="VND"from its settings; a Vietnamese app gets its250.000.000 ₫back from<ComitorLocaleProvider locale="vi">. Details in the changelog.
Anatomy
There are no parts: MaskInput is one native <input> with keystroke, paste and composition handlers attached. Everything interesting lives in the pattern — a string in which # takes one character from the user and every other character is inserted, plus two optional functions that say how raw text becomes a value and when that value is acceptable.
import { MaskInput, type MaskPattern } from '@comitor/ui'
// One element: a native <input>. No parts, no hidden input, no strings of its own.
<MaskInput
mask="currency" // a MASK_PATTERNS key, or a MaskPattern of your own
currency="VND" // ISO 4217 — a region setting, from the workspace
value={amount} // masked or unmasked: either is re-masked on render
onValueChange={(masked, unmasked) => setAmount(unmasked)}
onValidate={(isValid, unmasked) => setValid(isValid)} // only if the pattern has validate
/>
// What a pattern is:
interface MaskPattern {
// '#' takes one character from the user; anything else is inserted as typed.
pattern: string
// Raw text → unmasked value. Leave it out and only digits are kept.
transform?: (value: string, opts?: { currency?: string; locale?: string }) => string
// Runs on the unmasked value. min / max come from the props of the same name.
validate?: (value: string, opts?: { min?: number; max?: number }) => boolean
}Money in the workspace currency
onValueChange hands you both values on every keystroke and every paste: ₫250,000,000 for the screen and 250000000 for the database. Keep only the second — value accepts it and re-masks it on every render, so there is one source of truth instead of two strings to keep in step. Store it together with its currency; an amount without one is not a price. For a currency with cents, the unmasked value uses a dot and up to two decimals whatever the reader’s locale — 1234.5, never 1234,5.
masked = "₫250,000,000"
unmasked = "250000000" → store with currency "VND"
Typing 1234567 here walks ₫1,234 → ₫12,345 → ₫123,456 → ₫1,234,567: every digit is a whole đồng, because the field reads each currency’s own number of decimals — none for VND and JPY. Upstream assumed two for every currency, and guessed which separator was the decimal point; see What the port guarantees for what that did to VND, and to USD and EUR.
Percentage
mask="percentage" keeps digits and one decimal mark, allows two decimals and adds the sign. The mark is the locale’s: under vi, de, fr and the other comma-decimal locales the field reads a comma or a dot — the dot because a phone’s numeric keypad follows the device’s language, not the app’s — and shows 12,5%, while the unmasked value stays "12.5", which shows as 12,5% again when passed back as value. Its validate checks a range — 0 to 100 unless min / max say otherwise — and reports through onValidate. Reporting is all it does: the field turns red because the app passes invalid, which also writes aria-invalid. Type 12,5 under vi, then under en: English reads only a dot, as upstream, so there the comma is dropped, the value becomes 125%, and only the range check catches it.
Language — ComitorLocaleProvider
Up to 30%.
masked = "12.5%" · unmasked = "12.5"
valid = true
Reading the comma is new in 3.1.0. Until then every locale read only a dot, and apps put a “use a dot” hint beside the field — that hint can go. The unmasked value has not changed, but under vi or de the field’s text has: a test that expects 12.5% there now finds 12,5%.
A pattern of your own
Most identifiers a Comitor app handles are not in the built-in table, and several of the built-ins are American. A Vietnamese company’s tax code is ten digits, with three more after a hyphen for a dependent branch — one pattern string and a one-line validate. Two details a custom pattern does not get for free: a mobile keypad (pass inputMode="numeric") and a sensible moment to judge the value — validationMode="onBlur" waits until the user leaves the field instead of flagging every half-typed code.
10 digits, or 13 for a branch — the hyphen is added for you.
masked = "" · unmasked = ""
valid = not checked yet (onBlur)
The 15 built-in patterns
Read straight from MASK_PATTERNS — the set upstream ships, nothing added or removed. Six are United States formats, kept so the API matches upstream; in a Comitor app phone numbers go through Phone Input and dates through Date Picker.
| mask | pattern | US | Notes |
|---|---|---|---|
phone | (###) ###-#### | US | Exactly 10 digits. Phone numbers belong to PhoneInput. |
ssn | ###-##-#### | US | Social Security number. |
date | ##/##/#### | US | MM/DD/YYYY — validate reads the month first. Dates belong to DatePicker. |
time | ##:## | — | 24-hour HH:MM, validated up to 23:59. For a picker, TimePicker. |
creditCard | #### #### #### #### | — | Up to 16 digits in fours; validate runs the Luhn check. |
creditCardExpiry | ##/## | — | MM/YY; validate rejects a month already past. |
zipCode | ##### | US | ZIP code. |
zipCodeExtended | #####-#### | US | ZIP+4. |
currency | $###,###.## | — | Only a marker. Formatting goes through Intl.NumberFormat with currency and locale. |
percentage | ##.##% | — | Two decimals at most, written with the locale’s decimal mark; the unmasked value uses a dot. validate checks 0–100, or min and max. |
licensePlate | ###-### | — | Six letters or digits, upper-cased. |
ipv4 | ###.###.###.### | — | Keeps digits and dots and inserts nothing — the one pattern that adds no characters. |
macAddress | ##:##:##:##:##:## | — | Twelve hex characters in pairs, upper-cased. |
isbn | ###-#-###-#####-# | — | ISBN-13 with hyphens. |
ein | ##-####### | US | Employer Identification Number. |
In a form
Unlike the pickers from the same port, MaskInput has no hidden input: it is the input. Give it a name and the form posts exactly what the user sees — ₫48,000,000, in whatever format the reader’s locale produced. That is rarely what a server wants, so post the unmasked value beside it (or instead of it) from state. Press the button to see all three fields arrive.
Formatting on the server
Upstream is one file. The port splits it: the component stays behind 'use client', while the pattern table and the helpers live in a module without the directive. That split is not cosmetic — every export of a 'use client' file reaches a Server Component as a reference that reads as undefined, without an error or a warning. Printing an amount into a table, an email or a PDF is exactly the server-side job these helpers are for. They take currency and locale as arguments and never read the provider.
// A Server Component, an email template, a PDF job: the helpers live in a module
// WITHOUT 'use client', so they run anywhere — not just behind the client boundary.
import { applyCurrencyMask, getUnmaskedValue, MASK_PATTERNS } from '@comitor/ui'
const money = { currency: workspace.currency, locale: user.locale } // { currency: 'VND', locale: 'vi' }
applyCurrencyMask({ value: deal.amount, ...money }) // '250.000.000 ₫'
// Reading back a masked string that arrived from a plain form post:
getUnmaskedValue({ value: '250.000.000 ₫', transform: MASK_PATTERNS.currency.transform, ...money })
// '250000000'
// The helpers never read ComitorLocaleProvider. Leave currency and locale out and
// you get upstream's defaults, USD and en-US:
applyCurrencyMask({ value: '250000000' }) // '$250,000,000'What the port guarantees
- Zero-decimal currencies stay whole. Each currency’s natural number of decimals comes from
Intl— none for VND and JPY, two for USD and EUR — and in a zero-decimal currency every dot and comma is a group separator. Measured on upstream’s code, typing1234567in VND went1.234 ₫→1.234,5 ₫→1.234,56 ₫→1.234.567 ₫and reported"1234.5"on the way; now it is12.345 ₫→123.456 ₫and the unmasked value is digits only. No dangling1.234, ₫either. - The decimal separator is the locale’s. In a currency with decimals, upstream guessed the decimal point from how many digits followed a separator: deleting the last digit of
$123,456gave$123.45, and typing 12345 in USD undervistored"1234.5". Now, when both.and,appear, the last one is the decimal; when only one does, the locale’s decimal separator is the decimal and its group separator only ever groups — except a.typed at the very end in a comma-decimal locale (the numeric keypad’s key), which is read as the decimal. The unmasked value you store and pass back asvalue—"12345.56"— is read as it is. The cost is deliberate:1234,5pasted into anenfield becomes$12,345, because the comma groups in English; the result shows in the field, where it can be corrected. Fixed in3.0.1. - A percentage takes the locale’s decimal mark too. Upstream’s
percentageread only a dot, so undervi12,5became125%— ten times the discount. In a comma-decimal locale it now reads a comma or a dot and shows12,5%; the unmasked value keeps its dot. Fixed in3.1.0. - A paste reaches the app. In a currency or percentage field, upstream’s paste handler set the caret and returned before reporting: the field showed the pasted number, but
onValueChangenever ran, the next render put the old number back, and a form posted the old value. A paste now reports exactly like typing. Fixed in3.0.1. - The format is the reader’s. Upstream hard-coded
en-US; the field followsComitorLocaleProvider, or its ownlocaleprop, for separators and the position of the symbol — the same value on the server and in the browser. - The helpers work outside the client.
MASK_PATTERNSand the six functions are exported from a module without'use client', so a Server Component gets the real functions rather thanundefined. - It lines up with Input. Comitor’s Input classes replace shadcn’s —
h-9, the border roles, hover, focus ring and a destructive-ink error border — so a masked field and a plain one in the same form match.aria-invalidis written only wheninvalidis true, and one supplied by a form library still wins.
Language and region
MaskInput has no words of its own — no namespace in the provider, no labels prop, no DEFAULT_*_LABELS table, nothing under comitorMessagesVi. The label and placeholder are yours to translate. What the language changes is how a number is written: group and decimal separators and where the symbol sits, from the provider’s locale or the locale prop. What it must never change is which money it is. Switch the two independently below; the stored row only changes when the currency does.
Language — ComitorLocaleProvider
Currency — workspace setting
shown = "₫250,000,000"
stored = { amount: "250000000", currency: "VND" }
- The currency is a region setting. A Vietnamese salesperson who prefers the English interface still sells in VND; a Vietnamese-speaking team in a US workspace bills in USD. Pass
currencyfrom the workspace’s settings. 3.0.0 went back to upstream’s USD default precisely because no language can imply a currency. - The format is the locale, not just the language.
en-GBwritesUS$12,500whereenwrites$12,500. Set the locale once at the root — Set the language covers it — and override a single field withlocaleonly when you have a reason to.
| Stored | en | en-GB | vi |
|---|---|---|---|
| 250000000 VND | ₫250,000,000 | ₫250,000,000 | 250.000.000 ₫ |
| 12500 USD | $12,500 | US$12,500 | 12.500 US$ |
| 9800.75 EUR | €9,800.75 | €9,800.75 | 9.800,75 € |
| 1500000 JPY | ¥1,500,000 | JP¥1,500,000 | 1.500.000 ¥ |
Computed on this page with applyCurrencyMask — the helper the field itself uses.
Known limitations
percentagereads a comma only where the locale writes one. Underen,12,5still becomes125%, as upstream, and only the range check catches it. Under a comma-decimal locale the first mark is always the decimal, so a pasted number with a thousands separator is misread:1.234,5becomes1,23%.- Six built-in patterns are US formats —
phone,ssn,date,zipCode,zipCodeExtended,ein.datevalidates month first. Use Phone Input, Date Picker or your own pattern. - A custom pattern gets no keypad.
inputModeis chosen only for built-in names; passinputMode="numeric"yourself. nameposts the masked text. Post the unmasked value from state, or unmask on the server withgetUnmaskedValueand the same currency and locale the field used.validationMode="onSubmit"never callsonValidate. The component has no submit hook. Validate in your submit handler with the pattern’s own function —MASK_PATTERNS.percentage.validate?.(discount, { max: 30 }).- Three-decimal currencies are capped at two — KWD, BHD, OMR — because the rest of the logic only understands two.
Props
MaskInput
| Prop | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
mask | 'currency' | 'percentage' | 'creditCard' | … | MaskPattern | — | One of the 15 built-in pattern names (table above) or a MaskPattern of your own. Without a mask the component is a plain input. |
value | string | — | Controlled value. Masked or unmasked — either is unmasked and re-masked on render, so you can hold only the unmasked value. |
defaultValue | string | — | Uncontrolled initial value. |
onValueChange | (masked: string, unmasked: string) => void | — | masked is what the field shows; unmasked is what you store — digits, plus a dot and up to two decimals for a currency or a percentage. |
onValidate | (isValid: boolean, unmasked: string) => void | — | Called only when the pattern has validate, at the moments validationMode picks. It reports; it does not set invalid. |
validationMode | 'onChange' | 'onBlur' | 'onSubmit' | 'onTouched' | 'all' | 'onChange' | onTouched validates on blur until the first blur, then on every change. onSubmit never calls onValidate — the component has no submit hook. |
currency | string | 'USD' | ISO 4217 code for mask='currency'. A region setting: pass the workspace's currency. 2.1.0 defaulted to VND. |
locale | string | provider locale | BCP 47 tag for the separators and where the symbol goes — and for the decimal mark of percentage, or of a pattern that reuses its transform. Without a provider, en. 2.1.0 hard-coded vi-VN, upstream en-US. |
placeholder | string | — | Shown while the field is not focused. |
maskPlaceholder | string | — | Replaces placeholder while the field is focused — a template such as __/__ for the user to fill. |
min | number | string | — | Handed to validate as opts.min — percentage uses it (0 by default). Also forwarded to the input, where it does nothing for a text field. |
max | number | string | — | Same, as opts.max — percentage defaults to 100. |
invalid | boolean | false | Writes aria-invalid and the destructive-ink border. Nothing sets it for you. |
withoutMask | boolean | false | Switches masking off: the text passes through, and onValueChange gets it as both arguments. |
inputMode | HTMLAttributes['inputMode'] | per mask | decimal for currency, percentage and ipv4; numeric for the other digit-only built-ins; nothing for a custom pattern. |
maxLength | number | pattern length | For a # pattern the pattern's own length wins. Yours applies to currency, percentage and no mask. |
asChild | boolean | false | Render your own input element (Radix Slot) with the masking handlers attached. |
...rest | ComponentProps<'input'> | — | id, name, disabled, readOnly, required and aria-* reach the input unchanged. name posts the masked text; required is native. |
Pure helpers — safe in a Server Component
| Prop | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
MASK_PATTERNS | Record<MaskPatternKey, MaskPattern> | — | The 15 built-in patterns. Spread one to make your own variant. |
applyMask | ({ value, pattern, currency?, locale?, mask? }) => string | — | Masks an unmasked value. A pattern containing $, € or %, or mask 'currency', goes to the currency or percentage formatter; a percentage takes the locale's decimal mark only when mask is 'percentage'. |
applyCurrencyMask | ({ value, currency?, locale? }) => string | — | Intl formatting with the currency's own number of decimals (capped at two). Defaults to USD and en-US. |
applyPercentageMask | (value: string, opts?: { locale?: string }) => string | — | Up to two decimals and a % sign: '12.5' → '12.5%'. A comma-decimal locale writes a comma — ('12.5', { locale: 'vi' }) → '12,5%'. Without opts, a dot, as before 3.1.0. |
getUnmaskedValue | ({ value, transform?, currency?, locale? }) => string | — | Runs the pattern's transform; without one, keeps digits only. For currency, pass the same currency and locale the field used. |
toUnmaskedIndex | ({ masked, pattern, caret }) => number | — | A caret position in the masked text → the index in the unmasked value. |
fromUnmaskedIndex | ({ masked, pattern, unmaskedIndex }) => number | — | The reverse — for your own caret handling in a custom input. |
Accessibility
- It is a native text input, so everything a plain input gets it gets: label it with a
Label htmlForand anid, or spreadFormField’s control props onto it —id,aria-invalid,aria-requiredandaria-describedbyall reach the element. requiredis native here and blocks submission the usual way — unlike Time Picker and Phone Input, whose values travel in hidden inputs.- Mobile keypads follow the mask:
decimalfor currency, percentage and ipv4,numericfor the other digit-only built-ins, nothing for a custom pattern. A#pattern also setsmaxLength, so typing past the end is refused rather than silently trimmed. - After every keystroke the caret goes back to just after the character typed, not to the end — correcting a digit in the middle of an amount works. Backspace and Delete in a pattern step over the inserted characters.
- While an input method is composing text, masking waits and applies once, at
compositionend— it does not reformat half-composed input under the IME. - Screen readers read the masked text, symbol and separators included — “250.000.000 ₫” — which is what the user sees.
maskPlaceholdershows only while the field is focused. Neither it norplaceholderis a label or a lasting hint: put the expected format in visible text and pointaria-describedbyat it, as the demos do.- Still the app’s job: the label, the hint and the error text, turning
onValidateintoinvalid, and storing the unmasked value together with its currency. - Text is 16px below
mdand 14px frommdup, like every field since 3.13.4 — 14px at every width before. Leave the size to the package; Input explains why.
Related
- Phone Input — phone numbers, with a country and an E.164 value. Not
mask="phone". - Date Picker and Time Picker — dates and times of day. Not
mask="date"ormask="time". - Form Section —
FormField’s render prop spreads its id and aria props straight onto aMaskInput. - CRM — the deal pipeline prints stored amounts with
Intl.NumberFormat; this is the input side of the same number. - Commerce and HR — discounts at checkout, salaries in the payroll run: amounts and percentages that must be stored raw.