@astryxdesign/core 0.4.4 → 0.4.5-canary.1dc3e5d
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 +39 -0
- package/dist/BottomSheet/BottomSheet.d.ts +11 -2
- package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheet.js +4 -0
- package/dist/BottomSheet/BottomSheetPanel.d.ts +5 -2
- package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheetPanel.js +56 -13
- package/dist/BottomSheet/index.d.ts +1 -1
- package/dist/BottomSheet/index.d.ts.map +1 -1
- package/dist/BottomSheet/snapOffsets.d.ts +57 -20
- package/dist/BottomSheet/snapOffsets.d.ts.map +1 -1
- package/dist/BottomSheet/snapOffsets.js +106 -30
- package/dist/BottomSheet/useSheetGestures.d.ts +13 -8
- package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -1
- package/dist/BottomSheet/useSheetGestures.js +160 -64
- package/dist/Calendar/Calendar.d.ts +17 -0
- package/dist/Calendar/Calendar.d.ts.map +1 -1
- package/dist/Calendar/Calendar.js +34 -3
- package/dist/Calendar/getStandaloneShortWeekdayNames.d.ts +14 -0
- package/dist/Calendar/getStandaloneShortWeekdayNames.d.ts.map +1 -0
- package/dist/Calendar/getStandaloneShortWeekdayNames.js +26 -0
- 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/Calendar/hooks/useCalendarDays.d.ts.map +1 -1
- package/dist/Calendar/hooks/useCalendarDays.js +14 -10
- package/dist/Calendar/standaloneShortWeekdayNames.generated.d.ts +47 -0
- package/dist/Calendar/standaloneShortWeekdayNames.generated.d.ts.map +1 -0
- package/dist/Calendar/standaloneShortWeekdayNames.generated.js +40 -0
- 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 +6 -1
- package/dist/DateInput/DateInput.d.ts.map +1 -1
- package/dist/DateInput/DateInput.js +6 -1
- 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 +7 -2
- 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/Markdown/Markdown.d.ts +8 -0
- package/dist/Markdown/Markdown.d.ts.map +1 -1
- package/dist/Markdown/Markdown.js +31 -3
- package/dist/Markdown/parser.d.ts +14 -2
- package/dist/Markdown/parser.d.ts.map +1 -1
- package/dist/Markdown/parser.js +50 -2
- package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
- package/dist/MultiSelector/MultiSelector.js +6 -1
- package/dist/NumberInput/NumberInput.d.ts.map +1 -1
- package/dist/NumberInput/NumberInput.js +6 -1
- package/dist/Outline/parseOutlineFromMarkdown.d.ts +2 -0
- package/dist/Outline/parseOutlineFromMarkdown.d.ts.map +1 -1
- package/dist/Outline/parseOutlineFromMarkdown.js +5 -31
- 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 +6 -1
- 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 +9 -0
- 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 +6 -1
- 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/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 +4 -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 +3 -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 +3 -3
- package/src/BottomSheet/BottomSheet.doc.mjs +36 -3
- package/src/BottomSheet/BottomSheet.test.tsx +153 -2
- package/src/BottomSheet/BottomSheet.tsx +16 -2
- package/src/BottomSheet/BottomSheetPanel.tsx +67 -13
- package/src/BottomSheet/index.ts +5 -1
- package/src/BottomSheet/snapOffsets.test.ts +114 -47
- package/src/BottomSheet/snapOffsets.ts +119 -33
- package/src/BottomSheet/useSheetGestures.test.ts +113 -15
- package/src/BottomSheet/useSheetGestures.ts +196 -100
- package/src/Calendar/Calendar.doc.mjs +16 -0
- package/src/Calendar/Calendar.test.tsx +137 -0
- package/src/Calendar/Calendar.tsx +65 -2
- package/src/Calendar/getStandaloneShortWeekdayNames.test.ts +54 -0
- package/src/Calendar/getStandaloneShortWeekdayNames.ts +38 -0
- package/src/Calendar/hooks/useCalendarConstraints.ts +54 -3
- package/src/Calendar/hooks/useCalendarDays.ts +13 -10
- package/src/Calendar/standaloneShortWeekdayNames.generated.ts +50 -0
- package/src/CheckboxInput/CheckboxInput.tsx +6 -0
- package/src/ComplexSelector/ComplexSelector.tsx +3 -1
- package/src/DateInput/DateInput.tsx +3 -1
- 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.tsx +4 -2
- 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/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/Markdown/Markdown.doc.mjs +3 -0
- package/src/Markdown/Markdown.test.tsx +78 -0
- package/src/Markdown/Markdown.tsx +41 -1
- package/src/Markdown/parser.ts +60 -2
- package/src/MultiSelector/MultiSelector.tsx +3 -1
- package/src/NumberInput/NumberInput.tsx +3 -1
- package/src/Outline/Outline.doc.mjs +2 -0
- package/src/Outline/parseOutlineFromMarkdown.ts +10 -40
- package/src/RadioList/RadioList.tsx +9 -1
- package/src/Selector/Selector.tsx +3 -1
- 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.tsx +6 -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.tsx +3 -1
- package/src/hooks/useResolvedRequired.ts +42 -0
- package/src/utils/plainDate.test.ts +50 -0
- package/src/utils/plainDate.ts +12 -0
|
@@ -98,6 +98,18 @@ export const docs = {
|
|
|
98
98
|
type: 'Array<(date: Date) => boolean>',
|
|
99
99
|
description: 'Custom constraint functions to disable specific dates.',
|
|
100
100
|
},
|
|
101
|
+
{
|
|
102
|
+
name: 'maxRangeSpan',
|
|
103
|
+
type: 'number',
|
|
104
|
+
description:
|
|
105
|
+
'Maximum days a selected range may span, counting both endpoints (`7` = a 7-day window, start + 6). Once a start is picked, days beyond this distance are disabled so the range cannot stretch past the cap. Rolling window relative to the start — for fixed calendar bounds use `min`/`max`. Constrains selection only; it never rewrites a `value` already wider than the cap (flag that with `status`).',
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
name: 'minRangeSpan',
|
|
109
|
+
type: 'number',
|
|
110
|
+
description:
|
|
111
|
+
'Minimum days a selected range must span, counting both endpoints (`2` forbids a single-day range). Once a start is picked, days closer than this are disabled. Defaults to 1 (same-day start and end allowed).',
|
|
112
|
+
},
|
|
101
113
|
{
|
|
102
114
|
name: 'presets',
|
|
103
115
|
type: 'Array<DateRangePreset>',
|
|
@@ -313,6 +325,10 @@ export const docsDense = {
|
|
|
313
325
|
min: 'min selectable date: ISODateString template literal type (YYYY-MM-DD); use string literal or cast `as ISODateString`',
|
|
314
326
|
max: 'max selectable date: ISODateString template literal type (YYYY-MM-DD); use string literal or cast `as ISODateString`',
|
|
315
327
|
dateConstraints: 'custom constraint fns to disable dates',
|
|
328
|
+
maxRangeSpan:
|
|
329
|
+
'max days a range may span, both endpoints counted (7 = a 7-day window); caps the window from the picked start. Selection-only; does not rewrite an over-wide value',
|
|
330
|
+
minRangeSpan:
|
|
331
|
+
'min days a range must span, both endpoints counted (2 forbids a single-day range); default 1',
|
|
316
332
|
presets: 'preset ranges as quick-select options',
|
|
317
333
|
hasClear: 'clear button when range is set (default true)',
|
|
318
334
|
placeholder: 'placeholder when empty',
|
|
@@ -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
|
/>
|
|
@@ -84,6 +84,7 @@ import {
|
|
|
84
84
|
import type {BaseProps} from '../BaseProps';
|
|
85
85
|
import type {SizeValue} from '../utils/types';
|
|
86
86
|
import {useAnnounce} from '../hooks/useAnnounce';
|
|
87
|
+
import {useResolvedRequired} from '../hooks/useResolvedRequired';
|
|
87
88
|
import {useSize} from '../SizeContext/SizeContext';
|
|
88
89
|
import {themeProps} from '../utils/themeProps';
|
|
89
90
|
import {focusOutlineStyles} from '../utils/focusOutline.stylex';
|
|
@@ -448,6 +449,7 @@ export function DateTimeInput({
|
|
|
448
449
|
...rest
|
|
449
450
|
}: DateTimeInputProps) {
|
|
450
451
|
const t = useTranslator();
|
|
452
|
+
const isEffectivelyRequired = useResolvedRequired({isRequired, isOptional});
|
|
451
453
|
// Speaks arrow-key stepping results through the persistent live regions:
|
|
452
454
|
// stepping programmatically rewrites a plain textbox's value, which screen
|
|
453
455
|
// readers do not announce on their own (WCAG 4.1.2).
|
|
@@ -1009,7 +1011,7 @@ export function DateTimeInput({
|
|
|
1009
1011
|
aria-disabled={showsDisabledMessage ? 'true' : undefined}
|
|
1010
1012
|
readOnly={showsDisabledMessage || undefined}
|
|
1011
1013
|
aria-describedby={ariaDescribedBy}
|
|
1012
|
-
aria-required={
|
|
1014
|
+
aria-required={isEffectivelyRequired ? 'true' : undefined}
|
|
1013
1015
|
aria-invalid={
|
|
1014
1016
|
status?.type === 'error' || !isDateInputValid ? 'true' : undefined
|
|
1015
1017
|
}
|
|
@@ -1098,7 +1100,7 @@ export function DateTimeInput({
|
|
|
1098
1100
|
timeLabel ?? t('@astryx.dateTimeInput.timeSuffix', {label})
|
|
1099
1101
|
}
|
|
1100
1102
|
aria-describedby={ariaDescribedBy}
|
|
1101
|
-
aria-required={
|
|
1103
|
+
aria-required={isEffectivelyRequired ? 'true' : undefined}
|
|
1102
1104
|
aria-invalid={
|
|
1103
1105
|
status?.type === 'error' || !isTimeInputValid ? 'true' : undefined
|
|
1104
1106
|
}
|
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"
|
|
@@ -16,6 +16,12 @@ export const docs = {
|
|
|
16
16
|
'Controls field arrangement. Vertical stacks top-to-bottom, horizontal arranges left-to-right with equal flex-grow, and horizontal-labels uses CSS Grid with labels to the left of inputs (collapses to vertical on narrow viewports <=480px).',
|
|
17
17
|
default: "'vertical'",
|
|
18
18
|
},
|
|
19
|
+
{
|
|
20
|
+
name: 'defaultOptionality',
|
|
21
|
+
type: "'optional' | 'required'",
|
|
22
|
+
description:
|
|
23
|
+
'The state the form treats as its default, so only the exception shows an optional/required indicator. With "optional", only fields marked isRequired show an indicator; with "required", only fields marked isOptional do. A field that restates the default shows nothing. Under "required" the unmarked fields also expose aria-required so screen readers match the visual default — aria-required only, never the native required attribute. Leave unset for today\'s per-field behavior.',
|
|
24
|
+
},
|
|
19
25
|
{
|
|
20
26
|
name: 'children',
|
|
21
27
|
type: 'ReactNode',
|
|
@@ -64,6 +70,12 @@ export const docsZh = {
|
|
|
64
70
|
'控制字段排列方式。vertical 从上到下堆叠,horizontal 从左到右排列且等比弹性增长,horizontal-labels 使用 CSS Grid 将标签放在输入框左侧(在窄视口 <=480px 时折叠为垂直布局)。',
|
|
65
71
|
default: "'vertical'",
|
|
66
72
|
},
|
|
73
|
+
{
|
|
74
|
+
name: 'defaultOptionality',
|
|
75
|
+
type: "'optional' | 'required'",
|
|
76
|
+
description:
|
|
77
|
+
'表单视为默认的状态,因此仅例外字段显示可选/必填指示器。设为 "optional" 时,仅标记 isRequired 的字段显示指示器;设为 "required" 时,仅标记 isOptional 的字段显示。与默认一致的字段不显示任何内容。设为 "required" 时,未标记的字段仍会暴露 aria-required,使屏幕阅读器与视觉默认一致——仅作用于 aria-required,不改变原生 required 属性。不设置则保持当前逐字段行为。',
|
|
78
|
+
},
|
|
67
79
|
{
|
|
68
80
|
name: 'children',
|
|
69
81
|
type: 'ReactNode',
|
|
@@ -122,6 +134,7 @@ export const docsDense = {
|
|
|
122
134
|
},
|
|
123
135
|
propDescriptions: {
|
|
124
136
|
direction: 'Field arrangement. Vertical stacks top-to-bottom, horizontal arranges left-to-right w/ equal flex-grow, horizontal-labels uses CSS Grid w/ labels left of inputs (collapses <=480px).',
|
|
137
|
+
defaultOptionality: 'State the form treats as default, so only the exception is marked. "optional" → only isRequired fields show an indicator; "required" → only isOptional fields do. Under "required" unmarked fields also expose aria-required (aria only, not native required). Unset = per-field behavior.',
|
|
125
138
|
children: 'Form fields to arrange. Accepts Astryx inputs + Field-wrapped custom controls.',
|
|
126
139
|
xstyle: 'StyleX styles for layout customization. Must be stylex.create() value.',
|
|
127
140
|
},
|
|
@@ -16,6 +16,8 @@ import {FormLayout} from './FormLayout';
|
|
|
16
16
|
import {FormLayoutContext} from './FormLayoutContext';
|
|
17
17
|
import type {FormLayoutDirection} from './FormLayoutContext';
|
|
18
18
|
import {Field} from '../Field';
|
|
19
|
+
import {TextInput} from '../TextInput';
|
|
20
|
+
import {CheckboxInput} from '../CheckboxInput';
|
|
19
21
|
|
|
20
22
|
// Helper component to read context
|
|
21
23
|
function DirectionReader() {
|
|
@@ -23,6 +25,12 @@ function DirectionReader() {
|
|
|
23
25
|
return <span data-testid="direction">{direction}</span>;
|
|
24
26
|
}
|
|
25
27
|
|
|
28
|
+
// Helper component to read the defaultOptionality context value
|
|
29
|
+
function OptionalityReader() {
|
|
30
|
+
const {defaultOptionality} = use(FormLayoutContext);
|
|
31
|
+
return <span data-testid="optionality">{defaultOptionality ?? 'unset'}</span>;
|
|
32
|
+
}
|
|
33
|
+
|
|
26
34
|
describe('FormLayout', () => {
|
|
27
35
|
// ─── Basic rendering ────────────────────────────────────────────────────
|
|
28
36
|
|
|
@@ -154,6 +162,178 @@ describe('FormLayout', () => {
|
|
|
154
162
|
expect(screen.getByTestId('inner-child-2')).toBeInTheDocument();
|
|
155
163
|
});
|
|
156
164
|
|
|
165
|
+
// ─── defaultOptionality context propagation ─────────────────────────────
|
|
166
|
+
|
|
167
|
+
it('leaves defaultOptionality unset by default', () => {
|
|
168
|
+
render(
|
|
169
|
+
<FormLayout>
|
|
170
|
+
<OptionalityReader />
|
|
171
|
+
</FormLayout>,
|
|
172
|
+
);
|
|
173
|
+
expect(screen.getByTestId('optionality')).toHaveTextContent('unset');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('provides defaultOptionality="optional" to children', () => {
|
|
177
|
+
render(
|
|
178
|
+
<FormLayout defaultOptionality="optional">
|
|
179
|
+
<OptionalityReader />
|
|
180
|
+
</FormLayout>,
|
|
181
|
+
);
|
|
182
|
+
expect(screen.getByTestId('optionality')).toHaveTextContent('optional');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('provides defaultOptionality="required" to children', () => {
|
|
186
|
+
render(
|
|
187
|
+
<FormLayout defaultOptionality="required">
|
|
188
|
+
<OptionalityReader />
|
|
189
|
+
</FormLayout>,
|
|
190
|
+
);
|
|
191
|
+
expect(screen.getByTestId('optionality')).toHaveTextContent('required');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('an inner layout shadows the outer defaultOptionality', () => {
|
|
195
|
+
render(
|
|
196
|
+
<FormLayout defaultOptionality="optional">
|
|
197
|
+
<FormLayout defaultOptionality="required">
|
|
198
|
+
<OptionalityReader />
|
|
199
|
+
</FormLayout>
|
|
200
|
+
</FormLayout>,
|
|
201
|
+
);
|
|
202
|
+
expect(screen.getByTestId('optionality')).toHaveTextContent('required');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// ─── defaultOptionality indicator behavior (through Field) ───────────────
|
|
206
|
+
//
|
|
207
|
+
// The rule: only the *exception* is marked. A field that restates the form
|
|
208
|
+
// default shows nothing; a deviation shows its indicator.
|
|
209
|
+
|
|
210
|
+
it('optional default: only isRequired fields show an indicator', () => {
|
|
211
|
+
render(
|
|
212
|
+
<FormLayout defaultOptionality="optional">
|
|
213
|
+
<Field label="Bio" inputID="bio">
|
|
214
|
+
<input id="bio" />
|
|
215
|
+
</Field>
|
|
216
|
+
<Field label="Nickname" inputID="nick" isOptional>
|
|
217
|
+
<input id="nick" />
|
|
218
|
+
</Field>
|
|
219
|
+
<Field label="Email" inputID="email" isRequired>
|
|
220
|
+
<input id="email" />
|
|
221
|
+
</Field>
|
|
222
|
+
</FormLayout>,
|
|
223
|
+
);
|
|
224
|
+
// Plain + isOptional match the default → nothing shown.
|
|
225
|
+
expect(screen.queryByText(/Optional/)).not.toBeInTheDocument();
|
|
226
|
+
// isRequired deviates → the required indicator shows.
|
|
227
|
+
expect(screen.getByText(/Required/)).toBeInTheDocument();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('required default: only isOptional fields show an indicator', () => {
|
|
231
|
+
render(
|
|
232
|
+
<FormLayout defaultOptionality="required">
|
|
233
|
+
<Field label="Name" inputID="name">
|
|
234
|
+
<input id="name" />
|
|
235
|
+
</Field>
|
|
236
|
+
<Field label="Email" inputID="email" isRequired>
|
|
237
|
+
<input id="email" />
|
|
238
|
+
</Field>
|
|
239
|
+
<Field label="Nickname" inputID="nick" isOptional>
|
|
240
|
+
<input id="nick" />
|
|
241
|
+
</Field>
|
|
242
|
+
</FormLayout>,
|
|
243
|
+
);
|
|
244
|
+
// Plain + isRequired match the default → nothing shown.
|
|
245
|
+
expect(screen.queryByText(/Required/)).not.toBeInTheDocument();
|
|
246
|
+
// isOptional deviates → the optional indicator shows.
|
|
247
|
+
expect(screen.getByText(/Optional/)).toBeInTheDocument();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('unset default preserves per-field indicators (backwards compatible)', () => {
|
|
251
|
+
render(
|
|
252
|
+
<FormLayout>
|
|
253
|
+
<Field label="Email" inputID="email" isRequired>
|
|
254
|
+
<input id="email" />
|
|
255
|
+
</Field>
|
|
256
|
+
<Field label="Nickname" inputID="nick" isOptional>
|
|
257
|
+
<input id="nick" />
|
|
258
|
+
</Field>
|
|
259
|
+
</FormLayout>,
|
|
260
|
+
);
|
|
261
|
+
expect(screen.getByText(/Required/)).toBeInTheDocument();
|
|
262
|
+
expect(screen.getByText(/Optional/)).toBeInTheDocument();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// ─── defaultOptionality aria-required resolution ─────────────────────────
|
|
266
|
+
//
|
|
267
|
+
// The indicator is suppressed for the unmarked majority, so the matching
|
|
268
|
+
// `aria-required` must still be exposed — otherwise a sighted user reads a
|
|
269
|
+
// field as required (form default, no indicator) while a screen reader hears
|
|
270
|
+
// "not required". Native `required` stays bound to the explicit prop so a
|
|
271
|
+
// layout default never switches on browser validation.
|
|
272
|
+
|
|
273
|
+
it('required default: an unmarked input still exposes aria-required', () => {
|
|
274
|
+
render(
|
|
275
|
+
<FormLayout defaultOptionality="required">
|
|
276
|
+
<TextInput label="Name" value="" onChange={() => {}} />
|
|
277
|
+
</FormLayout>,
|
|
278
|
+
);
|
|
279
|
+
expect(screen.getByLabelText('Name')).toHaveAttribute(
|
|
280
|
+
'aria-required',
|
|
281
|
+
'true',
|
|
282
|
+
);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('required default: an isOptional input is not aria-required', () => {
|
|
286
|
+
render(
|
|
287
|
+
<FormLayout defaultOptionality="required">
|
|
288
|
+
<TextInput label="Nickname" value="" onChange={() => {}} isOptional />
|
|
289
|
+
</FormLayout>,
|
|
290
|
+
);
|
|
291
|
+
expect(screen.getByRole('textbox')).not.toHaveAttribute('aria-required');
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('optional default: an unmarked input is not aria-required', () => {
|
|
295
|
+
render(
|
|
296
|
+
<FormLayout defaultOptionality="optional">
|
|
297
|
+
<TextInput label="Bio" value="" onChange={() => {}} />
|
|
298
|
+
</FormLayout>,
|
|
299
|
+
);
|
|
300
|
+
expect(screen.getByLabelText('Bio')).not.toHaveAttribute('aria-required');
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('no layout: an unmarked input is not aria-required (backwards compatible)', () => {
|
|
304
|
+
render(<TextInput label="Solo" value="" onChange={() => {}} />);
|
|
305
|
+
expect(screen.getByLabelText('Solo')).not.toHaveAttribute('aria-required');
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('required default resolves aria-required without native required', () => {
|
|
309
|
+
render(
|
|
310
|
+
<FormLayout defaultOptionality="required">
|
|
311
|
+
<CheckboxInput label="Terms" value={false} onChange={() => {}} />
|
|
312
|
+
</FormLayout>,
|
|
313
|
+
);
|
|
314
|
+
const checkbox = screen.getByRole('checkbox', {name: 'Terms'});
|
|
315
|
+
// Announced as required (form default)…
|
|
316
|
+
expect(checkbox).toHaveAttribute('aria-required', 'true');
|
|
317
|
+
// …but the native `required` attribute is not switched on by the layout.
|
|
318
|
+
expect(checkbox).not.toHaveAttribute('required');
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('explicit isRequired still drives native required under a layout', () => {
|
|
322
|
+
render(
|
|
323
|
+
<FormLayout defaultOptionality="required">
|
|
324
|
+
<CheckboxInput
|
|
325
|
+
label="Consent"
|
|
326
|
+
value={false}
|
|
327
|
+
onChange={() => {}}
|
|
328
|
+
isRequired
|
|
329
|
+
/>
|
|
330
|
+
</FormLayout>,
|
|
331
|
+
);
|
|
332
|
+
const checkbox = screen.getByRole('checkbox', {name: 'Consent'});
|
|
333
|
+
expect(checkbox).toHaveAttribute('aria-required', 'true');
|
|
334
|
+
expect(checkbox).toHaveAttribute('required');
|
|
335
|
+
});
|
|
336
|
+
|
|
157
337
|
// ─── Snapshot tests ─────────────────────────────────────────────────────
|
|
158
338
|
|
|
159
339
|
it('matches snapshot for vertical direction', () => {
|
|
@@ -223,10 +403,7 @@ describe('FormLayout', () => {
|
|
|
223
403
|
it('horizontal-labels with Field: label and input wrapper are siblings under display:contents', () => {
|
|
224
404
|
render(
|
|
225
405
|
<FormLayout direction="horizontal-labels" data-testid="layout">
|
|
226
|
-
<Field
|
|
227
|
-
label="Username"
|
|
228
|
-
inputID="username"
|
|
229
|
-
data-testid="username-field">
|
|
406
|
+
<Field label="Username" inputID="username" data-testid="username-field">
|
|
230
407
|
<input id="username" data-testid="username-input" />
|
|
231
408
|
</Field>
|
|
232
409
|
</FormLayout>,
|