@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
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* SYNC: When DateRangeInput.tsx changes, update tests to match new behavior
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import {describe, it, expect, vi, beforeEach} from 'vitest';
|
|
12
|
+
import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest';
|
|
13
13
|
import {render, screen, fireEvent, waitFor} from '@testing-library/react';
|
|
14
14
|
import userEvent from '@testing-library/user-event';
|
|
15
15
|
|
|
@@ -712,3 +712,83 @@ describe('DateRangeInput disabled theme state', () => {
|
|
|
712
712
|
expect(root).not.toHaveAttribute('data-disabled');
|
|
713
713
|
});
|
|
714
714
|
});
|
|
715
|
+
|
|
716
|
+
describe('DateRangeInput range-span forwarding', () => {
|
|
717
|
+
// Pin "today" so the popover opens on a known month and the day buttons we
|
|
718
|
+
// query are guaranteed to render.
|
|
719
|
+
beforeEach(() => {
|
|
720
|
+
vi.useFakeTimers();
|
|
721
|
+
vi.setSystemTime(new Date('2026-01-05T12:00:00Z'));
|
|
722
|
+
});
|
|
723
|
+
afterEach(() => {
|
|
724
|
+
vi.useRealTimers();
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
// The calendar renders in the top layer; jsdom keeps day buttons in the DOM
|
|
728
|
+
// but role queries skip them, so reach them by their machine-readable
|
|
729
|
+
// data-date (ISO) attribute — the same approach Calendar's own tests use.
|
|
730
|
+
const dayButton = (iso: string): HTMLButtonElement | null =>
|
|
731
|
+
document.querySelector<HTMLButtonElement>(`button[data-date="${iso}"]`);
|
|
732
|
+
|
|
733
|
+
it('forwards maxRangeSpan so the window caps after a start is picked', () => {
|
|
734
|
+
render(
|
|
735
|
+
<DateRangeInput
|
|
736
|
+
label="Reporting period"
|
|
737
|
+
value={null}
|
|
738
|
+
onChange={() => {}}
|
|
739
|
+
maxRangeSpan={7}
|
|
740
|
+
/>,
|
|
741
|
+
);
|
|
742
|
+
|
|
743
|
+
fireEvent.click(getButton('Open calendar'));
|
|
744
|
+
|
|
745
|
+
// Before a start is picked, a far-off day is selectable.
|
|
746
|
+
expect(dayButton('2026-01-20')).not.toBeDisabled();
|
|
747
|
+
|
|
748
|
+
fireEvent.click(dayButton('2026-01-10') as HTMLButtonElement);
|
|
749
|
+
|
|
750
|
+
// A 7-day window spans start ± 6 days: Jan 16 is the edge, Jan 17 is out.
|
|
751
|
+
expect(dayButton('2026-01-16')).not.toBeDisabled();
|
|
752
|
+
expect(dayButton('2026-01-17')).toBeDisabled();
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
it('disables a preset whose range violates the span cap', () => {
|
|
756
|
+
const presets = [
|
|
757
|
+
{
|
|
758
|
+
label: 'Last 3 days',
|
|
759
|
+
getRange: (): DateRange => ({
|
|
760
|
+
start: '2026-01-08',
|
|
761
|
+
end: '2026-01-10',
|
|
762
|
+
}),
|
|
763
|
+
},
|
|
764
|
+
{
|
|
765
|
+
label: 'Last 30 days',
|
|
766
|
+
getRange: (): DateRange => ({
|
|
767
|
+
start: '2025-12-12',
|
|
768
|
+
end: '2026-01-10',
|
|
769
|
+
}),
|
|
770
|
+
},
|
|
771
|
+
];
|
|
772
|
+
const handleChange = vi.fn();
|
|
773
|
+
render(
|
|
774
|
+
<DateRangeInput
|
|
775
|
+
label="Reporting period"
|
|
776
|
+
value={null}
|
|
777
|
+
onChange={handleChange}
|
|
778
|
+
maxRangeSpan={7}
|
|
779
|
+
presets={presets}
|
|
780
|
+
/>,
|
|
781
|
+
);
|
|
782
|
+
|
|
783
|
+
fireEvent.click(getButton('Open calendar'));
|
|
784
|
+
|
|
785
|
+
// The 3-day preset fits the 7-day cap; the 30-day preset can't be committed.
|
|
786
|
+
const withinCap = getButton('Last 3 days');
|
|
787
|
+
const overCap = getButton('Last 30 days');
|
|
788
|
+
expect(withinCap).not.toBeDisabled();
|
|
789
|
+
expect(overCap).toBeDisabled();
|
|
790
|
+
|
|
791
|
+
fireEvent.click(overCap);
|
|
792
|
+
expect(handleChange).not.toHaveBeenCalled();
|
|
793
|
+
});
|
|
794
|
+
});
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
plainDateFromISO,
|
|
23
23
|
plainDateToday,
|
|
24
24
|
plainDateFormat,
|
|
25
|
+
plainDateDiffDays,
|
|
25
26
|
DATE_FORMAT_SHORT,
|
|
26
27
|
DATE_FORMAT_SHORT_WITH_YEAR,
|
|
27
28
|
} from '../utils/plainDate';
|
|
@@ -60,6 +61,7 @@ import type {BaseProps} from '../BaseProps';
|
|
|
60
61
|
import type {SizeValue} from '../utils/types';
|
|
61
62
|
import {useSize} from '../SizeContext/SizeContext';
|
|
62
63
|
import {useInputStatusIcon} from '../hooks/useInputStatusIcon';
|
|
64
|
+
import {useResolvedRequired} from '../hooks/useResolvedRequired';
|
|
63
65
|
import {themeProps} from '../utils/themeProps';
|
|
64
66
|
import {focusOutlineStyles} from '../utils/focusOutline.stylex';
|
|
65
67
|
import {stableClassName} from '../naming';
|
|
@@ -161,6 +163,11 @@ const styles = stylex.create({
|
|
|
161
163
|
backgroundColor: colorVars['--color-accent-muted'],
|
|
162
164
|
color: colorVars['--color-accent'],
|
|
163
165
|
},
|
|
166
|
+
presetButtonDisabled: {
|
|
167
|
+
color: colorVars['--color-text-disabled'],
|
|
168
|
+
cursor: 'not-allowed',
|
|
169
|
+
backgroundColor: 'transparent',
|
|
170
|
+
},
|
|
164
171
|
});
|
|
165
172
|
|
|
166
173
|
const sizeStyles = stylex.create({
|
|
@@ -201,6 +208,34 @@ function isRangeEqual(a: DateRange | null, b: DateRange | null): boolean {
|
|
|
201
208
|
return a.start === b.start && a.end === b.end;
|
|
202
209
|
}
|
|
203
210
|
|
|
211
|
+
// A preset that would land outside the span bounds is disabled rather than
|
|
212
|
+
// allowed to override them: the cap is authoritative, so an out-of-window
|
|
213
|
+
// preset stays visible (discoverable) but non-committable, mirroring how the
|
|
214
|
+
// calendar disables out-of-window days. Spans count both endpoints.
|
|
215
|
+
function isRangeWithinSpan(
|
|
216
|
+
range: DateRange,
|
|
217
|
+
maxRangeSpan: number | undefined,
|
|
218
|
+
minRangeSpan: number | undefined,
|
|
219
|
+
): boolean {
|
|
220
|
+
if (maxRangeSpan == null && minRangeSpan == null) {
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
const span =
|
|
224
|
+
Math.abs(
|
|
225
|
+
plainDateDiffDays(
|
|
226
|
+
plainDateFromISO(range.start),
|
|
227
|
+
plainDateFromISO(range.end),
|
|
228
|
+
),
|
|
229
|
+
) + 1;
|
|
230
|
+
if (maxRangeSpan != null && span > maxRangeSpan) {
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
if (minRangeSpan != null && span < minRangeSpan) {
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
|
|
204
239
|
export interface DateRangeInputProps extends Omit<
|
|
205
240
|
BaseProps,
|
|
206
241
|
'onChange' | 'defaultValue'
|
|
@@ -303,6 +338,30 @@ export interface DateRangeInputProps extends Omit<
|
|
|
303
338
|
*/
|
|
304
339
|
dateConstraints?: ReadonlyArray<(date: Date) => boolean>;
|
|
305
340
|
|
|
341
|
+
/**
|
|
342
|
+
* Maximum number of days the selected range may span, counting both
|
|
343
|
+
* endpoints — `maxRangeSpan={7}` allows a 7-day window (start + 6 days).
|
|
344
|
+
* Once a start date is picked, days beyond this distance from it are
|
|
345
|
+
* disabled, so the user can't stretch the range past the cap. Use for
|
|
346
|
+
* rolling windows like "at most a week from the chosen day"; for fixed
|
|
347
|
+
* calendar bounds use `min`/`max`.
|
|
348
|
+
*
|
|
349
|
+
* This constrains selection only — it never rewrites a `value` that is
|
|
350
|
+
* already wider than the cap. Surface such a value with `status` if you
|
|
351
|
+
* need to flag it. A `preset` whose range violates the cap is disabled
|
|
352
|
+
* (shown but not committable) rather than allowed to override it.
|
|
353
|
+
*/
|
|
354
|
+
maxRangeSpan?: number;
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Minimum number of days the selected range must span, counting both
|
|
358
|
+
* endpoints — `minRangeSpan={2}` forbids a single-day range. Once a start
|
|
359
|
+
* date is picked, days closer than this to it are disabled — except the
|
|
360
|
+
* start itself, which stays selectable as the active anchor. Defaults to 1
|
|
361
|
+
* (a same-day start and end is allowed).
|
|
362
|
+
*/
|
|
363
|
+
minRangeSpan?: number;
|
|
364
|
+
|
|
306
365
|
/**
|
|
307
366
|
* Preset date ranges shown as quick-select options beside the calendar.
|
|
308
367
|
*/
|
|
@@ -396,6 +455,8 @@ export function DateRangeInput({
|
|
|
396
455
|
min,
|
|
397
456
|
max,
|
|
398
457
|
dateConstraints,
|
|
458
|
+
maxRangeSpan,
|
|
459
|
+
minRangeSpan,
|
|
399
460
|
presets,
|
|
400
461
|
hasClear = true,
|
|
401
462
|
placeholder: placeholderFromProps,
|
|
@@ -413,6 +474,7 @@ export function DateRangeInput({
|
|
|
413
474
|
...rest
|
|
414
475
|
}: DateRangeInputProps) {
|
|
415
476
|
const t = useTranslator();
|
|
477
|
+
const isEffectivelyRequired = useResolvedRequired({isRequired, isOptional});
|
|
416
478
|
const placeholder =
|
|
417
479
|
placeholderFromProps ?? t('@astryx.dateRangeInput.placeholder');
|
|
418
480
|
const size = useSize(sizeProp, 'md');
|
|
@@ -609,7 +671,7 @@ export function DateRangeInput({
|
|
|
609
671
|
aria-disabled={showsDisabledMessage ? 'true' : undefined}
|
|
610
672
|
aria-label={triggerAriaLabel}
|
|
611
673
|
aria-describedby={ariaDescribedBy}
|
|
612
|
-
aria-required={
|
|
674
|
+
aria-required={isEffectivelyRequired ? 'true' : undefined}
|
|
613
675
|
aria-invalid={status?.type === 'error' ? 'true' : undefined}
|
|
614
676
|
aria-busy={isBusy || undefined}
|
|
615
677
|
aria-expanded={popover.isOpen}
|
|
@@ -642,6 +704,11 @@ export function DateRangeInput({
|
|
|
642
704
|
{presets.map(preset => {
|
|
643
705
|
const presetRange = preset.getRange();
|
|
644
706
|
const isActive = isRangeEqual(value, presetRange);
|
|
707
|
+
const isPresetDisabled = !isRangeWithinSpan(
|
|
708
|
+
presetRange,
|
|
709
|
+
maxRangeSpan,
|
|
710
|
+
minRangeSpan,
|
|
711
|
+
);
|
|
645
712
|
return (
|
|
646
713
|
<button
|
|
647
714
|
key={preset.label}
|
|
@@ -652,11 +719,13 @@ export function DateRangeInput({
|
|
|
652
719
|
// marked with aria-current (not aria-selected, a listbox
|
|
653
720
|
// concept that contradicted the Tab interaction) (forms-5).
|
|
654
721
|
aria-current={isActive ? 'true' : undefined}
|
|
722
|
+
disabled={isPresetDisabled}
|
|
655
723
|
onClick={() => handlePresetClick(preset)}
|
|
656
724
|
{...stylex.props(
|
|
657
725
|
focusOutlineStyles.focusVisible,
|
|
658
726
|
styles.presetButton,
|
|
659
727
|
isActive && styles.presetButtonActive,
|
|
728
|
+
isPresetDisabled && styles.presetButtonDisabled,
|
|
660
729
|
)}>
|
|
661
730
|
{preset.label}
|
|
662
731
|
</button>
|
|
@@ -671,6 +740,8 @@ export function DateRangeInput({
|
|
|
671
740
|
min={min}
|
|
672
741
|
max={max}
|
|
673
742
|
dateConstraints={dateConstraints}
|
|
743
|
+
maxRangeSpan={maxRangeSpan}
|
|
744
|
+
minRangeSpan={minRangeSpan}
|
|
674
745
|
numberOfMonths={numberOfMonths}
|
|
675
746
|
weekStartsOn={weekStartsOn}
|
|
676
747
|
/>
|
|
@@ -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,
|
|
@@ -83,6 +84,7 @@ import {
|
|
|
83
84
|
import type {BaseProps} from '../BaseProps';
|
|
84
85
|
import type {SizeValue} from '../utils/types';
|
|
85
86
|
import {useAnnounce} from '../hooks/useAnnounce';
|
|
87
|
+
import {useResolvedRequired} from '../hooks/useResolvedRequired';
|
|
86
88
|
import {useSize} from '../SizeContext/SizeContext';
|
|
87
89
|
import {themeProps} from '../utils/themeProps';
|
|
88
90
|
import {focusOutlineStyles} from '../utils/focusOutline.stylex';
|
|
@@ -447,6 +449,7 @@ export function DateTimeInput({
|
|
|
447
449
|
...rest
|
|
448
450
|
}: DateTimeInputProps) {
|
|
449
451
|
const t = useTranslator();
|
|
452
|
+
const isEffectivelyRequired = useResolvedRequired({isRequired, isOptional});
|
|
450
453
|
// Speaks arrow-key stepping results through the persistent live regions:
|
|
451
454
|
// stepping programmatically rewrites a plain textbox's value, which screen
|
|
452
455
|
// readers do not announce on their own (WCAG 4.1.2).
|
|
@@ -742,6 +745,13 @@ export function DateTimeInput({
|
|
|
742
745
|
|
|
743
746
|
const handleDateKeyDown = useCallback(
|
|
744
747
|
(e: React.KeyboardEvent) => {
|
|
748
|
+
// Guard the composing keydown (fires before compositionend): an IME uses
|
|
749
|
+
// Enter to commit the candidate and Escape to cancel it, so without this
|
|
750
|
+
// a CJK user committing a syllable with Enter would commit the pending
|
|
751
|
+
// date instead. See utils/ime.ts.
|
|
752
|
+
if (isImeKeyEvent(e.nativeEvent)) {
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
745
755
|
if (e.key === 'Escape' && popover.isOpen) {
|
|
746
756
|
e.preventDefault();
|
|
747
757
|
popover.hide();
|
|
@@ -835,6 +845,13 @@ export function DateTimeInput({
|
|
|
835
845
|
|
|
836
846
|
const handleTimeKeyDown = useCallback(
|
|
837
847
|
(e: KeyboardEvent<HTMLInputElement>) => {
|
|
848
|
+
// ArrowUp/ArrowDown step the time and preventDefault; an IME candidate
|
|
849
|
+
// window uses those same arrows to navigate candidates, so guard the
|
|
850
|
+
// composing keydown (fires before compositionend) to avoid stealing them
|
|
851
|
+
// mid-composition. See utils/ime.ts.
|
|
852
|
+
if (isImeKeyEvent(e.nativeEvent)) {
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
838
855
|
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
|
839
856
|
e.preventDefault();
|
|
840
857
|
|
|
@@ -960,7 +977,20 @@ export function DateTimeInput({
|
|
|
960
977
|
styles.iconButton,
|
|
961
978
|
isEffectivelyDisabled && styles.iconButtonDisabled,
|
|
962
979
|
)}>
|
|
963
|
-
<Icon
|
|
980
|
+
<Icon
|
|
981
|
+
icon="calendar"
|
|
982
|
+
size="sm"
|
|
983
|
+
color="secondary"
|
|
984
|
+
// Stable theme target on the calendar toggle glyph, so a theme
|
|
985
|
+
// can restyle just this icon (color, size, hover) — and each
|
|
986
|
+
// open/closed state — via `defineTheme`, mirroring
|
|
987
|
+
// `date-input-toggle-icon`. Same-element rules in
|
|
988
|
+
// @layer astryx-theme win over the icon's own base color/size,
|
|
989
|
+
// which a segment-level target could not reach.
|
|
990
|
+
{...themeProps('date-time-input-toggle-icon', {
|
|
991
|
+
state: popover.isOpen ? 'expanded' : 'collapsed',
|
|
992
|
+
})}
|
|
993
|
+
/>
|
|
964
994
|
</button>
|
|
965
995
|
<input
|
|
966
996
|
ref={mergeRefs(ref, dateInputRef)}
|
|
@@ -981,7 +1011,7 @@ export function DateTimeInput({
|
|
|
981
1011
|
aria-disabled={showsDisabledMessage ? 'true' : undefined}
|
|
982
1012
|
readOnly={showsDisabledMessage || undefined}
|
|
983
1013
|
aria-describedby={ariaDescribedBy}
|
|
984
|
-
aria-required={
|
|
1014
|
+
aria-required={isEffectivelyRequired ? 'true' : undefined}
|
|
985
1015
|
aria-invalid={
|
|
986
1016
|
status?.type === 'error' || !isDateInputValid ? 'true' : undefined
|
|
987
1017
|
}
|
|
@@ -1039,7 +1069,16 @@ export function DateTimeInput({
|
|
|
1039
1069
|
),
|
|
1040
1070
|
)}>
|
|
1041
1071
|
<div {...stylex.props(styles.icon)}>
|
|
1042
|
-
<Icon
|
|
1072
|
+
<Icon
|
|
1073
|
+
icon="clock"
|
|
1074
|
+
size="sm"
|
|
1075
|
+
color="secondary"
|
|
1076
|
+
// Stable theme target on the leading clock glyph, so a theme can
|
|
1077
|
+
// restyle just this icon (color, size) via `defineTheme`. The
|
|
1078
|
+
// time segment has no toggle button — the clock is a static
|
|
1079
|
+
// leading affordance — so this carries no interactive state.
|
|
1080
|
+
{...themeProps('date-time-input-clock-icon')}
|
|
1081
|
+
/>
|
|
1043
1082
|
</div>
|
|
1044
1083
|
<input
|
|
1045
1084
|
ref={timeInputRef}
|
|
@@ -1061,7 +1100,7 @@ export function DateTimeInput({
|
|
|
1061
1100
|
timeLabel ?? t('@astryx.dateTimeInput.timeSuffix', {label})
|
|
1062
1101
|
}
|
|
1063
1102
|
aria-describedby={ariaDescribedBy}
|
|
1064
|
-
aria-required={
|
|
1103
|
+
aria-required={isEffectivelyRequired ? 'true' : undefined}
|
|
1065
1104
|
aria-invalid={
|
|
1066
1105
|
status?.type === 'error' || !isTimeInputValid ? 'true' : undefined
|
|
1067
1106
|
}
|
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
package/src/Field/FieldLabel.tsx
CHANGED
|
@@ -4,18 +4,19 @@
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* @file FieldLabel.tsx
|
|
7
|
-
* @input Uses React, Icon, IconType, useTranslator
|
|
7
|
+
* @input Uses React, Icon, IconType, useTranslator, FormLayoutContext
|
|
8
8
|
* @output Exports FieldLabel component, FieldLabelProps
|
|
9
9
|
* @position Core label implementation; used by Field, CheckboxInput, Switch
|
|
10
10
|
*
|
|
11
11
|
* SYNC: When modified, update these files to stay in sync:
|
|
12
12
|
* - /packages/core/src/Field/Field.doc.mjs (props table, features, implementation notes)
|
|
13
13
|
* - /packages/core/src/Field/index.ts (exports if types change)
|
|
14
|
+
* - /packages/core/src/FormLayout/FormLayoutContext.ts (defaultOptionality drives the indicator)
|
|
14
15
|
* - /packages/cli/assets/templates/blocks/components/Field/ (showcase blocks)
|
|
15
16
|
* - /packages/core/locales/en.json (@astryx.field.required / @astryx.field.optional)
|
|
16
17
|
*/
|
|
17
18
|
|
|
18
|
-
import {useMemo, useRef, type ReactNode, type RefObject} from 'react';
|
|
19
|
+
import {use, useMemo, useRef, type ReactNode, type RefObject} from 'react';
|
|
19
20
|
import * as stylex from '@stylexjs/stylex';
|
|
20
21
|
import type {BaseProps} from '../BaseProps';
|
|
21
22
|
import {mergeProps} from '../utils';
|
|
@@ -32,6 +33,7 @@ import {Tooltip} from '../Tooltip';
|
|
|
32
33
|
import {useTranslator} from '../i18n';
|
|
33
34
|
import {themeProps} from '../utils/themeProps';
|
|
34
35
|
import {useInputContainer} from '../hooks';
|
|
36
|
+
import {FormLayoutContext} from '../FormLayout/FormLayoutContext';
|
|
35
37
|
|
|
36
38
|
const styles = stylex.create({
|
|
37
39
|
label: {
|
|
@@ -188,9 +190,23 @@ export function FieldLabel({
|
|
|
188
190
|
...rest
|
|
189
191
|
}: FieldLabelProps) {
|
|
190
192
|
const t = useTranslator();
|
|
191
|
-
const
|
|
193
|
+
const {defaultOptionality} = use(FormLayoutContext);
|
|
194
|
+
|
|
195
|
+
// A form-level `defaultOptionality` means "only the exception is marked": a
|
|
196
|
+
// field that merely restates the form's default shows no indicator, and only
|
|
197
|
+
// a deviation from it does. This is the *visible indicator* only; the
|
|
198
|
+
// matching `aria-required` is resolved on each control (see
|
|
199
|
+
// useResolvedRequired) so the unmarked majority is still announced.
|
|
200
|
+
//
|
|
201
|
+
// defaultOptionality isRequired isOptional
|
|
202
|
+
// 'optional' → required indicator → (matches default, hidden)
|
|
203
|
+
// 'required' → (matches, hidden) → optional indicator
|
|
204
|
+
// unset → required indicator → optional indicator
|
|
205
|
+
const showRequired = isRequired && defaultOptionality !== 'required';
|
|
206
|
+
const showOptional = isOptional && defaultOptionality !== 'optional';
|
|
207
|
+
const statusText = showOptional
|
|
192
208
|
? t('@astryx.field.optional')
|
|
193
|
-
:
|
|
209
|
+
: showRequired
|
|
194
210
|
? t('@astryx.field.required')
|
|
195
211
|
: null;
|
|
196
212
|
|
|
@@ -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"
|