@astryxdesign/core 0.4.2 → 0.4.3-canary.0a9618e
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +45 -0
- package/README.md +79 -45
- package/dist/Avatar/Avatar.d.ts.map +1 -1
- package/dist/Avatar/Avatar.js +3 -19
- package/dist/Banner/Banner.d.ts +9 -2
- package/dist/Banner/Banner.d.ts.map +1 -1
- package/dist/Banner/Banner.js +84 -33
- package/dist/Banner/index.d.ts +14 -12
- package/dist/Banner/index.d.ts.map +1 -1
- package/dist/Banner/index.js +10 -8
- package/dist/BottomSheet/BottomSheet.d.ts +66 -0
- package/dist/BottomSheet/BottomSheet.d.ts.map +1 -0
- package/dist/BottomSheet/BottomSheet.js +381 -0
- package/dist/BottomSheet/BottomSheetPanel.d.ts +57 -0
- package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -0
- package/dist/BottomSheet/BottomSheetPanel.js +395 -0
- package/dist/BottomSheet/BottomSheetSwitcher.d.ts +61 -0
- package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -0
- package/dist/BottomSheet/BottomSheetSwitcher.js +410 -0
- package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts +21 -0
- package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts.map +1 -0
- package/dist/BottomSheet/BottomSheetSwitcherContext.js +13 -0
- package/dist/BottomSheet/index.d.ts +11 -0
- package/dist/BottomSheet/index.d.ts.map +1 -0
- package/dist/BottomSheet/index.js +12 -0
- package/dist/BottomSheet/snapOffsets.d.ts +72 -0
- package/dist/BottomSheet/snapOffsets.d.ts.map +1 -0
- package/dist/BottomSheet/snapOffsets.js +130 -0
- package/dist/BottomSheet/useMobileKeyboard.d.ts +25 -0
- package/dist/BottomSheet/useMobileKeyboard.d.ts.map +1 -0
- package/dist/BottomSheet/useMobileKeyboard.js +384 -0
- package/dist/BottomSheet/useSheetGestures.d.ts +170 -0
- package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -0
- package/dist/BottomSheet/useSheetGestures.js +881 -0
- package/dist/Chat/ChatComposerInput.d.ts.map +1 -1
- package/dist/Chat/ChatComposerInput.js +3 -4
- package/dist/Chat/ChatTokenizedText.js +1 -1
- package/dist/ComplexSelector/ComplexSelector.d.ts +38 -4
- package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
- package/dist/ComplexSelector/ComplexSelector.js +98 -31
- package/dist/ComplexSelector/index.d.ts +2 -2
- package/dist/ComplexSelector/index.d.ts.map +1 -1
- package/dist/ComplexSelector/index.js +1 -1
- package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
- package/dist/ContextMenu/ContextMenu.js +4 -2
- package/dist/DateInput/DateInput.d.ts.map +1 -1
- package/dist/DateInput/DateInput.js +9 -1
- package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
- package/dist/DateTimeInput/DateTimeInput.js +31 -1
- package/dist/Dialog/Dialog.d.ts.map +1 -1
- package/dist/Dialog/Dialog.js +2 -1
- package/dist/Markdown/parser.d.ts.map +1 -1
- package/dist/Markdown/parser.js +55 -12
- package/dist/MobileNav/MobileNav.d.ts +12 -0
- package/dist/MobileNav/MobileNav.d.ts.map +1 -1
- package/dist/MobileNav/MobileNav.js +102 -30
- package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
- package/dist/MultiSelector/MultiSelector.js +10 -1
- package/dist/NumberInput/NumberInput.d.ts.map +1 -1
- package/dist/NumberInput/NumberInput.js +9 -1
- package/dist/PowerSearch/PowerSearch.d.ts.map +1 -1
- package/dist/PowerSearch/PowerSearch.js +4 -1
- package/dist/PowerSearch/PowerSearchEditPopover.js +1 -1
- package/dist/PowerSearch/formatFilterValue.d.ts.map +1 -1
- package/dist/PowerSearch/formatFilterValue.js +2 -4
- package/dist/Selector/Selector.d.ts.map +1 -1
- package/dist/Selector/Selector.js +10 -1
- package/dist/Switch/Switch.d.ts.map +1 -1
- package/dist/Switch/Switch.js +5 -1
- package/dist/Table/columnUtils.d.ts.map +1 -1
- package/dist/Table/columnUtils.js +4 -1
- package/dist/TextArea/TextArea.d.ts +6 -3
- package/dist/TextArea/TextArea.d.ts.map +1 -1
- package/dist/TextArea/TextArea.js +17 -6
- package/dist/TimeInput/TimeInput.d.ts.map +1 -1
- package/dist/TimeInput/TimeInput.js +8 -1
- package/dist/Tooltip/useTooltip.d.ts.map +1 -1
- package/dist/Tooltip/useTooltip.js +4 -1
- package/dist/TreeList/TreeList.js +2 -1
- package/dist/Typeahead/BaseTypeahead.js +1 -1
- package/dist/Typeahead/Typeahead.d.ts.map +1 -1
- package/dist/Typeahead/Typeahead.js +10 -1
- package/dist/astryx.css +25 -2
- package/dist/hooks/index.d.ts +8 -1
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +8 -1
- package/dist/hooks/useFocusTrap.d.ts +0 -11
- package/dist/hooks/useFocusTrap.d.ts.map +1 -1
- package/dist/hooks/useFocusTrap.js +1 -11
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/theme/defineTheme.d.ts +30 -7
- package/dist/theme/defineTheme.d.ts.map +1 -1
- package/dist/theme/defineTheme.js +36 -47
- package/dist/theme/expandColorScale.d.ts +17 -2
- package/dist/theme/expandColorScale.d.ts.map +1 -1
- package/dist/theme/expandColorScale.js +63 -33
- package/dist/theme/mergeComponents.d.ts +20 -0
- package/dist/theme/mergeComponents.d.ts.map +1 -0
- package/dist/theme/mergeComponents.js +56 -0
- package/dist/theme/onMediaTokens.d.ts +6 -1
- package/dist/theme/onMediaTokens.d.ts.map +1 -1
- package/dist/theme/onMediaTokens.js +11 -3
- package/dist/utils/characters.d.ts +27 -0
- package/dist/utils/characters.d.ts.map +1 -0
- package/dist/utils/characters.js +83 -0
- package/dist/utils/ime.d.ts +5 -0
- package/dist/utils/ime.d.ts.map +1 -0
- package/dist/utils/ime.js +63 -0
- package/dist/utils/index.d.ts +2 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +3 -1
- package/package.json +9 -8
- package/src/Avatar/Avatar.test.tsx +6 -1
- package/src/Avatar/Avatar.tsx +3 -21
- package/src/Banner/Banner.doc.mjs +12 -10
- package/src/Banner/Banner.test.tsx +108 -0
- package/src/Banner/Banner.tsx +124 -38
- package/src/Banner/index.ts +15 -13
- package/src/BottomSheet/BottomSheet.doc.mjs +266 -0
- package/src/BottomSheet/BottomSheet.test.tsx +1865 -0
- package/src/BottomSheet/BottomSheet.tsx +539 -0
- package/src/BottomSheet/BottomSheetPanel.test.tsx +240 -0
- package/src/BottomSheet/BottomSheetPanel.tsx +542 -0
- package/src/BottomSheet/BottomSheetSwitcher.doc.mjs +219 -0
- package/src/BottomSheet/BottomSheetSwitcher.test.tsx +850 -0
- package/src/BottomSheet/BottomSheetSwitcher.tsx +628 -0
- package/src/BottomSheet/BottomSheetSwitcherContext.ts +51 -0
- package/src/BottomSheet/index.ts +15 -0
- package/src/BottomSheet/snapOffsets.test.ts +187 -0
- package/src/BottomSheet/snapOffsets.ts +159 -0
- package/src/BottomSheet/useMobileKeyboard.ts +566 -0
- package/src/BottomSheet/useSheetGestures.test.ts +833 -0
- package/src/BottomSheet/useSheetGestures.ts +1333 -0
- package/src/Chat/ChatComposerInput.tsx +3 -4
- package/src/Chat/ChatTokenizedText.tsx +1 -1
- package/src/ComplexSelector/ComplexSelector.doc.mjs +55 -6
- package/src/ComplexSelector/ComplexSelector.test.tsx +197 -6
- package/src/ComplexSelector/ComplexSelector.tsx +156 -28
- package/src/ComplexSelector/index.ts +3 -1
- package/src/ContextMenu/ContextMenu.tsx +4 -2
- package/src/DateInput/DateInput.test.tsx +21 -0
- package/src/DateInput/DateInput.tsx +9 -1
- package/src/DateTimeInput/DateTimeInput.doc.mjs +4 -0
- package/src/DateTimeInput/DateTimeInput.test.tsx +137 -0
- package/src/DateTimeInput/DateTimeInput.tsx +39 -2
- package/src/Dialog/Dialog.tsx +2 -1
- package/src/Markdown/parser.test.ts +53 -0
- package/src/Markdown/parser.ts +53 -12
- package/src/MobileNav/MobileNav.tsx +128 -37
- package/src/MobileNav/MobileNavCloseEdgeCases.test.tsx +426 -0
- package/src/MobileNav/MobileNavCloseTiming.test.ts +58 -0
- package/src/MobileNav/MobileNavCloseVisibility.test.tsx +172 -0
- package/src/MultiSelector/MultiSelector.test.tsx +31 -0
- package/src/MultiSelector/MultiSelector.tsx +10 -1
- package/src/NumberInput/NumberInput.test.tsx +18 -0
- package/src/NumberInput/NumberInput.tsx +9 -1
- package/src/PowerSearch/PowerSearch.test.tsx +48 -3
- package/src/PowerSearch/PowerSearch.tsx +4 -1
- package/src/PowerSearch/PowerSearchEditPopover.tsx +1 -1
- package/src/PowerSearch/formatFilterValue.test.ts +22 -0
- package/src/PowerSearch/formatFilterValue.ts +2 -4
- package/src/Selector/Selector.test.tsx +33 -0
- package/src/Selector/Selector.tsx +10 -1
- package/src/Switch/Switch.test.tsx +16 -0
- package/src/Switch/Switch.tsx +7 -0
- package/src/Table/Table.test.tsx +6 -0
- package/src/Table/columnUtils.ts +3 -1
- package/src/TextArea/TextArea.doc.mjs +1 -1
- package/src/TextArea/TextArea.test.tsx +72 -0
- package/src/TextArea/TextArea.tsx +26 -8
- package/src/TimeInput/TimeInput.test.tsx +23 -0
- package/src/TimeInput/TimeInput.tsx +8 -0
- package/src/Tooltip/useTooltip.tsx +4 -1
- package/src/TreeList/TreeList.doc.mjs +2 -2
- package/src/TreeList/TreeList.tsx +1 -1
- package/src/Typeahead/BaseTypeahead.tsx +1 -1
- package/src/Typeahead/Typeahead.test.tsx +35 -0
- package/src/Typeahead/Typeahead.tsx +10 -1
- package/src/__tests__/stubMatchMedia.test.ts +79 -0
- package/src/__tests__/stubMatchMedia.ts +95 -0
- package/src/hooks/index.ts +9 -5
- package/src/hooks/useFocusTrap.ts +1 -14
- package/src/index.ts +1 -0
- package/src/theme/defineTheme.test.ts +127 -0
- package/src/theme/defineTheme.ts +70 -55
- package/src/theme/expandColorScale.test.ts +138 -0
- package/src/theme/expandColorScale.ts +89 -44
- package/src/theme/extensibleAxes.test.ts +365 -0
- package/src/theme/mergeComponents.ts +59 -0
- package/src/theme/onMediaTokens.ts +9 -2
- package/src/utils/characters.test.ts +141 -0
- package/src/utils/characters.ts +88 -0
- package/src/utils/ime.test.ts +32 -0
- package/src/utils/ime.ts +69 -0
- package/src/utils/index.ts +4 -0
- package/dist/astryx.umd.js +0 -147
- package/dist/astryx.umd.js.map +0 -7
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/* eslint-disable @typescript-eslint/no-require-imports */
|
|
4
|
+
/**
|
|
5
|
+
* @file Guards the EXTENSIBLE prop axes — the `*Map` interfaces a theme
|
|
6
|
+
* augments — against the theming surface that has to carry them.
|
|
7
|
+
* @input Component sources (*.tsx/*.ts) and their `{Name}.doc.mjs` files.
|
|
8
|
+
* @output Vitest failures naming each map whose axis a theme cannot reach.
|
|
9
|
+
* @position Third sibling of derivedVarRegistry.test.ts (`vars`, `derived`) and
|
|
10
|
+
* themingTargets.test.ts (`targets`). Those two check what a component
|
|
11
|
+
* RENDERS against what it DOCUMENTS. Neither one looks at the open prop
|
|
12
|
+
* unions, so nothing did.
|
|
13
|
+
*
|
|
14
|
+
* An extensible axis is a promise made in three places at once, and it is only
|
|
15
|
+
* kept if all three agree:
|
|
16
|
+
*
|
|
17
|
+
* 1. `export interface FooVariantMap` in `Foo/index.ts` — the augmentation
|
|
18
|
+
* point. A consumer writes `declare module '@astryxdesign/core/Foo'`
|
|
19
|
+
* against THAT subpath, and `astryx theme build` looks for the literal
|
|
20
|
+
* interface there when it emits `<theme>.variants.d.ts`. A map declared
|
|
21
|
+
* in a sibling file and only re-exported is invisible to both.
|
|
22
|
+
* 2. `themeProps('foo', {variant})` — the axis reaching the DOM. Without it
|
|
23
|
+
* a custom variant renders no selector, so there is nothing to style.
|
|
24
|
+
* 3. `visualProps: ['variant']` on the doc's theming target — discovery, and
|
|
25
|
+
* the reason `theme build` does not reject `'foo': {'variant:custom': …}`
|
|
26
|
+
* as an unknown prop.
|
|
27
|
+
*
|
|
28
|
+
* Miss (2) and the type says yes while the CSS says nothing: TreeList shipped
|
|
29
|
+
* `TreeListVariantMap`, with a module-augmentation example in its own JSDoc,
|
|
30
|
+
* while `themeProps('tree-list', {density})` never passed `variant` — so an
|
|
31
|
+
* augmented variant type-checked, rendered, and could not be themed.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import {describe, it, expect} from 'vitest';
|
|
35
|
+
import {readdirSync, readFileSync, existsSync} from 'node:fs';
|
|
36
|
+
import {join, relative} from 'node:path';
|
|
37
|
+
import ts from 'typescript';
|
|
38
|
+
import {stableClassName} from '../naming';
|
|
39
|
+
|
|
40
|
+
const SRC_DIR = join(__dirname, '..');
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Source scanning
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* An extensible axis, keyed by the map that owns it.
|
|
48
|
+
*
|
|
49
|
+
* The OWNER is the component whose index declares the interface — that is the
|
|
50
|
+
* subpath a consumer augments and the component that has to reflect the prop.
|
|
51
|
+
* Other components may declare a prop of the same type (`AlertDialog`'s
|
|
52
|
+
* `actionVariant: ButtonVariant`, every field's `statusVariant`); they forward
|
|
53
|
+
* the value to the owner and are not separately accountable for it.
|
|
54
|
+
*/
|
|
55
|
+
interface ExtensibleAxis {
|
|
56
|
+
/** Component directory under src/ whose index declares the map. */
|
|
57
|
+
dir: string;
|
|
58
|
+
/** Interface name, e.g. 'TreeListVariantMap'. */
|
|
59
|
+
mapName: string;
|
|
60
|
+
/** The prop it types, read off the interface name, e.g. 'variant'. */
|
|
61
|
+
prop: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function sourceFilesUnder(dir: string, out: string[] = []): string[] {
|
|
65
|
+
for (const entry of readdirSync(dir, {withFileTypes: true})) {
|
|
66
|
+
const full = join(dir, entry.name);
|
|
67
|
+
if (entry.isDirectory()) {
|
|
68
|
+
if (entry.name === 'node_modules' || entry.name === '__tests__') {continue;}
|
|
69
|
+
sourceFilesUnder(full, out);
|
|
70
|
+
} else if (/\.tsx?$/.test(entry.name) && !entry.name.includes('.test.')) {
|
|
71
|
+
out.push(full);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function parse(file: string): ts.SourceFile {
|
|
78
|
+
return ts.createSourceFile(
|
|
79
|
+
file,
|
|
80
|
+
readFileSync(file, 'utf8'),
|
|
81
|
+
ts.ScriptTarget.Latest,
|
|
82
|
+
true,
|
|
83
|
+
file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Every `type Alias = keyof SomethingMap` in the tree, as alias -> map name.
|
|
89
|
+
* This is the shape that makes a union OPEN: augmenting the interface widens
|
|
90
|
+
* the alias, which is the whole mechanism a theme package uses.
|
|
91
|
+
*/
|
|
92
|
+
function collectKeyofAliases(files: string[]): Map<string, string> {
|
|
93
|
+
const aliases = new Map<string, string>();
|
|
94
|
+
|
|
95
|
+
/** `keyof FooMap`, and the `keyof FooMap & string` narrowing form. */
|
|
96
|
+
const mapBehind = (type: ts.TypeNode): string | null => {
|
|
97
|
+
if (ts.isIntersectionTypeNode(type)) {
|
|
98
|
+
for (const member of type.types) {
|
|
99
|
+
const found = mapBehind(member);
|
|
100
|
+
if (found != null) {return found;}
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
if (
|
|
105
|
+
ts.isTypeOperatorNode(type) &&
|
|
106
|
+
type.operator === ts.SyntaxKind.KeyOfKeyword &&
|
|
107
|
+
ts.isTypeReferenceNode(type.type) &&
|
|
108
|
+
ts.isIdentifier(type.type.typeName) &&
|
|
109
|
+
type.type.typeName.text.endsWith('Map')
|
|
110
|
+
) {
|
|
111
|
+
return type.type.typeName.text;
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
for (const file of files) {
|
|
117
|
+
const visit = (node: ts.Node): void => {
|
|
118
|
+
if (ts.isTypeAliasDeclaration(node)) {
|
|
119
|
+
const map = mapBehind(node.type);
|
|
120
|
+
if (map != null) {aliases.set(node.name.text, map);}
|
|
121
|
+
}
|
|
122
|
+
ts.forEachChild(node, visit);
|
|
123
|
+
};
|
|
124
|
+
visit(parse(file));
|
|
125
|
+
}
|
|
126
|
+
return aliases;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Maps whose alias types a prop on some `*Props` interface — i.e. the open
|
|
131
|
+
* union is a COMPONENT PROP, which is what makes it a theming axis.
|
|
132
|
+
*
|
|
133
|
+
* This is the line between the two kinds of augmentable map in the tree.
|
|
134
|
+
* `ButtonVariantMap` widens `<Button variant>`, a visual prop that reaches the
|
|
135
|
+
* DOM and gets styled. `IndicatorMap` widens the set of registered indicator
|
|
136
|
+
* NAMES — a different extension mechanism (the theme's `indicators` field,
|
|
137
|
+
* swapping a React component), with no selector and no visual prop. Holding
|
|
138
|
+
* the second to the theming contract below would be a category error.
|
|
139
|
+
*/
|
|
140
|
+
function mapsTypingAProp(
|
|
141
|
+
files: string[],
|
|
142
|
+
aliases: Map<string, string>,
|
|
143
|
+
): Set<string> {
|
|
144
|
+
const used = new Set<string>();
|
|
145
|
+
for (const file of files) {
|
|
146
|
+
const visit = (node: ts.Node): void => {
|
|
147
|
+
if (ts.isInterfaceDeclaration(node) && node.name.text.endsWith('Props')) {
|
|
148
|
+
for (const member of node.members) {
|
|
149
|
+
if (!ts.isPropertySignature(member) || member.type == null) {continue;}
|
|
150
|
+
const typeNode = ts.isArrayTypeNode(member.type)
|
|
151
|
+
? member.type.elementType
|
|
152
|
+
: member.type;
|
|
153
|
+
if (
|
|
154
|
+
ts.isTypeReferenceNode(typeNode) &&
|
|
155
|
+
ts.isIdentifier(typeNode.typeName)
|
|
156
|
+
) {
|
|
157
|
+
const map = aliases.get(typeNode.typeName.text);
|
|
158
|
+
if (map != null) {used.add(map);}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
ts.forEachChild(node, visit);
|
|
163
|
+
};
|
|
164
|
+
visit(parse(file));
|
|
165
|
+
}
|
|
166
|
+
return used;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Every map that is (a) an open union — some `type X = keyof <Map>` exists, so
|
|
171
|
+
* augmenting it widens a real type — and (b) declared in a component index.
|
|
172
|
+
*
|
|
173
|
+
* The prop name is read off the interface, which is the same convention
|
|
174
|
+
* `astryx theme build` uses to find an augmentation point:
|
|
175
|
+
* `<Prefix><Prop>Map`. `TextColorMap` -> Text, `color`.
|
|
176
|
+
* `AvatarStatusDotVariantMap` -> Avatar, `variant`.
|
|
177
|
+
*/
|
|
178
|
+
function collectExtensibleAxes(
|
|
179
|
+
files: string[],
|
|
180
|
+
aliases: Map<string, string>,
|
|
181
|
+
propMaps: Set<string>,
|
|
182
|
+
): ExtensibleAxis[] {
|
|
183
|
+
const open = propMaps;
|
|
184
|
+
const axes: ExtensibleAxis[] = [];
|
|
185
|
+
for (const file of files) {
|
|
186
|
+
if (!/\/index\.tsx?$/.test(file)) {continue;}
|
|
187
|
+
const dir = relative(SRC_DIR, file).split('/')[0];
|
|
188
|
+
const visit = (node: ts.Node): void => {
|
|
189
|
+
if (
|
|
190
|
+
ts.isInterfaceDeclaration(node) &&
|
|
191
|
+
node.name.text.endsWith('Map') &&
|
|
192
|
+
open.has(node.name.text)
|
|
193
|
+
) {
|
|
194
|
+
const bare = node.name.text.slice(0, -'Map'.length);
|
|
195
|
+
// The trailing PascalCase word is the prop.
|
|
196
|
+
const match = /([A-Z][a-z0-9]*)$/.exec(bare);
|
|
197
|
+
if (match == null) {return;}
|
|
198
|
+
const prop = match[1].charAt(0).toLowerCase() + match[1].slice(1);
|
|
199
|
+
axes.push({dir, mapName: node.name.text, prop});
|
|
200
|
+
}
|
|
201
|
+
ts.forEachChild(node, visit);
|
|
202
|
+
};
|
|
203
|
+
visit(parse(file));
|
|
204
|
+
}
|
|
205
|
+
return axes;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Every `themeProps('name', {...})` site, as class -> the prop keys it passes. */
|
|
209
|
+
function collectThemePropsSites(files: string[]): Map<string, Set<string>> {
|
|
210
|
+
const sites = new Map<string, Set<string>>();
|
|
211
|
+
for (const file of files) {
|
|
212
|
+
const visit = (node: ts.Node): void => {
|
|
213
|
+
if (
|
|
214
|
+
ts.isCallExpression(node) &&
|
|
215
|
+
ts.isIdentifier(node.expression) &&
|
|
216
|
+
node.expression.text === 'themeProps'
|
|
217
|
+
) {
|
|
218
|
+
const [nameArg, propsArg] = node.arguments;
|
|
219
|
+
if (nameArg != null && ts.isStringLiteralLike(nameArg)) {
|
|
220
|
+
const cls = stableClassName(nameArg.text);
|
|
221
|
+
const keys = sites.get(cls) ?? new Set<string>();
|
|
222
|
+
if (propsArg != null && ts.isObjectLiteralExpression(propsArg)) {
|
|
223
|
+
for (const prop of propsArg.properties) {
|
|
224
|
+
const name = prop.name;
|
|
225
|
+
if (
|
|
226
|
+
name != null &&
|
|
227
|
+
(ts.isIdentifier(name) || ts.isStringLiteralLike(name))
|
|
228
|
+
) {
|
|
229
|
+
keys.add(name.text);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
sites.set(cls, keys);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
ts.forEachChild(node, visit);
|
|
237
|
+
};
|
|
238
|
+
visit(parse(file));
|
|
239
|
+
}
|
|
240
|
+
return sites;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
// Docs
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
interface DocTarget {
|
|
248
|
+
className: string;
|
|
249
|
+
visualProps: string[];
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function collectDocTargets(dir: string): Map<string, DocTarget[]> {
|
|
253
|
+
const byDir = new Map<string, DocTarget[]>();
|
|
254
|
+
const walk = (current: string): void => {
|
|
255
|
+
for (const entry of readdirSync(current, {withFileTypes: true})) {
|
|
256
|
+
const full = join(current, entry.name);
|
|
257
|
+
if (entry.isDirectory()) {
|
|
258
|
+
if (entry.name === 'node_modules' || entry.name === '__tests__') {
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
walk(full);
|
|
262
|
+
} else if (entry.name.endsWith('.doc.mjs')) {
|
|
263
|
+
const componentDir = relative(SRC_DIR, full).split('/')[0];
|
|
264
|
+
let doc;
|
|
265
|
+
try {
|
|
266
|
+
doc = require(full).docs;
|
|
267
|
+
} catch {
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
const targets = (doc?.theming?.targets ?? [])
|
|
271
|
+
.filter(
|
|
272
|
+
(t: unknown): t is {className: string} =>
|
|
273
|
+
typeof (t as {className?: unknown})?.className === 'string',
|
|
274
|
+
)
|
|
275
|
+
.map((t: {className: string; visualProps?: string[]}) => ({
|
|
276
|
+
className: t.className,
|
|
277
|
+
visualProps: t.visualProps ?? [],
|
|
278
|
+
}));
|
|
279
|
+
byDir.set(componentDir, [
|
|
280
|
+
...(byDir.get(componentDir) ?? []),
|
|
281
|
+
...targets,
|
|
282
|
+
]);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
walk(dir);
|
|
287
|
+
return byDir;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ---------------------------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
const files = sourceFilesUnder(SRC_DIR);
|
|
293
|
+
const aliases = collectKeyofAliases(files);
|
|
294
|
+
const propMaps = mapsTypingAProp(files, aliases);
|
|
295
|
+
const axes = collectExtensibleAxes(files, aliases, propMaps);
|
|
296
|
+
const themePropsSites = collectThemePropsSites(files);
|
|
297
|
+
const docTargets = collectDocTargets(SRC_DIR);
|
|
298
|
+
|
|
299
|
+
/** The target a component's own name maps to, e.g. TreeList -> astryx-tree-list. */
|
|
300
|
+
function ownTargets(axis: ExtensibleAxis): DocTarget[] {
|
|
301
|
+
return docTargets.get(axis.dir) ?? [];
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
describe('extensible prop axes are reachable by a theme', () => {
|
|
305
|
+
it('finds the axes at all (guards the guard)', () => {
|
|
306
|
+
// If the AST walk silently stopped matching, every assertion below would
|
|
307
|
+
// pass on an empty list.
|
|
308
|
+
expect(axes.length).toBeGreaterThan(10);
|
|
309
|
+
expect(axes.map(a => a.mapName)).toContain('ButtonVariantMap');
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('every open prop union is declared in an index a consumer can augment', () => {
|
|
313
|
+
// Module augmentation only widens the module where the interface is
|
|
314
|
+
// DECLARED, and the CLI greps the public subpath for that literal
|
|
315
|
+
// declaration. A map in a sibling file, re-exported, satisfies neither:
|
|
316
|
+
// `declare module '@astryxdesign/core/<Component>'` would create a new,
|
|
317
|
+
// unrelated interface and `theme build` would emit no augmentation.
|
|
318
|
+
// `collectExtensibleAxes` only reads indexes, so anything open and
|
|
319
|
+
// declared elsewhere is missing from `axes` entirely — compare against
|
|
320
|
+
// every open map in the tree to catch that.
|
|
321
|
+
const declaredInAnIndex = new Set(axes.map(a => a.mapName));
|
|
322
|
+
const notAugmentable = [...propMaps].filter(m => !declaredInAnIndex.has(m));
|
|
323
|
+
expect(
|
|
324
|
+
notAugmentable,
|
|
325
|
+
'these maps type an open union but are not declared in a component index, ' +
|
|
326
|
+
'so no consumer can augment them at @astryxdesign/core/<Component>',
|
|
327
|
+
).toEqual([]);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it.each(axes.map(a => [`${a.dir}.${a.prop} (${a.mapName})`, a] as const))(
|
|
331
|
+
'%s reaches the DOM through themeProps',
|
|
332
|
+
(_label, axis) => {
|
|
333
|
+
// The axis is extensible, so a consumer can add a value we have never
|
|
334
|
+
// seen. The only way their CSS can select it is if the component
|
|
335
|
+
// reflects the prop.
|
|
336
|
+
const targets = ownTargets(axis);
|
|
337
|
+
const reflected = targets.some(t =>
|
|
338
|
+
themePropsSites.get(t.className)?.has(axis.prop),
|
|
339
|
+
);
|
|
340
|
+
expect(
|
|
341
|
+
reflected,
|
|
342
|
+
`${axis.dir} lets a theme add \`${axis.prop}\` values via ${axis.mapName}, but no ` +
|
|
343
|
+
`themeProps() call passes \`${axis.prop}\` — a custom value renders no selector, ` +
|
|
344
|
+
`so it cannot be styled. Pass it: themeProps('<target>', {${axis.prop}}).`,
|
|
345
|
+
).toBe(true);
|
|
346
|
+
},
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
it.each(axes.map(a => [`${a.dir}.${a.prop} (${a.mapName})`, a] as const))(
|
|
350
|
+
'%s is documented as a visual prop',
|
|
351
|
+
(_label, axis) => {
|
|
352
|
+
// Undocumented, it is undiscoverable — and `theme build` rejects
|
|
353
|
+
// `'<target>': {'<prop>:custom': …}` as an unknown prop, because its
|
|
354
|
+
// known-prop set is built from exactly this field.
|
|
355
|
+
const targets = ownTargets(axis);
|
|
356
|
+
const documented = targets.some(t => t.visualProps.includes(axis.prop));
|
|
357
|
+
expect(
|
|
358
|
+
documented,
|
|
359
|
+
`${axis.dir} lets a theme add \`${axis.prop}\` values via ${axis.mapName}, but no ` +
|
|
360
|
+
`theming target documents \`${axis.prop}\` in visualProps — a theme author cannot ` +
|
|
361
|
+
`discover the axis, and \`astryx theme build\` warns "Unknown prop" on it.`,
|
|
362
|
+
).toBe(true);
|
|
363
|
+
},
|
|
364
|
+
);
|
|
365
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file Component style-map merging
|
|
5
|
+
*
|
|
6
|
+
* One merge rule for component overrides, shared by every layer that composes
|
|
7
|
+
* them: `extends` inheritance, generated type-scale rules, and the on-media
|
|
8
|
+
* (`onDark`/`onLight`) surfaces. Merging is per style key, so a child that
|
|
9
|
+
* restates one property of `button.base` keeps the rest of the base's.
|
|
10
|
+
*
|
|
11
|
+
* @input two ComponentStyleMaps — the base and the overrides that win
|
|
12
|
+
* @output a merged ComponentStyleMap
|
|
13
|
+
* @position packages/core/src/theme/mergeComponents.ts
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type {ComponentStyleMap} from './defineTheme';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Deep-merge component style maps: `overrides` wins per style key, and every
|
|
20
|
+
* component and key the base declared that the overrides do not mention is
|
|
21
|
+
* carried through untouched.
|
|
22
|
+
*/
|
|
23
|
+
export function deepMergeComponents(
|
|
24
|
+
base?: ComponentStyleMap,
|
|
25
|
+
overrides?: ComponentStyleMap,
|
|
26
|
+
): ComponentStyleMap | undefined {
|
|
27
|
+
if (!base && !overrides) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
if (!base) {
|
|
31
|
+
return overrides;
|
|
32
|
+
}
|
|
33
|
+
if (!overrides) {
|
|
34
|
+
return base;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const result: ComponentStyleMap = {};
|
|
38
|
+
|
|
39
|
+
// Start with all base entries
|
|
40
|
+
for (const [component, rules] of Object.entries(base)) {
|
|
41
|
+
result[component] = {...rules};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Merge overrides on top
|
|
45
|
+
for (const [component, rules] of Object.entries(overrides)) {
|
|
46
|
+
if (!result[component]) {
|
|
47
|
+
result[component] = {...rules};
|
|
48
|
+
} else {
|
|
49
|
+
for (const [key, styles] of Object.entries(rules)) {
|
|
50
|
+
result[component][key] = {
|
|
51
|
+
...result[component][key],
|
|
52
|
+
...styles,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import type {TokenValue, ComponentStyleMap} from './defineTheme';
|
|
24
|
+
import {deepMergeComponents} from './mergeComponents';
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
27
|
* On-media theme overrides — same shape as the main theme but scoped
|
|
@@ -89,15 +90,21 @@ function resolveValue(value: TokenValue): string {
|
|
|
89
90
|
/**
|
|
90
91
|
* Resolve on-media overrides: merge user tokens with defaults,
|
|
91
92
|
* pass through component overrides.
|
|
93
|
+
*
|
|
94
|
+
* `base` is the already-resolved surface of a theme being extended. It sits
|
|
95
|
+
* between the defaults and this theme's own input, so a child theme inherits
|
|
96
|
+
* the surface customizations of the theme it extends instead of silently
|
|
97
|
+
* reverting them to the defaults.
|
|
92
98
|
*/
|
|
93
99
|
export function resolveOnMedia(
|
|
94
100
|
surface: 'dark' | 'light',
|
|
95
101
|
input?: OnMediaOverrides,
|
|
102
|
+
base?: ResolvedOnMedia,
|
|
96
103
|
): ResolvedOnMedia {
|
|
97
104
|
const defaults =
|
|
98
105
|
surface === 'dark' ? defaultOnDarkTokens : defaultOnLightTokens;
|
|
99
106
|
|
|
100
|
-
const tokens = {...defaults};
|
|
107
|
+
const tokens = {...defaults, ...base?.tokens};
|
|
101
108
|
|
|
102
109
|
if (input?.tokens) {
|
|
103
110
|
for (const [key, value] of Object.entries(input.tokens)) {
|
|
@@ -109,6 +116,6 @@ export function resolveOnMedia(
|
|
|
109
116
|
|
|
110
117
|
return {
|
|
111
118
|
tokens,
|
|
112
|
-
components: input?.components,
|
|
119
|
+
components: deepMergeComponents(base?.components, input?.components),
|
|
113
120
|
};
|
|
114
121
|
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file characters.test.ts
|
|
5
|
+
* @input character-counting utilities
|
|
6
|
+
* @output Tests for characterCount, firstCharacter, truncateCharacters
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {describe, it, expect, vi, afterEach} from 'vitest';
|
|
10
|
+
import {characterCount, firstCharacter, truncateCharacters} from './characters';
|
|
11
|
+
|
|
12
|
+
// Multi-code-unit fixtures: an emoji surrogate pair (2 units), a flag
|
|
13
|
+
// sequence (4 units), a ZWJ family (11 units), and a combining mark (2 units).
|
|
14
|
+
const EMOJI = '\u{1F600}'; // 😀
|
|
15
|
+
const FLAG = '\u{1F1F9}\u{1F1F7}'; // 🇹🇷
|
|
16
|
+
const FAMILY = '\u{1F468}\u{1F469}\u{1F467}\u{1F466}'; // 👨👩👧👦
|
|
17
|
+
// é as base + combining mark, spelled as an escape so NFC-normalizing
|
|
18
|
+
// tools cannot silently precompose the fixture into single-code-unit é.
|
|
19
|
+
const E_ACUTE = 'e\u0301';
|
|
20
|
+
|
|
21
|
+
describe('characterCount', () => {
|
|
22
|
+
it('returns 0 for the empty string', () => {
|
|
23
|
+
expect(characterCount('')).toBe(0);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('counts ASCII one per character', () => {
|
|
27
|
+
expect(characterCount('hello')).toBe(5);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('counts an emoji surrogate pair as one', () => {
|
|
31
|
+
expect(characterCount(EMOJI.repeat(2))).toBe(2);
|
|
32
|
+
expect(EMOJI.repeat(2).length).toBe(4); // sanity: code units differ
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('counts a flag sequence as one', () => {
|
|
36
|
+
expect(characterCount(FLAG)).toBe(1);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('counts a ZWJ family emoji as one', () => {
|
|
40
|
+
expect(characterCount(FAMILY)).toBe(1);
|
|
41
|
+
expect(FAMILY.length).toBe(11); // sanity: code units differ
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('counts a combining-mark character as one', () => {
|
|
45
|
+
expect(characterCount(E_ACUTE)).toBe(1);
|
|
46
|
+
expect(E_ACUTE.length).toBe(2); // sanity: code units differ
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('counts mixed content by user-perceived characters', () => {
|
|
50
|
+
expect(characterCount(`a${EMOJI}b`)).toBe(3);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('firstCharacter', () => {
|
|
55
|
+
it('returns the empty string for the empty string', () => {
|
|
56
|
+
expect(firstCharacter('')).toBe('');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('returns the first ASCII character', () => {
|
|
60
|
+
expect(firstCharacter('abc')).toBe('a');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('returns a whole emoji, not half a surrogate pair', () => {
|
|
64
|
+
expect(firstCharacter(`${EMOJI}x`)).toBe(EMOJI);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('returns a whole ZWJ family emoji', () => {
|
|
68
|
+
expect(firstCharacter(`${FAMILY}x`)).toBe(FAMILY);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('keeps a combining mark attached to its base', () => {
|
|
72
|
+
expect(firstCharacter(`${E_ACUTE}cole`)).toBe(E_ACUTE);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('truncateCharacters', () => {
|
|
77
|
+
it('returns strings within max unchanged', () => {
|
|
78
|
+
expect(truncateCharacters('abc', 5)).toBe('abc');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('returns strings exactly at max unchanged', () => {
|
|
82
|
+
expect(truncateCharacters('abcde', 5)).toBe('abcde');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('truncates so the result, including the ellipsis, is max characters', () => {
|
|
86
|
+
expect(truncateCharacters('abcdefghij', 5)).toBe('abcd…');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('counts a multi-character ellipsis against max', () => {
|
|
90
|
+
// "..." is 3 characters, so 5 content characters remain.
|
|
91
|
+
expect(truncateCharacters('aaaaaaaaaa', 8, '...')).toBe('aaaaa...');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('passes strings within max through under a multi-character ellipsis', () => {
|
|
95
|
+
expect(truncateCharacters('aaaaaaaa', 8, '...')).toBe('aaaaaaaa');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('cuts between characters, never splitting an emoji', () => {
|
|
99
|
+
// The exact-string assertion also proves no surrogate pair was split.
|
|
100
|
+
expect(truncateCharacters(EMOJI.repeat(4), 3)).toBe(`${EMOJI.repeat(2)}…`);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('treats a ZWJ family emoji as a single unit when cutting', () => {
|
|
104
|
+
expect(truncateCharacters(`${FAMILY}abc`, 2)).toBe(`${FAMILY}…`);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('returns the empty string unchanged', () => {
|
|
108
|
+
expect(truncateCharacters('', 5)).toBe('');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('degrades to just the ellipsis when max is smaller than the ellipsis', () => {
|
|
112
|
+
// Documented degenerate behavior: no consumer passes max below the
|
|
113
|
+
// ellipsis length, but the clamp must stay predictable.
|
|
114
|
+
expect(truncateCharacters('abcdef', 2, '...')).toBe('...');
|
|
115
|
+
expect(truncateCharacters('abcdef', 0)).toBe('…');
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('code-point fallback (no Intl.Segmenter)', () => {
|
|
120
|
+
afterEach(() => {
|
|
121
|
+
vi.unstubAllGlobals();
|
|
122
|
+
vi.resetModules();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('keeps surrogate pairs intact while counting by code points', async () => {
|
|
126
|
+
vi.resetModules();
|
|
127
|
+
// characters.ts only reads Intl.Segmenter, so a minimal stub suffices.
|
|
128
|
+
vi.stubGlobal('Intl', {Segmenter: undefined});
|
|
129
|
+
const fallback = await import('./characters');
|
|
130
|
+
|
|
131
|
+
expect(fallback.characterCount(EMOJI.repeat(2))).toBe(2);
|
|
132
|
+
expect(fallback.firstCharacter(`${EMOJI}x`)).toBe(EMOJI);
|
|
133
|
+
expect(fallback.firstCharacter('')).toBe('');
|
|
134
|
+
expect(fallback.truncateCharacters(EMOJI.repeat(4), 3)).toBe(
|
|
135
|
+
`${EMOJI.repeat(2)}…`,
|
|
136
|
+
);
|
|
137
|
+
// Documented degradation: a flag sequence splits into its two
|
|
138
|
+
// regional-indicator code points under the fallback.
|
|
139
|
+
expect(fallback.characterCount(FLAG)).toBe(2);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file characters.ts
|
|
5
|
+
* @input Strings that may contain characters made of several code units
|
|
6
|
+
* (emoji, flags, joined emoji sequences, accented letters)
|
|
7
|
+
* @output Exports characterCount, firstCharacter, truncateCharacters —
|
|
8
|
+
* replacements for .length / .charAt(0) / .slice(0, n) that measure
|
|
9
|
+
* and cut user-visible strings the way a person reads them
|
|
10
|
+
* @position Shared utility; consumed by Avatar, TextArea, PowerSearch, Table
|
|
11
|
+
*
|
|
12
|
+
* SYNC: When modified, update:
|
|
13
|
+
* - /packages/core/src/utils/characters.test.ts
|
|
14
|
+
* - /packages/core/src/utils/index.ts
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Reuse a single segmenter when the runtime supports Intl.Segmenter.
|
|
19
|
+
* `grapheme` is the Intl granularity name for a user-perceived character.
|
|
20
|
+
*/
|
|
21
|
+
const characterSegmenter =
|
|
22
|
+
typeof Intl.Segmenter === 'function'
|
|
23
|
+
? new Intl.Segmenter(undefined, {granularity: 'grapheme'})
|
|
24
|
+
: null;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Split a string into user-perceived characters. The code-point fallback
|
|
28
|
+
* keeps surrogate pairs intact but may split joined emoji sequences and flag
|
|
29
|
+
* pairs on runtimes without Intl.Segmenter.
|
|
30
|
+
*/
|
|
31
|
+
function splitCharacters(str: string): string[] {
|
|
32
|
+
if (characterSegmenter) {
|
|
33
|
+
return [...characterSegmenter.segment(str)].map(s => s.segment);
|
|
34
|
+
}
|
|
35
|
+
return [...str];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Number of characters in a string — the count a person would give, where one
|
|
40
|
+
* emoji, flag, or accented letter is one character. Replacement for `.length`
|
|
41
|
+
* on user-visible strings, which counts internal storage units instead.
|
|
42
|
+
*/
|
|
43
|
+
export function characterCount(str: string): number {
|
|
44
|
+
if (str === '') {
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
return splitCharacters(str).length;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The first character of a string, or '' when empty. Replacement for
|
|
52
|
+
* `.charAt(0)`, which can return half of an emoji. Reads only the first
|
|
53
|
+
* character, so cost does not scale with the length of the string.
|
|
54
|
+
*/
|
|
55
|
+
export function firstCharacter(str: string): string {
|
|
56
|
+
if (characterSegmenter) {
|
|
57
|
+
const first = characterSegmenter.segment(str)[Symbol.iterator]().next();
|
|
58
|
+
return first.done ? '' : first.value.segment;
|
|
59
|
+
}
|
|
60
|
+
const codePoint = str.codePointAt(0);
|
|
61
|
+
return codePoint == null ? '' : String.fromCodePoint(codePoint);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Truncate to at most `max` characters, ellipsis included: strings within
|
|
66
|
+
* `max` characters pass through unchanged; longer strings are cut so the
|
|
67
|
+
* result — content plus `ellipsis` — is exactly `max` characters (or just the
|
|
68
|
+
* ellipsis, when `max` is smaller than the ellipsis itself). Replacement for
|
|
69
|
+
* `str.slice(0, n) + '…'`, which can cut an emoji in half.
|
|
70
|
+
*
|
|
71
|
+
* @example
|
|
72
|
+
* ```
|
|
73
|
+
* truncateCharacters('abcdefghij', 5) // 'abcd…'
|
|
74
|
+
* truncateCharacters('abcdefghij', 8, '...') // 'abcde...'
|
|
75
|
+
* ```
|
|
76
|
+
*/
|
|
77
|
+
export function truncateCharacters(
|
|
78
|
+
str: string,
|
|
79
|
+
max: number,
|
|
80
|
+
ellipsis = '…',
|
|
81
|
+
): string {
|
|
82
|
+
const characters = splitCharacters(str);
|
|
83
|
+
if (characters.length <= max) {
|
|
84
|
+
return str;
|
|
85
|
+
}
|
|
86
|
+
const keep = Math.max(max - characterCount(ellipsis), 0);
|
|
87
|
+
return characters.slice(0, keep).join('') + ellipsis;
|
|
88
|
+
}
|