panelui-native 0.28.1 → 0.32.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.
Files changed (121) hide show
  1. package/README.md +22 -3
  2. package/lib/module/components/calendar/index.js +450 -132
  3. package/lib/module/components/calendar/index.js.map +1 -1
  4. package/lib/module/components/code-block/highlight.js +363 -0
  5. package/lib/module/components/code-block/highlight.js.map +1 -0
  6. package/lib/module/components/code-block/index.js +313 -0
  7. package/lib/module/components/code-block/index.js.map +1 -0
  8. package/lib/module/components/date-picker/index.js +8 -1
  9. package/lib/module/components/date-picker/index.js.map +1 -1
  10. package/lib/module/components/heatmap-chart/index.js +9 -8
  11. package/lib/module/components/heatmap-chart/index.js.map +1 -1
  12. package/lib/module/components/map/index.js +50 -4
  13. package/lib/module/components/map/index.js.map +1 -1
  14. package/lib/module/components/menu/index.js +714 -0
  15. package/lib/module/components/menu/index.js.map +1 -0
  16. package/lib/module/components/plan/index.js +582 -0
  17. package/lib/module/components/plan/index.js.map +1 -0
  18. package/lib/module/components/popover/index.js +35 -4
  19. package/lib/module/components/popover/index.js.map +1 -1
  20. package/lib/module/components/post/index.js +992 -0
  21. package/lib/module/components/post/index.js.map +1 -0
  22. package/lib/module/components/radio-group/index.js +13 -8
  23. package/lib/module/components/radio-group/index.js.map +1 -1
  24. package/lib/module/components/reasoning/index.js +244 -0
  25. package/lib/module/components/reasoning/index.js.map +1 -0
  26. package/lib/module/components/response/index.js +296 -0
  27. package/lib/module/components/response/index.js.map +1 -0
  28. package/lib/module/components/response/markdown.js +463 -0
  29. package/lib/module/components/response/markdown.js.map +1 -0
  30. package/lib/module/components/select/index.js +97 -15
  31. package/lib/module/components/select/index.js.map +1 -1
  32. package/lib/module/components/sources/index.js +221 -0
  33. package/lib/module/components/sources/index.js.map +1 -0
  34. package/lib/module/components/table/index.js +632 -0
  35. package/lib/module/components/table/index.js.map +1 -0
  36. package/lib/module/components/task/index.js +300 -0
  37. package/lib/module/components/task/index.js.map +1 -0
  38. package/lib/module/components/textarea/index.js +295 -0
  39. package/lib/module/components/textarea/index.js.map +1 -0
  40. package/lib/module/components/tooltip/index.js +179 -23
  41. package/lib/module/components/tooltip/index.js.map +1 -1
  42. package/lib/module/icons/index.js +339 -0
  43. package/lib/module/icons/index.js.map +1 -1
  44. package/lib/module/index.js +12 -1
  45. package/lib/module/index.js.map +1 -1
  46. package/lib/module/primitives/collapse.js +112 -0
  47. package/lib/module/primitives/collapse.js.map +1 -0
  48. package/lib/module/utils/date.js +129 -22
  49. package/lib/module/utils/date.js.map +1 -1
  50. package/lib/typescript/src/components/calendar/index.d.ts +79 -5
  51. package/lib/typescript/src/components/calendar/index.d.ts.map +1 -1
  52. package/lib/typescript/src/components/code-block/highlight.d.ts +39 -0
  53. package/lib/typescript/src/components/code-block/highlight.d.ts.map +1 -0
  54. package/lib/typescript/src/components/code-block/index.d.ts +113 -0
  55. package/lib/typescript/src/components/code-block/index.d.ts.map +1 -0
  56. package/lib/typescript/src/components/date-picker/index.d.ts +7 -1
  57. package/lib/typescript/src/components/date-picker/index.d.ts.map +1 -1
  58. package/lib/typescript/src/components/heatmap-chart/index.d.ts.map +1 -1
  59. package/lib/typescript/src/components/map/index.d.ts +8 -1
  60. package/lib/typescript/src/components/map/index.d.ts.map +1 -1
  61. package/lib/typescript/src/components/menu/index.d.ts +261 -0
  62. package/lib/typescript/src/components/menu/index.d.ts.map +1 -0
  63. package/lib/typescript/src/components/plan/index.d.ts +238 -0
  64. package/lib/typescript/src/components/plan/index.d.ts.map +1 -0
  65. package/lib/typescript/src/components/popover/index.d.ts +35 -1
  66. package/lib/typescript/src/components/popover/index.d.ts.map +1 -1
  67. package/lib/typescript/src/components/post/index.d.ts +358 -0
  68. package/lib/typescript/src/components/post/index.d.ts.map +1 -0
  69. package/lib/typescript/src/components/radio-group/index.d.ts.map +1 -1
  70. package/lib/typescript/src/components/reasoning/index.d.ts +97 -0
  71. package/lib/typescript/src/components/reasoning/index.d.ts.map +1 -0
  72. package/lib/typescript/src/components/response/index.d.ts +91 -0
  73. package/lib/typescript/src/components/response/index.d.ts.map +1 -0
  74. package/lib/typescript/src/components/response/markdown.d.ts +88 -0
  75. package/lib/typescript/src/components/response/markdown.d.ts.map +1 -0
  76. package/lib/typescript/src/components/select/index.d.ts +27 -2
  77. package/lib/typescript/src/components/select/index.d.ts.map +1 -1
  78. package/lib/typescript/src/components/sources/index.d.ts +85 -0
  79. package/lib/typescript/src/components/sources/index.d.ts.map +1 -0
  80. package/lib/typescript/src/components/table/index.d.ts +274 -0
  81. package/lib/typescript/src/components/table/index.d.ts.map +1 -0
  82. package/lib/typescript/src/components/task/index.d.ts +103 -0
  83. package/lib/typescript/src/components/task/index.d.ts.map +1 -0
  84. package/lib/typescript/src/components/textarea/index.d.ts +175 -0
  85. package/lib/typescript/src/components/textarea/index.d.ts.map +1 -0
  86. package/lib/typescript/src/components/tooltip/index.d.ts +63 -8
  87. package/lib/typescript/src/components/tooltip/index.d.ts.map +1 -1
  88. package/lib/typescript/src/icons/index.d.ts +54 -1
  89. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  90. package/lib/typescript/src/index.d.ts +14 -3
  91. package/lib/typescript/src/index.d.ts.map +1 -1
  92. package/lib/typescript/src/primitives/collapse.d.ts +42 -0
  93. package/lib/typescript/src/primitives/collapse.d.ts.map +1 -0
  94. package/lib/typescript/src/utils/date.d.ts +49 -5
  95. package/lib/typescript/src/utils/date.d.ts.map +1 -1
  96. package/package.json +1 -1
  97. package/src/components/calendar/index.tsx +519 -139
  98. package/src/components/code-block/highlight.ts +278 -0
  99. package/src/components/code-block/index.tsx +341 -0
  100. package/src/components/date-picker/index.tsx +19 -1
  101. package/src/components/heatmap-chart/index.tsx +9 -9
  102. package/src/components/map/index.tsx +53 -4
  103. package/src/components/menu/index.tsx +812 -0
  104. package/src/components/plan/index.tsx +648 -0
  105. package/src/components/popover/index.tsx +69 -2
  106. package/src/components/post/index.tsx +1116 -0
  107. package/src/components/radio-group/index.tsx +11 -2
  108. package/src/components/reasoning/index.tsx +297 -0
  109. package/src/components/response/index.tsx +390 -0
  110. package/src/components/response/markdown.ts +489 -0
  111. package/src/components/select/index.tsx +132 -12
  112. package/src/components/sources/index.tsx +278 -0
  113. package/src/components/table/index.tsx +747 -0
  114. package/src/components/task/index.tsx +329 -0
  115. package/src/components/textarea/index.tsx +381 -0
  116. package/src/components/tooltip/index.tsx +203 -20
  117. package/src/icons/index.tsx +264 -1
  118. package/src/index.ts +131 -0
  119. package/src/primitives/collapse.tsx +128 -0
  120. package/src/utils/date.ts +146 -22
  121. package/theme.css +112 -0
package/src/index.ts CHANGED
@@ -21,6 +21,7 @@ export { Portal, PortalHost, PortalProvider } from './primitives/portal';
21
21
  // graceful fall back from blur to dim — as the built-in ones.
22
22
  export { Scrim, hasBlur, type ScrimProps } from './primitives/scrim';
23
23
  export { Text, type TextProps } from './primitives/text';
24
+ export { Collapse, type CollapseProps } from './primitives/collapse';
24
25
  export {
25
26
  KeyboardAvoider,
26
27
  type KeyboardAvoiderProps,
@@ -253,6 +254,20 @@ export {
253
254
  type TabsTriggerProps,
254
255
  type TabsContentProps,
255
256
  } from './components/tabs';
257
+ export {
258
+ Table,
259
+ type TableProps,
260
+ type TableFrameProps,
261
+ type TableHeaderProps,
262
+ type TableBodyProps,
263
+ type TableFooterProps,
264
+ type TableRowProps,
265
+ type TableHeadProps,
266
+ type TableCellProps,
267
+ type TableCaptionProps,
268
+ type TableEmptyProps,
269
+ type TableSortDirection,
270
+ } from './components/table';
256
271
  export {
257
272
  Carousel,
258
273
  useCarouselState,
@@ -271,6 +286,8 @@ export {
271
286
  export {
272
287
  Calendar,
273
288
  type CalendarProps,
289
+ type CalendarHeaderProps,
290
+ type CalendarNavProps,
274
291
  type CalendarMode,
275
292
  type CalendarCaptionLayout,
276
293
  type CalendarDisabled,
@@ -332,6 +349,7 @@ export {
332
349
  type NumberInputProps,
333
350
  } from './components/number-input';
334
351
  export { OtpInput, type OtpInputProps } from './components/otp-input';
352
+ export { Textarea, type TextareaProps } from './components/textarea';
335
353
  export {
336
354
  Item,
337
355
  type ItemProps,
@@ -375,6 +393,24 @@ export {
375
393
  type MarkerIconProps,
376
394
  type MarkerContentProps,
377
395
  } from './components/marker';
396
+ export {
397
+ Menu,
398
+ type MenuProps,
399
+ type MenuTriggerProps,
400
+ type MenuContentProps,
401
+ type MenuBackgroundProps,
402
+ type MenuLabelProps,
403
+ type MenuItemProps,
404
+ type MenuCheckboxItemProps,
405
+ type MenuRadioGroupProps,
406
+ type MenuRadioItemProps,
407
+ type MenuSeparatorProps,
408
+ type MenuSubProps,
409
+ type MenuSubTriggerProps,
410
+ type MenuSubContentProps,
411
+ type MenuItemVariant,
412
+ type MenuRadioIndicator,
413
+ } from './components/menu';
378
414
  export {
379
415
  MessageScroller,
380
416
  useMessageScroller,
@@ -409,6 +445,21 @@ export {
409
445
  type PopoverAlign,
410
446
  type PopoverPresentation,
411
447
  } from './components/popover';
448
+ export {
449
+ Post,
450
+ type PostProps,
451
+ type PostHeaderProps,
452
+ type PostAuthorProps,
453
+ type PostActionProps,
454
+ type PostCommunityProps,
455
+ type PostTitleProps,
456
+ type PostBodyProps,
457
+ type PostMediaProps,
458
+ type PostFooterProps,
459
+ type PostStatProps,
460
+ type PostVotesProps,
461
+ type PostVote,
462
+ } from './components/post';
412
463
  export { Progress, type ProgressProps } from './components/progress';
413
464
  export {
414
465
  RadioGroup,
@@ -448,6 +499,65 @@ export {
448
499
  type SignatureButtonProps,
449
500
  } from './components/signature';
450
501
  export { Shimmer, type ShimmerProps } from './components/shimmer';
502
+ export {
503
+ Reasoning,
504
+ type ReasoningProps,
505
+ type ReasoningTriggerProps,
506
+ type ReasoningContentProps,
507
+ } from './components/reasoning';
508
+ export {
509
+ Response,
510
+ type ResponseProps,
511
+ type ResponseComponents,
512
+ type ResponseBlock,
513
+ type ResponseInline,
514
+ } from './components/response';
515
+ export {
516
+ Sources,
517
+ type SourcesProps,
518
+ type SourcesTriggerProps,
519
+ type SourcesContentProps,
520
+ type SourcesSourceProps,
521
+ } from './components/sources';
522
+ export {
523
+ Task,
524
+ type TaskProps,
525
+ type TaskTriggerProps,
526
+ type TaskContentProps,
527
+ type TaskItemProps,
528
+ type TaskFileProps,
529
+ type TaskStatus,
530
+ } from './components/task';
531
+ export {
532
+ CodeBlock,
533
+ resolveLanguage,
534
+ type CodeBlockProps,
535
+ type CodeBlockHeaderProps,
536
+ type CodeBlockFilenameProps,
537
+ type CodeBlockLanguageProps,
538
+ type CodeBlockActionsProps,
539
+ type CodeBlockCopyButtonProps,
540
+ type CodeLanguage,
541
+ type Token,
542
+ type TokenKind,
543
+ } from './components/code-block';
544
+ export {
545
+ Plan,
546
+ type PlanProps,
547
+ type PlanHeaderProps,
548
+ type PlanIconProps,
549
+ type PlanTitleProps,
550
+ type PlanDescriptionProps,
551
+ type PlanActionProps,
552
+ type PlanProgressProps,
553
+ type PlanTriggerProps,
554
+ type PlanContentProps,
555
+ type PlanStepsProps,
556
+ type PlanStepProps,
557
+ type PlanStepStatus,
558
+ type PlanStepCounts,
559
+ type PlanFooterProps,
560
+ } from './components/plan';
451
561
  export {
452
562
  ScrollText,
453
563
  type ScrollTextProps,
@@ -508,9 +618,12 @@ export {
508
618
  type TooltipTriggerProps,
509
619
  type TooltipContentProps,
510
620
  type TooltipArrowProps,
621
+ type TooltipTitleProps,
622
+ type TooltipDescriptionProps,
511
623
  type TooltipPlacement,
512
624
  type TooltipAlign,
513
625
  type TooltipOpenOn,
626
+ type TooltipVariant,
514
627
  } from './components/tooltip';
515
628
  export {
516
629
  Typography,
@@ -525,7 +638,10 @@ export {
525
638
  export {
526
639
  AlertTriangleIcon,
527
640
  AppleIcon,
641
+ ArrowDownIcon,
642
+ ArrowUpIcon,
528
643
  ArrowUpRightIcon,
644
+ BadgeCheckIcon,
529
645
  BellIcon,
530
646
  BookmarkIcon,
531
647
  CalendarIcon,
@@ -536,16 +652,25 @@ export {
536
652
  ChevronLeftIcon,
537
653
  ChevronRightIcon,
538
654
  ChevronUpIcon,
655
+ CircleIcon,
656
+ CompassIcon,
657
+ CopyIcon,
658
+ CrosshairIcon,
539
659
  DownloadIcon,
540
660
  EllipsisIcon,
661
+ EyeIcon,
541
662
  FacebookIcon,
542
663
  FileIcon,
543
664
  GoogleIcon,
665
+ HeartIcon,
544
666
  IconColorProvider,
545
667
  InfoIcon,
546
668
  ImageIcon,
669
+ LinkIcon,
670
+ ListChecksIcon,
547
671
  LockIcon,
548
672
  MaximizeIcon,
673
+ MessageCircleIcon,
549
674
  MicIcon,
550
675
  MinusIcon,
551
676
  MoonIcon,
@@ -557,18 +682,24 @@ export {
557
682
  PlusIcon,
558
683
  PlusSquareIcon,
559
684
  ReceiptIcon,
685
+ RepeatIcon,
560
686
  RotateCcwIcon,
687
+ RotateCwIcon,
561
688
  SearchIcon,
562
689
  SendIcon,
563
690
  ShareNodesIcon,
564
691
  ShieldAlertIcon,
565
692
  ShieldCheckIcon,
693
+ SparklesIcon,
694
+ StarIcon,
566
695
  SunIcon,
567
696
  TrashIcon,
568
697
  UnlockIcon,
569
698
  XIcon,
570
699
  useIconColor,
571
700
  type IconProps,
701
+ type ToggleIconProps,
702
+ type BadgeCheckIconProps,
572
703
  } from './icons';
573
704
 
574
705
  // Hooks
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Collapse — a body that opens and closes by animating its own height.
3
+ *
4
+ * Height cannot be animated from `auto`, so the content is measured first and
5
+ * the measurement is what gets animated to. While that is happening the content
6
+ * is taken out of the flow, because a child of a view whose height is
7
+ * mid-animation reports that animated height back and the panel then settles on
8
+ * whatever it happened to measure.
9
+ *
10
+ * The moment the animation finishes, both go away and the content lays out
11
+ * normally again. That is what lets a Collapse hold another Collapse — or hold
12
+ * content that is still streaming in — without either of them being clipped to
13
+ * a number that was true a frame ago.
14
+ *
15
+ * ```tsx
16
+ * <Collapse open={open}>
17
+ * <Text>Anything, of any height.</Text>
18
+ * </Collapse>
19
+ * ```
20
+ *
21
+ * The alternative — unmounting the body and letting a layout transition on the
22
+ * parent carry the change — is what `Accordion` does, and it is right when the
23
+ * closed state should cost nothing. This is for a body that opens and closes
24
+ * repeatedly and often while its content is still arriving, where remounting
25
+ * would throw away scroll position and restart every animation inside it.
26
+ */
27
+ import { useEffect, useRef, useState, type ReactNode } from 'react';
28
+ import { View, type LayoutChangeEvent, type ViewProps } from 'react-native';
29
+ import Animated, {
30
+ runOnJS,
31
+ useAnimatedStyle,
32
+ useReducedMotion,
33
+ useSharedValue,
34
+ withTiming,
35
+ } from 'react-native-reanimated';
36
+ import { cn } from '../utils/cn';
37
+
38
+ /** Long enough to read as the panel opening, short enough not to be waited on. */
39
+ export const COLLAPSE_DURATION = 200;
40
+
41
+ export interface CollapseProps extends Omit<ViewProps, 'children'> {
42
+ open: boolean;
43
+ /** Classes on the measured content, not on the clipping frame. */
44
+ className?: string;
45
+ duration?: number;
46
+ children?: ReactNode;
47
+ }
48
+
49
+ export function Collapse({
50
+ open,
51
+ className,
52
+ duration = COLLAPSE_DURATION,
53
+ children,
54
+ ...props
55
+ }: CollapseProps) {
56
+ const reducedMotion = useReducedMotion();
57
+ const [height, setHeight] = useState(0);
58
+ const [animating, setAnimating] = useState(false);
59
+ const progress = useSharedValue(open ? 1 : 0);
60
+ const mounted = useRef(false);
61
+
62
+ useEffect(() => {
63
+ // The first pass is not a transition — a body that starts open should be
64
+ // open, not play its own entrance at nobody.
65
+ if (!mounted.current) {
66
+ mounted.current = true;
67
+ progress.value = open ? 1 : 0;
68
+ return;
69
+ }
70
+ if (reducedMotion) {
71
+ progress.value = open ? 1 : 0;
72
+ setAnimating(false);
73
+ return;
74
+ }
75
+ setAnimating(true);
76
+ progress.value = withTiming(open ? 1 : 0, { duration }, (finished) => {
77
+ if (finished) runOnJS(setAnimating)(false);
78
+ });
79
+ }, [open, reducedMotion, duration, progress]);
80
+
81
+ const style = useAnimatedStyle(() => ({
82
+ height: progress.value * height,
83
+ opacity: progress.value,
84
+ }));
85
+
86
+ // Re-measured rather than measured once: a body whose content is still
87
+ // streaming in grows, and a height captured on the first frame would crop it.
88
+ const onLayout = (event: LayoutChangeEvent) => {
89
+ const next = event.nativeEvent.layout.height;
90
+ setHeight((current) => (Math.abs(current - next) < 1 ? current : next));
91
+ };
92
+
93
+ /*
94
+ * Once it has finished opening, the measured height and the absolute
95
+ * positioning are both dropped and the content lays out normally.
96
+ *
97
+ * They are only needed *while* the height is being animated to a number, and
98
+ * keeping them costs real bugs: a Collapse whose content is itself a Collapse
99
+ * measures a child that has not settled yet, and settles on that wrong
100
+ * number, so the inner body ends up clipped to nothing. Content that is still
101
+ * streaming in has the same problem against itself. In natural flow both are
102
+ * simply laid out, and the measurement carries on in the background against
103
+ * the next time it closes.
104
+ */
105
+ const natural = open && !animating;
106
+
107
+ return (
108
+ <Animated.View
109
+ style={natural ? undefined : style}
110
+ className={natural ? undefined : 'overflow-hidden'}
111
+ >
112
+ {/*
113
+ The same two elements in both modes, so switching between them is a
114
+ style change rather than a remount — a remount would throw away the
115
+ state of everything inside.
116
+ */}
117
+ <View
118
+ onLayout={onLayout}
119
+ style={natural ? undefined : { position: 'absolute', left: 0, right: 0, top: 0 }}
120
+ className={cn(className)}
121
+ {...props}
122
+ >
123
+ {children}
124
+ </View>
125
+ </Animated.View>
126
+ );
127
+ }
128
+ Collapse.displayName = 'Collapse';
package/src/utils/date.ts CHANGED
@@ -12,6 +12,18 @@
12
12
  * comparison here goes through `startOfDay`.
13
13
  * - **Month arithmetic is done with `setMonth`, not by adding days.** The
14
14
  * platform already knows that a month is 28, 29, 30 or 31 days long.
15
+ *
16
+ * Three more that are easy to lose and expensive to get back:
17
+ *
18
+ * - **Days are stepped with `setDate`, never with milliseconds.** Adding
19
+ * `n * 86400000` is wrong by an hour on the two days a year the clocks move,
20
+ * which is enough to land the wrong side of midnight and shift a whole grid.
21
+ * - **A month is normalised to the 1st before months are added.** `setMonth`
22
+ * on the 31st of a month whose neighbour is shorter overflows into the month
23
+ * after next — January 31st plus one month is March 3rd.
24
+ * - **Nothing is ever parsed back out of formatted text.** `Intl` is asked for
25
+ * the *parts* and the numbers are read from those, because the order and the
26
+ * separators belong to the locale and only the numbers are ours.
15
27
  */
16
28
 
17
29
  /** Names of the weekdays and months, in the caller's locale. */
@@ -60,12 +72,6 @@ export function addDays(date: Date, days: number): Date {
60
72
  return copy;
61
73
  }
62
74
 
63
- /** Whole days from `a` to `b`, ignoring the time of day on both. */
64
- export function daysBetween(a: Date, b: Date): number {
65
- const MS = 24 * 60 * 60 * 1000;
66
- return Math.round((startOfDay(b).getTime() - startOfDay(a).getTime()) / MS);
67
- }
68
-
69
75
  export function isBefore(a: Date, b: Date): boolean {
70
76
  return startOfDay(a).getTime() < startOfDay(b).getTime();
71
77
  }
@@ -82,12 +88,59 @@ export function isWithin(date: Date, from: Date, to: Date): boolean {
82
88
  return time >= Math.min(start, end) && time <= Math.max(start, end);
83
89
  }
84
90
 
91
+ /**
92
+ * `date` pulled inside `min`..`max`, at day resolution.
93
+ *
94
+ * The bound is returned at its own start of day rather than as given, so a
95
+ * `maxDate` that happens to carry a time of 17:30 does not hand back a value
96
+ * that then compares as *after* the last selectable day.
97
+ */
85
98
  export function clampDate(date: Date, min?: Date, max?: Date): Date {
86
- if (min && isBefore(date, min)) return min;
87
- if (max && isAfter(date, max)) return max;
99
+ if (min && isBefore(date, min)) return startOfDay(min);
100
+ if (max && isAfter(date, max)) return startOfDay(max);
88
101
  return date;
89
102
  }
90
103
 
104
+ /**
105
+ * A first-day-of-week index made safe.
106
+ *
107
+ * `weekStartsOn` arrives from a caller and reaches an array index by way of
108
+ * `(day + weekStartsOn) % 7`, where a negative or out-of-range value yields a
109
+ * negative index and an `undefined` weekday name. Both directions wrap.
110
+ */
111
+ export function normalizeWeekStart(weekStartsOn: number): number {
112
+ if (!Number.isFinite(weekStartsOn)) return 0;
113
+ return ((Math.trunc(weekStartsOn) % 7) + 7) % 7;
114
+ }
115
+
116
+ /**
117
+ * The day the week starts on where this locale is spoken, as `Date.getDay()`
118
+ * counts it — 0 for Sunday.
119
+ *
120
+ * Sunday is a poor default outside North America and a handful of other
121
+ * regions, but it is the one the platform gives us when it knows nothing. Where
122
+ * `Intl` carries week data, ask it: `en-GB` and `fr-FR` start on Monday, and a
123
+ * calendar that says otherwise is wrong in a way its reader notices immediately.
124
+ */
125
+ export function localeWeekStart(locale: DateLocale): number {
126
+ try {
127
+ const tag = (locale ?? 'en').split('-u-')[0] || 'en';
128
+ const resolved = new Intl.Locale(tag) as Intl.Locale & {
129
+ getWeekInfo?: () => { firstDay?: number };
130
+ weekInfo?: { firstDay?: number };
131
+ };
132
+ // Proposed as a method and shipped as a property first; both are in the
133
+ // wild, and neither is guaranteed to be there at all.
134
+ const info = resolved.getWeekInfo?.() ?? resolved.weekInfo;
135
+ const first = info?.firstDay;
136
+ // `Intl` counts 1 = Monday … 7 = Sunday; `Date.getDay()` counts 0 = Sunday.
137
+ if (typeof first === 'number' && Number.isFinite(first)) return first % 7;
138
+ } catch {
139
+ // No week data in this build.
140
+ }
141
+ return 0;
142
+ }
143
+
91
144
  /**
92
145
  * The six-week grid for a month.
93
146
  *
@@ -98,12 +151,20 @@ export function clampDate(date: Date, min?: Date, max?: Date): Date {
98
151
  *
99
152
  * The leading and trailing cells belong to the neighbouring months; whether
100
153
  * they are drawn or left blank is the caller's decision.
154
+ *
155
+ * `system` and `locale` are what make this work for a Hijri grid, where the
156
+ * month it has to open on is not the Gregorian 1st.
101
157
  */
102
- export function monthGrid(month: Date, weekStartsOn: number): Date[][] {
103
- const first = startOfMonth(month);
158
+ export function monthGrid(
159
+ month: Date,
160
+ weekStartsOn: number,
161
+ system: 'gregory' | 'islamic' = 'gregory',
162
+ locale?: DateLocale
163
+ ): Date[][] {
164
+ const first = startOfCalendarMonth(month, system, locale);
104
165
  // How far back to the most recent `weekStartsOn`. The `+ 7` keeps the
105
166
  // modulo positive when the week starts later than the first of the month.
106
- const lead = (first.getDay() - weekStartsOn + 7) % 7;
167
+ const lead = (first.getDay() - normalizeWeekStart(weekStartsOn) + 7) % 7;
107
168
  const start = addDays(first, -lead);
108
169
 
109
170
  return Array.from({ length: 6 }, (_unusedWeek, week) =>
@@ -119,10 +180,49 @@ export function monthGrid(month: Date, weekStartsOn: number): Date[][] {
119
180
  * locales they do not carry. Every formatter here falls back to English rather
120
181
  * than taking the screen down over a month name.
121
182
  */
183
+ /**
184
+ * Formatters, kept.
185
+ *
186
+ * Building an `Intl.DateTimeFormat` is the expensive part of formatting, and a
187
+ * Hijri grid asks for one per cell twice over — once to decide which month the
188
+ * cell belongs to and once for the number in it — which is 84 constructions a
189
+ * render before anyone touches the year dropdown, where a century jump used to
190
+ * cost tens of thousands. The set of shapes asked for is tiny and fixed, so it
191
+ * is cached for the life of the process. A failure is cached too: a build with
192
+ * no data for a locale will not acquire any by being asked again.
193
+ */
194
+ const formatters = new Map<string, Intl.DateTimeFormat | null>();
195
+
196
+ function formatter(
197
+ locale: DateLocale,
198
+ options: Intl.DateTimeFormatOptions
199
+ ): Intl.DateTimeFormat | null {
200
+ // Sorted, so two call sites writing the same options in a different order
201
+ // share one entry rather than building two.
202
+ const shape = Object.keys(options)
203
+ .sort()
204
+ .map((key) => `${key}=${String(options[key as keyof Intl.DateTimeFormatOptions])}`)
205
+ .join(',');
206
+ const key = `${locale ?? ''}|${shape}`;
207
+
208
+ if (formatters.has(key)) return formatters.get(key) ?? null;
209
+
210
+ let made: Intl.DateTimeFormat | null = null;
211
+ try {
212
+ made = new Intl.DateTimeFormat(locale, options);
213
+ } catch {
214
+ made = null;
215
+ }
216
+ formatters.set(key, made);
217
+ return made;
218
+ }
219
+
122
220
  function format(date: Date, locale: DateLocale, options: Intl.DateTimeFormatOptions): string | null {
123
221
  try {
124
- return new Intl.DateTimeFormat(locale, options).format(date);
222
+ return formatter(locale, options)?.format(date) ?? null;
125
223
  } catch {
224
+ // Constructing succeeded and formatting still failed — some builds only
225
+ // discover a missing calendar at format time.
126
226
  return null;
127
227
  }
128
228
  }
@@ -189,7 +289,7 @@ export interface CalendarParts {
189
289
  export function resolveCalendar(system: CalendarSystem, locale: DateLocale): 'gregory' | 'islamic' {
190
290
  if (system !== 'auto') return system;
191
291
  try {
192
- const resolved = new Intl.DateTimeFormat(locale).resolvedOptions().calendar ?? '';
292
+ const resolved = formatter(locale, {})?.resolvedOptions().calendar ?? '';
193
293
  return resolved.startsWith('islamic') ? 'islamic' : 'gregory';
194
294
  } catch {
195
295
  return 'gregory';
@@ -214,11 +314,12 @@ export function calendarParts(
214
314
  return { year: date.getFullYear(), month: date.getMonth() + 1, day: date.getDate() };
215
315
  }
216
316
  try {
217
- const parts = new Intl.DateTimeFormat(withCalendar(locale, 'islamic'), {
317
+ const parts = formatter(withCalendar(locale, 'islamic'), {
218
318
  year: 'numeric',
219
319
  month: 'numeric',
220
320
  day: 'numeric',
221
- }).formatToParts(date);
321
+ })?.formatToParts(date);
322
+ if (!parts) throw new Error('unusable');
222
323
  const read = (type: string) => Number(parts.find((part) => part.type === type)?.value);
223
324
  const year = read('year');
224
325
  const month = read('month');
@@ -246,13 +347,24 @@ export function isSameCalendarMonth(
246
347
  return one.year === two.year && one.month === two.month;
247
348
  }
248
349
 
350
+ /** Whether two days fall in the same year and month of the given calendar. */
351
+ function sameCalendarMonthParts(a: CalendarParts, b: CalendarParts): boolean {
352
+ return a.year === b.year && a.month === b.month;
353
+ }
354
+
249
355
  /**
250
356
  * The first day of the calendar month `date` falls in.
251
357
  *
252
358
  * Walked back a day at a time rather than computed. A Hijri month is 29 or 30
253
359
  * days depending on the year, and the platform's own calendar data already
254
- * knows which — stepping until the day number reads 1 asks it, instead of
255
- * shipping a table that will disagree with the device.
360
+ * knows which — asking it is better than shipping a table that will disagree
361
+ * with the device.
362
+ *
363
+ * The walk stops where the *month number changes*, not where the day number
364
+ * reads 1. The observational Islamic calendars the platforms ship do not always
365
+ * label a day 1: in 1450 AH, month 11 day 30 is followed directly by month 12
366
+ * day 2. Hunting for a day 1 that is never emitted walked the cursor off the
367
+ * end of its budget and then paged the calendar to the same month forever.
256
368
  */
257
369
  export function startOfCalendarMonth(
258
370
  date: Date,
@@ -261,9 +373,15 @@ export function startOfCalendarMonth(
261
373
  ): Date {
262
374
  if (system === 'gregory') return startOfMonth(date);
263
375
  let cursor = startOfDay(date);
264
- for (let step = 0; step < 31; step += 1) {
265
- if (calendarParts(cursor, system, locale).day === 1) return cursor;
266
- cursor = addDays(cursor, -1);
376
+ const target = calendarParts(cursor, system, locale);
377
+ // 32, not 31: one step more than the longest month, so the loop is bounded
378
+ // by a real answer rather than by running out.
379
+ for (let step = 0; step < 32; step += 1) {
380
+ const previous = addDays(cursor, -1);
381
+ if (!sameCalendarMonthParts(calendarParts(previous, system, locale), target)) {
382
+ return cursor;
383
+ }
384
+ cursor = previous;
267
385
  }
268
386
  return cursor;
269
387
  }
@@ -276,8 +394,13 @@ export function daysInCalendarMonth(
276
394
  ): number {
277
395
  if (system === 'gregory') return endOfMonth(date).getDate();
278
396
  const start = startOfCalendarMonth(date, system, locale);
397
+ const parts = calendarParts(start, system, locale);
398
+ // The first offset that lands outside this month is its length, whatever the
399
+ // day numbers along the way are called.
279
400
  for (let length = 28; length <= 31; length += 1) {
280
- if (calendarParts(addDays(start, length), system, locale).day === 1) return length;
401
+ if (!sameCalendarMonthParts(calendarParts(addDays(start, length), system, locale), parts)) {
402
+ return length;
403
+ }
281
404
  }
282
405
  return 30;
283
406
  }
@@ -413,9 +536,10 @@ export function monthNames(locale: DateLocale, style: 'long' | 'short' = 'long')
413
536
 
414
537
  /** Column headings, rotated so the first one is `weekStartsOn`. */
415
538
  export function weekdayNames(locale: DateLocale, weekStartsOn: number): string[] {
539
+ const first = normalizeWeekStart(weekStartsOn);
416
540
  // 2021-08-01 was a Sunday, so adding the index lands on each weekday in turn.
417
541
  return Array.from({ length: 7 }, (_unused, day) => {
418
- const index = (day + weekStartsOn) % 7;
542
+ const index = (day + first) % 7;
419
543
  const formatted = formatGregory(new Date(2021, 7, 1 + index), locale, { weekday: 'short' });
420
544
  return formatted ?? WEEKDAYS_EN[index]!;
421
545
  });