@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.
Files changed (176) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/BottomSheet/BottomSheet.d.ts +11 -2
  3. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
  4. package/dist/BottomSheet/BottomSheet.js +4 -0
  5. package/dist/BottomSheet/BottomSheetPanel.d.ts +5 -2
  6. package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -1
  7. package/dist/BottomSheet/BottomSheetPanel.js +56 -13
  8. package/dist/BottomSheet/index.d.ts +1 -1
  9. package/dist/BottomSheet/index.d.ts.map +1 -1
  10. package/dist/BottomSheet/snapOffsets.d.ts +57 -20
  11. package/dist/BottomSheet/snapOffsets.d.ts.map +1 -1
  12. package/dist/BottomSheet/snapOffsets.js +106 -30
  13. package/dist/BottomSheet/useSheetGestures.d.ts +13 -8
  14. package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -1
  15. package/dist/BottomSheet/useSheetGestures.js +160 -64
  16. package/dist/Calendar/Calendar.d.ts +17 -0
  17. package/dist/Calendar/Calendar.d.ts.map +1 -1
  18. package/dist/Calendar/Calendar.js +34 -3
  19. package/dist/Calendar/getStandaloneShortWeekdayNames.d.ts +14 -0
  20. package/dist/Calendar/getStandaloneShortWeekdayNames.d.ts.map +1 -0
  21. package/dist/Calendar/getStandaloneShortWeekdayNames.js +26 -0
  22. package/dist/Calendar/hooks/useCalendarConstraints.d.ts +22 -1
  23. package/dist/Calendar/hooks/useCalendarConstraints.d.ts.map +1 -1
  24. package/dist/Calendar/hooks/useCalendarConstraints.js +26 -4
  25. package/dist/Calendar/hooks/useCalendarDays.d.ts.map +1 -1
  26. package/dist/Calendar/hooks/useCalendarDays.js +14 -10
  27. package/dist/Calendar/standaloneShortWeekdayNames.generated.d.ts +47 -0
  28. package/dist/Calendar/standaloneShortWeekdayNames.generated.d.ts.map +1 -0
  29. package/dist/Calendar/standaloneShortWeekdayNames.generated.js +40 -0
  30. package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
  31. package/dist/CheckboxInput/CheckboxInput.js +9 -0
  32. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  33. package/dist/ComplexSelector/ComplexSelector.js +6 -1
  34. package/dist/DateInput/DateInput.d.ts.map +1 -1
  35. package/dist/DateInput/DateInput.js +6 -1
  36. package/dist/DateRangeInput/DateRangeInput.d.ts +23 -1
  37. package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
  38. package/dist/DateRangeInput/DateRangeInput.js +38 -3
  39. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  40. package/dist/DateTimeInput/DateTimeInput.js +7 -2
  41. package/dist/Field/FieldLabel.d.ts +2 -1
  42. package/dist/Field/FieldLabel.d.ts.map +1 -1
  43. package/dist/Field/FieldLabel.js +21 -3
  44. package/dist/Field/InputClearButton.d.ts +2 -1
  45. package/dist/Field/InputClearButton.d.ts.map +1 -1
  46. package/dist/Field/InputClearButton.js +6 -1
  47. package/dist/FormLayout/FormLayout.d.ts +24 -2
  48. package/dist/FormLayout/FormLayout.d.ts.map +1 -1
  49. package/dist/FormLayout/FormLayout.js +7 -2
  50. package/dist/FormLayout/FormLayoutContext.d.ts +15 -2
  51. package/dist/FormLayout/FormLayoutContext.d.ts.map +1 -1
  52. package/dist/FormLayout/FormLayoutContext.js +19 -4
  53. package/dist/FormLayout/index.d.ts +1 -1
  54. package/dist/FormLayout/index.d.ts.map +1 -1
  55. package/dist/Markdown/Markdown.d.ts +8 -0
  56. package/dist/Markdown/Markdown.d.ts.map +1 -1
  57. package/dist/Markdown/Markdown.js +31 -3
  58. package/dist/Markdown/parser.d.ts +14 -2
  59. package/dist/Markdown/parser.d.ts.map +1 -1
  60. package/dist/Markdown/parser.js +50 -2
  61. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  62. package/dist/MultiSelector/MultiSelector.js +6 -1
  63. package/dist/NumberInput/NumberInput.d.ts.map +1 -1
  64. package/dist/NumberInput/NumberInput.js +6 -1
  65. package/dist/Outline/parseOutlineFromMarkdown.d.ts +2 -0
  66. package/dist/Outline/parseOutlineFromMarkdown.d.ts.map +1 -1
  67. package/dist/Outline/parseOutlineFromMarkdown.js +5 -31
  68. package/dist/RadioList/RadioList.d.ts.map +1 -1
  69. package/dist/RadioList/RadioList.js +12 -1
  70. package/dist/Selector/Selector.d.ts.map +1 -1
  71. package/dist/Selector/Selector.js +6 -1
  72. package/dist/StatusDot/StatusDot.d.ts +43 -3
  73. package/dist/StatusDot/StatusDot.d.ts.map +1 -1
  74. package/dist/StatusDot/StatusDot.js +42 -5
  75. package/dist/Switch/Switch.d.ts.map +1 -1
  76. package/dist/Switch/Switch.js +9 -0
  77. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.d.ts.map +1 -1
  78. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.js +7 -2
  79. package/dist/TextArea/TextArea.d.ts.map +1 -1
  80. package/dist/TextArea/TextArea.js +6 -1
  81. package/dist/TextInput/TextInput.d.ts.map +1 -1
  82. package/dist/TextInput/TextInput.js +6 -1
  83. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  84. package/dist/TimeInput/TimeInput.js +6 -1
  85. package/dist/hooks/useResolvedRequired.d.ts +19 -0
  86. package/dist/hooks/useResolvedRequired.d.ts.map +1 -0
  87. package/dist/hooks/useResolvedRequired.js +40 -0
  88. package/dist/utils/plainDate.d.ts +6 -0
  89. package/dist/utils/plainDate.d.ts.map +1 -1
  90. package/dist/utils/plainDate.js +12 -0
  91. package/locales/af-ZA.json +958 -0
  92. package/locales/ar-SA.json +998 -0
  93. package/locales/ca-ES.json +974 -0
  94. package/locales/cs-CZ.json +990 -0
  95. package/locales/da-DK.json +966 -0
  96. package/locales/de-DE.json +966 -0
  97. package/locales/el-GR.json +990 -0
  98. package/locales/en.json +4 -0
  99. package/locales/es-ES.json +978 -0
  100. package/locales/fi-FI.json +990 -0
  101. package/locales/fr-FR.json +928 -0
  102. package/locales/he-IL.json +990 -0
  103. package/locales/hu-HU.json +990 -0
  104. package/locales/it-IT.json +978 -0
  105. package/locales/ja-JP.json +998 -0
  106. package/locales/ko-KR.json +990 -0
  107. package/locales/nl-NL.json +954 -0
  108. package/locales/no-NO.json +978 -0
  109. package/locales/pl-PL.json +982 -0
  110. package/locales/pseudo.json +3 -0
  111. package/locales/pt-BR.json +982 -0
  112. package/locales/pt-PT.json +982 -0
  113. package/locales/ro-RO.json +982 -0
  114. package/locales/ru-RU.json +990 -0
  115. package/locales/sr-SP.json +990 -0
  116. package/locales/sv-SE.json +986 -0
  117. package/locales/tr-TR.json +990 -0
  118. package/locales/uk-UA.json +990 -0
  119. package/locales/vi-VN.json +986 -0
  120. package/locales/zh-CN.json +998 -0
  121. package/locales/zh-TW.json +998 -0
  122. package/package.json +3 -3
  123. package/src/BottomSheet/BottomSheet.doc.mjs +36 -3
  124. package/src/BottomSheet/BottomSheet.test.tsx +153 -2
  125. package/src/BottomSheet/BottomSheet.tsx +16 -2
  126. package/src/BottomSheet/BottomSheetPanel.tsx +67 -13
  127. package/src/BottomSheet/index.ts +5 -1
  128. package/src/BottomSheet/snapOffsets.test.ts +114 -47
  129. package/src/BottomSheet/snapOffsets.ts +119 -33
  130. package/src/BottomSheet/useSheetGestures.test.ts +113 -15
  131. package/src/BottomSheet/useSheetGestures.ts +196 -100
  132. package/src/Calendar/Calendar.doc.mjs +16 -0
  133. package/src/Calendar/Calendar.test.tsx +137 -0
  134. package/src/Calendar/Calendar.tsx +65 -2
  135. package/src/Calendar/getStandaloneShortWeekdayNames.test.ts +54 -0
  136. package/src/Calendar/getStandaloneShortWeekdayNames.ts +38 -0
  137. package/src/Calendar/hooks/useCalendarConstraints.ts +54 -3
  138. package/src/Calendar/hooks/useCalendarDays.ts +13 -10
  139. package/src/Calendar/standaloneShortWeekdayNames.generated.ts +50 -0
  140. package/src/CheckboxInput/CheckboxInput.tsx +6 -0
  141. package/src/ComplexSelector/ComplexSelector.tsx +3 -1
  142. package/src/DateInput/DateInput.tsx +3 -1
  143. package/src/DateRangeInput/DateRangeInput.doc.mjs +16 -0
  144. package/src/DateRangeInput/DateRangeInput.test.tsx +81 -1
  145. package/src/DateRangeInput/DateRangeInput.tsx +72 -1
  146. package/src/DateTimeInput/DateTimeInput.tsx +4 -2
  147. package/src/Field/Field.doc.mjs +1 -0
  148. package/src/Field/FieldLabel.tsx +20 -4
  149. package/src/Field/InputClearButton.test.tsx +25 -0
  150. package/src/Field/InputClearButton.tsx +4 -1
  151. package/src/FormLayout/FormLayout.doc.mjs +13 -0
  152. package/src/FormLayout/FormLayout.test.tsx +181 -4
  153. package/src/FormLayout/FormLayout.tsx +33 -2
  154. package/src/FormLayout/FormLayoutContext.ts +22 -7
  155. package/src/FormLayout/index.ts +1 -1
  156. package/src/Markdown/Markdown.doc.mjs +3 -0
  157. package/src/Markdown/Markdown.test.tsx +78 -0
  158. package/src/Markdown/Markdown.tsx +41 -1
  159. package/src/Markdown/parser.ts +60 -2
  160. package/src/MultiSelector/MultiSelector.tsx +3 -1
  161. package/src/NumberInput/NumberInput.tsx +3 -1
  162. package/src/Outline/Outline.doc.mjs +2 -0
  163. package/src/Outline/parseOutlineFromMarkdown.ts +10 -40
  164. package/src/RadioList/RadioList.tsx +9 -1
  165. package/src/Selector/Selector.tsx +3 -1
  166. package/src/StatusDot/StatusDot.doc.mjs +13 -0
  167. package/src/StatusDot/StatusDot.test.tsx +115 -8
  168. package/src/StatusDot/StatusDot.tsx +74 -9
  169. package/src/Switch/Switch.tsx +6 -0
  170. package/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +20 -2
  171. package/src/TextArea/TextArea.tsx +3 -1
  172. package/src/TextInput/TextInput.tsx +3 -1
  173. package/src/TimeInput/TimeInput.tsx +3 -1
  174. package/src/hooks/useResolvedRequired.ts +42 -0
  175. package/src/utils/plainDate.test.ts +50 -0
  176. package/src/utils/plainDate.ts +12 -0
@@ -21,6 +21,8 @@ import {calendarStyles} from './styles';
21
21
  import {defineTheme} from '../theme/defineTheme';
22
22
  import {generateThemeCSS} from '../theme/generateThemeRules';
23
23
  import {__resetLiveRegionsForTest} from '../hooks/useAnnounce';
24
+ import {InternationalizationProvider} from '../i18n/InternationalizationProvider';
25
+ import {standaloneShortWeekdayNamesByLocale} from './standaloneShortWeekdayNames.generated';
24
26
 
25
27
  function generateThemeTestCSS(theme: Parameters<typeof generateThemeCSS>[0]) {
26
28
  const {prose, component} = generateThemeCSS(theme);
@@ -120,6 +122,45 @@ describe('Calendar', () => {
120
122
  expect(screen.getByText('Sa')).toBeInTheDocument();
121
123
  });
122
124
 
125
+ it('uses the provider locale for stand-alone short day names', () => {
126
+ const localizedNames = standaloneShortWeekdayNamesByLocale.es;
127
+ const englishNames = standaloneShortWeekdayNamesByLocale.en;
128
+ expect(localizedNames).not.toEqual(englishNames);
129
+
130
+ const {rerender} = render(
131
+ <InternationalizationProvider locale="es-ES">
132
+ <Calendar />
133
+ </InternationalizationProvider>,
134
+ );
135
+
136
+ expect(
137
+ screen.getAllByRole('columnheader').map(header => header.textContent),
138
+ ).toEqual(localizedNames);
139
+
140
+ rerender(
141
+ <InternationalizationProvider locale="en">
142
+ <Calendar />
143
+ </InternationalizationProvider>,
144
+ );
145
+ expect(
146
+ screen.getAllByRole('columnheader').map(header => header.textContent),
147
+ ).toEqual(englishNames);
148
+ });
149
+
150
+ it('rotates localized day names by numeric weekday index', () => {
151
+ const localizedNames = standaloneShortWeekdayNamesByLocale.es;
152
+
153
+ render(
154
+ <InternationalizationProvider locale="es-ES">
155
+ <Calendar weekStartsOn={1} />
156
+ </InternationalizationProvider>,
157
+ );
158
+
159
+ expect(
160
+ screen.getAllByRole('columnheader').map(header => header.textContent),
161
+ ).toEqual([...localizedNames.slice(1), localizedNames[0]]);
162
+ });
163
+
123
164
  it('displays correct number of day cells', () => {
124
165
  render(<Calendar />);
125
166
 
@@ -236,6 +277,102 @@ describe('Calendar', () => {
236
277
  expect(sunday).toBeDisabled();
237
278
  });
238
279
 
280
+ it('caps the end date to maxRangeSpan once a start is picked', async () => {
281
+ const user = userEvent.setup();
282
+
283
+ render(<Calendar mode="range" focusDate="2026-01-01" maxRangeSpan={7} />);
284
+
285
+ // Before a start is picked every day is selectable.
286
+ expect(getDayButton(20)).not.toBeDisabled();
287
+
288
+ // Pick Jan 10 as the start. A 7-day window spans Jan 4–16 (start ± 6).
289
+ await user.click(getDayButton(10));
290
+
291
+ expect(getDayButton(16)).not.toBeDisabled(); // 6 days after — the edge
292
+ expect(getDayButton(17)).toBeDisabled(); // 7 days after — beyond the cap
293
+ expect(getDayButton(4)).not.toBeDisabled(); // 6 days before — symmetric
294
+ expect(getDayButton(3)).toBeDisabled(); // 7 days before — beyond the cap
295
+ });
296
+
297
+ it('enforces minRangeSpan once a start is picked', async () => {
298
+ const user = userEvent.setup();
299
+
300
+ render(<Calendar mode="range" focusDate="2026-01-01" minRangeSpan={3} />);
301
+
302
+ // Pick Jan 10. A 3-day minimum forbids ends closer than 2 days away.
303
+ await user.click(getDayButton(10));
304
+
305
+ // The picked start itself stays enabled — it is the active selection
306
+ // anchor, not an unreachable end.
307
+ expect(getDayButton(10)).not.toBeDisabled();
308
+ expect(getDayButton(11)).toBeDisabled(); // 1 day apart — too short
309
+ expect(getDayButton(12)).not.toBeDisabled(); // 3-day span — allowed
310
+ expect(getDayButton(8)).not.toBeDisabled(); // 3-day span the other way
311
+ expect(getDayButton(9)).toBeDisabled(); // 2-day span — too short
312
+ });
313
+
314
+ it('clears the in-progress start when the anchor is clicked again', async () => {
315
+ const user = userEvent.setup();
316
+ const handleChange = vi.fn();
317
+
318
+ render(
319
+ <Calendar
320
+ mode="range"
321
+ focusDate="2026-01-01"
322
+ onChange={handleChange}
323
+ minRangeSpan={3}
324
+ />,
325
+ );
326
+
327
+ // Pick Jan 10 as the start, then click it again.
328
+ await user.click(getDayButton(10));
329
+ await user.click(getDayButton(10));
330
+
331
+ // No zero-length range is committed…
332
+ expect(handleChange).not.toHaveBeenCalled();
333
+
334
+ // …and the start is cleared: the days that minRangeSpan disabled around
335
+ // the old anchor are selectable again, so a new start can be placed there.
336
+ expect(getDayButton(11)).not.toBeDisabled();
337
+ expect(getDayButton(9)).not.toBeDisabled();
338
+ });
339
+
340
+ it('does not apply range-span constraints in single mode', async () => {
341
+ const user = userEvent.setup();
342
+
343
+ render(<Calendar focusDate="2026-01-01" maxRangeSpan={7} />);
344
+
345
+ // A picked single date must not disable far-away days.
346
+ await user.click(getDayButton(10));
347
+ expect(getDayButton(25)).not.toBeDisabled();
348
+ });
349
+
350
+ it('keeps a controlled value that is wider than maxRangeSpan (selection-only)', () => {
351
+ // The span constraint governs which days are pickable, not validation of
352
+ // an already-set value — so a 15-day value under a 7-day cap stays
353
+ // rendered and is never silently rewritten.
354
+ render(
355
+ <Calendar
356
+ mode="range"
357
+ value={{start: '2026-01-05', end: '2026-01-20'}}
358
+ focusDate="2026-01-01"
359
+ maxRangeSpan={7}
360
+ />,
361
+ );
362
+
363
+ // No selection is in progress (both endpoints are set), so no anchor →
364
+ // every day is pickable and both endpoints render selected.
365
+ expect(getDayButton(20)).not.toBeDisabled();
366
+ expect(getDayButton(5).closest('[role="gridcell"]')).toHaveAttribute(
367
+ 'aria-selected',
368
+ 'true',
369
+ );
370
+ expect(getDayButton(20).closest('[role="gridcell"]')).toHaveAttribute(
371
+ 'aria-selected',
372
+ 'true',
373
+ );
374
+ });
375
+
239
376
  // ─── Multi-Month ─────────────────────────────────────────────
240
377
 
241
378
  it('renders two months when numberOfMonths={2}', () => {
@@ -120,6 +120,25 @@ interface CalendarBaseProps extends Omit<
120
120
  */
121
121
  dateConstraints?: ReadonlyArray<(date: Date) => boolean>;
122
122
 
123
+ /**
124
+ * Range mode only. Maximum number of days a selected range may span,
125
+ * counting both endpoints — `maxRangeSpan={7}` allows a 7-day window
126
+ * (start + 6 days). Once a start date is picked, days beyond this distance
127
+ * from it are disabled in either direction; before a start is picked every
128
+ * otherwise-valid day stays selectable. Use for rolling windows like "at
129
+ * most a week from the chosen day". For fixed calendar bounds use min/max.
130
+ */
131
+ maxRangeSpan?: number;
132
+
133
+ /**
134
+ * Range mode only. Minimum number of days a selected range must span,
135
+ * counting both endpoints — `minRangeSpan={2}` forbids a single-day range.
136
+ * Once a start date is picked, days closer than this to it are disabled —
137
+ * except the start itself, which stays selectable as the active anchor.
138
+ * Defaults to 1 (a same-day start and end is allowed).
139
+ */
140
+ minRangeSpan?: number;
141
+
123
142
  /**
124
143
  * Controlled focus date (which month is visible).
125
144
  * If not provided, defaults to selected date or today.
@@ -211,6 +230,8 @@ export function Calendar({ref, ...props}: CalendarProps) {
211
230
  min,
212
231
  max,
213
232
  dateConstraints,
233
+ maxRangeSpan,
234
+ minRangeSpan,
214
235
  focusDate: focusDateProp,
215
236
  onFocusDateChange,
216
237
  hasOutsideDays = true,
@@ -425,6 +446,23 @@ export function Calendar({ref, ...props}: CalendarProps) {
425
446
  } else {
426
447
  // Second click - complete the range
427
448
  const startPd = plainDateFromISO(rangeSelectionStart);
449
+
450
+ // Clicking the anchor again clears the in-progress start rather than
451
+ // committing a zero-length range. This is also the escape hatch when
452
+ // `minRangeSpan` disables the days around the anchor: without it the
453
+ // anchor would be the only clickable day left and the start could
454
+ // never be moved. `minRangeSpan` leaves the anchor itself enabled
455
+ // precisely so this toggle stays reachable.
456
+ if (plainDateIsEqual(date, startPd)) {
457
+ setRangeSelectionStart(null);
458
+ announce(
459
+ t('@astryx.calendar.rangeClearedAnnounce', {
460
+ date: plainDateFormat(date, DATE_FORMAT_WITH_WEEKDAY),
461
+ }),
462
+ );
463
+ return;
464
+ }
465
+
428
466
  let start: ISODateString;
429
467
  let end: ISODateString;
430
468
 
@@ -528,6 +566,8 @@ export function Calendar({ref, ...props}: CalendarProps) {
528
566
  min={min}
529
567
  max={max}
530
568
  dateConstraints={dateConstraints}
569
+ maxRangeSpan={maxRangeSpan}
570
+ minRangeSpan={minRangeSpan}
531
571
  hasOutsideDays={hasOutsideDays}
532
572
  hasWeekNumbers={hasWeekNumbers}
533
573
  hasVariableRowCount={hasVariableRowCount}
@@ -567,6 +607,8 @@ interface MonthGridProps {
567
607
  min?: ISODateString;
568
608
  max?: ISODateString;
569
609
  dateConstraints?: ReadonlyArray<(date: Date) => boolean>;
610
+ maxRangeSpan?: number;
611
+ minRangeSpan?: number;
570
612
  hasOutsideDays: boolean;
571
613
  hasWeekNumbers: boolean;
572
614
  hasVariableRowCount: boolean;
@@ -589,6 +631,8 @@ function MonthGrid({
589
631
  min,
590
632
  max,
591
633
  dateConstraints,
634
+ maxRangeSpan,
635
+ minRangeSpan,
592
636
  hasOutsideDays,
593
637
  hasWeekNumbers,
594
638
  hasVariableRowCount,
@@ -610,11 +654,30 @@ function MonthGrid({
610
654
  weekStartsOn,
611
655
  hasVariableRowCount,
612
656
  });
657
+ const dayNameHeaders = useMemo(
658
+ () =>
659
+ dayNames.map((name, offset) => ({
660
+ dayOfWeek: ((weekStartsOn + offset) % 7) as DayOfWeek,
661
+ name,
662
+ })),
663
+ [dayNames, weekStartsOn],
664
+ );
665
+
666
+ const rangeAnchor = useMemo(
667
+ () =>
668
+ mode === 'range' && rangeSelectionStart
669
+ ? plainDateFromISO(rangeSelectionStart)
670
+ : null,
671
+ [mode, rangeSelectionStart],
672
+ );
613
673
 
614
674
  const {isDateDisabled} = useCalendarConstraints({
615
675
  min,
616
676
  max,
617
677
  dateConstraints,
678
+ maxRangeSpan,
679
+ minRangeSpan,
680
+ rangeAnchor,
618
681
  });
619
682
 
620
683
  // Parse selected date for roving tabindex priority
@@ -833,9 +896,9 @@ function MonthGrid({
833
896
  )}
834
897
  />
835
898
  )}
836
- {dayNames.map(name => (
899
+ {dayNameHeaders.map(({dayOfWeek, name}) => (
837
900
  <div
838
- key={name}
901
+ key={dayOfWeek}
839
902
  role="columnheader"
840
903
  {...stylex.props(monthGridStyles.dayName)}>
841
904
  {name}
@@ -0,0 +1,54 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file getStandaloneShortWeekdayNames.test.ts
5
+ * @input Private Calendar weekday locale resolver and generated CLDR data
6
+ * @output Regression coverage for exact, language, extension, and safe fallback
7
+ * @position Tests for getStandaloneShortWeekdayNames.ts
8
+ */
9
+
10
+ import {describe, expect, it} from 'vitest';
11
+ import {getStandaloneShortWeekdayNames} from './getStandaloneShortWeekdayNames';
12
+ import {standaloneShortWeekdayNamesByLocale} from './standaloneShortWeekdayNames.generated';
13
+
14
+ describe('getStandaloneShortWeekdayNames', () => {
15
+ it('preserves the default English weekday names', () => {
16
+ expect(getStandaloneShortWeekdayNames('en')).toEqual([
17
+ 'Su',
18
+ 'Mo',
19
+ 'Tu',
20
+ 'We',
21
+ 'Th',
22
+ 'Fr',
23
+ 'Sa',
24
+ ]);
25
+ });
26
+
27
+ it('returns an exact generated locale entry', () => {
28
+ expect(getStandaloneShortWeekdayNames('ar-SA')).toBe(
29
+ standaloneShortWeekdayNamesByLocale['ar-SA'],
30
+ );
31
+ });
32
+
33
+ it('ignores Unicode extensions when resolving an exact locale', () => {
34
+ const expected = standaloneShortWeekdayNamesByLocale['zh-TW'];
35
+
36
+ expect(getStandaloneShortWeekdayNames('zh-TW')).toBe(expected);
37
+ expect(getStandaloneShortWeekdayNames('zh-TW-u-ca-chinese')).toBe(expected);
38
+ });
39
+
40
+ it('falls back from a regional locale to its base language', () => {
41
+ expect(getStandaloneShortWeekdayNames('es-ES')).toBe(
42
+ standaloneShortWeekdayNamesByLocale.es,
43
+ );
44
+ });
45
+
46
+ it('safely falls back to English for malformed and unsupported locales', () => {
47
+ expect(getStandaloneShortWeekdayNames('not_a_locale')).toBe(
48
+ standaloneShortWeekdayNamesByLocale.en,
49
+ );
50
+ expect(getStandaloneShortWeekdayNames('xx-ZZ')).toBe(
51
+ standaloneShortWeekdayNamesByLocale.en,
52
+ );
53
+ });
54
+ });
@@ -0,0 +1,38 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file getStandaloneShortWeekdayNames.ts
5
+ * @input Generated CLDR stand-alone-short weekday table
6
+ * @output Private locale resolver for Calendar weekday headers
7
+ * @position Calendar-internal pure utility; not exported from the package
8
+ */
9
+
10
+ import {
11
+ standaloneShortWeekdayNamesByLocale,
12
+ type StandaloneShortWeekdayNames,
13
+ } from './standaloneShortWeekdayNames.generated';
14
+
15
+ const weekdayNamesByLocale: Readonly<
16
+ Record<string, StandaloneShortWeekdayNames>
17
+ > = standaloneShortWeekdayNamesByLocale;
18
+ const englishWeekdayNames = weekdayNamesByLocale.en;
19
+
20
+ /**
21
+ * Resolve generated weekday reference data as exact locale -> language -> English.
22
+ * Unicode extensions are ignored through Intl.Locale.baseName. Invalid tags and
23
+ * valid but unsupported languages safely use English.
24
+ */
25
+ export function getStandaloneShortWeekdayNames(
26
+ locale: string,
27
+ ): StandaloneShortWeekdayNames {
28
+ try {
29
+ const parsed = new Intl.Locale(locale);
30
+ return (
31
+ weekdayNamesByLocale[parsed.baseName] ??
32
+ weekdayNamesByLocale[parsed.language] ??
33
+ englishWeekdayNames
34
+ );
35
+ } catch {
36
+ return englishWeekdayNames;
37
+ }
38
+ }
@@ -20,6 +20,7 @@ import {
20
20
  plainDateToDate,
21
21
  plainDateIsBefore,
22
22
  plainDateIsAfter,
23
+ plainDateDiffDays,
23
24
  } from '../../utils/plainDate';
24
25
 
25
26
  /**
@@ -35,6 +36,25 @@ export interface UseCalendarConstraintsOptions {
35
36
  * Date is disabled if ANY function returns false.
36
37
  */
37
38
  dateConstraints?: ReadonlyArray<(date: Date) => boolean>;
39
+ /**
40
+ * Maximum number of days a range may span, counting both endpoints
41
+ * (a value of 7 allows a 7-day window). In range mode, once a start is
42
+ * picked, dates further than this from it are disabled. No effect until a
43
+ * range anchor exists.
44
+ */
45
+ maxRangeSpan?: number;
46
+ /**
47
+ * Minimum number of days a range must span, counting both endpoints
48
+ * (a value of 2 forbids a single-day range). In range mode, once a start
49
+ * is picked, dates closer than this to it are disabled.
50
+ */
51
+ minRangeSpan?: number;
52
+ /**
53
+ * The in-progress range start (first click, awaiting the second). Span
54
+ * constraints are measured from this date. Null when no selection is
55
+ * underway.
56
+ */
57
+ rangeAnchor?: PlainDate | null;
38
58
  }
39
59
 
40
60
  /**
@@ -53,13 +73,15 @@ export interface UseCalendarConstraintsReturn {
53
73
  * Hook for managing calendar date validation constraints.
54
74
  *
55
75
  * Provides a function to check if a date is disabled based on
56
- * min/max bounds and custom constraint functions.
76
+ * min/max bounds, range-span bounds, and custom constraint functions.
57
77
  *
58
78
  * @example
59
79
  * ```
60
80
  * const {isDateDisabled} = useCalendarConstraints({
61
81
  * min: '2026-01-01',
62
82
  * max: '2026-12-31',
83
+ * maxRangeSpan: 7, // once a start is picked, cap the window at 7 days
84
+ * rangeAnchor, // the in-progress start (null before the first click)
63
85
  * dateConstraints: [
64
86
  * (date) => date.getDay() !== 0, // No Sundays (receives native Date)
65
87
  * ],
@@ -74,7 +96,8 @@ export interface UseCalendarConstraintsReturn {
74
96
  export function useCalendarConstraints(
75
97
  options: UseCalendarConstraintsOptions,
76
98
  ): UseCalendarConstraintsReturn {
77
- const {min, max, dateConstraints} = options;
99
+ const {min, max, dateConstraints, maxRangeSpan, minRangeSpan, rangeAnchor} =
100
+ options;
78
101
 
79
102
  // Parse min/max dates
80
103
  const minDate = useMemo(() => (min ? plainDateFromISO(min) : null), [min]);
@@ -93,6 +116,27 @@ export function useCalendarConstraints(
93
116
  return true;
94
117
  }
95
118
 
119
+ // Range-span bounds, measured from the in-progress start. Spans count
120
+ // both endpoints (a span of 7 spans a 7-day window), so the reachable
121
+ // distance from the anchor is `span - 1` days in either direction. Only
122
+ // active once a start is picked — before that, every day stays pickable.
123
+ if (rangeAnchor) {
124
+ const distance = Math.abs(plainDateDiffDays(rangeAnchor, date));
125
+ if (maxRangeSpan != null && distance > maxRangeSpan - 1) {
126
+ return true;
127
+ }
128
+ // The anchor itself (distance 0) is never disabled by minRangeSpan —
129
+ // it is the picked start, and disabling it would render the active
130
+ // selection start as aria-disabled to keyboard and screen-reader users.
131
+ if (
132
+ minRangeSpan != null &&
133
+ distance > 0 &&
134
+ distance < minRangeSpan - 1
135
+ ) {
136
+ return true;
137
+ }
138
+ }
139
+
96
140
  // Check custom constraints (convert to Date for public API compatibility)
97
141
  if (dateConstraints) {
98
142
  for (const constraint of dateConstraints) {
@@ -104,7 +148,14 @@ export function useCalendarConstraints(
104
148
 
105
149
  return false;
106
150
  },
107
- [minDate, maxDate, dateConstraints],
151
+ [
152
+ minDate,
153
+ maxDate,
154
+ dateConstraints,
155
+ maxRangeSpan,
156
+ minRangeSpan,
157
+ rangeAnchor,
158
+ ],
108
159
  );
109
160
 
110
161
  return {
@@ -4,7 +4,7 @@
4
4
 
5
5
  /**
6
6
  * @file useCalendarDays.ts
7
- * @input Uses React useMemo, PlainDate utilities
7
+ * @input Uses React context via use(), useMemo, PlainDate utilities, generated CLDR weekday data
8
8
  * @output Exports useCalendarDays hook for generating calendar day grids
9
9
  * @position Calendar-specific hook; used by Calendar
10
10
  *
@@ -12,7 +12,7 @@
12
12
  * - /packages/core/src/Calendar/hooks/index.ts
13
13
  */
14
14
 
15
- import {useMemo} from 'react';
15
+ import {use, useMemo} from 'react';
16
16
  import type {DayOfWeek, ISODateString} from '../../utils/dateTypes';
17
17
  import {
18
18
  type PlainDate,
@@ -21,6 +21,8 @@ import {
21
21
  plainDateDayOfWeek,
22
22
  plainDateAddDays,
23
23
  } from '../../utils/plainDate';
24
+ import {InternationalizationContext} from '../../i18n/InternationalizationContext';
25
+ import {getStandaloneShortWeekdayNames} from '../getStandaloneShortWeekdayNames';
24
26
 
25
27
  /**
26
28
  * Represents a single day in the calendar grid.
@@ -84,6 +86,7 @@ export function useCalendarDays(
84
86
  options: UseCalendarDaysOptions,
85
87
  ): UseCalendarDaysReturn {
86
88
  const {year, month, weekStartsOn = 0, hasVariableRowCount = false} = options;
89
+ const {locale} = use(InternationalizationContext);
87
90
 
88
91
  // Calculate grid structure
89
92
  const gridInfo = useMemo(() => {
@@ -108,15 +111,15 @@ export function useCalendarDays(
108
111
  };
109
112
  }, [year, month, weekStartsOn, hasVariableRowCount]);
110
113
 
111
- // Generate day names
114
+ // Generated strings avoid Date anchors, so weekday headers cannot shift with
115
+ // the runtime timezone. Keep numeric indices as the rotation source of truth.
112
116
  const dayNames = useMemo(() => {
113
- const names = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
114
- const rotated: string[] = [];
115
- for (let i = 0; i < 7; i++) {
116
- rotated.push(names[(i + weekStartsOn) % 7]);
117
- }
118
- return rotated;
119
- }, [weekStartsOn]);
117
+ const names = getStandaloneShortWeekdayNames(locale);
118
+ return Array.from(
119
+ {length: 7},
120
+ (_, index) => names[(index + weekStartsOn) % 7],
121
+ );
122
+ }, [locale, weekStartsOn]);
120
123
 
121
124
  // Generate days array
122
125
  const days = useMemo(() => {
@@ -0,0 +1,50 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @generated by scripts/generate-cldr-weekdays.mjs from Unicode CLDR 48.2.0.
5
+ * Do not edit by hand. Run `pnpm generate:cldr-weekdays` instead.
6
+ */
7
+
8
+ export type StandaloneShortWeekdayNames = readonly [
9
+ string,
10
+ string,
11
+ string,
12
+ string,
13
+ string,
14
+ string,
15
+ string,
16
+ ];
17
+
18
+ export const standaloneShortWeekdayNamesByLocale = {
19
+ af: ['So.', 'Ma.', 'Di.', 'Wo.', 'Do.', 'Vr.', 'Sa.'],
20
+ ar: ['أحد', 'إثنين', 'ثلاثاء', 'أربعاء', 'خميس', 'جمعة', 'سبت'],
21
+ 'ar-SA': ['أحد', 'إثنين', 'ثلاثاء', 'أربعاء', 'خميس', 'جمعة', 'سبت'],
22
+ ca: ['dg.', 'dl.', 'dt.', 'dc.', 'dj.', 'dv.', 'ds.'],
23
+ cs: ['ne', 'po', 'út', 'st', 'čt', 'pá', 'so'],
24
+ da: ['sø.', 'ma.', 'ti.', 'on.', 'to.', 'fr.', 'lø.'],
25
+ de: ['So.', 'Mo.', 'Di.', 'Mi.', 'Do.', 'Fr.', 'Sa.'],
26
+ el: ['Κυ', 'Δε', 'Τρ', 'Τε', 'Πέ', 'Πα', 'Σά'],
27
+ en: ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'],
28
+ es: ['DO', 'LU', 'MA', 'MI', 'JU', 'VI', 'SA'],
29
+ fi: ['su', 'ma', 'ti', 'ke', 'to', 'pe', 'la'],
30
+ fr: ['di', 'lu', 'ma', 'me', 'je', 've', 'sa'],
31
+ he: ['א׳', 'ב׳', 'ג׳', 'ד׳', 'ה׳', 'ו׳', 'ש׳'],
32
+ hu: ['V', 'H', 'K', 'Sze', 'Cs', 'P', 'Szo'],
33
+ it: ['dom', 'lun', 'mar', 'mer', 'gio', 'ven', 'sab'],
34
+ ja: ['日', '月', '火', '水', '木', '金', '土'],
35
+ ko: ['일', '월', '화', '수', '목', '금', '토'],
36
+ nl: ['zo', 'ma', 'di', 'wo', 'do', 'vr', 'za'],
37
+ no: ['sø.', 'ma.', 'ti.', 'on.', 'to.', 'fr.', 'lø.'],
38
+ pl: ['nie', 'pon', 'wto', 'śro', 'czw', 'pią', 'sob'],
39
+ pt: ['dom.', 'seg.', 'ter.', 'qua.', 'qui.', 'sex.', 'sáb.'],
40
+ 'pt-PT': ['dom.', 'seg.', 'ter.', 'qua.', 'qui.', 'sex.', 'sáb.'],
41
+ ro: ['du.', 'lu.', 'ma.', 'mi.', 'joi', 'vi.', 'sâ.'],
42
+ ru: ['вс', 'пн', 'вт', 'ср', 'чт', 'пт', 'сб'],
43
+ sr: ['не', 'по', 'ут', 'ср', 'че', 'пе', 'су'],
44
+ sv: ['sö', 'må', 'ti', 'on', 'to', 'fr', 'lö'],
45
+ tr: ['Pa', 'Pt', 'Sa', 'Ça', 'Pe', 'Cu', 'Ct'],
46
+ uk: ['нд', 'пн', 'вт', 'ср', 'чт', 'пт', 'сб'],
47
+ vi: ['CN', 'T2', 'T3', 'T4', 'T5', 'T6', 'T7'],
48
+ zh: ['周日', '周一', '周二', '周三', '周四', '周五', '周六'],
49
+ 'zh-TW': ['日', '一', '二', '三', '四', '五', '六'],
50
+ } as const satisfies Readonly<Record<string, StandaloneShortWeekdayNames>>;
@@ -47,6 +47,7 @@ import {useTooltip} from '../Tooltip';
47
47
  import {mergeProps, mergeRefs} from '../utils';
48
48
  import {indicatorScope} from '../Indicator/indicator.markers.stylex';
49
49
  import {useIndicatorFocusRing} from '../hooks/useIndicatorFocusRing';
50
+ import {useResolvedRequired} from '../hooks/useResolvedRequired';
50
51
  import {useIndicator} from '../Indicator';
51
52
  import {themeProps} from '../utils/themeProps';
52
53
  import {CheckboxListContext} from '../CheckboxList/CheckboxListContext';
@@ -301,6 +302,10 @@ export function CheckboxInput({
301
302
  const id = useId();
302
303
  const descriptionID = useId();
303
304
  const statusMessageID = useId();
305
+ // Announce the effective required state (form default included) while the
306
+ // native `required` stays bound to the explicit `isRequired` so a layout
307
+ // default never switches on browser validation.
308
+ const isEffectivelyRequired = useResolvedRequired({isRequired, isOptional});
304
309
 
305
310
  const [, startTransition] = useTransition();
306
311
  const [optimisticValue, setOptimisticValue] = useOptimistic(value);
@@ -421,6 +426,7 @@ export function CheckboxInput({
421
426
  form={isFocusableDisabled ? '' : undefined}
422
427
  readOnly={isReadOnly}
423
428
  required={isRequired}
429
+ aria-required={isEffectivelyRequired ? 'true' : undefined}
424
430
  onChange={e => {
425
431
  if (isDisabled || isBusy || isReadOnly) {
426
432
  return;
@@ -35,6 +35,7 @@ import {useTranslator} from '../i18n';
35
35
  import {layerAnimations} from '../Layer/layerAnimations.stylex';
36
36
  import type {LayerAlignment, LayerPlacement} from '../Layer/useLayer';
37
37
  import {usePopover} from '../Popover/usePopover';
38
+ import {useResolvedRequired} from '../hooks/useResolvedRequired';
38
39
  import {
39
40
  colorVars,
40
41
  durationVars,
@@ -344,6 +345,7 @@ export function ComplexSelector<Value>({
344
345
  ...props
345
346
  }: ComplexSelectorProps<Value>) {
346
347
  const t = useTranslator();
348
+ const isEffectivelyRequired = useResolvedRequired({isRequired, isOptional});
347
349
  const placeholder = placeholderFromProps ?? t('@astryx.selector.placeholder');
348
350
  const effectiveStatusVariant =
349
351
  variant === 'ghost' && statusVariant === 'attached'
@@ -497,7 +499,7 @@ export function ComplexSelector<Value>({
497
499
  aria-controls={contentId}
498
500
  aria-describedby={ariaDescribedBy}
499
501
  aria-labelledby={labelId}
500
- aria-required={isRequired ? 'true' : undefined}
502
+ aria-required={isEffectivelyRequired ? 'true' : undefined}
501
503
  aria-invalid={status?.type === 'error' ? 'true' : undefined}
502
504
  aria-busy={isBusy || undefined}
503
505
  disabled={isDisabled}
@@ -58,6 +58,7 @@ import {
58
58
  } from '../Calendar';
59
59
  import {useCalendarConstraints} from '../Calendar/hooks';
60
60
  import {useInputStatusIcon} from '../hooks/useInputStatusIcon';
61
+ import {useResolvedRequired} from '../hooks/useResolvedRequired';
61
62
  import {usePopover} from '../Popover';
62
63
  import {useTooltip} from '../Tooltip';
63
64
  import {getInputARIA, isImeKeyEvent, parseDateInput} from '../utils';
@@ -399,6 +400,7 @@ export function DateInput({
399
400
  ...rest
400
401
  }: DateInputProps) {
401
402
  const t = useTranslator();
403
+ const isEffectivelyRequired = useResolvedRequired({isRequired, isOptional});
402
404
  const placeholder =
403
405
  placeholderFromProps ?? t('@astryx.dateInput.placeholder');
404
406
  const size = useSize(sizeProp, 'md');
@@ -737,7 +739,7 @@ export function DateInput({
737
739
  readOnly={showsDisabledMessage || undefined}
738
740
  aria-labelledby={ariaLabelledBy}
739
741
  aria-describedby={ariaDescribedBy}
740
- aria-required={isRequired === true ? 'true' : undefined}
742
+ aria-required={isEffectivelyRequired ? 'true' : undefined}
741
743
  aria-invalid={
742
744
  status?.type === 'error' || !isInputValid ? 'true' : undefined
743
745
  }