@astryxdesign/core 0.4.3 → 0.4.4-canary.0e8af82
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 +75 -0
- package/dist/BottomSheet/BottomSheet.d.ts.map +1 -0
- package/dist/BottomSheet/BottomSheet.js +396 -0
- package/dist/BottomSheet/BottomSheetPanel.d.ts +63 -0
- package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -0
- package/dist/BottomSheet/BottomSheetPanel.js +441 -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 +109 -0
- package/dist/BottomSheet/snapOffsets.d.ts.map +1 -0
- package/dist/BottomSheet/snapOffsets.js +206 -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 +185 -0
- package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -0
- package/dist/BottomSheet/useSheetGestures.js +1038 -0
- package/dist/Calendar/Calendar.d.ts +17 -0
- package/dist/Calendar/Calendar.d.ts.map +1 -1
- package/dist/Calendar/Calendar.js +25 -1
- package/dist/Calendar/hooks/useCalendarConstraints.d.ts +22 -1
- package/dist/Calendar/hooks/useCalendarConstraints.d.ts.map +1 -1
- package/dist/Calendar/hooks/useCalendarConstraints.js +26 -4
- package/dist/Chat/ChatComposerInput.d.ts.map +1 -1
- package/dist/Chat/ChatComposerInput.js +3 -4
- package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
- package/dist/CheckboxInput/CheckboxInput.js +9 -0
- package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
- package/dist/ComplexSelector/ComplexSelector.js +18 -7
- 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 +15 -2
- package/dist/DateRangeInput/DateRangeInput.d.ts +23 -1
- package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
- package/dist/DateRangeInput/DateRangeInput.js +38 -3
- package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
- package/dist/DateTimeInput/DateTimeInput.js +38 -3
- package/dist/Dialog/Dialog.d.ts.map +1 -1
- package/dist/Dialog/Dialog.js +2 -1
- package/dist/Field/FieldLabel.d.ts +2 -1
- package/dist/Field/FieldLabel.d.ts.map +1 -1
- package/dist/Field/FieldLabel.js +21 -3
- package/dist/Field/InputClearButton.d.ts +2 -1
- package/dist/Field/InputClearButton.d.ts.map +1 -1
- package/dist/Field/InputClearButton.js +6 -1
- package/dist/FormLayout/FormLayout.d.ts +24 -2
- package/dist/FormLayout/FormLayout.d.ts.map +1 -1
- package/dist/FormLayout/FormLayout.js +7 -2
- package/dist/FormLayout/FormLayoutContext.d.ts +15 -2
- package/dist/FormLayout/FormLayoutContext.d.ts.map +1 -1
- package/dist/FormLayout/FormLayoutContext.js +19 -4
- package/dist/FormLayout/index.d.ts +1 -1
- package/dist/FormLayout/index.d.ts.map +1 -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 +27 -8
- package/dist/NumberInput/NumberInput.d.ts.map +1 -1
- package/dist/NumberInput/NumberInput.js +15 -2
- package/dist/PowerSearch/PowerSearchEditPopover.js +1 -1
- package/dist/RadioList/RadioList.d.ts.map +1 -1
- package/dist/RadioList/RadioList.js +12 -1
- package/dist/Selector/Selector.d.ts.map +1 -1
- package/dist/Selector/Selector.js +20 -4
- package/dist/StatusDot/StatusDot.d.ts +43 -3
- package/dist/StatusDot/StatusDot.d.ts.map +1 -1
- package/dist/StatusDot/StatusDot.js +42 -5
- package/dist/Switch/Switch.d.ts.map +1 -1
- package/dist/Switch/Switch.js +14 -1
- package/dist/Table/plugins/rowExpansion/useTableRowExpansion.d.ts.map +1 -1
- package/dist/Table/plugins/rowExpansion/useTableRowExpansion.js +7 -2
- package/dist/TextArea/TextArea.d.ts.map +1 -1
- package/dist/TextArea/TextArea.js +6 -1
- package/dist/TextInput/TextInput.d.ts.map +1 -1
- package/dist/TextInput/TextInput.js +6 -1
- package/dist/TimeInput/TimeInput.d.ts.map +1 -1
- package/dist/TimeInput/TimeInput.js +14 -2
- 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/hooks/useResolvedRequired.d.ts +19 -0
- package/dist/hooks/useResolvedRequired.d.ts.map +1 -0
- package/dist/hooks/useResolvedRequired.js +40 -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/dist/utils/plainDate.d.ts +6 -0
- package/dist/utils/plainDate.d.ts.map +1 -1
- package/dist/utils/plainDate.js +12 -0
- package/locales/af-ZA.json +958 -0
- package/locales/ar-SA.json +998 -0
- package/locales/ca-ES.json +974 -0
- package/locales/cs-CZ.json +990 -0
- package/locales/da-DK.json +966 -0
- package/locales/de-DE.json +966 -0
- package/locales/el-GR.json +990 -0
- package/locales/en.json +52 -0
- package/locales/es-ES.json +978 -0
- package/locales/fi-FI.json +990 -0
- package/locales/fr-FR.json +928 -0
- package/locales/he-IL.json +990 -0
- package/locales/hu-HU.json +990 -0
- package/locales/it-IT.json +978 -0
- package/locales/ja-JP.json +998 -0
- package/locales/ko-KR.json +990 -0
- package/locales/nl-NL.json +954 -0
- package/locales/no-NO.json +978 -0
- package/locales/pl-PL.json +982 -0
- package/locales/pseudo.json +39 -0
- package/locales/pt-BR.json +982 -0
- package/locales/pt-PT.json +982 -0
- package/locales/ro-RO.json +982 -0
- package/locales/ru-RU.json +990 -0
- package/locales/sr-SP.json +990 -0
- package/locales/sv-SE.json +986 -0
- package/locales/tr-TR.json +990 -0
- package/locales/uk-UA.json +990 -0
- package/locales/vi-VN.json +986 -0
- package/locales/zh-CN.json +998 -0
- package/locales/zh-TW.json +998 -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 +299 -0
- package/src/BottomSheet/BottomSheet.test.tsx +2265 -0
- package/src/BottomSheet/BottomSheet.tsx +559 -0
- package/src/BottomSheet/BottomSheetPanel.test.tsx +240 -0
- package/src/BottomSheet/BottomSheetPanel.tsx +601 -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 +19 -0
- package/src/BottomSheet/snapOffsets.test.ts +254 -0
- package/src/BottomSheet/snapOffsets.ts +245 -0
- package/src/BottomSheet/useMobileKeyboard.ts +499 -0
- package/src/BottomSheet/useSheetGestures.test.ts +1112 -0
- package/src/BottomSheet/useSheetGestures.ts +1503 -0
- package/src/Calendar/Calendar.doc.mjs +16 -0
- package/src/Calendar/Calendar.test.tsx +96 -0
- package/src/Calendar/Calendar.tsx +55 -0
- package/src/Calendar/hooks/useCalendarConstraints.ts +54 -3
- package/src/Chat/ChatComposerInput.tsx +3 -4
- package/src/CheckboxInput/CheckboxInput.tsx +6 -0
- package/src/ComplexSelector/ComplexSelector.tsx +15 -6
- package/src/ContextMenu/ContextMenu.tsx +4 -2
- package/src/DateInput/DateInput.test.tsx +21 -0
- package/src/DateInput/DateInput.tsx +12 -2
- package/src/DateRangeInput/DateRangeInput.doc.mjs +16 -0
- package/src/DateRangeInput/DateRangeInput.test.tsx +81 -1
- package/src/DateRangeInput/DateRangeInput.tsx +72 -1
- package/src/DateTimeInput/DateTimeInput.doc.mjs +4 -0
- package/src/DateTimeInput/DateTimeInput.test.tsx +137 -0
- package/src/DateTimeInput/DateTimeInput.tsx +43 -4
- package/src/Dialog/Dialog.tsx +2 -1
- package/src/Field/Field.doc.mjs +1 -0
- package/src/Field/FieldLabel.tsx +20 -4
- package/src/Field/InputClearButton.test.tsx +25 -0
- package/src/Field/InputClearButton.tsx +4 -1
- package/src/FileInput/FileInput.test.tsx +53 -9
- package/src/FormLayout/FormLayout.doc.mjs +13 -0
- package/src/FormLayout/FormLayout.test.tsx +181 -4
- package/src/FormLayout/FormLayout.tsx +33 -2
- package/src/FormLayout/FormLayoutContext.ts +22 -7
- package/src/FormLayout/index.ts +1 -1
- 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 +25 -9
- package/src/NumberInput/NumberInput.test.tsx +18 -0
- package/src/NumberInput/NumberInput.tsx +12 -2
- package/src/PowerSearch/PowerSearchEditPopover.tsx +1 -1
- package/src/RadioList/RadioList.tsx +9 -1
- package/src/Selector/Selector.test.tsx +85 -26
- package/src/Selector/Selector.tsx +16 -5
- package/src/StatusDot/StatusDot.doc.mjs +13 -0
- package/src/StatusDot/StatusDot.test.tsx +115 -8
- package/src/StatusDot/StatusDot.tsx +74 -9
- package/src/Switch/Switch.test.tsx +16 -0
- package/src/Switch/Switch.tsx +13 -0
- package/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +20 -2
- package/src/TextArea/TextArea.tsx +3 -1
- package/src/TextInput/TextInput.tsx +3 -1
- package/src/TimeInput/TimeInput.test.tsx +23 -0
- package/src/TimeInput/TimeInput.tsx +11 -1
- 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/hooks/useResolvedRequired.ts +42 -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/src/utils/plainDate.test.ts +50 -0
- package/src/utils/plainDate.ts +12 -0
- package/dist/astryx.umd.js +0 -147
- package/dist/astryx.umd.js.map +0 -7
|
@@ -23,6 +23,7 @@ import {spacingVars, colorVars, radiusVars} from '../../../theme/tokens.stylex';
|
|
|
23
23
|
import {Icon} from '../../../Icon';
|
|
24
24
|
import {resolveContextActions} from '../../tableContextMenu';
|
|
25
25
|
import {useTranslator} from '../../../i18n';
|
|
26
|
+
import {rtlStyles} from '../../../utils';
|
|
26
27
|
import type {
|
|
27
28
|
TablePlugin,
|
|
28
29
|
TableColumn,
|
|
@@ -92,8 +93,20 @@ const expansionStyles = stylex.create({
|
|
|
92
93
|
color: colorVars['--color-icon-primary'],
|
|
93
94
|
},
|
|
94
95
|
},
|
|
96
|
+
// The RTL mirror is folded into each state's transform rather than living
|
|
97
|
+
// on a parent span, matching TreeListItem's chevron (both are `transform`,
|
|
98
|
+
// so on one element the later value would win).
|
|
95
99
|
chevronExpanded: {
|
|
96
|
-
transform:
|
|
100
|
+
transform: {
|
|
101
|
+
default: 'rotate(90deg)',
|
|
102
|
+
':is([dir="rtl"] *)': 'scaleX(-1) rotate(90deg)',
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
chevronCollapsed: {
|
|
106
|
+
transform: {
|
|
107
|
+
default: 'rotate(0deg)',
|
|
108
|
+
':is([dir="rtl"] *)': 'scaleX(-1) rotate(0deg)',
|
|
109
|
+
},
|
|
97
110
|
},
|
|
98
111
|
expandedRow: {
|
|
99
112
|
backgroundColor: colorVars['--color-background-muted'],
|
|
@@ -121,7 +134,9 @@ function ExpansionChevron({
|
|
|
121
134
|
type="button"
|
|
122
135
|
{...stylex.props(
|
|
123
136
|
expansionStyles.chevronButton,
|
|
124
|
-
isExpanded
|
|
137
|
+
isExpanded
|
|
138
|
+
? expansionStyles.chevronExpanded
|
|
139
|
+
: expansionStyles.chevronCollapsed,
|
|
125
140
|
)}
|
|
126
141
|
onClick={e => {
|
|
127
142
|
e.stopPropagation();
|
|
@@ -249,6 +264,9 @@ export function useTableRowExpansion<T extends Record<string, unknown>>(
|
|
|
249
264
|
icon={isExpanded ? 'chevronDown' : 'chevronRight'}
|
|
250
265
|
size="xsm"
|
|
251
266
|
aria-hidden
|
|
267
|
+
// chevronDown needs no mirroring; chevronRight (collapsed,
|
|
268
|
+
// pointing toward the reveal direction) does.
|
|
269
|
+
xstyle={!isExpanded && rtlStyles.mirror}
|
|
252
270
|
/>
|
|
253
271
|
),
|
|
254
272
|
onSelect: () => onToggle(key),
|
|
@@ -52,6 +52,7 @@ import type {SizeValue} from '../utils/types';
|
|
|
52
52
|
import {useInputContainer} from '../hooks/useInputContainer';
|
|
53
53
|
import {useInputStatusIcon} from '../hooks/useInputStatusIcon';
|
|
54
54
|
import {useAnnounce} from '../hooks/useAnnounce';
|
|
55
|
+
import {useResolvedRequired} from '../hooks/useResolvedRequired';
|
|
55
56
|
import {useSize} from '../SizeContext/SizeContext';
|
|
56
57
|
import {themeProps} from '../utils/themeProps';
|
|
57
58
|
import {characterCount} from '../utils/characters';
|
|
@@ -394,6 +395,7 @@ export function TextArea({
|
|
|
394
395
|
}: TextAreaProps) {
|
|
395
396
|
const size = useSize(sizeProp, 'md');
|
|
396
397
|
const t = useTranslator();
|
|
398
|
+
const isEffectivelyRequired = useResolvedRequired({isRequired, isOptional});
|
|
397
399
|
const announce = useAnnounce();
|
|
398
400
|
const id = useId();
|
|
399
401
|
const descriptionID = useId();
|
|
@@ -598,7 +600,7 @@ export function TextArea({
|
|
|
598
600
|
autoFocus={hasAutoFocus}
|
|
599
601
|
data-autofocus={hasAutoFocus || undefined}
|
|
600
602
|
aria-describedby={ariaDescribedBy}
|
|
601
|
-
aria-required={
|
|
603
|
+
aria-required={isEffectivelyRequired ? 'true' : undefined}
|
|
602
604
|
aria-invalid={
|
|
603
605
|
status?.type === 'error' ||
|
|
604
606
|
(maxLength != null && valueLength > maxLength)
|
|
@@ -101,6 +101,7 @@ import {mergeProps, mergeRefs} from '../utils';
|
|
|
101
101
|
import {useSize} from '../SizeContext/SizeContext';
|
|
102
102
|
import {useInputContainer} from '../hooks/useInputContainer';
|
|
103
103
|
import {useInputStatusIcon} from '../hooks/useInputStatusIcon';
|
|
104
|
+
import {useResolvedRequired} from '../hooks/useResolvedRequired';
|
|
104
105
|
import {useInputGroup} from '../InputGroup/InputGroupContext';
|
|
105
106
|
import type {BaseProps} from '../BaseProps';
|
|
106
107
|
import type {SizeValue} from '../utils/types';
|
|
@@ -301,6 +302,7 @@ export function TextInput({
|
|
|
301
302
|
...rest
|
|
302
303
|
}: TextInputProps) {
|
|
303
304
|
const t = useTranslator();
|
|
305
|
+
const isEffectivelyRequired = useResolvedRequired({isRequired, isOptional});
|
|
304
306
|
const size = useSize(sizeProp, 'md');
|
|
305
307
|
|
|
306
308
|
const id = useId();
|
|
@@ -445,7 +447,7 @@ export function TextInput({
|
|
|
445
447
|
autoFocus={hasAutoFocus}
|
|
446
448
|
data-autofocus={hasAutoFocus || undefined}
|
|
447
449
|
aria-describedby={ariaDescribedBy}
|
|
448
|
-
aria-required={
|
|
450
|
+
aria-required={isEffectivelyRequired ? 'true' : undefined}
|
|
449
451
|
aria-invalid={status?.type === 'error' ? 'true' : undefined}
|
|
450
452
|
aria-busy={isBusy || undefined}
|
|
451
453
|
aria-labelledby={ariaLabelledBy}
|
|
@@ -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,
|
|
@@ -66,6 +67,7 @@ import {useSize} from '../SizeContext/SizeContext';
|
|
|
66
67
|
import {useAnnounce} from '../hooks/useAnnounce';
|
|
67
68
|
import {useInputContainer} from '../hooks/useInputContainer';
|
|
68
69
|
import {useInputStatusIcon} from '../hooks/useInputStatusIcon';
|
|
70
|
+
import {useResolvedRequired} from '../hooks/useResolvedRequired';
|
|
69
71
|
import {useInputGroup} from '../InputGroup/InputGroupContext';
|
|
70
72
|
import {groupStyles} from '../InputGroup/groupStyles';
|
|
71
73
|
import {useTooltip} from '../Tooltip';
|
|
@@ -346,6 +348,7 @@ export function TimeInput({
|
|
|
346
348
|
ref,
|
|
347
349
|
}: TimeInputProps) {
|
|
348
350
|
const t = useTranslator();
|
|
351
|
+
const isEffectivelyRequired = useResolvedRequired({isRequired, isOptional});
|
|
349
352
|
const placeholder =
|
|
350
353
|
placeholderFromProps ?? t('@astryx.timeInput.placeholder');
|
|
351
354
|
const size = useSize(sizeProp, 'md');
|
|
@@ -530,6 +533,13 @@ export function TimeInput({
|
|
|
530
533
|
// Handle keyboard navigation on input
|
|
531
534
|
const handleInputKeyDown = useCallback(
|
|
532
535
|
(e: KeyboardEvent<HTMLInputElement>) => {
|
|
536
|
+
// ArrowUp/ArrowDown step the time and preventDefault; an IME candidate
|
|
537
|
+
// window uses those same arrows to navigate candidates, so guard the
|
|
538
|
+
// composing keydown (fires before compositionend) to avoid stealing them
|
|
539
|
+
// mid-composition. See utils/ime.ts.
|
|
540
|
+
if (isImeKeyEvent(e.nativeEvent)) {
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
533
543
|
// Arrow-key adjustment mutates the value; block it while showing a
|
|
534
544
|
// disabled reason (the input keeps focusability via aria-disabled).
|
|
535
545
|
if (isDisabled) {
|
|
@@ -656,7 +666,7 @@ export function TimeInput({
|
|
|
656
666
|
autoFocus={hasAutoFocus}
|
|
657
667
|
data-autofocus={hasAutoFocus || undefined}
|
|
658
668
|
aria-describedby={ariaDescribedBy}
|
|
659
|
-
aria-required={
|
|
669
|
+
aria-required={isEffectivelyRequired ? 'true' : undefined}
|
|
660
670
|
aria-invalid={
|
|
661
671
|
status?.type === 'error' || !isInputValid ? 'true' : undefined
|
|
662
672
|
}
|
|
@@ -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', () => {
|
|
@@ -45,7 +45,7 @@ import {VisuallyHidden} from '../VisuallyHidden';
|
|
|
45
45
|
import {spacingVars, sizeVars} from '../theme/tokens.stylex';
|
|
46
46
|
import {groupStyles} from '../InputGroup/groupStyles';
|
|
47
47
|
import {useInputGroup} from '../InputGroup/InputGroupContext';
|
|
48
|
-
import {getInputARIA, mergeProps, mergeRefs} from '../utils';
|
|
48
|
+
import {getInputARIA, isImeKeyEvent, mergeProps, mergeRefs} from '../utils';
|
|
49
49
|
import type {BaseProps} from '../BaseProps';
|
|
50
50
|
import type {SizeValue} from '../utils/types';
|
|
51
51
|
import type {SearchableItem, SearchSource} from './types';
|
|
@@ -369,6 +369,15 @@ export function Typeahead<T extends SearchableItem>({
|
|
|
369
369
|
// Handle Escape during edit mode — restore token
|
|
370
370
|
const handleKeyDown = useCallback(
|
|
371
371
|
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
372
|
+
// BaseTypeahead invokes this external handler *before* its own IME
|
|
373
|
+
// guard, so we must guard here too: an IME candidate window uses Escape
|
|
374
|
+
// to cancel the pending composition, and that composing Escape fires
|
|
375
|
+
// before compositionend. Without this, a Korean/Japanese/Chinese user
|
|
376
|
+
// cancelling a candidate would instead exit edit mode and blur the
|
|
377
|
+
// field. See utils/ime.ts.
|
|
378
|
+
if (isImeKeyEvent(e.nativeEvent)) {
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
372
381
|
if (e.key === 'Escape' && editingValue) {
|
|
373
382
|
e.preventDefault();
|
|
374
383
|
setIsEditing(false);
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file stubMatchMedia.test.ts
|
|
5
|
+
* @input Uses vitest, stubMatchMedia
|
|
6
|
+
* @output Unit coverage for the shared query-aware matchMedia stub
|
|
7
|
+
* @position Testing; guards the helper other suites trust to route queries
|
|
8
|
+
*
|
|
9
|
+
* The helper exists because a stub that ignores its query silently reroutes
|
|
10
|
+
* code branching on a different one — the failure mode leaves every test
|
|
11
|
+
* green. A helper sold on that promise has to be held to it, so these assert
|
|
12
|
+
* the routing itself rather than any component behaviour.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {describe, it, expect, afterEach, vi} from 'vitest';
|
|
16
|
+
import {stubMatchMedia} from './stubMatchMedia';
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
vi.unstubAllGlobals();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('stubMatchMedia', () => {
|
|
23
|
+
it('routes prefers-reduced-motion independently of every other query', () => {
|
|
24
|
+
// The exact combination the blanket stub could not express: below a
|
|
25
|
+
// breakpoint (matches) while motion is NOT reduced.
|
|
26
|
+
stubMatchMedia({reduceMotion: false, matches: true});
|
|
27
|
+
|
|
28
|
+
expect(matchMedia('(prefers-reduced-motion: reduce)').matches).toBe(false);
|
|
29
|
+
expect(matchMedia('(max-width: 768px)').matches).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('answers no-preference as the opposite of reduce', () => {
|
|
33
|
+
// Two spellings, opposite meanings. A substring test on
|
|
34
|
+
// 'prefers-reduced-motion' alone would hand both the same answer.
|
|
35
|
+
stubMatchMedia({reduceMotion: true});
|
|
36
|
+
|
|
37
|
+
expect(matchMedia('(prefers-reduced-motion: reduce)').matches).toBe(true);
|
|
38
|
+
expect(matchMedia('(prefers-reduced-motion: no-preference)').matches).toBe(
|
|
39
|
+
false,
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('treats the bare preference query as reduce', () => {
|
|
44
|
+
stubMatchMedia({reduceMotion: true});
|
|
45
|
+
expect(matchMedia('(prefers-reduced-motion)').matches).toBe(true);
|
|
46
|
+
|
|
47
|
+
stubMatchMedia({reduceMotion: false});
|
|
48
|
+
expect(matchMedia('(prefers-reduced-motion)').matches).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('defaults to full motion and matching everything else', () => {
|
|
52
|
+
stubMatchMedia();
|
|
53
|
+
|
|
54
|
+
expect(matchMedia('(prefers-reduced-motion: reduce)').matches).toBe(false);
|
|
55
|
+
expect(matchMedia('(max-width: 768px)').matches).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('lets a non-motion query be answered false', () => {
|
|
59
|
+
// Above the breakpoint, motion still reduced — the other axis of the
|
|
60
|
+
// combination, so neither knob can be quietly driving both.
|
|
61
|
+
stubMatchMedia({reduceMotion: true, matches: false});
|
|
62
|
+
|
|
63
|
+
expect(matchMedia('(prefers-reduced-motion: reduce)').matches).toBe(true);
|
|
64
|
+
expect(matchMedia('(max-width: 768px)').matches).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('echoes the query and exposes the listener API components subscribe to', () => {
|
|
68
|
+
stubMatchMedia();
|
|
69
|
+
const mql = matchMedia('(max-width: 768px)');
|
|
70
|
+
|
|
71
|
+
// useMediaQuery subscribes; a stub without these throws on mount rather
|
|
72
|
+
// than failing an assertion, which reads as an unrelated crash.
|
|
73
|
+
expect(mql.media).toBe('(max-width: 768px)');
|
|
74
|
+
expect(typeof mql.addEventListener).toBe('function');
|
|
75
|
+
expect(typeof mql.removeEventListener).toBe('function');
|
|
76
|
+
expect(typeof mql.addListener).toBe('function');
|
|
77
|
+
expect(typeof mql.removeListener).toBe('function');
|
|
78
|
+
});
|
|
79
|
+
});
|