Composites
Text Utilities
Five pure functions that make Vietnamese text behave: strip diacritics, normalise a comparison key, match a search box, build avatar initials, and cut a string to length. They are not components — no React, no state, no markup — which is why the same five names back both the composites and the shell.
Importing
The module ships from the main entry alongside the components. Because nothing here touches React, the functions cross every boundary the package has: server render, client bundle, a Node script that emails a PDF. The package leans on that itself — the shell’s utils.ts re-exports two of these bindings rather than re-declaring them, after shipping a version where getVietnameseInitials had two bodies (toUpperCase() in one, toLocaleUpperCase('vi-VN') in the other) and gave two answers for one name depending on which import you reached for.
import {
getVietnameseInitials,
matchesSearch,
normalizeVi,
removeVietnameseTones,
truncate,
} from '@comitor/ui'
// No 'use client' needed. These are plain functions with no React import,
// so they run in a Server Component, a route handler, a script or a test.Playground
Every output below is a real call into @comitor/ui on whatever you type. Results are wrapped in quotes so leading and trailing spaces stay visible — worth watching when you compare removeVietnameseTones against normalizeVi.
removeVietnameseTones(text)normalizeVi(text)getVietnameseInitials(text)truncate(text, 12)matchesSearch(text, query)removeVietnameseTones
Decomposes the string with normalize('NFD') — which splits ế into a base letter plus combining marks — then deletes the combining range U+0300–U+036F. Vietnamese horn and breve live in that range, so ư, ơ and ă come out as plain letters for free. The one letter that does not is đ: U+0111 is its own Unicode character with no decomposition, so the function replaces đ and Đ by hand. Drop that line and typing duc silently stops finding Đức — which is exactly how this bug reaches production.
'Nguyễn Đức Thành''Nguyen Duc Thanh''Đà Nẵng''Da Nang''Lưu Thuỳ Dương''Luu Thuy Duong''ĐƠN HÀNG #A-102''DON HANG #A-102'Note what it does not do: no lowercasing, no trimming. 'ĐƠN HÀNG' stays shouting. This is the lower of the two levels — reach for it when case carries meaning, such as slug generation, and reach for normalizeVi for anything you intend to compare.
normalizeVi
Diacritics off, lowercase, trim — in that order. This is the package’s only normalisation, and that is a deliberate constraint rather than a convenience: when each feature normalises its own way, one keyword returns two different result sets depending on which box the user typed into, and no test catches it because each function is correct on its own. The shell’s workspace search calls this exact binding.
' NGUYỄN Đức Thành ''nguyen duc thanh''nguyễn đức thành''nguyen duc thanh''Nguyen Duc Thanh''nguyen duc thanh'Use the output as a key — a map key, a Set member, the left side of a comparison. Never render it. See Accessibility below for why a de-toned Vietnamese name is not the same text to a screen reader.
matchesSearch
A substring test with both sides pushed through normalizeVi, so a user who types on an English keyboard still finds the Vietnamese record. Two behaviours worth knowing before you use it: an empty needle returns true, which keeps the “no filter yet” branch out of your call site; and it is a plain includes(), not fuzzy matching and not word-boundary aware — an matches Thành.
- Nguyễn Đức ThànhNguyễn Đức ThànhOperations
- Trần Thị HoàTrần Thị HoàFinance
- Lê Hoàng MinhLê Hoàng MinhEngineering
- Phạm Thuỳ DươngPhạm Thuỳ DươngDesign
- Đỗ Quang HuyĐỗ Quang HuySupport
- Ada LovelaceAda LovelaceEngineering
6 of 6 people match
Combobox, MultiCombobox and CommandPalette do accent-insensitive search too, but they call removeVietnameseTones directly rather than this function: cmdk’s filter contract wants a numeric score (1 or 0), not a boolean, and it also searches the item’s keywords alongside its label. So you do not need matchesSearch for those — it is for filtering your own arrays with the same rule they use.
getVietnameseInitials
First letter of the first word plus first letter of the last word — not the first two, which is the Western habit. Vietnamese names run family name first and people address each other by the given name, the final word, so Nguyễn Đức Thành is NT, never NĐ. Uppercasing goes through toLocaleUpperCase('vi-VN'), and the diacritics stay: these two letters are display text, not a lookup key.
"Nguyễn Đức Thành"
→ "NT"
"Trần Thị Hoà"
→ "TH"
"Đà Nẵng"
→ "ĐN"
"Mai"
→ "M"
"Ada Lovelace"
→ "AL"
" "
→ "?"
Edge cases are answered rather than thrown: one word yields one letter, and a blank or whitespace-only string yields '?' so an avatar never renders as an empty coloured square. Note that the avatar’s colour does not come from here — LetterAvatar hashes the full name through getAvatarToneClasses, which is why two people sharing initials do not automatically share a colour.
truncate
Returns the input untouched when it already fits; otherwise cuts to maxLength − 1, trims the trailing space and appends a real ellipsis character. So maxLength is a ceiling on the result, ellipsis included — and the result is often one shorter, because the trim ate a space rather than leaving 'Nguyễn Đức …'. Change maxLength below and watch the character count.
truncate('Nguyễn Đức Thành', 12)truncate('Kế hoạch triển khai quý IV', 12)truncate('Đà Nẵng', 12)- Not the Tailwind
truncateclass. They share a name and do opposite things. The CSS utility clips visually while the DOM keeps the whole string; this function destroys the tail. Inside a layout box the CSS one is almost always the right answer. - It counts UTF-16 code units, not glyphs. Fine for text typed into a form, which arrives composed. Text from an external source can arrive decomposed (macOS filenames are NFD, so ế is three units — a bare e plus two combining marks) or carry emoji, and a cut can land inside a surrogate pair. Normalise to NFC first when the source is not your own input.
- Character budgets are not width budgets. Thirty capitals are far wider than thirty lowercase letters. Use it for hard limits that are genuinely counted in characters — a document title, an SMS body, a column in a CSV export — not to make text fit a box.
Signatures
| Function | Signature | What it returns |
|---|---|---|
removeVietnameseTones | (input: string) => string | Strips Vietnamese diacritics via NFD decomposition plus a manual đ/Đ → d/D pass. Case and spacing survive untouched. |
normalizeVi | (value: string) => string | removeVietnameseTones + toLowerCase + trim. The single comparison key of the whole package — the shell re-exports this exact binding. |
matchesSearch | (haystack: string, needle: string) => boolean | Substring test with both sides run through normalizeVi. An empty or whitespace-only needle returns true. |
getVietnameseInitials | (name: string) => string | First letter of the first word + first letter of the LAST word, uppercased with the vi-VN locale. One word → one letter; empty → "?". |
truncate | (input: string, maxLength: number) => string | Returns input unchanged when it already fits, otherwise cuts to maxLength − 1 UTF-16 units, trims trailing space and appends "…". |
Every function is total: no throws, no null returns, no options bag. They are also referentially transparent, so they are safe to call inline during render — the demos on this page do exactly that, with no useMemo in sight.
Where the package calls these itself
Worth knowing, because it tells you when you are duplicating work a component already does:
LetterAvatarand the shell’sUserAvatar/WorkspaceSwitcherbuild their letters withgetVietnameseInitials. Pass a name, not initials.Combobox,MultiComboboxandCommandPalettefilter withremoveVietnameseTonesinside a cmdk scoring function. Accent-insensitive matching is already on; you do not pre-filter their items.- The shell’s workspace search normalises both sides with
normalizeViand matches against name and slug — the same rule your own list filter gets frommatchesSearch. truncatehas no internal caller. Components clip with CSS, because they have a box to clip against; this function exists for the places that do not.
Removed in 0.2.0: hashString and pickByString
Version 0.1.0 exported six names from this module. Two of them — hashString (an FNV-1a hash) and pickByString (pick an array element by that hash) — were deleted from the public surface, on purpose and with no public replacement. They were never really utilities: they existed so the package could pick an avatar tone, and exporting them invited every app to rebuild that same choice with its own table. That is precisely what makes one person show up teal in the web app and green in the emailed report, with nothing to catch it. The 0.1.0 README already carried a warning telling you not to use pickByString — an API whose documentation has to say “do not use this” is not an API. The five names documented here are those four survivors plus normalizeVi, which the same release folded in from the shell’s duplicate copy of it. Removing the two also meant this file stopped being re-exported with export *: the barrel now names its five exports, so an internal helper cannot become public by accident again.
// ✗ 0.1.0 — removed in 0.2.0, and there is no public replacement.
import { hashString, pickByString } from '@comitor/ui'
const tone = pickByString(myOwnPalette, user.name, myOwnPalette[0])
// ✓ Tailwind is available (DOM) — token class string for the tone.
import { LetterAvatar, getAvatarToneClasses } from '@comitor/ui'
const classes = getAvatarToneClasses(user.name)
// ✓ No Tailwind (canvas, email HTML, PDF) — the same tone as opaque hex.
import { getAvatarToneColors } from '@comitor/ui/tokens'
const { background, foreground } = getAvatarToneColors(user.name, 'dark')
// ✓ A colour that is not derived from the name at all — pass it in.
<LetterAvatar name={app.name} toneClassName="bg-teal/15 text-teal-ink" square />Both replacements read the same tone table and hash the same key (the display name, trimmed and lowercased with the vi-VN locale), which is the whole point: one person, one colour, whether the pixels are drawn by Tailwind or by a canvas. See Letter Avatar for the full tone story.
Accessibility
These functions set no roles, wire no ARIA and handle no keys — there is no markup here to get right. What they decide is which text ends up in the accessibility tree, and that is where they can do real damage.
- Never render a de-toned string. Diacritics are spelling in Vietnamese, not decoration: ma, má, mà and mã are four different words, and a Vietnamese speech synthesiser reads “Nguyen Duc Thanh” as a different set of sounds from “Nguyễn Đức Thành”. Keep the original for display, for
titleand foraria-label; let the normalised form live only on the left of a comparison. - Initials are decoration, and the package treats them that way.
LetterAvatarrendersgetVietnameseInitials(name)insidearia-hidden="true"and puts the full name in a siblingsr-onlyspan — in both branches, image and letters, so a photo avatar is not a silent square either. “NT” spoken aloud is noise. If you call the function yourself, do the same: those two letters can never be the only place the name appears. - Truncation removes information; keep a way back to it. The
…is a real character in the DOM — some screen readers announce it, others skip it, and either way the tail is gone for everyone. Whenever you truncate, keep the full string reachable viatitleoraria-label, and never truncate an accessible name: a button called “Delete inv…” is not a name anyone can act on, and it breaks speech-input users who say what they see (WCAG 2.5.3). - Prefer CSS clipping where there is a box.
className="truncate"hides the overflow visually while the full text stays in the DOM, so the accessible name, browser find-in-page, text selection and translation tools all still see it. It also reflows correctly when the reader raises the font size — a fixed character count does not. - Filtering is a silent change.
matchesSearchrewrites the page below a search box and announces nothing. Pair it with a polite live region carrying the result count, as the demo above does; the count is also the only feedback a sighted user gets that “3 of 6” is a filtered view rather than the whole list. - The empty-needle rule is a usability decision, not a shortcut. Because
matchesSearch(x, '')istrue, clearing the box restores the full list without any call site remembering to handle it — so the “I cleared the search and everything vanished” failure cannot happen in the first place. - Accent-insensitive matching is itself an accessibility feature. Not everyone has a Vietnamese keyboard layout, and voice input and predictive keyboards drop tone marks routinely. Matching on the normalised key means those users reach the same records as everyone else instead of hitting an empty result set.