// Copyright (c) Meta Platforms, Inc. and affiliates. /** * @file characters.ts * @input Strings that may contain characters made of several code units * (emoji, flags, joined emoji sequences, accented letters) * @output Exports characterCount, firstCharacter, truncateCharacters — * replacements for .length / .charAt(0) / .slice(0, n) that measure * and cut user-visible strings the way a person reads them * @position Shared utility; consumed by Avatar, TextArea, PowerSearch, Table * * SYNC: When modified, update: * - /packages/core/src/utils/characters.test.ts * - /packages/core/src/utils/index.ts */ /** * Reuse a single segmenter when the runtime supports Intl.Segmenter. * `grapheme` is the Intl granularity name for a user-perceived character. */ const characterSegmenter = typeof Intl.Segmenter === 'function' ? new Intl.Segmenter(undefined, { granularity: 'grapheme' }) : null; /** * Split a string into user-perceived characters. The code-point fallback * keeps surrogate pairs intact but may split joined emoji sequences and flag * pairs on runtimes without Intl.Segmenter. */ function splitCharacters(str: string): string[] { if (characterSegmenter) { return [...characterSegmenter.segment(str)].map(s => s.segment); } return [...str]; } /** * Number of characters in a string — the count a person would give, where one * emoji, flag, or accented letter is one character. Replacement for `.length` * on user-visible strings, which counts internal storage units instead. */ export function characterCount(str: string): number { if (str === '') { return 0; } return splitCharacters(str).length; } /** * The first character of a string, or '' when empty. Replacement for * `.charAt(0)`, which can return half of an emoji. Reads only the first * character, so cost does not scale with the length of the string. */ export function firstCharacter(str: string): string { if (characterSegmenter) { const first = characterSegmenter.segment(str)[Symbol.iterator]().next(); return first.done ? '' : first.value.segment; } const codePoint = str.codePointAt(0); return codePoint == null ? '' : String.fromCodePoint(codePoint); } /** * Truncate to at most `max` characters, ellipsis included: strings within * `max` characters pass through unchanged; longer strings are cut so the * result — content plus `ellipsis` — is exactly `max` characters (or just the * ellipsis, when `max` is smaller than the ellipsis itself). Replacement for * `str.slice(0, n) + '…'`, which can cut an emoji in half. * * @example * ``` * truncateCharacters('abcdefghij', 5) // 'abcd…' * truncateCharacters('abcdefghij', 8, '...') // 'abcde...' * ``` */ export function truncateCharacters(str: string, max: number, ellipsis = '…'): string { const characters = splitCharacters(str); if (characters.length <= max) { return str; } const keep = Math.max(max - characterCount(ellipsis), 0); return characters.slice(0, keep).join('') + ellipsis; }