@xsolla/xui-calendar 0.227.0 → 0.228.0

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/README.md CHANGED
@@ -208,6 +208,7 @@ export default function DualRange() {
208
208
  - Each panel navigates on its own. The arrows and the month/year dropdowns of one panel do not move the other panel.
209
209
  - A chip, or a `month` value that neither panel shows, re-pairs the panels: the left panel shows that month and the right panel shows the next month.
210
210
  - With a complete range, a click in the right panel changes only the end date. A click on a day before the start date, or any click in the left panel, starts a new range.
211
+ - Keyboard focus in a panel's day grid stays inside that panel's visible month; use the panel's arrows or month/year menus to change month.
211
212
 
212
213
  ### CalendarChips
213
214
 
@@ -229,20 +230,21 @@ interface CalendarChipOption {
229
230
 
230
231
  ### CalendarGrid
231
232
 
232
- | Prop | Type | Default | Description |
233
- | :------------- | :--------------------- | :------- | :---------------------------- |
234
- | currentMonth | `Date` | required | The month to render. |
235
- | locale | `string` | `"enUS"` | Date-fns locale identifier. |
236
- | firstDayOfWeek | `number` | `0` | First day of week. |
237
- | selectsRange | `boolean` | `false` | Enable range mode. |
238
- | minDate | `Date \| null` | - | Minimum selectable date. |
239
- | maxDate | `Date \| null` | - | Maximum selectable date. |
240
- | startDate | `Date \| null` | - | Range start date. |
241
- | endDate | `Date \| null` | - | Range end date. |
242
- | selectedDate | `Date \| null` | - | Selected date (single mode). |
243
- | selectingRange | `Date \| null` | - | Intermediate range selection. |
244
- | onDayPress | `(date: Date) => void` | - | Day press callback. |
245
- | testID | `string` | - | Test identifier. |
233
+ | Prop | Type | Default | Description |
234
+ | :------------- | :---------------------- | :------- | :--------------------------------------------------------------------------------------------------------------------------- |
235
+ | currentMonth | `Date` | required | The month to render. |
236
+ | locale | `string` | `"enUS"` | Date-fns locale identifier. |
237
+ | firstDayOfWeek | `number` | `0` | First day of week. |
238
+ | selectsRange | `boolean` | `false` | Enable range mode. |
239
+ | minDate | `Date \| null` | - | Minimum selectable date. |
240
+ | maxDate | `Date \| null` | - | Maximum selectable date. |
241
+ | startDate | `Date \| null` | - | Range start date. |
242
+ | endDate | `Date \| null` | - | Range end date. |
243
+ | selectedDate | `Date \| null` | - | Selected date (single mode). |
244
+ | selectingRange | `Date \| null` | - | Intermediate range selection. |
245
+ | onDayPress | `(date: Date) => void` | - | Day press callback. Also fired by Enter / Space on the focused day. |
246
+ | onMonthChange | `(month: Date) => void` | - | Called with the first day of a month when keyboard focus moves past the visible month. Without it, focus stays in the month. |
247
+ | testID | `string` | - | Test identifier. |
246
248
 
247
249
  ## Platform Support
248
250
 
@@ -256,6 +258,21 @@ This package works on both **web** and **React Native**. All components use cros
256
258
  ## Accessibility
257
259
 
258
260
  - Navigation buttons have aria-labels ("Previous month", "Next month")
259
- - Month and year selectors are accessible via Select component
260
- - Day cells support keyboard navigation
261
+ - Month and year selectors are Select components: each is a tab stop and opens, navigates and selects with the keyboard (see the Select docs)
262
+ - The day grid has `role="grid"`, week rows `role="row"`, weekday labels `role="columnheader"` and days `role="gridcell"`
263
+ - Each day in the visible month has an `aria-label` with the full date (e.g. "Thursday, 15 January 2026"), plus `aria-selected`, `aria-disabled` for days outside `minDate` / `maxDate`, and `aria-current="date"` for today
261
264
  - Chip selection follows radio-group pattern (single active item)
265
+
266
+ ### Keyboard (web)
267
+
268
+ Tab order: Previous month → month menu → year menu → Next month → day grid. The day grid is a single tab stop (roving `tabIndex`): the selected day (or the range start), else today, else the first day of the month.
269
+
270
+ | Key | Action |
271
+ | :-- | :----- |
272
+ | ← / → | Previous / next day |
273
+ | ↑ / ↓ | Same day, previous / next week |
274
+ | Home / End | First / last day of the week |
275
+ | PageUp / PageDown | Same day, previous / next month |
276
+ | Enter / Space | Selects the focused day (no effect on a disabled day) |
277
+
278
+ When focus moves past the visible month, Calendar switches to that month. In DualCalendar, focus stays inside each panel's month.
@@ -40,6 +40,12 @@ interface CalendarGridProps extends ThemeOverrideProps {
40
40
  selectedDate?: Date | null;
41
41
  selectingRange?: Date | null;
42
42
  onDayPress?: (date: Date) => void;
43
+ /**
44
+ * Called with the first day of a month when keyboard navigation moves past
45
+ * the visible month (arrow keys, PageUp/PageDown). Without it, keyboard
46
+ * focus stays inside the visible month.
47
+ */
48
+ onMonthChange?: (month: Date) => void;
43
49
  testID?: string;
44
50
  }
45
51
  interface CalendarHeaderProps extends ThemeOverrideProps {
package/native/index.d.ts CHANGED
@@ -40,6 +40,12 @@ interface CalendarGridProps extends ThemeOverrideProps {
40
40
  selectedDate?: Date | null;
41
41
  selectingRange?: Date | null;
42
42
  onDayPress?: (date: Date) => void;
43
+ /**
44
+ * Called with the first day of a month when keyboard navigation moves past
45
+ * the visible month (arrow keys, PageUp/PageDown). Without it, keyboard
46
+ * focus stays inside the visible month.
47
+ */
48
+ onMonthChange?: (month: Date) => void;
43
49
  testID?: string;
44
50
  }
45
51
  interface CalendarHeaderProps extends ThemeOverrideProps {
package/native/index.js CHANGED
@@ -348,6 +348,9 @@ var Text = ({
348
348
  );
349
349
  };
350
350
 
351
+ // ../../foundation/primitives-native/src/index.tsx
352
+ var isWeb = false;
353
+
351
354
  // src/Calendar.tsx
352
355
  var import_xui_core3 = require("@xsolla/xui-core");
353
356
  var import_date_fns3 = require("date-fns");
@@ -509,6 +512,7 @@ var locales2 = __toESM(require("date-fns/locale"));
509
512
  var import_jsx_runtime4 = require("react/jsx-runtime");
510
513
  var DAY_CELL_WIDTH = 40;
511
514
  var GRID_WIDTH = DAY_CELL_WIDTH * 7;
515
+ var dayKeyOf = (date) => (0, import_date_fns2.format)(date, "yyyy-MM-dd");
512
516
  var CalendarGrid = ({
513
517
  currentMonth,
514
518
  locale = "enUS",
@@ -521,6 +525,7 @@ var CalendarGrid = ({
521
525
  selectedDate,
522
526
  selectingRange,
523
527
  onDayPress,
528
+ onMonthChange,
524
529
  testID,
525
530
  themeMode,
526
531
  themeProductContext
@@ -569,33 +574,122 @@ var CalendarGrid = ({
569
574
  },
570
575
  [selectsRange, startDate, endDate, selectingRange]
571
576
  );
572
- return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(Box, { testID, children: [
577
+ const [focusedDate, setFocusedDate] = (0, import_react2.useState)(null);
578
+ const dayRefs = (0, import_react2.useRef)(/* @__PURE__ */ new Map());
579
+ const shouldMoveFocus = (0, import_react2.useRef)(false);
580
+ const tabStopDate = (0, import_react2.useMemo)(() => {
581
+ if (focusedDate && (0, import_date_fns2.isSameMonth)(focusedDate, currentMonth)) {
582
+ return focusedDate;
583
+ }
584
+ const preferred = selectsRange ? startDate : selectedDate;
585
+ if (preferred && (0, import_date_fns2.isSameMonth)(preferred, currentMonth)) return preferred;
586
+ const today = /* @__PURE__ */ new Date();
587
+ if ((0, import_date_fns2.isSameMonth)(today, currentMonth)) return today;
588
+ return monthStart;
589
+ }, [
590
+ focusedDate,
591
+ currentMonth,
592
+ selectsRange,
593
+ startDate,
594
+ selectedDate,
595
+ monthStart
596
+ ]);
597
+ const tabStopKey = dayKeyOf(tabStopDate);
598
+ (0, import_react2.useEffect)(() => {
599
+ if (!shouldMoveFocus.current) return;
600
+ shouldMoveFocus.current = false;
601
+ dayRefs.current.get(tabStopKey)?.focus();
602
+ }, [tabStopKey, currentMonth]);
603
+ const moveFocus = (0, import_react2.useCallback)(
604
+ (target) => {
605
+ if (!(0, import_date_fns2.isSameMonth)(target, currentMonth)) {
606
+ if (!onMonthChange) return;
607
+ onMonthChange((0, import_date_fns2.startOfMonth)(target));
608
+ }
609
+ shouldMoveFocus.current = true;
610
+ setFocusedDate(target);
611
+ },
612
+ [currentMonth, onMonthChange]
613
+ );
614
+ const handleDayKeyDown = (0, import_react2.useCallback)(
615
+ (event, day) => {
616
+ let target = null;
617
+ switch (event.key) {
618
+ case "ArrowLeft":
619
+ target = (0, import_date_fns2.addDays)(day, -1);
620
+ break;
621
+ case "ArrowRight":
622
+ target = (0, import_date_fns2.addDays)(day, 1);
623
+ break;
624
+ case "ArrowUp":
625
+ target = (0, import_date_fns2.addDays)(day, -7);
626
+ break;
627
+ case "ArrowDown":
628
+ target = (0, import_date_fns2.addDays)(day, 7);
629
+ break;
630
+ case "Home":
631
+ target = (0, import_date_fns2.startOfWeek)(day, { weekStartsOn });
632
+ break;
633
+ case "End":
634
+ target = (0, import_date_fns2.endOfWeek)(day, { weekStartsOn });
635
+ break;
636
+ case "PageUp":
637
+ target = (0, import_date_fns2.addMonths)(day, -1);
638
+ break;
639
+ case "PageDown":
640
+ target = (0, import_date_fns2.addMonths)(day, 1);
641
+ break;
642
+ case "Enter":
643
+ case " ":
644
+ event.preventDefault();
645
+ if (!isDateDisabled(day)) onDayPress?.(day);
646
+ return;
647
+ default:
648
+ return;
649
+ }
650
+ event.preventDefault();
651
+ moveFocus(target);
652
+ },
653
+ [isDateDisabled, moveFocus, onDayPress, weekStartsOn]
654
+ );
655
+ return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(Box, { testID, role: "grid", children: [
573
656
  /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
574
657
  Box,
575
658
  {
659
+ role: "row",
576
660
  flexDirection: "row",
577
661
  justifyContent: "flex-start",
578
662
  width: GRID_WIDTH,
579
663
  marginBottom: 8,
580
- children: weekDays.map((day, i) => /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(Box, { width: DAY_CELL_WIDTH, alignItems: "center", children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
581
- Text,
664
+ children: weekDays.map((day, i) => /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
665
+ Box,
582
666
  {
583
- fontSize: 12,
584
- fontWeight: "600",
585
- color: theme.colors.content.tertiary,
586
- children: day.toUpperCase()
587
- }
588
- ) }, i))
667
+ role: "columnheader",
668
+ width: DAY_CELL_WIDTH,
669
+ alignItems: "center",
670
+ children: /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
671
+ Text,
672
+ {
673
+ fontSize: 12,
674
+ fontWeight: "600",
675
+ color: theme.colors.content.tertiary,
676
+ children: day.toUpperCase()
677
+ }
678
+ )
679
+ },
680
+ i
681
+ ))
589
682
  }
590
683
  ),
591
684
  /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(Box, { gap: 2, children: weeks.map((week, i) => /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
592
685
  Box,
593
686
  {
687
+ role: "row",
594
688
  flexDirection: "row",
595
689
  justifyContent: "flex-start",
596
690
  width: GRID_WIDTH,
597
691
  children: week.map((day, j) => {
598
- const dayKey = (0, import_date_fns2.format)(day, "yyyy-MM-dd");
692
+ const dayKey = dayKeyOf(day);
599
693
  const isOutsideMonth = !(0, import_date_fns2.isSameMonth)(day, currentMonth);
600
694
  const isSelected = !selectsRange && selectedDate && (0, import_date_fns2.isSameDay)(day, selectedDate) || selectsRange && startDate && endDate && (0, import_date_fns2.isSameDay)(day, startDate) && (0, import_date_fns2.isSameDay)(day, endDate);
601
695
  const isRangeStart = selectsRange && startDate && (0, import_date_fns2.isSameDay)(day, startDate);
@@ -605,6 +699,7 @@ var CalendarGrid = ({
605
699
  const disabled = isDateDisabled(day);
606
700
  const isHoverable = !disabled && !isOutsideMonth && !isSelected && !isRangeStart && !isRangeEnd;
607
701
  const isHovered = isHoverable && hoveredKey === dayKey;
702
+ const isTabStop = !isOutsideMonth && dayKey === tabStopKey;
608
703
  let bgColor = "transparent";
609
704
  let textColor = theme.colors.content.primary;
610
705
  if (isSelected || isRangeStart || isRangeEnd || inRange) {
@@ -616,9 +711,23 @@ var CalendarGrid = ({
616
711
  } else if (isOutsideMonth || disabled) {
617
712
  textColor = theme.colors.control.text.disable;
618
713
  }
714
+ const keyboardProps = isWeb && !isOutsideMonth ? {
715
+ ref: (node) => {
716
+ if (node) dayRefs.current.set(dayKey, node);
717
+ else dayRefs.current.delete(dayKey);
718
+ },
719
+ tabIndex: isTabStop ? 0 : -1,
720
+ onKeyDown: (event) => handleDayKeyDown(event, day)
721
+ } : {};
619
722
  return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
620
723
  Box,
621
724
  {
725
+ ...keyboardProps,
726
+ role: "gridcell",
727
+ "aria-label": isOutsideMonth ? void 0 : (0, import_date_fns2.format)(day, "EEEE, d MMMM yyyy", { locale: localeObj }),
728
+ "aria-selected": isOutsideMonth ? void 0 : Boolean(isSelected || isRangeStart || isRangeEnd),
729
+ "aria-disabled": disabled || isOutsideMonth || void 0,
730
+ "aria-current": today ? "date" : void 0,
622
731
  width: DAY_CELL_WIDTH,
623
732
  height: 32,
624
733
  alignItems: "center",
@@ -629,7 +738,11 @@ var CalendarGrid = ({
629
738
  borderBottomRightRadius: isSelected || isRangeEnd ? 4 : 0,
630
739
  backgroundColor: bgColor,
631
740
  cursor: disabled || isOutsideMonth ? "default" : "pointer",
632
- onPress: () => !disabled && !isOutsideMonth && onDayPress?.(day),
741
+ onPress: () => {
742
+ if (disabled || isOutsideMonth) return;
743
+ setFocusedDate(day);
744
+ onDayPress?.(day);
745
+ },
633
746
  onMouseEnter: () => isHoverable && setHoveredKey(dayKey),
634
747
  onMouseLeave: () => setHoveredKey(
635
748
  (current) => current === dayKey ? null : current
@@ -897,6 +1010,7 @@ var Calendar = (0, import_react3.forwardRef)(
897
1010
  selectedDate: locSelectedDate,
898
1011
  selectingRange,
899
1012
  onDayPress: handleChange,
1013
+ onMonthChange: setCurrentMonth,
900
1014
  themeMode,
901
1015
  themeProductContext
902
1016
  }