@astryxdesign/core 0.4.3 → 0.4.4-canary.2022d94
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 +60 -0
- package/README.md +79 -45
- package/dist/Banner/Banner.d.ts +3 -0
- package/dist/Banner/Banner.d.ts.map +1 -1
- package/dist/Banner/Banner.js +13 -3
- 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 +989 -0
- package/dist/Chat/ChatComposerInput.d.ts.map +1 -1
- package/dist/Chat/ChatComposerInput.js +3 -4
- package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
- package/dist/ComplexSelector/ComplexSelector.js +12 -6
- 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/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/PowerSearchEditPopover.js +1 -1
- 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/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/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 +21 -0
- 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 +22 -4
- package/dist/theme/defineTheme.d.ts.map +1 -1
- 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/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 +1 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +2 -1
- package/locales/en.json +48 -0
- package/locales/pseudo.json +36 -0
- package/package.json +9 -8
- package/src/Banner/Banner.doc.mjs +3 -3
- package/src/Banner/Banner.test.tsx +40 -0
- package/src/Banner/Banner.tsx +27 -2
- 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 +1059 -0
- package/src/BottomSheet/useSheetGestures.ts +1459 -0
- package/src/Chat/ChatComposerInput.tsx +3 -4
- package/src/ComplexSelector/ComplexSelector.tsx +12 -5
- 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/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/PowerSearchEditPopover.tsx +1 -1
- 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/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/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.ts +22 -4
- package/src/theme/expandColorScale.test.ts +138 -0
- package/src/theme/expandColorScale.ts +89 -44
- package/src/utils/ime.test.ts +32 -0
- package/src/utils/ime.ts +69 -0
- package/src/utils/index.ts +2 -0
- package/dist/astryx.umd.js +0 -147
- package/dist/astryx.umd.js.map +0 -7
|
@@ -26,6 +26,7 @@ import {RadioIndicator} from '../Indicator';
|
|
|
26
26
|
import {InputGroup, InputGroupText} from '../InputGroup';
|
|
27
27
|
import {__resetLiveRegionsForTest} from '../hooks/useAnnounce';
|
|
28
28
|
import {__resetInteractionModalityForTest} from '../utils/interactionModality';
|
|
29
|
+
import {InternationalizationProvider} from '../i18n';
|
|
29
30
|
import {defineTheme} from '../theme/defineTheme';
|
|
30
31
|
import {Theme} from '../theme/Theme';
|
|
31
32
|
import {generateThemeCSS} from '../theme/generateThemeRules';
|
|
@@ -1000,60 +1001,118 @@ describe('Selector', () => {
|
|
|
1000
1001
|
).toBeInTheDocument();
|
|
1001
1002
|
});
|
|
1002
1003
|
|
|
1004
|
+
it('does not select the highlighted option on a composing Enter (IME)', async () => {
|
|
1005
|
+
const user = userEvent.setup();
|
|
1006
|
+
const onChange = vi.fn();
|
|
1007
|
+
render(
|
|
1008
|
+
<Selector
|
|
1009
|
+
label="Fruit"
|
|
1010
|
+
options={OPTIONS}
|
|
1011
|
+
onChange={onChange}
|
|
1012
|
+
hasSearch
|
|
1013
|
+
/>,
|
|
1014
|
+
);
|
|
1015
|
+
await user.click(screen.getByRole('button', {name: 'Fruit'}));
|
|
1016
|
+
const search = screen.getByRole('combobox', h);
|
|
1017
|
+
// Filter to Banana and highlight it so an unguarded Enter would commit a
|
|
1018
|
+
// selection.
|
|
1019
|
+
await user.type(search, 'ban');
|
|
1020
|
+
await user.keyboard('{ArrowDown}');
|
|
1021
|
+
expect(search).toHaveAttribute('aria-activedescendant');
|
|
1022
|
+
|
|
1023
|
+
// The browser fires this composing keydown for the Enter that commits an
|
|
1024
|
+
// IME candidate (isComposing: true, or the legacy keyCode 229) before
|
|
1025
|
+
// compositionend writes the syllable. It must NOT be read as "select the
|
|
1026
|
+
// highlighted option".
|
|
1027
|
+
fireEvent.keyDown(search, {key: 'Enter', isComposing: true});
|
|
1028
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
1029
|
+
fireEvent.keyDown(search, {key: 'Enter', keyCode: 229});
|
|
1030
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
1031
|
+
|
|
1032
|
+
// A real, non-composing Enter still selects the highlighted option.
|
|
1033
|
+
fireEvent.keyDown(search, {key: 'Enter'});
|
|
1034
|
+
expect(onChange).toHaveBeenCalledWith('Banana');
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1003
1037
|
describe('result announcements', () => {
|
|
1004
1038
|
it('announces the match count politely while searching', async () => {
|
|
1005
1039
|
const user = userEvent.setup();
|
|
1006
1040
|
render(
|
|
1007
|
-
<
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1041
|
+
<InternationalizationProvider
|
|
1042
|
+
locale="fr"
|
|
1043
|
+
overrides={{
|
|
1044
|
+
fr: {
|
|
1045
|
+
'@astryx.selector.resultCount':
|
|
1046
|
+
'{count, number} {count, plural, one {résultat} other {résultats}}',
|
|
1047
|
+
},
|
|
1048
|
+
}}>
|
|
1049
|
+
<Selector
|
|
1050
|
+
label="Fruit"
|
|
1051
|
+
options={OPTIONS}
|
|
1052
|
+
value="Apple"
|
|
1053
|
+
onChange={() => {}}
|
|
1054
|
+
hasSearch
|
|
1055
|
+
/>
|
|
1056
|
+
</InternationalizationProvider>,
|
|
1014
1057
|
);
|
|
1015
1058
|
await user.click(screen.getByRole('button', {name: 'Fruit'}));
|
|
1016
1059
|
// "a" matches Apple and Banana.
|
|
1017
1060
|
await user.type(screen.getByRole('combobox', h), 'a');
|
|
1018
1061
|
await waitFor(() => {
|
|
1019
|
-
|
|
1062
|
+
// The plural branch of a message no catalog supplies.
|
|
1063
|
+
expect(politeRegion()?.textContent).toBe('2 résultats');
|
|
1020
1064
|
});
|
|
1021
1065
|
});
|
|
1022
1066
|
|
|
1023
1067
|
it('announces the singular form when one option matches', async () => {
|
|
1024
1068
|
const user = userEvent.setup();
|
|
1025
1069
|
render(
|
|
1026
|
-
<
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1070
|
+
<InternationalizationProvider
|
|
1071
|
+
locale="fr"
|
|
1072
|
+
overrides={{
|
|
1073
|
+
fr: {
|
|
1074
|
+
'@astryx.selector.resultCount':
|
|
1075
|
+
'{count, number} {count, plural, one {résultat} other {résultats}}',
|
|
1076
|
+
},
|
|
1077
|
+
}}>
|
|
1078
|
+
<Selector
|
|
1079
|
+
label="Fruit"
|
|
1080
|
+
options={OPTIONS}
|
|
1081
|
+
value="Apple"
|
|
1082
|
+
onChange={() => {}}
|
|
1083
|
+
hasSearch
|
|
1084
|
+
/>
|
|
1085
|
+
</InternationalizationProvider>,
|
|
1033
1086
|
);
|
|
1034
1087
|
await user.click(screen.getByRole('button', {name: 'Fruit'}));
|
|
1035
|
-
// "ban" matches only Banana.
|
|
1088
|
+
// "ban" matches only Banana. Exact, so "1 résultats" would fail.
|
|
1036
1089
|
await user.type(screen.getByRole('combobox', h), 'ban');
|
|
1037
1090
|
await waitFor(() => {
|
|
1038
|
-
expect(politeRegion()).
|
|
1091
|
+
expect(politeRegion()?.textContent).toBe('1 résultat');
|
|
1039
1092
|
});
|
|
1040
1093
|
});
|
|
1041
1094
|
|
|
1042
|
-
it('announces
|
|
1095
|
+
it('announces the empty-results message when nothing matches', async () => {
|
|
1043
1096
|
const user = userEvent.setup();
|
|
1044
1097
|
render(
|
|
1045
|
-
<
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1098
|
+
<InternationalizationProvider
|
|
1099
|
+
locale="fr"
|
|
1100
|
+
overrides={{
|
|
1101
|
+
fr: {'@astryx.selector.emptySearchResults': 'Aucun résultat'},
|
|
1102
|
+
}}>
|
|
1103
|
+
<Selector
|
|
1104
|
+
label="Fruit"
|
|
1105
|
+
options={OPTIONS}
|
|
1106
|
+
value="Apple"
|
|
1107
|
+
onChange={() => {}}
|
|
1108
|
+
hasSearch
|
|
1109
|
+
/>
|
|
1110
|
+
</InternationalizationProvider>,
|
|
1052
1111
|
);
|
|
1053
1112
|
await user.click(screen.getByRole('button', {name: 'Fruit'}));
|
|
1054
1113
|
await user.type(screen.getByRole('combobox', h), 'xyz');
|
|
1055
1114
|
await waitFor(() => {
|
|
1056
|
-
expect(politeRegion()).
|
|
1115
|
+
expect(politeRegion()?.textContent).toBe('Aucun résultat');
|
|
1057
1116
|
});
|
|
1058
1117
|
});
|
|
1059
1118
|
|
|
@@ -72,7 +72,7 @@ import {
|
|
|
72
72
|
import {useCombobox, useSelectedItemOffset} from './hooks';
|
|
73
73
|
import {useTypeahead} from '../hooks/useTypeahead';
|
|
74
74
|
import {SelectorOption} from './SelectorOption';
|
|
75
|
-
import {getInputARIA, mergeProps} from '../utils';
|
|
75
|
+
import {getInputARIA, isImeKeyEvent, mergeProps} from '../utils';
|
|
76
76
|
import {useSize} from '../SizeContext/SizeContext';
|
|
77
77
|
import type {BaseProps} from '../BaseProps';
|
|
78
78
|
import type {SizeValue} from '../utils/types';
|
|
@@ -844,11 +844,11 @@ export function Selector<T extends SelectorOptionType>(
|
|
|
844
844
|
const count = filterOptionsByQuery(selectableItems, nextQuery).length;
|
|
845
845
|
announce(
|
|
846
846
|
count === 0
|
|
847
|
-
? '
|
|
848
|
-
:
|
|
847
|
+
? t('@astryx.selector.emptySearchResults')
|
|
848
|
+
: t('@astryx.selector.resultCount', {count}),
|
|
849
849
|
);
|
|
850
850
|
},
|
|
851
|
-
[announce, selectableItems],
|
|
851
|
+
[announce, selectableItems, t],
|
|
852
852
|
);
|
|
853
853
|
|
|
854
854
|
// Calculate offset to position selected item over trigger. Explicit
|
|
@@ -1055,6 +1055,15 @@ export function Selector<T extends SelectorOptionType>(
|
|
|
1055
1055
|
}
|
|
1056
1056
|
}}
|
|
1057
1057
|
onKeyDown={e => {
|
|
1058
|
+
// An in-progress IME composition uses these same keys (Enter to
|
|
1059
|
+
// commit the candidate, Escape/Arrows to navigate the candidate
|
|
1060
|
+
// window); the composing keydown fires before compositionend, so
|
|
1061
|
+
// without this guard a Korean/Japanese/Chinese user committing a
|
|
1062
|
+
// syllable with Enter would instead select the highlighted option.
|
|
1063
|
+
// See utils/ime.ts.
|
|
1064
|
+
if (isImeKeyEvent(e.nativeEvent)) {
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1058
1067
|
// Arrow keys navigate options; Enter selects; Escape closes.
|
|
1059
1068
|
// Home/End are left to the input for caret movement (APG editable
|
|
1060
1069
|
// combobox); PageUp/PageDown are the sanctioned substitute for
|
|
@@ -262,6 +262,22 @@ describe('Switch', () => {
|
|
|
262
262
|
expect(screen.getByLabelText('Toggle row')).toBeInTheDocument();
|
|
263
263
|
});
|
|
264
264
|
|
|
265
|
+
it('drops the label gap when isLabelHidden so the row is only as wide as the track', () => {
|
|
266
|
+
const {rerender} = render(
|
|
267
|
+
<Switch
|
|
268
|
+
label="Toggle row"
|
|
269
|
+
isLabelHidden
|
|
270
|
+
value={false}
|
|
271
|
+
onChange={() => {}}
|
|
272
|
+
/>,
|
|
273
|
+
);
|
|
274
|
+
const row = screen.getByRole('switch').parentElement!.parentElement!;
|
|
275
|
+
expect(getComputedStyle(row).gap).toMatch(/^0(px)?$/);
|
|
276
|
+
|
|
277
|
+
rerender(<Switch label="Toggle row" value={false} onChange={() => {}} />);
|
|
278
|
+
expect(getComputedStyle(row).gap).not.toMatch(/^0(px)?$/);
|
|
279
|
+
});
|
|
280
|
+
|
|
265
281
|
it('keeps description linked via aria-describedby when isLabelHidden', () => {
|
|
266
282
|
render(
|
|
267
283
|
<Switch
|
package/src/Switch/Switch.tsx
CHANGED
|
@@ -135,6 +135,12 @@ const styles = stylex.create({
|
|
|
135
135
|
alignItems: 'center',
|
|
136
136
|
gap: spacingVars['--spacing-2'],
|
|
137
137
|
},
|
|
138
|
+
// A hidden label is sr-only, so its wrapper is a zero-width flex item — the
|
|
139
|
+
// row gap would still be painted beside the track, making the field box
|
|
140
|
+
// wider than the control it contains. Matches CheckboxInput.
|
|
141
|
+
containerLabelHidden: {
|
|
142
|
+
gap: 0,
|
|
143
|
+
},
|
|
138
144
|
containerSpread: {
|
|
139
145
|
justifyContent: 'space-between',
|
|
140
146
|
width: '100%',
|
|
@@ -655,6 +661,7 @@ export function Switch({
|
|
|
655
661
|
}}
|
|
656
662
|
{...stylex.props(
|
|
657
663
|
styles.container,
|
|
664
|
+
isLabelHidden && styles.containerLabelHidden,
|
|
658
665
|
labelSpacing === 'spread' && styles.containerSpread,
|
|
659
666
|
!isDisabled && switchScope,
|
|
660
667
|
)}>
|
|
@@ -42,6 +42,29 @@ describe('TimeInput', () => {
|
|
|
42
42
|
expect(screen.getByPlaceholderText('Pick a time')).toBeInTheDocument();
|
|
43
43
|
});
|
|
44
44
|
|
|
45
|
+
it('does not step the time on a composing ArrowUp/ArrowDown (IME)', () => {
|
|
46
|
+
const onChange = vi.fn();
|
|
47
|
+
render(
|
|
48
|
+
<TimeInput
|
|
49
|
+
label="Time"
|
|
50
|
+
value={'14:30' as ISOTimeString}
|
|
51
|
+
onChange={onChange}
|
|
52
|
+
/>,
|
|
53
|
+
);
|
|
54
|
+
const input = screen.getByLabelText('Time');
|
|
55
|
+
|
|
56
|
+
// An IME candidate window navigates with the arrows; a composing keydown
|
|
57
|
+
// (isComposing / legacy keyCode 229) must not step the time value.
|
|
58
|
+
fireEvent.keyDown(input, {key: 'ArrowUp', isComposing: true});
|
|
59
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
60
|
+
fireEvent.keyDown(input, {key: 'ArrowDown', keyCode: 229});
|
|
61
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
62
|
+
|
|
63
|
+
// A real, non-composing ArrowUp still steps the time by one minute.
|
|
64
|
+
fireEvent.keyDown(input, {key: 'ArrowUp'});
|
|
65
|
+
expect(onChange).toHaveBeenCalledWith('14:31');
|
|
66
|
+
});
|
|
67
|
+
|
|
45
68
|
it('displays formatted time in 12h format', () => {
|
|
46
69
|
render(
|
|
47
70
|
<TimeInput
|
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
formatDisplayTime24h,
|
|
56
56
|
formatISOTime,
|
|
57
57
|
adjustTime,
|
|
58
|
+
isImeKeyEvent,
|
|
58
59
|
isTimeInRange,
|
|
59
60
|
mergeProps,
|
|
60
61
|
mergeRefs,
|
|
@@ -530,6 +531,13 @@ export function TimeInput({
|
|
|
530
531
|
// Handle keyboard navigation on input
|
|
531
532
|
const handleInputKeyDown = useCallback(
|
|
532
533
|
(e: KeyboardEvent<HTMLInputElement>) => {
|
|
534
|
+
// ArrowUp/ArrowDown step the time and preventDefault; an IME candidate
|
|
535
|
+
// window uses those same arrows to navigate candidates, so guard the
|
|
536
|
+
// composing keydown (fires before compositionend) to avoid stealing them
|
|
537
|
+
// mid-composition. See utils/ime.ts.
|
|
538
|
+
if (isImeKeyEvent(e.nativeEvent)) {
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
533
541
|
// Arrow-key adjustment mutates the value; block it while showing a
|
|
534
542
|
// disabled reason (the input keeps focusability via aria-disabled).
|
|
535
543
|
if (isDisabled) {
|
|
@@ -16,6 +16,16 @@ import {Tokenizer} from './Tokenizer';
|
|
|
16
16
|
import {__resetLiveRegionsForTest} from '../hooks/useAnnounce';
|
|
17
17
|
import type {SearchSource, SearchableItem} from '../Typeahead/types';
|
|
18
18
|
import {TestIcon} from '../__tests__/TestIcon';
|
|
19
|
+
import {InternationalizationProvider} from '../i18n';
|
|
20
|
+
|
|
21
|
+
// Test-supplied announcement strings: the assertions below depend on no
|
|
22
|
+
// catalog, and no hardcoded English in the component can satisfy them.
|
|
23
|
+
const TOKEN_MESSAGES = {
|
|
24
|
+
fr: {
|
|
25
|
+
'@astryx.tokenizer.tokenAdded': 'Ajouté : {label}',
|
|
26
|
+
'@astryx.tokenizer.tokenRemoved': 'Retiré : {label}',
|
|
27
|
+
},
|
|
28
|
+
};
|
|
19
29
|
|
|
20
30
|
function politeRegion(): HTMLElement | null {
|
|
21
31
|
return document.querySelector('[data-astryx-live-region="polite"]');
|
|
@@ -980,12 +990,14 @@ describe('Tokenizer', () => {
|
|
|
980
990
|
it('announces removal politely on Backspace with an empty input', async () => {
|
|
981
991
|
const onChange = vi.fn();
|
|
982
992
|
render(
|
|
983
|
-
<
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
993
|
+
<InternationalizationProvider locale="fr" overrides={TOKEN_MESSAGES}>
|
|
994
|
+
<Tokenizer
|
|
995
|
+
label="Members"
|
|
996
|
+
searchSource={userSource}
|
|
997
|
+
value={[users[0], users[1]]}
|
|
998
|
+
onChange={onChange}
|
|
999
|
+
/>
|
|
1000
|
+
</InternationalizationProvider>,
|
|
989
1001
|
);
|
|
990
1002
|
const input = screen.getByRole('combobox');
|
|
991
1003
|
fireEvent.keyDown(input, {key: 'Backspace'});
|
|
@@ -994,35 +1006,39 @@ describe('Tokenizer', () => {
|
|
|
994
1006
|
type: 'remove',
|
|
995
1007
|
});
|
|
996
1008
|
await waitFor(() => {
|
|
997
|
-
expect(politeRegion()).
|
|
1009
|
+
expect(politeRegion()?.textContent).toBe('Retiré : Bob');
|
|
998
1010
|
});
|
|
999
1011
|
});
|
|
1000
1012
|
|
|
1001
1013
|
it("announces removal politely when clicking a token's remove button", async () => {
|
|
1002
1014
|
render(
|
|
1003
|
-
<
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1015
|
+
<InternationalizationProvider locale="fr" overrides={TOKEN_MESSAGES}>
|
|
1016
|
+
<Tokenizer
|
|
1017
|
+
label="Members"
|
|
1018
|
+
searchSource={userSource}
|
|
1019
|
+
value={[users[0], users[1]]}
|
|
1020
|
+
onChange={() => {}}
|
|
1021
|
+
/>
|
|
1022
|
+
</InternationalizationProvider>,
|
|
1009
1023
|
);
|
|
1010
1024
|
fireEvent.click(screen.getByRole('button', {name: 'Remove Alice'}));
|
|
1011
1025
|
await waitFor(() => {
|
|
1012
|
-
expect(politeRegion()).
|
|
1026
|
+
expect(politeRegion()?.textContent).toBe('Retiré : Alice');
|
|
1013
1027
|
});
|
|
1014
1028
|
});
|
|
1015
1029
|
|
|
1016
1030
|
it('announces addition politely when selecting a search result', async () => {
|
|
1017
1031
|
render(
|
|
1018
|
-
<
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1032
|
+
<InternationalizationProvider locale="fr" overrides={TOKEN_MESSAGES}>
|
|
1033
|
+
<Tokenizer
|
|
1034
|
+
label="Members"
|
|
1035
|
+
searchSource={userSource}
|
|
1036
|
+
value={[]}
|
|
1037
|
+
onChange={() => {}}
|
|
1038
|
+
hasEntriesOnFocus
|
|
1039
|
+
debounceMs={0}
|
|
1040
|
+
/>
|
|
1041
|
+
</InternationalizationProvider>,
|
|
1026
1042
|
);
|
|
1027
1043
|
const input = screen.getByRole('combobox');
|
|
1028
1044
|
fireEvent.focus(input);
|
|
@@ -1031,7 +1047,7 @@ describe('Tokenizer', () => {
|
|
|
1031
1047
|
});
|
|
1032
1048
|
fireEvent.click(screen.getByText('Alice'));
|
|
1033
1049
|
await waitFor(() => {
|
|
1034
|
-
expect(politeRegion()).
|
|
1050
|
+
expect(politeRegion()?.textContent).toBe('Ajouté : Alice');
|
|
1035
1051
|
});
|
|
1036
1052
|
});
|
|
1037
1053
|
|
|
@@ -1041,14 +1057,16 @@ describe('Tokenizer', () => {
|
|
|
1041
1057
|
bootstrap: () => [],
|
|
1042
1058
|
};
|
|
1043
1059
|
render(
|
|
1044
|
-
<
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1060
|
+
<InternationalizationProvider locale="fr" overrides={TOKEN_MESSAGES}>
|
|
1061
|
+
<Tokenizer
|
|
1062
|
+
label="Tags"
|
|
1063
|
+
searchSource={emptySource}
|
|
1064
|
+
value={[]}
|
|
1065
|
+
onChange={() => {}}
|
|
1066
|
+
hasCreate
|
|
1067
|
+
debounceMs={0}
|
|
1068
|
+
/>
|
|
1069
|
+
</InternationalizationProvider>,
|
|
1052
1070
|
);
|
|
1053
1071
|
const input = screen.getByRole('combobox');
|
|
1054
1072
|
await act(async () => {
|
|
@@ -1059,7 +1077,7 @@ describe('Tokenizer', () => {
|
|
|
1059
1077
|
});
|
|
1060
1078
|
fireEvent.click(screen.getByText('Create "new-tag"'));
|
|
1061
1079
|
await waitFor(() => {
|
|
1062
|
-
expect(politeRegion()).
|
|
1080
|
+
expect(politeRegion()?.textContent).toBe('Ajouté : new-tag');
|
|
1063
1081
|
});
|
|
1064
1082
|
});
|
|
1065
1083
|
|
|
@@ -600,7 +600,7 @@ export function Tokenizer<T extends SearchableItem>({
|
|
|
600
600
|
const realItem = base as T;
|
|
601
601
|
const newItems = [...value, realItem];
|
|
602
602
|
onChange(newItems, {item: realItem, type: 'create'});
|
|
603
|
-
announce(
|
|
603
|
+
announce(t('@astryx.tokenizer.tokenAdded', {label: createdValue}));
|
|
604
604
|
return;
|
|
605
605
|
}
|
|
606
606
|
|
|
@@ -609,9 +609,9 @@ export function Tokenizer<T extends SearchableItem>({
|
|
|
609
609
|
}
|
|
610
610
|
const newItems = [...value, item];
|
|
611
611
|
onChange(newItems, {item, type: 'add'});
|
|
612
|
-
announce(
|
|
612
|
+
announce(t('@astryx.tokenizer.tokenAdded', {label: item.label}));
|
|
613
613
|
},
|
|
614
|
-
[value, onChange, isAtMax, selectedIds, hasCreate, announce],
|
|
614
|
+
[value, onChange, isAtMax, selectedIds, hasCreate, announce, t],
|
|
615
615
|
);
|
|
616
616
|
|
|
617
617
|
// Handle removing an item. Single removal path: both Backspace on an empty
|
|
@@ -621,10 +621,10 @@ export function Tokenizer<T extends SearchableItem>({
|
|
|
621
621
|
(item: T) => {
|
|
622
622
|
const newItems = value.filter(v => v.id !== item.id);
|
|
623
623
|
onChange(newItems, {item, type: 'remove'});
|
|
624
|
-
announce(
|
|
624
|
+
announce(t('@astryx.tokenizer.tokenRemoved', {label: item.label}));
|
|
625
625
|
inputRef.current?.focus();
|
|
626
626
|
},
|
|
627
|
-
[value, onChange, announce],
|
|
627
|
+
[value, onChange, announce, t],
|
|
628
628
|
);
|
|
629
629
|
|
|
630
630
|
// Handle clearing all items
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
} from '../Layer/useLayer';
|
|
29
29
|
import {layerAnimations} from '../Layer/layerAnimations.stylex';
|
|
30
30
|
import {themeProps} from '../utils/themeProps';
|
|
31
|
+
import {isImeKeyEvent} from '../utils/ime';
|
|
31
32
|
import {
|
|
32
33
|
colorVars,
|
|
33
34
|
radiusVars,
|
|
@@ -454,7 +455,9 @@ export function useTooltip(options: TooltipOptions = {}): TooltipReturn {
|
|
|
454
455
|
if (e.key !== 'Escape') {
|
|
455
456
|
return;
|
|
456
457
|
}
|
|
457
|
-
if (e
|
|
458
|
+
if (isImeKeyEvent(e)) {
|
|
459
|
+
// Ignore Escape that is committing/cancelling an IME composition;
|
|
460
|
+
// see utils/ime.ts for why.
|
|
458
461
|
return;
|
|
459
462
|
}
|
|
460
463
|
clearTimeouts();
|
|
@@ -30,7 +30,7 @@ import * as stylex from '@stylexjs/stylex';
|
|
|
30
30
|
import type {StyleXStyles} from '@stylexjs/stylex';
|
|
31
31
|
import {usePopover} from '../Popover/usePopover';
|
|
32
32
|
import {useAnnounce} from '../hooks/useAnnounce';
|
|
33
|
-
import {isImeKeyEvent} from '../
|
|
33
|
+
import {isImeKeyEvent} from '../utils/ime';
|
|
34
34
|
import {TypeaheadItem} from './TypeaheadItem';
|
|
35
35
|
import {Icon} from '../Icon';
|
|
36
36
|
import {
|
|
@@ -449,7 +449,7 @@ export const BaseTypeahead = function BaseTypeahead<T extends SearchableItem>({
|
|
|
449
449
|
announce(
|
|
450
450
|
shown.length === 0
|
|
451
451
|
? emptySearchResultsText
|
|
452
|
-
:
|
|
452
|
+
: t('@astryx.typeahead.resultCount', {count: shown.length}),
|
|
453
453
|
);
|
|
454
454
|
}
|
|
455
455
|
} catch {
|
|
@@ -464,7 +464,14 @@ export const BaseTypeahead = function BaseTypeahead<T extends SearchableItem>({
|
|
|
464
464
|
}
|
|
465
465
|
}
|
|
466
466
|
},
|
|
467
|
-
[
|
|
467
|
+
[
|
|
468
|
+
searchSource,
|
|
469
|
+
maxMenuItems,
|
|
470
|
+
showLayer,
|
|
471
|
+
announce,
|
|
472
|
+
emptySearchResultsText,
|
|
473
|
+
t,
|
|
474
|
+
],
|
|
468
475
|
);
|
|
469
476
|
|
|
470
477
|
// Perform bootstrap
|
|
@@ -23,6 +23,7 @@ import userEvent from '@testing-library/user-event';
|
|
|
23
23
|
import {Typeahead} from './Typeahead';
|
|
24
24
|
import {BaseTypeahead} from './BaseTypeahead';
|
|
25
25
|
import type {SearchSource, SearchableItem} from './types';
|
|
26
|
+
import {InternationalizationProvider} from '../i18n';
|
|
26
27
|
|
|
27
28
|
// Store original matches to restore later
|
|
28
29
|
const originalMatches = HTMLElement.prototype.matches;
|
|
@@ -132,21 +133,94 @@ describe('BaseTypeahead', () => {
|
|
|
132
133
|
|
|
133
134
|
it('announces the result count to a live region (comboboxes-6)', async () => {
|
|
134
135
|
render(
|
|
135
|
-
<
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
136
|
+
<InternationalizationProvider
|
|
137
|
+
locale="fr"
|
|
138
|
+
overrides={{
|
|
139
|
+
fr: {
|
|
140
|
+
'@astryx.typeahead.resultCount':
|
|
141
|
+
'{count, number} {count, plural, one {résultat} other {résultats}}',
|
|
142
|
+
},
|
|
143
|
+
}}>
|
|
144
|
+
<BaseTypeahead
|
|
145
|
+
searchSource={fruitSource}
|
|
146
|
+
value={null}
|
|
147
|
+
onChange={() => {}}
|
|
148
|
+
debounceMs={0}
|
|
149
|
+
/>
|
|
150
|
+
</InternationalizationProvider>,
|
|
141
151
|
);
|
|
142
152
|
const input = screen.getByRole('combobox');
|
|
153
|
+
// "Ap" matches Apple only — the singular ICU branch.
|
|
143
154
|
fireEvent.change(input, {target: {value: 'Ap'}});
|
|
144
155
|
|
|
145
156
|
await waitFor(() => {
|
|
146
157
|
const region = document.querySelector(
|
|
147
158
|
'[data-astryx-live-region="polite"]',
|
|
148
159
|
);
|
|
149
|
-
|
|
160
|
+
// Exact, so the plural branch ("1 résultats") would fail.
|
|
161
|
+
expect(region?.textContent).toBe('1 résultat');
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('announces the plural result count', async () => {
|
|
166
|
+
render(
|
|
167
|
+
<InternationalizationProvider
|
|
168
|
+
locale="fr"
|
|
169
|
+
overrides={{
|
|
170
|
+
fr: {
|
|
171
|
+
'@astryx.typeahead.resultCount':
|
|
172
|
+
'{count, number} {count, plural, one {résultat} other {résultats}}',
|
|
173
|
+
},
|
|
174
|
+
}}>
|
|
175
|
+
<BaseTypeahead
|
|
176
|
+
searchSource={fruitSource}
|
|
177
|
+
value={null}
|
|
178
|
+
onChange={() => {}}
|
|
179
|
+
debounceMs={0}
|
|
180
|
+
/>
|
|
181
|
+
</InternationalizationProvider>,
|
|
182
|
+
);
|
|
183
|
+
const input = screen.getByRole('combobox');
|
|
184
|
+
// "err" matches Cherry and Elderberry.
|
|
185
|
+
fireEvent.change(input, {target: {value: 'err'}});
|
|
186
|
+
|
|
187
|
+
await waitFor(() => {
|
|
188
|
+
const region = document.querySelector(
|
|
189
|
+
'[data-astryx-live-region="polite"]',
|
|
190
|
+
);
|
|
191
|
+
expect(region?.textContent).toBe('2 résultats');
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('speaks the result count from a provider catalog', async () => {
|
|
196
|
+
render(
|
|
197
|
+
<InternationalizationProvider
|
|
198
|
+
locale="fr"
|
|
199
|
+
messages={{
|
|
200
|
+
fr: {
|
|
201
|
+
'@astryx.typeahead.resultCount': {
|
|
202
|
+
defaultMessage:
|
|
203
|
+
'{count, number} {count, plural, one {résultat} other {résultats}}',
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
}}>
|
|
207
|
+
<BaseTypeahead
|
|
208
|
+
searchSource={fruitSource}
|
|
209
|
+
value={null}
|
|
210
|
+
onChange={() => {}}
|
|
211
|
+
debounceMs={0}
|
|
212
|
+
/>
|
|
213
|
+
</InternationalizationProvider>,
|
|
214
|
+
);
|
|
215
|
+
const input = screen.getByRole('combobox');
|
|
216
|
+
fireEvent.change(input, {target: {value: 'err'}});
|
|
217
|
+
|
|
218
|
+
await waitFor(() => {
|
|
219
|
+
const region = document.querySelector(
|
|
220
|
+
'[data-astryx-live-region="polite"]',
|
|
221
|
+
);
|
|
222
|
+
// Same key through the catalog path rather than `overrides`.
|
|
223
|
+
expect(region?.textContent).toBe('2 résultats');
|
|
150
224
|
});
|
|
151
225
|
});
|
|
152
226
|
|
|
@@ -796,6 +870,41 @@ describe('Typeahead edit mode', () => {
|
|
|
796
870
|
// onChange should not have been called — value restored
|
|
797
871
|
expect(onChange).not.toHaveBeenCalled();
|
|
798
872
|
});
|
|
873
|
+
|
|
874
|
+
it('does not exit edit mode on a composing Escape (IME)', async () => {
|
|
875
|
+
const onChange = vi.fn();
|
|
876
|
+
render(
|
|
877
|
+
<Typeahead
|
|
878
|
+
label="Fruit"
|
|
879
|
+
searchSource={fruitSource}
|
|
880
|
+
value={fruits[0]}
|
|
881
|
+
onChange={onChange}
|
|
882
|
+
/>,
|
|
883
|
+
);
|
|
884
|
+
|
|
885
|
+
// Enter edit mode by clicking the token; the input uncollapses and
|
|
886
|
+
// rejoins the Tab order (tabindex is cleared).
|
|
887
|
+
const tokenText = screen.getByText(fruits[0].label);
|
|
888
|
+
fireEvent.click(tokenText.closest('div')!);
|
|
889
|
+
await act(async () => {
|
|
890
|
+
await new Promise(r => requestAnimationFrame(r));
|
|
891
|
+
});
|
|
892
|
+
const input = screen.getByRole('combobox');
|
|
893
|
+
expect(input).not.toHaveAttribute('tabindex', '-1');
|
|
894
|
+
|
|
895
|
+
// BaseTypeahead invokes this external handler before its own IME guard, so
|
|
896
|
+
// a composing Escape (isComposing / legacy keyCode 229) — which an IME uses
|
|
897
|
+
// to cancel the pending candidate — must not exit edit mode here.
|
|
898
|
+
fireEvent.keyDown(input, {key: 'Escape', isComposing: true});
|
|
899
|
+
expect(screen.getByRole('combobox')).not.toHaveAttribute('tabindex', '-1');
|
|
900
|
+
fireEvent.keyDown(input, {key: 'Escape', keyCode: 229});
|
|
901
|
+
expect(screen.getByRole('combobox')).not.toHaveAttribute('tabindex', '-1');
|
|
902
|
+
|
|
903
|
+
// A real, non-composing Escape still exits edit mode: the token is
|
|
904
|
+
// restored and the collapsed input drops back out of the Tab order.
|
|
905
|
+
fireEvent.keyDown(input, {key: 'Escape'});
|
|
906
|
+
expect(screen.getByRole('combobox')).toHaveAttribute('tabindex', '-1');
|
|
907
|
+
});
|
|
799
908
|
});
|
|
800
909
|
|
|
801
910
|
describe('Typeahead collapsed input tab order', () => {
|