panelui-native 0.30.0 → 0.34.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 (81) hide show
  1. package/README.md +4 -2
  2. package/lib/module/components/button/index.js +106 -11
  3. package/lib/module/components/button/index.js.map +1 -1
  4. package/lib/module/components/calendar/index.js +107 -24
  5. package/lib/module/components/calendar/index.js.map +1 -1
  6. package/lib/module/components/drawer/index.js +477 -0
  7. package/lib/module/components/drawer/index.js.map +1 -0
  8. package/lib/module/components/heatmap-chart/index.js +9 -8
  9. package/lib/module/components/heatmap-chart/index.js.map +1 -1
  10. package/lib/module/components/map/index.js +50 -4
  11. package/lib/module/components/map/index.js.map +1 -1
  12. package/lib/module/components/panelside/index.js +1024 -0
  13. package/lib/module/components/panelside/index.js.map +1 -0
  14. package/lib/module/components/plan/index.js +307 -21
  15. package/lib/module/components/plan/index.js.map +1 -1
  16. package/lib/module/components/post/index.js +992 -0
  17. package/lib/module/components/post/index.js.map +1 -0
  18. package/lib/module/components/response/index.js +296 -0
  19. package/lib/module/components/response/index.js.map +1 -0
  20. package/lib/module/components/response/markdown.js +463 -0
  21. package/lib/module/components/response/markdown.js.map +1 -0
  22. package/lib/module/components/select/index.js +111 -18
  23. package/lib/module/components/select/index.js.map +1 -1
  24. package/lib/module/components/table/index.js +632 -0
  25. package/lib/module/components/table/index.js.map +1 -0
  26. package/lib/module/icons/index.js +220 -0
  27. package/lib/module/icons/index.js.map +1 -1
  28. package/lib/module/index.js +6 -1
  29. package/lib/module/index.js.map +1 -1
  30. package/lib/module/native/index.js +30 -0
  31. package/lib/module/native/index.js.map +1 -1
  32. package/lib/module/utils/date.js +129 -22
  33. package/lib/module/utils/date.js.map +1 -1
  34. package/lib/typescript/src/components/button/index.d.ts +22 -1
  35. package/lib/typescript/src/components/button/index.d.ts.map +1 -1
  36. package/lib/typescript/src/components/calendar/index.d.ts +12 -3
  37. package/lib/typescript/src/components/calendar/index.d.ts.map +1 -1
  38. package/lib/typescript/src/components/drawer/index.d.ts +146 -0
  39. package/lib/typescript/src/components/drawer/index.d.ts.map +1 -0
  40. package/lib/typescript/src/components/heatmap-chart/index.d.ts.map +1 -1
  41. package/lib/typescript/src/components/map/index.d.ts +8 -1
  42. package/lib/typescript/src/components/map/index.d.ts.map +1 -1
  43. package/lib/typescript/src/components/panelside/index.d.ts +354 -0
  44. package/lib/typescript/src/components/panelside/index.d.ts.map +1 -0
  45. package/lib/typescript/src/components/plan/index.d.ts +121 -10
  46. package/lib/typescript/src/components/plan/index.d.ts.map +1 -1
  47. package/lib/typescript/src/components/post/index.d.ts +358 -0
  48. package/lib/typescript/src/components/post/index.d.ts.map +1 -0
  49. package/lib/typescript/src/components/response/index.d.ts +91 -0
  50. package/lib/typescript/src/components/response/index.d.ts.map +1 -0
  51. package/lib/typescript/src/components/response/markdown.d.ts +88 -0
  52. package/lib/typescript/src/components/response/markdown.d.ts.map +1 -0
  53. package/lib/typescript/src/components/select/index.d.ts +27 -2
  54. package/lib/typescript/src/components/select/index.d.ts.map +1 -1
  55. package/lib/typescript/src/components/table/index.d.ts +274 -0
  56. package/lib/typescript/src/components/table/index.d.ts.map +1 -0
  57. package/lib/typescript/src/icons/index.d.ts +41 -1
  58. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  59. package/lib/typescript/src/index.d.ts +7 -2
  60. package/lib/typescript/src/index.d.ts.map +1 -1
  61. package/lib/typescript/src/native/index.d.ts +20 -0
  62. package/lib/typescript/src/native/index.d.ts.map +1 -1
  63. package/lib/typescript/src/utils/date.d.ts +49 -5
  64. package/lib/typescript/src/utils/date.d.ts.map +1 -1
  65. package/package.json +1 -1
  66. package/src/components/button/index.tsx +140 -8
  67. package/src/components/calendar/index.tsx +136 -23
  68. package/src/components/drawer/index.tsx +662 -0
  69. package/src/components/heatmap-chart/index.tsx +9 -9
  70. package/src/components/map/index.tsx +53 -4
  71. package/src/components/panelside/index.tsx +1358 -0
  72. package/src/components/plan/index.tsx +344 -21
  73. package/src/components/post/index.tsx +1116 -0
  74. package/src/components/response/index.tsx +390 -0
  75. package/src/components/response/markdown.ts +489 -0
  76. package/src/components/select/index.tsx +155 -15
  77. package/src/components/table/index.tsx +747 -0
  78. package/src/icons/index.tsx +176 -1
  79. package/src/index.ts +93 -0
  80. package/src/native/index.ts +51 -0
  81. package/src/utils/date.ts +146 -22
@@ -133,12 +133,18 @@ export function CalendarIcon({ size = 16, color, ...props }: IconProps) {
133
133
  );
134
134
  }
135
135
 
136
- export function BookmarkIcon({ size = 16, color, ...props }: IconProps) {
136
+ export function BookmarkIcon({
137
+ size = 16,
138
+ color,
139
+ filled,
140
+ ...props
141
+ }: IconProps & { filled?: boolean }) {
137
142
  const resolved = useResolvedColor(color, '#737373');
138
143
  return (
139
144
  <Svg width={size} height={size} viewBox="0 0 24 24" fill="none" {...props}>
140
145
  <Path
141
146
  d="m19 21-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2Z"
147
+ fill={filled ? resolved : 'none'}
142
148
  stroke={resolved}
143
149
  strokeWidth={2}
144
150
  strokeLinecap="round"
@@ -225,6 +231,20 @@ export function EllipsisIcon({ size = 16, color, ...props }: IconProps) {
225
231
  );
226
232
  }
227
233
 
234
+ export function MenuIcon({ size = 20, color, ...props }: IconProps) {
235
+ const resolved = useResolvedColor(color, '#737373');
236
+ return (
237
+ <Svg width={size} height={size} viewBox="0 0 24 24" fill="none" {...props}>
238
+ <Path
239
+ d="M4 6h16M4 12h16M4 18h16"
240
+ stroke={resolved}
241
+ strokeWidth={2}
242
+ strokeLinecap="round"
243
+ />
244
+ </Svg>
245
+ );
246
+ }
247
+
228
248
  export function XIcon({ size = 16, color, ...props }: IconProps) {
229
249
  const resolved = useResolvedColor(color, '#737373');
230
250
  return (
@@ -974,3 +994,158 @@ export function CircleIcon({ size = 16, color, ...props }: IconProps) {
974
994
  </Svg>
975
995
  );
976
996
  }
997
+
998
+ /* -------------------------------------------------------------------------- */
999
+ /* Social */
1000
+ /* -------------------------------------------------------------------------- */
1001
+
1002
+ /**
1003
+ * The icons a post's action row is made of.
1004
+ *
1005
+ * These are the only ones in the set that take `filled`, and it is not a
1006
+ * stylistic choice: a like, a save and a vote are toggles, and the outline and
1007
+ * the solid are the two states of one control. Drawn as two different icons
1008
+ * they would swap shape under the finger; drawn as one that fills, the shape
1009
+ * stays put and only the inside changes.
1010
+ */
1011
+ export interface ToggleIconProps extends IconProps {
1012
+ /** Solid rather than outlined — the on state of a like, a save, a vote. */
1013
+ filled?: boolean;
1014
+ }
1015
+
1016
+ /** Upvote. Filled once the vote is cast. */
1017
+ export function ArrowUpIcon({ size = 16, color, filled, ...props }: ToggleIconProps) {
1018
+ const resolved = useResolvedColor(color, '#737373');
1019
+ return (
1020
+ <Svg width={size} height={size} viewBox="0 0 24 24" fill="none" {...props}>
1021
+ <Path
1022
+ d="M9 18v-6H5l7-7 7 7h-4v6z"
1023
+ fill={filled ? resolved : 'none'}
1024
+ stroke={resolved}
1025
+ strokeWidth={2}
1026
+ strokeLinecap="round"
1027
+ strokeLinejoin="round"
1028
+ />
1029
+ </Svg>
1030
+ );
1031
+ }
1032
+
1033
+ /** Downvote. The same arrow, turned over. */
1034
+ export function ArrowDownIcon({ size = 16, color, filled, ...props }: ToggleIconProps) {
1035
+ const resolved = useResolvedColor(color, '#737373');
1036
+ return (
1037
+ <Svg width={size} height={size} viewBox="0 0 24 24" fill="none" {...props}>
1038
+ <Path
1039
+ d="M9 6v6H5l7 7 7-7h-4V6z"
1040
+ fill={filled ? resolved : 'none'}
1041
+ stroke={resolved}
1042
+ strokeWidth={2}
1043
+ strokeLinecap="round"
1044
+ strokeLinejoin="round"
1045
+ />
1046
+ </Svg>
1047
+ );
1048
+ }
1049
+
1050
+ /** A like. */
1051
+ export function HeartIcon({ size = 16, color, filled, ...props }: ToggleIconProps) {
1052
+ const resolved = useResolvedColor(color, '#737373');
1053
+ return (
1054
+ <Svg width={size} height={size} viewBox="0 0 24 24" fill="none" {...props}>
1055
+ <Path
1056
+ d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"
1057
+ fill={filled ? resolved : 'none'}
1058
+ stroke={resolved}
1059
+ strokeWidth={2}
1060
+ strokeLinecap="round"
1061
+ strokeLinejoin="round"
1062
+ />
1063
+ </Svg>
1064
+ );
1065
+ }
1066
+
1067
+ /** A reply, or the count of them. */
1068
+ export function MessageCircleIcon({ size = 16, color, filled, ...props }: ToggleIconProps) {
1069
+ const resolved = useResolvedColor(color, '#737373');
1070
+ return (
1071
+ <Svg width={size} height={size} viewBox="0 0 24 24" fill="none" {...props}>
1072
+ <Path
1073
+ d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"
1074
+ fill={filled ? resolved : 'none'}
1075
+ stroke={resolved}
1076
+ strokeWidth={2}
1077
+ strokeLinecap="round"
1078
+ strokeLinejoin="round"
1079
+ />
1080
+ </Svg>
1081
+ );
1082
+ }
1083
+
1084
+ /** Views — how many times a thing has been seen rather than acted on. */
1085
+ export function EyeIcon({ size = 16, color, ...props }: IconProps) {
1086
+ const resolved = useResolvedColor(color, '#737373');
1087
+ return (
1088
+ <Svg width={size} height={size} viewBox="0 0 24 24" fill="none" {...props}>
1089
+ <Path
1090
+ d="M2.06 12.35a1 1 0 0 1 0-.7 10.75 10.75 0 0 1 19.88 0 1 1 0 0 1 0 .7 10.75 10.75 0 0 1-19.88 0"
1091
+ stroke={resolved}
1092
+ strokeWidth={2}
1093
+ strokeLinecap="round"
1094
+ strokeLinejoin="round"
1095
+ />
1096
+ <Circle cx={12} cy={12} r={3} stroke={resolved} strokeWidth={2} />
1097
+ </Svg>
1098
+ );
1099
+ }
1100
+
1101
+ /** A repost — the same thing sent round again. */
1102
+ export function RepeatIcon({ size = 16, color, ...props }: IconProps) {
1103
+ const resolved = useResolvedColor(color, '#737373');
1104
+ return (
1105
+ <Svg width={size} height={size} viewBox="0 0 24 24" fill="none" {...props}>
1106
+ <Path
1107
+ d="m2 9 3-3 3 3M5 6v10a2 2 0 0 0 2 2h6M22 15l-3 3-3-3M19 18V8a2 2 0 0 0-2-2h-6"
1108
+ stroke={resolved}
1109
+ strokeWidth={2}
1110
+ strokeLinecap="round"
1111
+ strokeLinejoin="round"
1112
+ />
1113
+ </Svg>
1114
+ );
1115
+ }
1116
+
1117
+ export interface BadgeCheckIconProps extends IconProps {
1118
+ /** The tick's colour when the rosette is solid. Defaults to white. */
1119
+ checkColor?: string;
1120
+ }
1121
+
1122
+ /**
1123
+ * The verified rosette.
1124
+ *
1125
+ * Solid by default, unlike everything else here. A verification mark is a
1126
+ * claim about the account rather than a control, and an outlined one next to
1127
+ * an outlined like button reads as another thing you could press.
1128
+ */
1129
+ export function BadgeCheckIcon({
1130
+ size = 16,
1131
+ color,
1132
+ checkColor = '#ffffff',
1133
+ ...props
1134
+ }: BadgeCheckIconProps) {
1135
+ const resolved = useResolvedColor(color, '#737373');
1136
+ return (
1137
+ <Svg width={size} height={size} viewBox="0 0 24 24" fill="none" {...props}>
1138
+ <Path
1139
+ d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"
1140
+ fill={resolved}
1141
+ />
1142
+ <Path
1143
+ d="m9 12 2 2 4-4"
1144
+ stroke={checkColor}
1145
+ strokeWidth={2.5}
1146
+ strokeLinecap="round"
1147
+ strokeLinejoin="round"
1148
+ />
1149
+ </Svg>
1150
+ );
1151
+ }
package/src/index.ts CHANGED
@@ -254,6 +254,20 @@ export {
254
254
  type TabsTriggerProps,
255
255
  type TabsContentProps,
256
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';
257
271
  export {
258
272
  Carousel,
259
273
  useCarouselState,
@@ -290,6 +304,18 @@ export {
290
304
  type ChipVariant,
291
305
  type ChipSize,
292
306
  } from './components/chip';
307
+ export {
308
+ Drawer,
309
+ type DrawerProps,
310
+ type DrawerTriggerProps,
311
+ type DrawerContentProps,
312
+ type DrawerHeaderProps,
313
+ type DrawerBodyProps,
314
+ type DrawerFooterProps,
315
+ type DrawerCloseProps,
316
+ type DrawerSide,
317
+ type DrawerSize,
318
+ } from './components/drawer';
293
319
  export {
294
320
  EmptyState,
295
321
  type EmptyStateProps,
@@ -335,6 +361,26 @@ export {
335
361
  type NumberInputProps,
336
362
  } from './components/number-input';
337
363
  export { OtpInput, type OtpInputProps } from './components/otp-input';
364
+ export {
365
+ Panelside,
366
+ usePanelside,
367
+ type PanelsideProps,
368
+ type PanelsidePanelProps,
369
+ type PanelsideHeaderProps,
370
+ type PanelsideSearchProps,
371
+ type PanelsideContentProps,
372
+ type PanelsideGroupProps,
373
+ type PanelsideGroupLabelProps,
374
+ type PanelsideItemProps,
375
+ type PanelsideActionProps,
376
+ type PanelsideFooterProps,
377
+ type PanelsideCtaProps,
378
+ type PanelsideSceneProps,
379
+ type PanelsideTriggerProps,
380
+ type PanelsideMode,
381
+ type PanelsideSwipeFrom,
382
+ type UsePanelsideResult,
383
+ } from './components/panelside';
338
384
  export { Textarea, type TextareaProps } from './components/textarea';
339
385
  export {
340
386
  Item,
@@ -431,6 +477,21 @@ export {
431
477
  type PopoverAlign,
432
478
  type PopoverPresentation,
433
479
  } from './components/popover';
480
+ export {
481
+ Post,
482
+ type PostProps,
483
+ type PostHeaderProps,
484
+ type PostAuthorProps,
485
+ type PostActionProps,
486
+ type PostCommunityProps,
487
+ type PostTitleProps,
488
+ type PostBodyProps,
489
+ type PostMediaProps,
490
+ type PostFooterProps,
491
+ type PostStatProps,
492
+ type PostVotesProps,
493
+ type PostVote,
494
+ } from './components/post';
434
495
  export { Progress, type ProgressProps } from './components/progress';
435
496
  export {
436
497
  RadioGroup,
@@ -476,6 +537,13 @@ export {
476
537
  type ReasoningTriggerProps,
477
538
  type ReasoningContentProps,
478
539
  } from './components/reasoning';
540
+ export {
541
+ Response,
542
+ type ResponseProps,
543
+ type ResponseComponents,
544
+ type ResponseBlock,
545
+ type ResponseInline,
546
+ } from './components/response';
479
547
  export {
480
548
  Sources,
481
549
  type SourcesProps,
@@ -509,11 +577,17 @@ export {
509
577
  Plan,
510
578
  type PlanProps,
511
579
  type PlanHeaderProps,
580
+ type PlanIconProps,
512
581
  type PlanTitleProps,
513
582
  type PlanDescriptionProps,
514
583
  type PlanActionProps,
584
+ type PlanProgressProps,
515
585
  type PlanTriggerProps,
516
586
  type PlanContentProps,
587
+ type PlanStepsProps,
588
+ type PlanStepProps,
589
+ type PlanStepStatus,
590
+ type PlanStepCounts,
517
591
  type PlanFooterProps,
518
592
  } from './components/plan';
519
593
  export {
@@ -596,7 +670,10 @@ export {
596
670
  export {
597
671
  AlertTriangleIcon,
598
672
  AppleIcon,
673
+ ArrowDownIcon,
674
+ ArrowUpIcon,
599
675
  ArrowUpRightIcon,
676
+ BadgeCheckIcon,
600
677
  BellIcon,
601
678
  BookmarkIcon,
602
679
  CalendarIcon,
@@ -607,16 +684,26 @@ export {
607
684
  ChevronLeftIcon,
608
685
  ChevronRightIcon,
609
686
  ChevronUpIcon,
687
+ CircleIcon,
688
+ CompassIcon,
689
+ CopyIcon,
690
+ CrosshairIcon,
610
691
  DownloadIcon,
611
692
  EllipsisIcon,
693
+ EyeIcon,
612
694
  FacebookIcon,
613
695
  FileIcon,
614
696
  GoogleIcon,
697
+ HeartIcon,
615
698
  IconColorProvider,
616
699
  InfoIcon,
617
700
  ImageIcon,
701
+ LinkIcon,
702
+ ListChecksIcon,
618
703
  LockIcon,
619
704
  MaximizeIcon,
705
+ MenuIcon,
706
+ MessageCircleIcon,
620
707
  MicIcon,
621
708
  MinusIcon,
622
709
  MoonIcon,
@@ -628,18 +715,24 @@ export {
628
715
  PlusIcon,
629
716
  PlusSquareIcon,
630
717
  ReceiptIcon,
718
+ RepeatIcon,
631
719
  RotateCcwIcon,
720
+ RotateCwIcon,
632
721
  SearchIcon,
633
722
  SendIcon,
634
723
  ShareNodesIcon,
635
724
  ShieldAlertIcon,
636
725
  ShieldCheckIcon,
726
+ SparklesIcon,
727
+ StarIcon,
637
728
  SunIcon,
638
729
  TrashIcon,
639
730
  UnlockIcon,
640
731
  XIcon,
641
732
  useIconColor,
642
733
  type IconProps,
734
+ type ToggleIconProps,
735
+ type BadgeCheckIconProps,
643
736
  } from './icons';
644
737
 
645
738
  // Hooks
@@ -97,3 +97,54 @@ export function getNativeUI(): NativeUIModule | null {
97
97
  export function hasNativeUI(): boolean {
98
98
  return getNativeUI() !== null;
99
99
  }
100
+
101
+ /**
102
+ * The SwiftUI view modifiers, for the handful of things the portable props
103
+ * cannot express.
104
+ *
105
+ * The universal components cover the common ground — a variant, a size, an
106
+ * enabled flag — but the platform has looks with no cross-platform equivalent,
107
+ * and Liquid Glass is the one that matters: it is the material iOS 26 draws its
108
+ * own floating controls in, and nothing in the portable API asks for it. A
109
+ * modifier is how SwiftUI is configured, so this is the door to it.
110
+ *
111
+ * iOS only, and lazily resolved like everything else here. Android's toolkit
112
+ * has its own modifier system and no equivalent material, so asking for glass
113
+ * there is not a downgrade — it is a different question.
114
+ */
115
+ interface SwiftUIModifiers {
116
+ buttonStyle: (
117
+ style:
118
+ | 'automatic'
119
+ | 'bordered'
120
+ | 'borderedProminent'
121
+ | 'borderless'
122
+ | 'glass'
123
+ | 'glassProminent'
124
+ | 'plain'
125
+ ) => unknown;
126
+ buttonBorderShape: (
127
+ shape: 'automatic' | 'capsule' | 'roundedRectangle' | 'circle',
128
+ cornerRadius?: number
129
+ ) => unknown;
130
+ controlSize: (size: 'mini' | 'small' | 'regular' | 'large' | 'extraLarge') => unknown;
131
+ }
132
+
133
+ let modifiersResolved = false;
134
+ let modifiers: SwiftUIModifiers | null = null;
135
+
136
+ export function getSwiftUIModifiers(): SwiftUIModifiers | null {
137
+ if (modifiersResolved) return modifiers;
138
+ modifiersResolved = true;
139
+
140
+ if (Platform.OS !== 'ios') return null;
141
+
142
+ try {
143
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
144
+ modifiers = require('@expo/ui/swift-ui/modifiers') as SwiftUIModifiers;
145
+ } catch {
146
+ modifiers = null;
147
+ }
148
+
149
+ return modifiers;
150
+ }
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
  });