@astryxdesign/core 0.4.2 → 0.4.3-canary.00bed29
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 +392 -0
- package/dist/BottomSheet/BottomSheetPanel.d.ts +60 -0
- package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -0
- package/dist/BottomSheet/BottomSheetPanel.js +398 -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 +35 -0
- package/dist/BottomSheet/useMobileKeyboard.d.ts.map +1 -0
- package/dist/BottomSheet/useMobileKeyboard.js +373 -0
- package/dist/BottomSheet/useSheetGestures.d.ts +180 -0
- package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -0
- package/dist/BottomSheet/useSheetGestures.js +942 -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/Lightbox/Lightbox.d.ts.map +1 -1
- package/dist/Lightbox/Lightbox.js +9 -3
- 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 +21 -7
- 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 +14 -3
- 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/Tokenizer/Tokenizer.js +11 -5
- 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.d.ts.map +1 -1
- package/dist/Typeahead/BaseTypeahead.js +5 -3
- 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/hooks/useInputStatusIcon.d.ts.map +1 -1
- package/dist/hooks/useInputStatusIcon.js +1 -0
- 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/locales/en.json +48 -0
- package/locales/pseudo.json +36 -0
- 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 +2114 -0
- package/src/BottomSheet/BottomSheet.tsx +545 -0
- package/src/BottomSheet/BottomSheetPanel.test.tsx +240 -0
- package/src/BottomSheet/BottomSheetPanel.tsx +547 -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 +499 -0
- package/src/BottomSheet/useSheetGestures.test.ts +1014 -0
- package/src/BottomSheet/useSheetGestures.ts +1407 -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/FileInput/FileInput.test.tsx +53 -9
- package/src/Lightbox/Lightbox.test.tsx +49 -28
- package/src/Lightbox/Lightbox.tsx +7 -3
- 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 +195 -51
- package/src/MultiSelector/MultiSelector.tsx +22 -8
- 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 +85 -26
- package/src/Selector/Selector.tsx +13 -4
- 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/Tokenizer/Tokenizer.test.tsx +50 -32
- package/src/Tokenizer/Tokenizer.tsx +5 -5
- 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 +10 -3
- package/src/Typeahead/Typeahead.test.tsx +116 -7
- 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/hooks/useInputStatusIcon.test.tsx +19 -0
- package/src/hooks/useInputStatusIcon.tsx +7 -0
- 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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
import {describe, expect, it} from 'vitest';
|
|
4
|
+
import {isImeKeyEvent} from './ime';
|
|
5
|
+
|
|
6
|
+
describe('isImeKeyEvent', () => {
|
|
7
|
+
it('detects the modern isComposing signal', () => {
|
|
8
|
+
expect(isImeKeyEvent({isComposing: true})).toBe(true);
|
|
9
|
+
expect(isImeKeyEvent({isComposing: true, keyCode: 13})).toBe(true);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('detects the legacy keyCode 229 fallback (IME processing sentinel)', () => {
|
|
13
|
+
// Some IMEs / older Safari fire the composing keydown with isComposing
|
|
14
|
+
// not yet set to true but report keyCode 229.
|
|
15
|
+
expect(isImeKeyEvent({keyCode: 229})).toBe(true);
|
|
16
|
+
expect(isImeKeyEvent({isComposing: false, keyCode: 229})).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('returns false for ordinary (non-composing) keydowns', () => {
|
|
20
|
+
expect(isImeKeyEvent({})).toBe(false);
|
|
21
|
+
expect(isImeKeyEvent({isComposing: false})).toBe(false);
|
|
22
|
+
expect(isImeKeyEvent({keyCode: 13})).toBe(false); // plain Enter
|
|
23
|
+
expect(isImeKeyEvent({keyCode: 27})).toBe(false); // plain Escape
|
|
24
|
+
expect(isImeKeyEvent({isComposing: false, keyCode: 13})).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('only treats a literal `true` isComposing as composing', () => {
|
|
28
|
+
// Guards against truthy-but-not-true values leaking through.
|
|
29
|
+
// @ts-expect-error intentionally passing a non-boolean to assert strictness
|
|
30
|
+
expect(isImeKeyEvent({isComposing: 1})).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
});
|
package/src/utils/ime.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file ime.ts
|
|
5
|
+
* @input Receives a keyboard event (React SyntheticEvent's `nativeEvent`, or a
|
|
6
|
+
* native DOM KeyboardEvent) with optional `isComposing` / `keyCode`.
|
|
7
|
+
* @output Exports `isImeKeyEvent`, the canonical predicate for "this keydown is
|
|
8
|
+
* part of an in-progress IME composition and must not be treated as a command".
|
|
9
|
+
* @position Shared low-level utility; consumed by every editable/overlay surface
|
|
10
|
+
* that handles Enter/Escape/arrows in `onKeyDown` (Typeahead, PowerSearch,
|
|
11
|
+
* Dialog, ContextMenu, Tooltip, Chat composer, focus-trap Escape, ...).
|
|
12
|
+
*
|
|
13
|
+
* ── Why this exists (read once, here — do not re-explain at call sites) ──
|
|
14
|
+
*
|
|
15
|
+
* When a CJK user (Korean / Japanese / Chinese) is composing text via an IME,
|
|
16
|
+
* the browser fires a `keydown` event to COMMIT or CANCEL the pending
|
|
17
|
+
* composition (Enter commits the highlighted candidate; Escape cancels it;
|
|
18
|
+
* ArrowUp/Down/Home/End navigate the candidate window). Crucially this
|
|
19
|
+
* `keydown` fires BEFORE the `compositionend` event that actually writes the
|
|
20
|
+
* committed text into the field. So a naive `onKeyDown` handler sees a bare
|
|
21
|
+
* "Enter" or "Escape" and misreads the composition-commit/cancel as an
|
|
22
|
+
* application command — accepting a typeahead suggestion, submitting a chat
|
|
23
|
+
* message, closing a dialog/menu/tooltip, or saving a filter — mid-composition.
|
|
24
|
+
* The fix is to detect the composing keydown and early-return before running
|
|
25
|
+
* any command logic.
|
|
26
|
+
*
|
|
27
|
+
* Two signals, both needed:
|
|
28
|
+
*
|
|
29
|
+
* 1. `event.isComposing === true` — the modern, spec'd signal
|
|
30
|
+
* (https://www.w3.org/TR/uievents/#dom-keyboardevent-iscomposing). This is
|
|
31
|
+
* the primary check and is reliable in current evergreen browsers.
|
|
32
|
+
*
|
|
33
|
+
* 2. `event.keyCode === 229` — the legacy fallback. `229` is the sentinel
|
|
34
|
+
* keyCode browsers report for "the key event is being processed by an IME".
|
|
35
|
+
* It is still load-bearing: some IMEs and older Safari fire the composing
|
|
36
|
+
* `keydown` with `isComposing` NOT yet set to `true`, but DO report
|
|
37
|
+
* keyCode 229. Keeping both makes the guard robust across the browser
|
|
38
|
+
* matrix we support. Do not drop the 229 fallback without a browser-matrix
|
|
39
|
+
* audit.
|
|
40
|
+
*
|
|
41
|
+
* ── Which event object to pass ──
|
|
42
|
+
*
|
|
43
|
+
* React's SyntheticEvent for KeyboardEvent *does* surface `isComposing`, but to
|
|
44
|
+
* avoid any cross-browser normalization gap prefer passing the *native* event
|
|
45
|
+
* (`e.nativeEvent`) from React handlers — that is what BaseTypeahead,
|
|
46
|
+
* PowerSearch, and the Chat composer do. Native DOM listeners (Dialog,
|
|
47
|
+
* ContextMenu, Tooltip, focus-trap) pass the DOM `KeyboardEvent` directly.
|
|
48
|
+
* The parameter is intentionally structurally typed so both shapes are accepted.
|
|
49
|
+
*
|
|
50
|
+
* Note: `keyCode` is deprecated on the DOM `KeyboardEvent` type but is still
|
|
51
|
+
* present at runtime; we read it defensively via the optional structural field.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The sentinel `keyCode` browsers report while a key event is being processed
|
|
56
|
+
* by an IME (the composing keydown that fires before `compositionend`). See the
|
|
57
|
+
* file header for why this legacy signal is still load-bearing alongside
|
|
58
|
+
* `isComposing`.
|
|
59
|
+
*/
|
|
60
|
+
const IME_PROCESSING_KEY_CODE = 229;
|
|
61
|
+
|
|
62
|
+
export function isImeKeyEvent(event: {
|
|
63
|
+
isComposing?: boolean;
|
|
64
|
+
keyCode?: number;
|
|
65
|
+
}): boolean {
|
|
66
|
+
return (
|
|
67
|
+
event.isComposing === true || event.keyCode === IME_PROCESSING_KEY_CODE
|
|
68
|
+
);
|
|
69
|
+
}
|
package/src/utils/index.ts
CHANGED
|
@@ -70,6 +70,8 @@ export type {ISOTimeString, ParsedTime} from './timeParser';
|
|
|
70
70
|
export {parseStyleKey} from './parseStyleKey';
|
|
71
71
|
export {getKey, type Key, type KeyFallback} from './getKey';
|
|
72
72
|
|
|
73
|
+
export {characterCount, firstCharacter, truncateCharacters} from './characters';
|
|
74
|
+
|
|
73
75
|
export {mergeProps} from './mergeProps';
|
|
74
76
|
export {mergeRefs} from './mergeRefs';
|
|
75
77
|
export {isFocusDetached} from './focusReturn';
|
|
@@ -105,3 +107,5 @@ export {rtlStyles} from './rtlStyles';
|
|
|
105
107
|
// The shared focus ring, exported for @astryxdesign/lab — same reason
|
|
106
108
|
// rtlStyles is: a style that must be identical across packages, not copied.
|
|
107
109
|
export {focusOutlineStyles, focusOutlineProps} from './focusOutline.stylex';
|
|
110
|
+
|
|
111
|
+
export {isImeKeyEvent} from './ime';
|