@astryxdesign/core 0.4.3 → 0.4.4-canary.0f4d45f
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/Field/InputClearButton.d.ts +2 -1
- package/dist/Field/InputClearButton.d.ts.map +1 -1
- package/dist/Field/InputClearButton.js +6 -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/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 +5 -1
- package/dist/Table/plugins/rowExpansion/useTableRowExpansion.d.ts.map +1 -1
- package/dist/Table/plugins/rowExpansion/useTableRowExpansion.js +7 -2
- 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/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 +48 -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 +36 -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 +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/Field/Field.doc.mjs +1 -0
- 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/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/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 +7 -0
- package/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +20 -2
- 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
|
@@ -43,7 +43,7 @@ import {
|
|
|
43
43
|
typeScaleVars,
|
|
44
44
|
typographyVars,
|
|
45
45
|
} from '../theme/tokens.stylex';
|
|
46
|
-
import {mergeProps} from '../utils';
|
|
46
|
+
import {mergeProps, isImeKeyEvent} from '../utils';
|
|
47
47
|
import {useTriggerMenu} from './useTriggerMenu';
|
|
48
48
|
import {useChatComposerTokens, isCustomToken} from './useChatComposerTokens';
|
|
49
49
|
import {ensureCaretInside, insertTextAtCursor} from './chatComposerSelection';
|
|
@@ -559,9 +559,8 @@ export function ChatComposerInput(props: ChatComposerInputProps) {
|
|
|
559
559
|
|
|
560
560
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
561
561
|
// Never submit mid-composition — an IME uses Enter to commit a
|
|
562
|
-
// candidate
|
|
563
|
-
|
|
564
|
-
if (e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229) {
|
|
562
|
+
// candidate. See utils/ime.ts for the full rationale.
|
|
563
|
+
if (isImeKeyEvent(e.nativeEvent)) {
|
|
565
564
|
return;
|
|
566
565
|
}
|
|
567
566
|
|
|
@@ -46,7 +46,7 @@ import {
|
|
|
46
46
|
typographyVars,
|
|
47
47
|
typeScaleVars,
|
|
48
48
|
} from '../theme/tokens.stylex';
|
|
49
|
-
import {mergeProps} from '../utils';
|
|
49
|
+
import {isRenderable, mergeProps} from '../utils';
|
|
50
50
|
import {composeEventHandlers} from '../utils/composeEventHandlers';
|
|
51
51
|
import {focusOutlineStyles} from '../utils/focusOutline.stylex';
|
|
52
52
|
import type {SizeValue} from '../utils/types';
|
|
@@ -439,6 +439,11 @@ export function ComplexSelector<Value>({
|
|
|
439
439
|
|
|
440
440
|
const triggerContent = triggerLabel ?? placeholder;
|
|
441
441
|
|
|
442
|
+
const startIconSlot = renderIconSlot(startIcon, {
|
|
443
|
+
size: 'sm',
|
|
444
|
+
color: 'secondary',
|
|
445
|
+
});
|
|
446
|
+
|
|
442
447
|
const content = (
|
|
443
448
|
<div id={contentId} {...stylex.props(styles.content, contentXstyle)}>
|
|
444
449
|
{children(optimisticValue, commitValue, close, {
|
|
@@ -467,9 +472,12 @@ export function ComplexSelector<Value>({
|
|
|
467
472
|
inputWrapperStyles.base,
|
|
468
473
|
styles.triggerContainer,
|
|
469
474
|
styles[size],
|
|
470
|
-
|
|
475
|
+
// The ring belongs to the wrapper (the focusable `<button>` sits
|
|
476
|
+
// inside it), but it must still be a KEYBOARD ring: `:focus-within`
|
|
477
|
+
// matched a mouse click on the trigger and drew the outline for
|
|
478
|
+
// pointer users too. `focusWithin` here is `:has(:focus-visible)`.
|
|
479
|
+
focusOutlineStyles.focusWithin,
|
|
471
480
|
variant === 'ghost' && styles.triggerGhost,
|
|
472
|
-
variant === 'ghost' && focusOutlineStyles.focusWithin,
|
|
473
481
|
isDisabled && inputWrapperStyles.disabled,
|
|
474
482
|
variant === 'ghost' && isDisabled && styles.triggerGhostDisabled,
|
|
475
483
|
isDisabled && styles.disabled,
|
|
@@ -479,8 +487,7 @@ export function ComplexSelector<Value>({
|
|
|
479
487
|
className,
|
|
480
488
|
style,
|
|
481
489
|
)}>
|
|
482
|
-
{
|
|
483
|
-
renderIconSlot(startIcon, {size: 'sm', color: 'secondary'})}
|
|
490
|
+
{isRenderable(startIconSlot) && startIconSlot}
|
|
484
491
|
<button
|
|
485
492
|
ref={triggerRef}
|
|
486
493
|
id={triggerId}
|
|
@@ -63,7 +63,7 @@ import {
|
|
|
63
63
|
easeVars,
|
|
64
64
|
shadowVars,
|
|
65
65
|
} from '../theme/tokens.stylex';
|
|
66
|
-
import {mergeProps, mergeRefs} from '../utils';
|
|
66
|
+
import {mergeProps, mergeRefs, isImeKeyEvent} from '../utils';
|
|
67
67
|
import type {BaseProps} from '../BaseProps';
|
|
68
68
|
import type {StyleXStyles} from '../theme/types';
|
|
69
69
|
import {themeProps} from '../utils/themeProps';
|
|
@@ -334,7 +334,9 @@ export function ContextMenu({
|
|
|
334
334
|
if (e.key !== 'Escape') {
|
|
335
335
|
return;
|
|
336
336
|
}
|
|
337
|
-
if (e
|
|
337
|
+
if (isImeKeyEvent(e)) {
|
|
338
|
+
// Ignore Escape that is committing/cancelling an IME composition;
|
|
339
|
+
// see utils/ime.ts for why.
|
|
338
340
|
return;
|
|
339
341
|
}
|
|
340
342
|
e.preventDefault();
|
|
@@ -536,6 +536,27 @@ describe('DateInput', () => {
|
|
|
536
536
|
expect(onChange).toHaveBeenCalledWith('2026-03-15');
|
|
537
537
|
});
|
|
538
538
|
|
|
539
|
+
it('does not commit on a composing Enter (IME)', () => {
|
|
540
|
+
const onChange = vi.fn();
|
|
541
|
+
render(<DateInput label="Date" onChange={onChange} />);
|
|
542
|
+
|
|
543
|
+
const input = screen.getByRole('combobox');
|
|
544
|
+
fireEvent.change(input, {target: {value: '03/15/2026'}});
|
|
545
|
+
onChange.mockClear();
|
|
546
|
+
|
|
547
|
+
// The composing keydown (isComposing / legacy keyCode 229) that commits an
|
|
548
|
+
// IME candidate fires before compositionend; it must not be read as
|
|
549
|
+
// "commit the typed date".
|
|
550
|
+
fireEvent.keyDown(input, {key: 'Enter', isComposing: true});
|
|
551
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
552
|
+
fireEvent.keyDown(input, {key: 'Enter', keyCode: 229});
|
|
553
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
554
|
+
|
|
555
|
+
// A real, non-composing Enter still commits.
|
|
556
|
+
fireEvent.keyDown(input, {key: 'Enter'});
|
|
557
|
+
expect(onChange).toHaveBeenCalledWith('2026-03-15');
|
|
558
|
+
});
|
|
559
|
+
|
|
539
560
|
// --- Arrow-down opens calendar popover ---
|
|
540
561
|
|
|
541
562
|
// Note: Tests involving popover rendering (show/hide with calendar)
|
|
@@ -60,7 +60,7 @@ import {useCalendarConstraints} from '../Calendar/hooks';
|
|
|
60
60
|
import {useInputStatusIcon} from '../hooks/useInputStatusIcon';
|
|
61
61
|
import {usePopover} from '../Popover';
|
|
62
62
|
import {useTooltip} from '../Tooltip';
|
|
63
|
-
import {getInputARIA, parseDateInput} from '../utils';
|
|
63
|
+
import {getInputARIA, isImeKeyEvent, parseDateInput} from '../utils';
|
|
64
64
|
import {
|
|
65
65
|
plainDateFromISO,
|
|
66
66
|
plainDateToISO,
|
|
@@ -627,6 +627,14 @@ export function DateInput({
|
|
|
627
627
|
// Handle keyboard events on input
|
|
628
628
|
const handleInputKeyDown = useCallback(
|
|
629
629
|
(e: React.KeyboardEvent) => {
|
|
630
|
+
// An in-progress IME composition uses Enter to commit the candidate and
|
|
631
|
+
// Escape to cancel it; that composing keydown fires before
|
|
632
|
+
// compositionend, so without this guard a Korean/Japanese/Chinese user
|
|
633
|
+
// committing a syllable with Enter would instead commit the pending date
|
|
634
|
+
// (or Escape would close the calendar mid-composition). See utils/ime.ts.
|
|
635
|
+
if (isImeKeyEvent(e.nativeEvent)) {
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
630
638
|
if (e.key === 'Escape' && popover.isOpen) {
|
|
631
639
|
e.preventDefault();
|
|
632
640
|
popover.hide();
|
|
@@ -204,6 +204,8 @@ export const docs = {
|
|
|
204
204
|
className: 'astryx-date-time-input-time-segment',
|
|
205
205
|
visualProps: ['size', 'status'],
|
|
206
206
|
},
|
|
207
|
+
{className: 'astryx-date-time-input-toggle-icon', states: ['state']},
|
|
208
|
+
{className: 'astryx-date-time-input-clock-icon'},
|
|
207
209
|
],
|
|
208
210
|
},
|
|
209
211
|
usage: {
|
|
@@ -508,6 +510,8 @@ export const docsZh = {
|
|
|
508
510
|
className: 'astryx-date-time-input-time-segment',
|
|
509
511
|
visualProps: ['size', 'status'],
|
|
510
512
|
},
|
|
513
|
+
{className: 'astryx-date-time-input-toggle-icon', states: ['state']},
|
|
514
|
+
{className: 'astryx-date-time-input-clock-icon'},
|
|
511
515
|
],
|
|
512
516
|
},
|
|
513
517
|
};
|
|
@@ -15,6 +15,7 @@ import userEvent from '@testing-library/user-event';
|
|
|
15
15
|
import {getButton, queryButton} from '../__tests__/fastRoleQueries';
|
|
16
16
|
import {DateTimeInput} from './DateTimeInput';
|
|
17
17
|
import type {ISODateTimeString} from './DateTimeInput';
|
|
18
|
+
import {Icon} from '../Icon';
|
|
18
19
|
import {defineTheme} from '../theme/defineTheme';
|
|
19
20
|
import {generateThemeCSS} from '../theme/generateThemeRules';
|
|
20
21
|
import {InternationalizationProvider} from '../i18n';
|
|
@@ -96,6 +97,48 @@ describe('DateTimeInput', () => {
|
|
|
96
97
|
expect(screen.getByLabelText('Meeting time')).toBeInTheDocument();
|
|
97
98
|
});
|
|
98
99
|
|
|
100
|
+
it('does not commit the date on a composing Enter (IME)', () => {
|
|
101
|
+
const onChange = vi.fn();
|
|
102
|
+
render(<DateTimeInput label="Meeting" onChange={onChange} />);
|
|
103
|
+
const dateInput = screen.getByRole('combobox');
|
|
104
|
+
fireEvent.change(dateInput, {target: {value: '03/15/2026'}});
|
|
105
|
+
onChange.mockClear();
|
|
106
|
+
|
|
107
|
+
// The composing keydown (isComposing / legacy keyCode 229) that commits an
|
|
108
|
+
// IME candidate must not be read as "commit the typed date".
|
|
109
|
+
fireEvent.keyDown(dateInput, {key: 'Enter', isComposing: true});
|
|
110
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
111
|
+
fireEvent.keyDown(dateInput, {key: 'Enter', keyCode: 229});
|
|
112
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
113
|
+
|
|
114
|
+
// A real, non-composing Enter still commits.
|
|
115
|
+
fireEvent.keyDown(dateInput, {key: 'Enter'});
|
|
116
|
+
expect(onChange).toHaveBeenCalled();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('does not step the time on a composing ArrowUp (IME)', () => {
|
|
120
|
+
const onChange = vi.fn();
|
|
121
|
+
render(
|
|
122
|
+
<DateTimeInput
|
|
123
|
+
label="Meeting"
|
|
124
|
+
value={'2026-03-15T14:30' as ISODateTimeString}
|
|
125
|
+
onChange={onChange}
|
|
126
|
+
/>,
|
|
127
|
+
);
|
|
128
|
+
const timeInput = screen.getByLabelText('Meeting time');
|
|
129
|
+
|
|
130
|
+
// An IME candidate window navigates with the arrows; a composing ArrowUp
|
|
131
|
+
// must not step the time value.
|
|
132
|
+
fireEvent.keyDown(timeInput, {key: 'ArrowUp', isComposing: true});
|
|
133
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
134
|
+
fireEvent.keyDown(timeInput, {key: 'ArrowUp', keyCode: 229});
|
|
135
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
136
|
+
|
|
137
|
+
// A real, non-composing ArrowUp still steps the time by one minute.
|
|
138
|
+
fireEvent.keyDown(timeInput, {key: 'ArrowUp'});
|
|
139
|
+
expect(onChange).toHaveBeenCalledWith('2026-03-15T14:31');
|
|
140
|
+
});
|
|
141
|
+
|
|
99
142
|
it('displays formatted date in date input when value is provided', () => {
|
|
100
143
|
render(
|
|
101
144
|
<DateTimeInput
|
|
@@ -1063,6 +1106,100 @@ describe('DateTimeInput', () => {
|
|
|
1063
1106
|
expect(css).toContain('block-size: var(--size-element-lg)');
|
|
1064
1107
|
expect(css).toContain('padding-inline: var(--spacing-4)');
|
|
1065
1108
|
});
|
|
1109
|
+
|
|
1110
|
+
it('renders the toggle-icon target on the calendar glyph with open/closed state', async () => {
|
|
1111
|
+
const {container} = render(
|
|
1112
|
+
<DateTimeInput label="Meeting" onChange={() => {}} />,
|
|
1113
|
+
);
|
|
1114
|
+
// The stable target lands on the icon element itself (not the button),
|
|
1115
|
+
// so a theme can restyle just this glyph — mirroring
|
|
1116
|
+
// `date-input-toggle-icon`.
|
|
1117
|
+
const icon = container.querySelector(
|
|
1118
|
+
'.astryx-date-time-input-toggle-icon',
|
|
1119
|
+
);
|
|
1120
|
+
expect(icon).not.toBeNull();
|
|
1121
|
+
expect(icon).toHaveClass('astryx-icon');
|
|
1122
|
+
expect(icon).toHaveAttribute('data-state', 'collapsed');
|
|
1123
|
+
|
|
1124
|
+
fireEvent.click(getButton('Open calendar'));
|
|
1125
|
+
await waitFor(() => {
|
|
1126
|
+
expect(
|
|
1127
|
+
container.querySelector('.astryx-date-time-input-toggle-icon'),
|
|
1128
|
+
).toHaveAttribute('data-state', 'expanded');
|
|
1129
|
+
});
|
|
1130
|
+
});
|
|
1131
|
+
|
|
1132
|
+
it('renders the clock-icon target on the leading time glyph', () => {
|
|
1133
|
+
const {container} = render(
|
|
1134
|
+
<DateTimeInput label="Meeting" onChange={() => {}} />,
|
|
1135
|
+
);
|
|
1136
|
+
const icon = container.querySelector(
|
|
1137
|
+
'.astryx-date-time-input-clock-icon',
|
|
1138
|
+
);
|
|
1139
|
+
expect(icon).not.toBeNull();
|
|
1140
|
+
expect(icon).toHaveClass('astryx-icon');
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1143
|
+
it('leaves both leading glyphs byte-identical to a plain secondary/sm icon by default', () => {
|
|
1144
|
+
// The targets are purely additive: the stable target class and its
|
|
1145
|
+
// reflected state add nothing to the render until a theme targets them.
|
|
1146
|
+
// Guard that by diffing each glyph's StyleX classes against a standalone
|
|
1147
|
+
// secondary/sm icon, excluding only the additive target/state classes.
|
|
1148
|
+
const {container} = render(
|
|
1149
|
+
<DateTimeInput label="Meeting" onChange={() => {}} />,
|
|
1150
|
+
);
|
|
1151
|
+
const calendarIcon = container.querySelector(
|
|
1152
|
+
'.astryx-date-time-input-toggle-icon',
|
|
1153
|
+
) as HTMLElement;
|
|
1154
|
+
const clockIcon = container.querySelector(
|
|
1155
|
+
'.astryx-date-time-input-clock-icon',
|
|
1156
|
+
) as HTMLElement;
|
|
1157
|
+
|
|
1158
|
+
const {container: refContainer} = render(
|
|
1159
|
+
<Icon icon="calendar" size="sm" color="secondary" />,
|
|
1160
|
+
);
|
|
1161
|
+
const refIcon = refContainer.querySelector('.astryx-icon') as HTMLElement;
|
|
1162
|
+
|
|
1163
|
+
const themeTargetClasses = new Set([
|
|
1164
|
+
'astryx-date-time-input-toggle-icon',
|
|
1165
|
+
'astryx-date-time-input-clock-icon',
|
|
1166
|
+
'collapsed',
|
|
1167
|
+
'expanded',
|
|
1168
|
+
]);
|
|
1169
|
+
const styleClasses = (el: HTMLElement) =>
|
|
1170
|
+
el.className
|
|
1171
|
+
.split(' ')
|
|
1172
|
+
.filter(c => !themeTargetClasses.has(c))
|
|
1173
|
+
.sort();
|
|
1174
|
+
|
|
1175
|
+
expect(styleClasses(calendarIcon)).toEqual(styleClasses(refIcon));
|
|
1176
|
+
expect(styleClasses(clockIcon)).toEqual(styleClasses(refIcon));
|
|
1177
|
+
});
|
|
1178
|
+
|
|
1179
|
+
it('exposes the icon targets so a theme reaches their size and per-state color', () => {
|
|
1180
|
+
// jsdom cannot resolve the @layer cascade, so the generated CSS is what
|
|
1181
|
+
// proves a same-element theme rule can reach these glyphs and win over
|
|
1182
|
+
// the icon's own base size/color.
|
|
1183
|
+
const theme = defineTheme({
|
|
1184
|
+
name: 'date-time-input-icon-targets-test',
|
|
1185
|
+
components: {
|
|
1186
|
+
'date-time-input-toggle-icon': {
|
|
1187
|
+
base: {width: '14px', height: '14px', fontSize: '14px'},
|
|
1188
|
+
'state:expanded': {color: 'var(--color-icon-primary)'},
|
|
1189
|
+
},
|
|
1190
|
+
'date-time-input-clock-icon': {
|
|
1191
|
+
base: {width: '14px', height: '14px', fontSize: '14px'},
|
|
1192
|
+
},
|
|
1193
|
+
},
|
|
1194
|
+
});
|
|
1195
|
+
const css = generateThemeTestCSS(theme);
|
|
1196
|
+
|
|
1197
|
+
expect(css).toContain('.astryx-date-time-input-toggle-icon {');
|
|
1198
|
+
expect(css).toContain('.astryx-date-time-input-toggle-icon.expanded');
|
|
1199
|
+
expect(css).toContain('.astryx-date-time-input-clock-icon {');
|
|
1200
|
+
expect(css).toContain('width: 14px');
|
|
1201
|
+
expect(css).toContain('color: var(--color-icon-primary)');
|
|
1202
|
+
});
|
|
1066
1203
|
});
|
|
1067
1204
|
});
|
|
1068
1205
|
|
|
@@ -69,6 +69,7 @@ import {
|
|
|
69
69
|
formatISOTime,
|
|
70
70
|
isTimeInRange,
|
|
71
71
|
adjustTime,
|
|
72
|
+
isImeKeyEvent,
|
|
72
73
|
mergeProps,
|
|
73
74
|
mergeRefs,
|
|
74
75
|
isFocusDetached,
|
|
@@ -742,6 +743,13 @@ export function DateTimeInput({
|
|
|
742
743
|
|
|
743
744
|
const handleDateKeyDown = useCallback(
|
|
744
745
|
(e: React.KeyboardEvent) => {
|
|
746
|
+
// Guard the composing keydown (fires before compositionend): an IME uses
|
|
747
|
+
// Enter to commit the candidate and Escape to cancel it, so without this
|
|
748
|
+
// a CJK user committing a syllable with Enter would commit the pending
|
|
749
|
+
// date instead. See utils/ime.ts.
|
|
750
|
+
if (isImeKeyEvent(e.nativeEvent)) {
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
745
753
|
if (e.key === 'Escape' && popover.isOpen) {
|
|
746
754
|
e.preventDefault();
|
|
747
755
|
popover.hide();
|
|
@@ -835,6 +843,13 @@ export function DateTimeInput({
|
|
|
835
843
|
|
|
836
844
|
const handleTimeKeyDown = useCallback(
|
|
837
845
|
(e: KeyboardEvent<HTMLInputElement>) => {
|
|
846
|
+
// ArrowUp/ArrowDown step the time and preventDefault; an IME candidate
|
|
847
|
+
// window uses those same arrows to navigate candidates, so guard the
|
|
848
|
+
// composing keydown (fires before compositionend) to avoid stealing them
|
|
849
|
+
// mid-composition. See utils/ime.ts.
|
|
850
|
+
if (isImeKeyEvent(e.nativeEvent)) {
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
838
853
|
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
|
839
854
|
e.preventDefault();
|
|
840
855
|
|
|
@@ -960,7 +975,20 @@ export function DateTimeInput({
|
|
|
960
975
|
styles.iconButton,
|
|
961
976
|
isEffectivelyDisabled && styles.iconButtonDisabled,
|
|
962
977
|
)}>
|
|
963
|
-
<Icon
|
|
978
|
+
<Icon
|
|
979
|
+
icon="calendar"
|
|
980
|
+
size="sm"
|
|
981
|
+
color="secondary"
|
|
982
|
+
// Stable theme target on the calendar toggle glyph, so a theme
|
|
983
|
+
// can restyle just this icon (color, size, hover) — and each
|
|
984
|
+
// open/closed state — via `defineTheme`, mirroring
|
|
985
|
+
// `date-input-toggle-icon`. Same-element rules in
|
|
986
|
+
// @layer astryx-theme win over the icon's own base color/size,
|
|
987
|
+
// which a segment-level target could not reach.
|
|
988
|
+
{...themeProps('date-time-input-toggle-icon', {
|
|
989
|
+
state: popover.isOpen ? 'expanded' : 'collapsed',
|
|
990
|
+
})}
|
|
991
|
+
/>
|
|
964
992
|
</button>
|
|
965
993
|
<input
|
|
966
994
|
ref={mergeRefs(ref, dateInputRef)}
|
|
@@ -1039,7 +1067,16 @@ export function DateTimeInput({
|
|
|
1039
1067
|
),
|
|
1040
1068
|
)}>
|
|
1041
1069
|
<div {...stylex.props(styles.icon)}>
|
|
1042
|
-
<Icon
|
|
1070
|
+
<Icon
|
|
1071
|
+
icon="clock"
|
|
1072
|
+
size="sm"
|
|
1073
|
+
color="secondary"
|
|
1074
|
+
// Stable theme target on the leading clock glyph, so a theme can
|
|
1075
|
+
// restyle just this icon (color, size) via `defineTheme`. The
|
|
1076
|
+
// time segment has no toggle button — the clock is a static
|
|
1077
|
+
// leading affordance — so this carries no interactive state.
|
|
1078
|
+
{...themeProps('date-time-input-clock-icon')}
|
|
1079
|
+
/>
|
|
1043
1080
|
</div>
|
|
1044
1081
|
<input
|
|
1045
1082
|
ref={timeInputRef}
|
package/src/Dialog/Dialog.tsx
CHANGED
|
@@ -27,7 +27,8 @@ import {
|
|
|
27
27
|
import type {BaseProps} from '../BaseProps';
|
|
28
28
|
import * as stylex from '@stylexjs/stylex';
|
|
29
29
|
import {useScrollLock} from '../hooks/useScrollLock';
|
|
30
|
-
import {hasActiveFocusTrapEscape
|
|
30
|
+
import {hasActiveFocusTrapEscape} from '../hooks/useFocusTrap';
|
|
31
|
+
import {isImeKeyEvent} from '../utils/ime';
|
|
31
32
|
import {
|
|
32
33
|
colorVars,
|
|
33
34
|
radiusVars,
|
package/src/Field/Field.doc.mjs
CHANGED
|
@@ -114,4 +114,29 @@ describe('InputClearButton', () => {
|
|
|
114
114
|
expect(css).toContain('.astryx-input-clear-icon:hover {');
|
|
115
115
|
expect(css).toContain('color: var(--color-icon-primary)');
|
|
116
116
|
});
|
|
117
|
+
|
|
118
|
+
it('renders the astryx-input-clear-button target on the button wrapper', () => {
|
|
119
|
+
render(<InputClearButton label="Clear" onClick={() => {}} />);
|
|
120
|
+
const button = screen.getByRole('button', {name: 'Clear'});
|
|
121
|
+
expect(button).toHaveClass('astryx-input-clear-button');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('exposes input-clear-button so a theme controls the button size and hover', () => {
|
|
125
|
+
const theme = defineTheme({
|
|
126
|
+
name: 'input-clear-button-test',
|
|
127
|
+
components: {
|
|
128
|
+
'input-clear-button': {
|
|
129
|
+
base: {
|
|
130
|
+
height: '28px',
|
|
131
|
+
':hover': {backgroundImage: 'none'},
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
const css = generateThemeTestCSS(theme);
|
|
137
|
+
expect(css).toContain('.astryx-input-clear-button {');
|
|
138
|
+
expect(css).toContain('height: 28px');
|
|
139
|
+
expect(css).toContain('.astryx-input-clear-button:hover {');
|
|
140
|
+
expect(css).toContain('background-image: none');
|
|
141
|
+
});
|
|
117
142
|
});
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
* TextInput, NumberInput, TimeInput, DateInput, DateTimeInput,
|
|
12
12
|
* DateRangeInput, Selector, MultiSelector, Typeahead, Tokenizer, FileInput —
|
|
13
13
|
* routes it through here, so the glyph is themed in one place via the
|
|
14
|
-
* `astryx-input-clear-icon` target
|
|
14
|
+
* `astryx-input-clear-icon` target and the button wrapper is themed via the
|
|
15
|
+
* `astryx-input-clear-button` target.
|
|
15
16
|
*/
|
|
16
17
|
|
|
17
18
|
import type {ReactNode} from 'react';
|
|
@@ -48,11 +49,13 @@ export function InputClearButton({
|
|
|
48
49
|
iconClassName,
|
|
49
50
|
}: InputClearButtonProps): ReactNode {
|
|
50
51
|
const {className: iconTargetClassName} = themeProps('input-clear-icon');
|
|
52
|
+
const {className: buttonTargetClassName} = themeProps('input-clear-button');
|
|
51
53
|
return (
|
|
52
54
|
<Button
|
|
53
55
|
variant="ghost"
|
|
54
56
|
size="sm"
|
|
55
57
|
label={label}
|
|
58
|
+
className={buttonTargetClassName}
|
|
56
59
|
icon={
|
|
57
60
|
<Icon
|
|
58
61
|
icon="close"
|
|
@@ -20,6 +20,12 @@ import {
|
|
|
20
20
|
import userEvent from '@testing-library/user-event';
|
|
21
21
|
import {FileInput} from './FileInput';
|
|
22
22
|
import {__resetLiveRegionsForTest} from '../hooks/useAnnounce';
|
|
23
|
+
import {InternationalizationProvider} from '../i18n';
|
|
24
|
+
|
|
25
|
+
// The `=1` branch names the file; the `other` branch must not. Both come from
|
|
26
|
+
// this test, so neither can pass against a hardcoded English string.
|
|
27
|
+
const FILE_SELECTED = 'Un fichier choisi : {fileName}';
|
|
28
|
+
const FILES_SELECTED = '{count, number} fichiers choisis';
|
|
23
29
|
|
|
24
30
|
afterEach(() => {
|
|
25
31
|
__resetLiveRegionsForTest();
|
|
@@ -324,23 +330,36 @@ describe('FileInput', () => {
|
|
|
324
330
|
|
|
325
331
|
describe('announcements', () => {
|
|
326
332
|
it('announces a single file selection politely', async () => {
|
|
327
|
-
render(
|
|
333
|
+
render(
|
|
334
|
+
<InternationalizationProvider
|
|
335
|
+
locale="fr"
|
|
336
|
+
overrides={{fr: {'@astryx.fileInput.fileSelected': FILE_SELECTED}}}>
|
|
337
|
+
<FileInput label="Upload" value={null} onChange={() => {}} />
|
|
338
|
+
</InternationalizationProvider>,
|
|
339
|
+
);
|
|
328
340
|
fireEvent.change(fileInputEl(), {
|
|
329
341
|
target: {files: [createFile('report.pdf', 100)]},
|
|
330
342
|
});
|
|
331
343
|
await waitFor(() => {
|
|
332
|
-
|
|
344
|
+
// The single-file key, carrying the file name.
|
|
345
|
+
expect(politeRegion()?.textContent).toBe(
|
|
346
|
+
'Un fichier choisi : report.pdf',
|
|
347
|
+
);
|
|
333
348
|
});
|
|
334
349
|
});
|
|
335
350
|
|
|
336
351
|
it('announces a multi-file count politely', async () => {
|
|
337
352
|
render(
|
|
338
|
-
<
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
353
|
+
<InternationalizationProvider
|
|
354
|
+
locale="fr"
|
|
355
|
+
overrides={{fr: {'@astryx.fileInput.filesSelected': FILES_SELECTED}}}>
|
|
356
|
+
<FileInput
|
|
357
|
+
label="Upload"
|
|
358
|
+
value={null}
|
|
359
|
+
onChange={() => {}}
|
|
360
|
+
isMultiple
|
|
361
|
+
/>
|
|
362
|
+
</InternationalizationProvider>,
|
|
344
363
|
);
|
|
345
364
|
const files = [
|
|
346
365
|
createFile('a.txt', 100),
|
|
@@ -349,7 +368,32 @@ describe('FileInput', () => {
|
|
|
349
368
|
];
|
|
350
369
|
fireEvent.change(fileInputEl(), {target: {files}});
|
|
351
370
|
await waitFor(() => {
|
|
352
|
-
|
|
371
|
+
// The multi-file key: a count, and no file name.
|
|
372
|
+
expect(politeRegion()?.textContent).toBe('3 fichiers choisis');
|
|
373
|
+
});
|
|
374
|
+
expect(politeRegion()?.textContent).not.toContain('a.txt');
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('speaks the selection from a provider catalog', async () => {
|
|
378
|
+
render(
|
|
379
|
+
<InternationalizationProvider
|
|
380
|
+
locale="fr"
|
|
381
|
+
messages={{
|
|
382
|
+
fr: {
|
|
383
|
+
'@astryx.fileInput.fileSelected': {defaultMessage: FILE_SELECTED},
|
|
384
|
+
},
|
|
385
|
+
}}>
|
|
386
|
+
<FileInput label="Upload" value={null} onChange={() => {}} />
|
|
387
|
+
</InternationalizationProvider>,
|
|
388
|
+
);
|
|
389
|
+
fireEvent.change(fileInputEl(), {
|
|
390
|
+
target: {files: [createFile('report.pdf', 100)]},
|
|
391
|
+
});
|
|
392
|
+
await waitFor(() => {
|
|
393
|
+
// Same key through the catalog path rather than `overrides`.
|
|
394
|
+
expect(politeRegion()?.textContent).toBe(
|
|
395
|
+
'Un fichier choisi : report.pdf',
|
|
396
|
+
);
|
|
353
397
|
});
|
|
354
398
|
});
|
|
355
399
|
|