@proyecto-viviana/solid-stately 0.3.0 → 0.4.1

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 (77) hide show
  1. package/dist/calendar/IncompleteDate.d.ts +61 -0
  2. package/dist/calendar/createCalendarState.d.ts +20 -10
  3. package/dist/calendar/createDateFieldState.d.ts +65 -20
  4. package/dist/calendar/createRangeCalendarState.d.ts +20 -2
  5. package/dist/calendar/createTimeFieldState.d.ts +38 -67
  6. package/dist/calendar/index.d.ts +1 -1
  7. package/dist/calendar/intl/index.d.ts +10 -0
  8. package/dist/collections/ListCollection.d.ts +8 -0
  9. package/dist/collections/createListState.d.ts +16 -1
  10. package/dist/collections/createSelectionState.d.ts +17 -5
  11. package/dist/collections/getChildNodes.d.ts +19 -0
  12. package/dist/collections/index.d.ts +3 -2
  13. package/dist/collections/types.d.ts +9 -0
  14. package/dist/color/intl/index.d.ts +14 -0
  15. package/dist/combobox/createComboBoxState.d.ts +7 -0
  16. package/dist/dnd/createDroppableCollectionState.d.ts +17 -2
  17. package/dist/dnd/index.d.ts +1 -1
  18. package/dist/dnd/types.d.ts +3 -1
  19. package/dist/grid/types.d.ts +17 -3
  20. package/dist/index.d.ts +6 -6
  21. package/dist/index.js +4372 -1087
  22. package/dist/index.js.map +1 -1
  23. package/dist/select/createSelectState.d.ts +6 -0
  24. package/dist/selection/Selection.d.ts +16 -0
  25. package/dist/selection/SelectionManager.d.ts +119 -0
  26. package/dist/selection/createMultipleSelectionState.d.ts +59 -0
  27. package/dist/selection/index.d.ts +1 -0
  28. package/dist/table/TableCollection.d.ts +30 -0
  29. package/dist/table/createTreeGridState.d.ts +15 -0
  30. package/dist/table/index.d.ts +2 -1
  31. package/dist/table/types.d.ts +58 -0
  32. package/dist/toast/createToastState.d.ts +16 -0
  33. package/dist/toast/index.d.ts +1 -1
  34. package/dist/tree/types.d.ts +14 -6
  35. package/package.json +1 -1
  36. package/src/calendar/IncompleteDate.ts +430 -0
  37. package/src/calendar/createCalendarState.ts +129 -40
  38. package/src/calendar/createDateFieldState.ts +633 -477
  39. package/src/calendar/createRangeCalendarState.ts +343 -30
  40. package/src/calendar/createTimeFieldState.ts +179 -467
  41. package/src/calendar/index.ts +2 -0
  42. package/src/calendar/intl/index.ts +364 -0
  43. package/src/collections/ListCollection.ts +31 -0
  44. package/src/collections/createListState.ts +139 -17
  45. package/src/collections/createMenuState.ts +3 -0
  46. package/src/collections/createSelectionState.ts +38 -28
  47. package/src/collections/getChildNodes.ts +129 -0
  48. package/src/collections/index.ts +12 -0
  49. package/src/collections/types.ts +10 -0
  50. package/src/color/Color.ts +104 -133
  51. package/src/color/intl/index.ts +1409 -0
  52. package/src/combobox/createComboBoxState.ts +38 -13
  53. package/src/dnd/createDroppableCollectionState.ts +125 -6
  54. package/src/dnd/index.d.ts +1 -0
  55. package/src/dnd/index.ts +1 -0
  56. package/src/dnd/types.d.ts +3 -1
  57. package/src/dnd/types.ts +4 -1
  58. package/src/grid/createGridState.ts +36 -2
  59. package/src/grid/types.ts +17 -3
  60. package/src/index.d.ts +1 -0
  61. package/src/index.ts +14 -0
  62. package/src/select/createSelectState.ts +15 -2
  63. package/src/selection/Selection.ts +28 -0
  64. package/src/selection/SelectionManager.ts +541 -0
  65. package/src/selection/createMultipleSelectionState.ts +247 -0
  66. package/src/selection/index.ts +6 -0
  67. package/src/steplist/createStepListState.ts +27 -4
  68. package/src/table/TableCollection.ts +213 -43
  69. package/src/table/createTableState.ts +20 -0
  70. package/src/table/createTreeGridState.ts +146 -0
  71. package/src/table/index.ts +3 -0
  72. package/src/table/types.ts +66 -0
  73. package/src/tabs/createTabListState.ts +11 -9
  74. package/src/toast/createToastState.ts +43 -10
  75. package/src/toast/index.ts +1 -0
  76. package/src/tree/createTreeState.ts +37 -6
  77. package/src/tree/types.ts +14 -6
@@ -21,6 +21,8 @@ import {
21
21
  endOfMonth,
22
22
  startOfWeek,
23
23
  getWeeksInMonth,
24
+ maxDate,
25
+ minDate,
24
26
  DateFormatter,
25
27
  createCalendar as intlCreateCalendar,
26
28
  toCalendar as intlToCalendar,
@@ -28,6 +30,7 @@ import {
28
30
  } from "@internationalized/date";
29
31
  import { access, type MaybeAccessor } from "../utils";
30
32
  import type { CalendarDayOfWeek, ValidationState } from "./createCalendarState";
33
+ import { getFormatOptions } from "./createDateFieldState";
31
34
 
32
35
  const dayOfWeekNames: CalendarDayOfWeek[] = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
33
36
  type RangeCalendarSelectionAlignment = "start" | "center" | "end";
@@ -68,8 +71,12 @@ export interface RangeCalendarStateProps<T extends DateValue = DateValue> {
68
71
  locale?: MaybeAccessor<string | undefined>;
69
72
  /** Creates a calendar object for a locale calendar identifier. */
70
73
  createCalendar?: (identifier: CalendarIdentifier) => InternationalizedCalendar;
71
- /** Callback to determine if a date is unavailable. */
72
- isDateUnavailable?: (date: DateValue) => boolean;
74
+ /**
75
+ * Callback that determines if a date is unavailable. The second argument is the
76
+ * current selection anchor (the first selected date), so availability can be
77
+ * derived from it. Mirrors @react-stately/calendar 1.18.
78
+ */
79
+ isDateUnavailable?: (date: DateValue, anchorDate: CalendarDate | null) => boolean;
73
80
  /** The number of months to display at once. */
74
81
  visibleMonths?: MaybeAccessor<number | undefined>;
75
82
  /** Controls how the initial visible months are aligned around the current value. */
@@ -95,6 +102,10 @@ export interface RangeCalendarState<T extends DateValue = DateValue> {
95
102
  focusedDate: Accessor<CalendarDate>;
96
103
  /** Sets the focused date. */
97
104
  setFocusedDate: (date: CalendarDate) => void;
105
+ /** The minimum allowed date that a user may select. */
106
+ minValue: Accessor<DateValue | null>;
107
+ /** The maximum allowed date that a user may select. */
108
+ maxValue: Accessor<DateValue | null>;
98
109
  /** The anchor date when selecting a range (first click). */
99
110
  anchorDate: Accessor<CalendarDate | null>;
100
111
  /** Sets the anchor date. */
@@ -179,6 +190,16 @@ export interface RangeCalendarState<T extends DateValue = DateValue> {
179
190
  isDragging: Accessor<boolean>;
180
191
  /** Sets whether the user is dragging to select. */
181
192
  setDragging: (dragging: boolean) => void;
193
+ /**
194
+ * Formats the selected range as a `{ start, end }` pair of localized strings,
195
+ * or `null` when the range is incomplete. Mirrors upstream
196
+ * `DateRangePickerState.formatValue` — the port collapses that state into the
197
+ * range calendar state, so the range picker reads its SR description from here.
198
+ */
199
+ formatValue: (
200
+ locale: string,
201
+ fieldOptions: Intl.DateTimeFormatOptions,
202
+ ) => { start: string; end: string } | null;
182
203
  }
183
204
 
184
205
  /**
@@ -189,6 +210,8 @@ export function createRangeCalendarState<T extends DateValue = CalendarDate>(
189
210
  ): RangeCalendarState<T> {
190
211
  const timeZone = getLocalTimeZone();
191
212
  const locale = createMemo(() => access(props.locale) ?? "en-US");
213
+ const minValueState = createMemo(() => access(props.minValue) ?? null);
214
+ const maxValueState = createMemo(() => access(props.maxValue) ?? null);
192
215
  const resolvedOptions = createMemo(() => new DateFormatter(locale()).resolvedOptions());
193
216
  const calendar = createMemo(() =>
194
217
  (props.createCalendar ?? intlCreateCalendar)(resolvedOptions().calendar as CalendarIdentifier),
@@ -200,17 +223,56 @@ export function createRangeCalendarState<T extends DateValue = CalendarDate>(
200
223
  const toDisplayCalendarDate = (date: DateValue): CalendarDate =>
201
224
  intlToCalendar(toCalendarDate(date), calendar());
202
225
 
226
+ // The anchor date is the first selected date during a range drag. Declared up
227
+ // here (ahead of constrainDate) because the available-range derivation below
228
+ // depends on it, and constrainDate consults that range.
229
+ const [anchorDate, setAnchorDate] = createSignal<CalendarDate | null>(null);
230
+
231
+ // Mirrors @react-stately/calendar useRangeCalendarState getAvailableRange: once
232
+ // a range selection is anchored and unavailable dates exist, contiguous ranges
233
+ // are required (unless allowsNonContiguousRanges), so the selectable span is
234
+ // bounded by the nearest unavailable date on each side of the anchor.
235
+ const getAvailableRange = (
236
+ anchor: CalendarDate | null,
237
+ ): { start?: CalendarDate; end?: CalendarDate } | null => {
238
+ if (anchor && props.isDateUnavailable && !props.allowsNonContiguousRanges) {
239
+ const predicate = (date: CalendarDate): boolean => props.isDateUnavailable!(date, anchor);
240
+ return {
241
+ start: nextUnavailableDate(anchor, predicate, visibleMonths, -1),
242
+ end: nextUnavailableDate(anchor, predicate, visibleMonths, 1),
243
+ };
244
+ }
245
+ return null;
246
+ };
247
+
248
+ const availableRange = createMemo(() => getAvailableRange(anchorDate()));
249
+
250
+ // minValue/maxValue narrowed by the available range, in display-calendar space
251
+ // (what every cell/navigation bound below compares against). Mirrors upstream's
252
+ // maxDate(minValue, availableRange?.start) / minDate(maxValue, availableRange?.end);
253
+ // maxDate/minDate ignore undefined operands, so an absent bound passes through.
254
+ const effectiveMinValue = createMemo<CalendarDate | null>(() => {
255
+ const min = access(props.minValue);
256
+ const minCal = min != null ? toDisplayCalendarDate(min) : undefined;
257
+ return maxDate(minCal, availableRange()?.start) ?? null;
258
+ });
259
+ const effectiveMaxValue = createMemo<CalendarDate | null>(() => {
260
+ const max = access(props.maxValue);
261
+ const maxCal = max != null ? toDisplayCalendarDate(max) : undefined;
262
+ return minDate(maxCal, availableRange()?.end) ?? null;
263
+ });
264
+
203
265
  const constrainDate = (date: CalendarDate): CalendarDate => {
204
- const minValue = access(props.minValue);
205
- const maxValue = access(props.maxValue);
266
+ const min = effectiveMinValue();
267
+ const max = effectiveMaxValue();
206
268
 
207
269
  let constrained = date;
208
270
 
209
- if (minValue && constrained.compare(toDisplayCalendarDate(minValue)) < 0) {
210
- constrained = toDisplayCalendarDate(minValue);
271
+ if (min && constrained.compare(min) < 0) {
272
+ constrained = min;
211
273
  }
212
- if (maxValue && constrained.compare(toDisplayCalendarDate(maxValue)) > 0) {
213
- constrained = toDisplayCalendarDate(maxValue);
274
+ if (max && constrained.compare(max) > 0) {
275
+ constrained = max;
214
276
  }
215
277
 
216
278
  return constrained;
@@ -319,7 +381,6 @@ export function createRangeCalendarState<T extends DateValue = CalendarDate>(
319
381
  const [visibleRangeStart, setVisibleRangeStart] = createSignal<CalendarDate>(
320
382
  alignVisibleRangeStart(initialFocusedDate),
321
383
  );
322
- const [anchorDate, setAnchorDate] = createSignal<CalendarDate | null>(null);
323
384
  const [isFocused, setFocused] = createSignal(false);
324
385
  const [isDragging, setDragging] = createSignal(false);
325
386
 
@@ -344,7 +405,27 @@ export function createRangeCalendarState<T extends DateValue = CalendarDate>(
344
405
  const isDisabled = createMemo(() => access(props.isDisabled) ?? false);
345
406
  const isReadOnly = createMemo(() => access(props.isReadOnly) ?? false);
346
407
  const validationState = createMemo(() => access(props.validationState));
347
- const isValueInvalid = createMemo(() => validationState() === "invalid");
408
+ // A committed range is invalid when (not mid-drag) either endpoint is
409
+ // unavailable or falls outside [minValue, maxValue]. Mirrors upstream
410
+ // useRangeCalendarState's isInvalid derivation. Calls props.isDateUnavailable
411
+ // directly (with a null anchor) rather than the closures defined further down,
412
+ // since this memo runs eagerly during init.
413
+ const isInvalidSelection = createMemo(() => {
414
+ const v = sourceValue();
415
+ if (!v || anchorDate()) return false;
416
+ if (props.isDateUnavailable?.(v.start, null) || props.isDateUnavailable?.(v.end, null)) {
417
+ return true;
418
+ }
419
+ const min = access(props.minValue);
420
+ const max = access(props.maxValue);
421
+ const minCal = min != null ? toDisplayCalendarDate(min) : null;
422
+ const maxCal = max != null ? toDisplayCalendarDate(max) : null;
423
+ return (
424
+ isDateOutsideRange(toDisplayCalendarDate(v.start), minCal, maxCal) ||
425
+ isDateOutsideRange(toDisplayCalendarDate(v.end), minCal, maxCal)
426
+ );
427
+ });
428
+ const isValueInvalid = createMemo(() => validationState() === "invalid" || isInvalidSelection());
348
429
 
349
430
  // Highlighted range during selection
350
431
  const highlightedRange = createMemo<RangeValue<CalendarDate> | null>(() => {
@@ -373,6 +454,23 @@ export function createRangeCalendarState<T extends DateValue = CalendarDate>(
373
454
  return { start, end: visibleRangeEndFromStart(start) };
374
455
  });
375
456
 
457
+ // Realign the visible range only when the focused date moves outside it,
458
+ // mirroring @react-stately/calendar useCalendarState (the render-time
459
+ // `focusedDate.compare(startDate) < 0 → alignEnd` / `compare(endDate) > 0 →
460
+ // alignStart` branches — focusCell itself never touches the range). Guarding
461
+ // on out-of-range keeps a same-month focus from re-firing visibleRangeStart
462
+ // with a fresh equal-valued object, which would otherwise recompute the grid
463
+ // and recreate every cell on focus.
464
+ const syncVisibleRangeForFocusedDate = (nextFocusedDate: CalendarDate) => {
465
+ const range = visibleRange();
466
+
467
+ if (nextFocusedDate.compare(range.start) < 0) {
468
+ setVisibleRangeStart(alignEnd(nextFocusedDate));
469
+ } else if (nextFocusedDate.compare(range.end) > 0) {
470
+ setVisibleRangeStart(alignStart(nextFocusedDate));
471
+ }
472
+ };
473
+
376
474
  createEffect(() => {
377
475
  const controlledFocused = access(props.focusedValue);
378
476
  if (!controlledFocused) {
@@ -483,8 +581,8 @@ export function createRangeCalendarState<T extends DateValue = CalendarDate>(
483
581
  return;
484
582
  }
485
583
 
584
+ syncVisibleRangeForFocusedDate(constrained);
486
585
  setFocusedDateInternal(constrained);
487
- setVisibleRangeStart(alignVisibleRangeStart(constrained));
488
586
  props.onFocusChange?.(constrained);
489
587
  };
490
588
 
@@ -494,7 +592,17 @@ export function createRangeCalendarState<T extends DateValue = CalendarDate>(
494
592
  if (!range) return false;
495
593
 
496
594
  const calDate = toDisplayCalendarDate(date);
497
- return calDate.compare(range.start) >= 0 && calDate.compare(range.end) <= 0;
595
+ // Mirror @react-stately/calendar useRangeCalendarState isSelected: a disabled
596
+ // or unavailable cell is NOT reported selected, so its accessible name drops
597
+ // the "… selected" suffix + aria-selected (e.g. when the whole calendar is
598
+ // disabled). The "Selected Range: …" description prefix is separate and still
599
+ // applies to the range endpoints.
600
+ return (
601
+ calDate.compare(range.start) >= 0 &&
602
+ calDate.compare(range.end) <= 0 &&
603
+ !isCellDisabled(date) &&
604
+ !isCellUnavailable(date)
605
+ );
498
606
  };
499
607
 
500
608
  // Check if a date is the start of the selection
@@ -513,12 +621,18 @@ export function createRangeCalendarState<T extends DateValue = CalendarDate>(
513
621
 
514
622
  // Check if a date is focused
515
623
  const isCellFocused = (date: DateValue): boolean => {
516
- return isSameDay(toDisplayCalendarDate(date), focusedDate());
624
+ // Gate on the calendar-level `isFocused` flag, mirroring @react-stately
625
+ // useCalendarState (`isFocused && focusedDate && isSameDay`, inherited by
626
+ // useRangeCalendarState). The roving tabIndex tracks `focusedDate` directly
627
+ // at the cell layer, so one cell stays tabbable without this being true —
628
+ // this drives focusSafely + the range-selection prompt, which must NOT fire
629
+ // until the calendar actually holds focus.
630
+ return isFocused() && isSameDay(toDisplayCalendarDate(date), focusedDate());
517
631
  };
518
632
 
519
633
  // Check if a date is unavailable
520
634
  const isCellUnavailable = (date: DateValue): boolean => {
521
- return props.isDateUnavailable?.(date) ?? false;
635
+ return props.isDateUnavailable?.(date, anchorDate()) ?? false;
522
636
  };
523
637
 
524
638
  // Check if a date is disabled
@@ -526,23 +640,33 @@ export function createRangeCalendarState<T extends DateValue = CalendarDate>(
526
640
  if (isDisabled()) return true;
527
641
  if (props.isDateDisabled?.(date)) return true;
528
642
 
529
- const minValue = access(props.minValue);
530
- const maxValue = access(props.maxValue);
643
+ const min = effectiveMinValue();
644
+ const max = effectiveMaxValue();
531
645
  const calDate = toDisplayCalendarDate(date);
532
646
 
533
- if (minValue && calDate.compare(toDisplayCalendarDate(minValue)) < 0) return true;
534
- if (maxValue && calDate.compare(toDisplayCalendarDate(maxValue)) > 0) return true;
647
+ // Dates outside the visible range are disabled, mirroring @react-stately
648
+ // useCalendarState isCellDisabled (`date.compare(startDate) < 0 ||
649
+ // date.compare(endDate) > 0` over the visible range, inherited by
650
+ // useRangeCalendarState). This marks the leading/trailing padding cells
651
+ // (days from adjacent months filling the first/last grid weeks) as
652
+ // aria-disabled.
653
+ const range = visibleRange();
654
+ if (calDate.compare(toDisplayCalendarDate(range.start)) < 0) return true;
655
+ if (calDate.compare(toDisplayCalendarDate(range.end)) > 0) return true;
656
+
657
+ if (min && calDate.compare(min) < 0) return true;
658
+ if (max && calDate.compare(max) > 0) return true;
535
659
 
536
660
  return false;
537
661
  };
538
662
 
539
663
  const isDateOutsideAllowedRange = (date: DateValue): boolean => {
540
- const minValue = access(props.minValue);
541
- const maxValue = access(props.maxValue);
664
+ const min = effectiveMinValue();
665
+ const max = effectiveMaxValue();
542
666
  const calDate = toDisplayCalendarDate(date);
543
667
 
544
- if (minValue && calDate.compare(toDisplayCalendarDate(minValue)) < 0) return true;
545
- if (maxValue && calDate.compare(toDisplayCalendarDate(maxValue)) > 0) return true;
668
+ if (min && calDate.compare(min) < 0) return true;
669
+ if (max && calDate.compare(max) > 0) return true;
546
670
 
547
671
  return false;
548
672
  };
@@ -560,14 +684,26 @@ export function createRangeCalendarState<T extends DateValue = CalendarDate>(
560
684
  };
561
685
 
562
686
  // Navigation methods
687
+ // Paging realigns the visible range start explicitly, mirroring
688
+ // @react-stately/calendar focusPreviousPage/focusNextPage (which set both
689
+ // focusedDate and startDate directly). This must NOT route through
690
+ // setFocusedDate: that only realigns the range when the focused date leaves
691
+ // the window, so a single-month page whose focus stays in view would never
692
+ // advance the grid.
563
693
  const focusPreviousPage = () => {
564
694
  const pageMonths = access(props.pageBehavior) === "single" ? 1 : visibleMonths;
565
- setFocusedDate(focusedDate().subtract({ months: pageMonths }));
695
+ const nextFocusedDate = constrainDate(focusedDate().subtract({ months: pageMonths }));
696
+ setFocusedDateInternal(nextFocusedDate);
697
+ setVisibleRangeStart(startOfMonth(visibleRangeStart().subtract({ months: pageMonths })));
698
+ props.onFocusChange?.(nextFocusedDate);
566
699
  };
567
700
 
568
701
  const focusNextPage = () => {
569
702
  const pageMonths = access(props.pageBehavior) === "single" ? 1 : visibleMonths;
570
- setFocusedDate(focusedDate().add({ months: pageMonths }));
703
+ const nextFocusedDate = constrainDate(focusedDate().add({ months: pageMonths }));
704
+ setFocusedDateInternal(nextFocusedDate);
705
+ setVisibleRangeStart(startOfMonth(visibleRangeStart().add({ months: pageMonths })));
706
+ props.onFocusChange?.(nextFocusedDate);
571
707
  };
572
708
 
573
709
  const focusPreviousSection = () => {
@@ -621,26 +757,51 @@ export function createRangeCalendarState<T extends DateValue = CalendarDate>(
621
757
  };
622
758
 
623
759
  const selectDate = (date: CalendarDate) => {
760
+ // Mirror @react-stately/calendar useRangeCalendarState.selectDate: NO
761
+ // isCellDisabled/isCellUnavailable guard here. Upstream gates selection at
762
+ // the cell layer (createRangeCalendarCell handlePointerDown/handleClick check
763
+ // isSelectable before calling selectDate); the state method itself constrains
764
+ // to [minValue, maxValue] (narrowed by the available range) and walks back
765
+ // over unavailable dates, refusing only when no available date remains — and
766
+ // when the whole calendar is disabled/readOnly. Double-guarding on
767
+ // isCellDisabled also broke programmatic selection of dates outside the
768
+ // current visible range (now that isCellDisabled bounds on the visible range,
769
+ // like upstream useCalendarState).
624
770
  if (isReadOnly() || isDisabled()) return;
625
- if (isCellDisabled(date) || isCellUnavailable(date)) return;
771
+
772
+ // constrainDate == upstream constrainValue(date, min, max): clamp into the
773
+ // (available-range-narrowed) [minValue, maxValue] interval.
774
+ const constrainedDate = constrainDate(date);
775
+ // Availability is evaluated relative to the current anchor, matching
776
+ // isCellUnavailable, then previousAvailableDate walks back to the last
777
+ // available date at/before the constrained date within the visible range.
778
+ const predicate = props.isDateUnavailable
779
+ ? (d: CalendarDate) => props.isDateUnavailable!(d, anchorDate())
780
+ : undefined;
781
+ const selectableDate = previousAvailableDate(
782
+ constrainedDate,
783
+ toDisplayCalendarDate(visibleRange().start),
784
+ predicate,
785
+ );
786
+ if (!selectableDate) return;
626
787
 
627
788
  const anchor = anchorDate();
628
789
 
629
790
  if (!anchor) {
630
791
  // First click - set anchor
631
- setAnchorDate(date);
792
+ setAnchorDate(selectableDate);
632
793
  setDragging(true);
633
794
  } else {
634
795
  // Second click - complete selection
635
796
  let start: CalendarDate;
636
797
  let end: CalendarDate;
637
798
 
638
- if (date.compare(anchor) < 0) {
639
- start = date;
799
+ if (selectableDate.compare(anchor) < 0) {
800
+ start = selectableDate;
640
801
  end = anchor;
641
802
  } else {
642
803
  start = anchor;
643
- end = date;
804
+ end = selectableDate;
644
805
  }
645
806
 
646
807
  setValue({
@@ -651,7 +812,7 @@ export function createRangeCalendarState<T extends DateValue = CalendarDate>(
651
812
  setDragging(false);
652
813
  }
653
814
 
654
- setFocusedDate(date);
815
+ setFocusedDate(selectableDate);
655
816
  };
656
817
 
657
818
  // Get dates in a specific week
@@ -681,6 +842,89 @@ export function createRangeCalendarState<T extends DateValue = CalendarDate>(
681
842
  return getWeeksInMonth(monthDate, locale(), firstDayOfWeekName());
682
843
  };
683
844
 
845
+ // Format the selected range as `{ start, end }` strings. Faithful port of
846
+ // @react-stately/datepicker `useDateRangePickerState.formatValue`: when both
847
+ // endpoints share timezone + granularity and are distinct, it uses
848
+ // `formatRangeToParts` to split at the last shared literal so shared fields
849
+ // (e.g. the month/year) render once (=> "February 3" / "14, 2025").
850
+ const formatValue = (
851
+ locale2: string,
852
+ fieldOptions: Intl.DateTimeFormatOptions,
853
+ ): { start: string; end: string } | null => {
854
+ const v = value();
855
+ if (!v || !v.start || !v.end) {
856
+ return null;
857
+ }
858
+
859
+ const start = v.start as unknown as DateValue;
860
+ const end = v.end as unknown as DateValue;
861
+ const startTimeZone = "timeZone" in start ? (start as { timeZone: string }).timeZone : undefined;
862
+ const startGranularity = "minute" in start ? "minute" : "day";
863
+ const endTimeZone = "timeZone" in end ? (end as { timeZone: string }).timeZone : undefined;
864
+ const endGranularity = "minute" in end ? "minute" : "day";
865
+
866
+ const startOptions = getFormatOptions(fieldOptions, {
867
+ granularity: startGranularity,
868
+ timeZone: startTimeZone,
869
+ showEra:
870
+ (start.calendar.identifier === "gregory" && (start as { era: string }).era === "BC") ||
871
+ (end.calendar.identifier === "gregory" && (end as { era: string }).era === "BC"),
872
+ });
873
+
874
+ const startDate = start.toDate(startTimeZone || "UTC");
875
+ const endDate = end.toDate(endTimeZone || "UTC");
876
+
877
+ const startFormatter = new DateFormatter(locale2, startOptions);
878
+ let endFormatter: DateFormatter;
879
+ if (
880
+ startTimeZone === endTimeZone &&
881
+ startGranularity === endGranularity &&
882
+ start.compare(end) !== 0
883
+ ) {
884
+ // Use formatRange, as it results in shorter output when some of the fields
885
+ // are shared between the start and end dates (e.g. the same month).
886
+ try {
887
+ const parts = startFormatter.formatRangeToParts(startDate, endDate);
888
+
889
+ // Find the separator between the start and end date: the last shared
890
+ // literal before the end range begins.
891
+ let separatorIndex = -1;
892
+ for (let i = 0; i < parts.length; i++) {
893
+ const part = parts[i];
894
+ if (part.source === "shared" && part.type === "literal") {
895
+ separatorIndex = i;
896
+ } else if (part.source === "endRange") {
897
+ break;
898
+ }
899
+ }
900
+
901
+ let startStr = "";
902
+ let endStr = "";
903
+ for (let i = 0; i < parts.length; i++) {
904
+ if (i < separatorIndex) startStr += parts[i].value;
905
+ else if (i > separatorIndex) endStr += parts[i].value;
906
+ }
907
+
908
+ return { start: startStr, end: endStr };
909
+ } catch {
910
+ // ignore — fall through to per-endpoint formatting
911
+ }
912
+
913
+ endFormatter = startFormatter;
914
+ } else {
915
+ const endOptions = getFormatOptions(fieldOptions, {
916
+ granularity: endGranularity,
917
+ timeZone: endTimeZone,
918
+ });
919
+ endFormatter = new DateFormatter(locale2, endOptions);
920
+ }
921
+
922
+ return {
923
+ start: startFormatter.format(startDate),
924
+ end: endFormatter.format(endDate),
925
+ };
926
+ };
927
+
684
928
  return {
685
929
  value,
686
930
  setValue,
@@ -694,6 +938,8 @@ export function createRangeCalendarState<T extends DateValue = CalendarDate>(
694
938
  visibleRange,
695
939
  timeZone,
696
940
  locale,
941
+ minValue: minValueState,
942
+ maxValue: maxValueState,
697
943
  firstDayOfWeek: firstDayOfWeekName,
698
944
  validationState,
699
945
  isValueInvalid,
@@ -728,6 +974,7 @@ export function createRangeCalendarState<T extends DateValue = CalendarDate>(
728
974
  visibleMonths,
729
975
  isDragging,
730
976
  setDragging,
977
+ formatValue,
731
978
  };
732
979
  }
733
980
 
@@ -747,3 +994,69 @@ function convertValue(newValue: CalendarDate, oldValue?: DateValue | null): Date
747
994
 
748
995
  return localValue;
749
996
  }
997
+
998
+ /** Whether a date falls outside the closed [minValue, maxValue] interval. */
999
+ function isDateOutsideRange(
1000
+ date: CalendarDate,
1001
+ minValue?: CalendarDate | null,
1002
+ maxValue?: CalendarDate | null,
1003
+ ): boolean {
1004
+ return (
1005
+ (minValue != null && date.compare(minValue) < 0) ||
1006
+ (maxValue != null && date.compare(maxValue) > 0)
1007
+ );
1008
+ }
1009
+
1010
+ /**
1011
+ * Walks backward from `date` one day at a time while the date is unavailable and
1012
+ * still at/after `minValue` (the visible-range start), returning the last
1013
+ * available date — or null when the walk falls before `minValue`. With no
1014
+ * `isDateUnavailable` predicate the date is returned unchanged. Mirrors
1015
+ * @react-stately/calendar previousAvailableDate.
1016
+ */
1017
+ function previousAvailableDate(
1018
+ date: CalendarDate,
1019
+ minValue: CalendarDate,
1020
+ isDateUnavailable?: (date: CalendarDate) => boolean,
1021
+ ): CalendarDate | null {
1022
+ if (!isDateUnavailable) {
1023
+ return date;
1024
+ }
1025
+
1026
+ while (date.compare(minValue) >= 0 && isDateUnavailable(date)) {
1027
+ date = date.subtract({ days: 1 });
1028
+ }
1029
+
1030
+ if (date.compare(minValue) >= 0) {
1031
+ return date;
1032
+ }
1033
+ return null;
1034
+ }
1035
+
1036
+ /**
1037
+ * Walks outward from the anchor one day at a time in `dir` until it reaches an
1038
+ * unavailable date, then returns the last available date before it — or
1039
+ * undefined when no unavailable date is found within `visibleMonths` of the
1040
+ * anchor (no narrowing needed on that side). Mirrors @react-stately/calendar
1041
+ * nextUnavailableDate.
1042
+ */
1043
+ function nextUnavailableDate(
1044
+ anchorDate: CalendarDate,
1045
+ isDateUnavailable: (date: CalendarDate) => boolean,
1046
+ visibleMonths: number,
1047
+ dir: number,
1048
+ ): CalendarDate | undefined {
1049
+ let nextDate = anchorDate.add({ days: dir });
1050
+ const minBound = anchorDate.subtract({ months: visibleMonths });
1051
+ const maxBound = anchorDate.add({ months: visibleMonths });
1052
+ while (
1053
+ (dir < 0 ? nextDate.compare(minBound) >= 0 : nextDate.compare(maxBound) <= 0) &&
1054
+ !isDateUnavailable(nextDate)
1055
+ ) {
1056
+ nextDate = nextDate.add({ days: dir });
1057
+ }
1058
+ if (isDateUnavailable(nextDate)) {
1059
+ return nextDate.add({ days: -dir });
1060
+ }
1061
+ return undefined;
1062
+ }