panelui-native 0.30.0 → 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 (61) hide show
  1. package/README.md +4 -2
  2. package/lib/module/components/calendar/index.js +107 -24
  3. package/lib/module/components/calendar/index.js.map +1 -1
  4. package/lib/module/components/heatmap-chart/index.js +9 -8
  5. package/lib/module/components/heatmap-chart/index.js.map +1 -1
  6. package/lib/module/components/map/index.js +50 -4
  7. package/lib/module/components/map/index.js.map +1 -1
  8. package/lib/module/components/plan/index.js +307 -21
  9. package/lib/module/components/plan/index.js.map +1 -1
  10. package/lib/module/components/post/index.js +992 -0
  11. package/lib/module/components/post/index.js.map +1 -0
  12. package/lib/module/components/response/index.js +296 -0
  13. package/lib/module/components/response/index.js.map +1 -0
  14. package/lib/module/components/response/markdown.js +463 -0
  15. package/lib/module/components/response/markdown.js.map +1 -0
  16. package/lib/module/components/select/index.js +97 -15
  17. package/lib/module/components/select/index.js.map +1 -1
  18. package/lib/module/components/table/index.js +632 -0
  19. package/lib/module/components/table/index.js.map +1 -0
  20. package/lib/module/icons/index.js +200 -0
  21. package/lib/module/icons/index.js.map +1 -1
  22. package/lib/module/index.js +4 -1
  23. package/lib/module/index.js.map +1 -1
  24. package/lib/module/utils/date.js +129 -22
  25. package/lib/module/utils/date.js.map +1 -1
  26. package/lib/typescript/src/components/calendar/index.d.ts +12 -3
  27. package/lib/typescript/src/components/calendar/index.d.ts.map +1 -1
  28. package/lib/typescript/src/components/heatmap-chart/index.d.ts.map +1 -1
  29. package/lib/typescript/src/components/map/index.d.ts +8 -1
  30. package/lib/typescript/src/components/map/index.d.ts.map +1 -1
  31. package/lib/typescript/src/components/plan/index.d.ts +121 -10
  32. package/lib/typescript/src/components/plan/index.d.ts.map +1 -1
  33. package/lib/typescript/src/components/post/index.d.ts +358 -0
  34. package/lib/typescript/src/components/post/index.d.ts.map +1 -0
  35. package/lib/typescript/src/components/response/index.d.ts +91 -0
  36. package/lib/typescript/src/components/response/index.d.ts.map +1 -0
  37. package/lib/typescript/src/components/response/markdown.d.ts +88 -0
  38. package/lib/typescript/src/components/response/markdown.d.ts.map +1 -0
  39. package/lib/typescript/src/components/select/index.d.ts +27 -2
  40. package/lib/typescript/src/components/select/index.d.ts.map +1 -1
  41. package/lib/typescript/src/components/table/index.d.ts +274 -0
  42. package/lib/typescript/src/components/table/index.d.ts.map +1 -0
  43. package/lib/typescript/src/icons/index.d.ts +40 -1
  44. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  45. package/lib/typescript/src/index.d.ts +5 -2
  46. package/lib/typescript/src/index.d.ts.map +1 -1
  47. package/lib/typescript/src/utils/date.d.ts +49 -5
  48. package/lib/typescript/src/utils/date.d.ts.map +1 -1
  49. package/package.json +1 -1
  50. package/src/components/calendar/index.tsx +136 -23
  51. package/src/components/heatmap-chart/index.tsx +9 -9
  52. package/src/components/map/index.tsx +53 -4
  53. package/src/components/plan/index.tsx +344 -21
  54. package/src/components/post/index.tsx +1116 -0
  55. package/src/components/response/index.tsx +390 -0
  56. package/src/components/response/markdown.ts +489 -0
  57. package/src/components/select/index.tsx +132 -12
  58. package/src/components/table/index.tsx +747 -0
  59. package/src/icons/index.tsx +162 -1
  60. package/src/index.ts +60 -0
  61. 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"
@@ -974,3 +980,158 @@ export function CircleIcon({ size = 16, color, ...props }: IconProps) {
974
980
  </Svg>
975
981
  );
976
982
  }
983
+
984
+ /* -------------------------------------------------------------------------- */
985
+ /* Social */
986
+ /* -------------------------------------------------------------------------- */
987
+
988
+ /**
989
+ * The icons a post's action row is made of.
990
+ *
991
+ * These are the only ones in the set that take `filled`, and it is not a
992
+ * stylistic choice: a like, a save and a vote are toggles, and the outline and
993
+ * the solid are the two states of one control. Drawn as two different icons
994
+ * they would swap shape under the finger; drawn as one that fills, the shape
995
+ * stays put and only the inside changes.
996
+ */
997
+ export interface ToggleIconProps extends IconProps {
998
+ /** Solid rather than outlined — the on state of a like, a save, a vote. */
999
+ filled?: boolean;
1000
+ }
1001
+
1002
+ /** Upvote. Filled once the vote is cast. */
1003
+ export function ArrowUpIcon({ size = 16, color, filled, ...props }: ToggleIconProps) {
1004
+ const resolved = useResolvedColor(color, '#737373');
1005
+ return (
1006
+ <Svg width={size} height={size} viewBox="0 0 24 24" fill="none" {...props}>
1007
+ <Path
1008
+ d="M9 18v-6H5l7-7 7 7h-4v6z"
1009
+ fill={filled ? resolved : 'none'}
1010
+ stroke={resolved}
1011
+ strokeWidth={2}
1012
+ strokeLinecap="round"
1013
+ strokeLinejoin="round"
1014
+ />
1015
+ </Svg>
1016
+ );
1017
+ }
1018
+
1019
+ /** Downvote. The same arrow, turned over. */
1020
+ export function ArrowDownIcon({ size = 16, color, filled, ...props }: ToggleIconProps) {
1021
+ const resolved = useResolvedColor(color, '#737373');
1022
+ return (
1023
+ <Svg width={size} height={size} viewBox="0 0 24 24" fill="none" {...props}>
1024
+ <Path
1025
+ d="M9 6v6H5l7 7 7-7h-4V6z"
1026
+ fill={filled ? resolved : 'none'}
1027
+ stroke={resolved}
1028
+ strokeWidth={2}
1029
+ strokeLinecap="round"
1030
+ strokeLinejoin="round"
1031
+ />
1032
+ </Svg>
1033
+ );
1034
+ }
1035
+
1036
+ /** A like. */
1037
+ export function HeartIcon({ size = 16, color, filled, ...props }: ToggleIconProps) {
1038
+ const resolved = useResolvedColor(color, '#737373');
1039
+ return (
1040
+ <Svg width={size} height={size} viewBox="0 0 24 24" fill="none" {...props}>
1041
+ <Path
1042
+ 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"
1043
+ fill={filled ? resolved : 'none'}
1044
+ stroke={resolved}
1045
+ strokeWidth={2}
1046
+ strokeLinecap="round"
1047
+ strokeLinejoin="round"
1048
+ />
1049
+ </Svg>
1050
+ );
1051
+ }
1052
+
1053
+ /** A reply, or the count of them. */
1054
+ export function MessageCircleIcon({ size = 16, color, filled, ...props }: ToggleIconProps) {
1055
+ const resolved = useResolvedColor(color, '#737373');
1056
+ return (
1057
+ <Svg width={size} height={size} viewBox="0 0 24 24" fill="none" {...props}>
1058
+ <Path
1059
+ d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"
1060
+ fill={filled ? resolved : 'none'}
1061
+ stroke={resolved}
1062
+ strokeWidth={2}
1063
+ strokeLinecap="round"
1064
+ strokeLinejoin="round"
1065
+ />
1066
+ </Svg>
1067
+ );
1068
+ }
1069
+
1070
+ /** Views — how many times a thing has been seen rather than acted on. */
1071
+ export function EyeIcon({ size = 16, color, ...props }: IconProps) {
1072
+ const resolved = useResolvedColor(color, '#737373');
1073
+ return (
1074
+ <Svg width={size} height={size} viewBox="0 0 24 24" fill="none" {...props}>
1075
+ <Path
1076
+ 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"
1077
+ stroke={resolved}
1078
+ strokeWidth={2}
1079
+ strokeLinecap="round"
1080
+ strokeLinejoin="round"
1081
+ />
1082
+ <Circle cx={12} cy={12} r={3} stroke={resolved} strokeWidth={2} />
1083
+ </Svg>
1084
+ );
1085
+ }
1086
+
1087
+ /** A repost — the same thing sent round again. */
1088
+ export function RepeatIcon({ size = 16, color, ...props }: IconProps) {
1089
+ const resolved = useResolvedColor(color, '#737373');
1090
+ return (
1091
+ <Svg width={size} height={size} viewBox="0 0 24 24" fill="none" {...props}>
1092
+ <Path
1093
+ 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"
1094
+ stroke={resolved}
1095
+ strokeWidth={2}
1096
+ strokeLinecap="round"
1097
+ strokeLinejoin="round"
1098
+ />
1099
+ </Svg>
1100
+ );
1101
+ }
1102
+
1103
+ export interface BadgeCheckIconProps extends IconProps {
1104
+ /** The tick's colour when the rosette is solid. Defaults to white. */
1105
+ checkColor?: string;
1106
+ }
1107
+
1108
+ /**
1109
+ * The verified rosette.
1110
+ *
1111
+ * Solid by default, unlike everything else here. A verification mark is a
1112
+ * claim about the account rather than a control, and an outlined one next to
1113
+ * an outlined like button reads as another thing you could press.
1114
+ */
1115
+ export function BadgeCheckIcon({
1116
+ size = 16,
1117
+ color,
1118
+ checkColor = '#ffffff',
1119
+ ...props
1120
+ }: BadgeCheckIconProps) {
1121
+ const resolved = useResolvedColor(color, '#737373');
1122
+ return (
1123
+ <Svg width={size} height={size} viewBox="0 0 24 24" fill="none" {...props}>
1124
+ <Path
1125
+ 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"
1126
+ fill={resolved}
1127
+ />
1128
+ <Path
1129
+ d="m9 12 2 2 4-4"
1130
+ stroke={checkColor}
1131
+ strokeWidth={2.5}
1132
+ strokeLinecap="round"
1133
+ strokeLinejoin="round"
1134
+ />
1135
+ </Svg>
1136
+ );
1137
+ }
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,
@@ -431,6 +445,21 @@ export {
431
445
  type PopoverAlign,
432
446
  type PopoverPresentation,
433
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';
434
463
  export { Progress, type ProgressProps } from './components/progress';
435
464
  export {
436
465
  RadioGroup,
@@ -476,6 +505,13 @@ export {
476
505
  type ReasoningTriggerProps,
477
506
  type ReasoningContentProps,
478
507
  } from './components/reasoning';
508
+ export {
509
+ Response,
510
+ type ResponseProps,
511
+ type ResponseComponents,
512
+ type ResponseBlock,
513
+ type ResponseInline,
514
+ } from './components/response';
479
515
  export {
480
516
  Sources,
481
517
  type SourcesProps,
@@ -509,11 +545,17 @@ export {
509
545
  Plan,
510
546
  type PlanProps,
511
547
  type PlanHeaderProps,
548
+ type PlanIconProps,
512
549
  type PlanTitleProps,
513
550
  type PlanDescriptionProps,
514
551
  type PlanActionProps,
552
+ type PlanProgressProps,
515
553
  type PlanTriggerProps,
516
554
  type PlanContentProps,
555
+ type PlanStepsProps,
556
+ type PlanStepProps,
557
+ type PlanStepStatus,
558
+ type PlanStepCounts,
517
559
  type PlanFooterProps,
518
560
  } from './components/plan';
519
561
  export {
@@ -596,7 +638,10 @@ export {
596
638
  export {
597
639
  AlertTriangleIcon,
598
640
  AppleIcon,
641
+ ArrowDownIcon,
642
+ ArrowUpIcon,
599
643
  ArrowUpRightIcon,
644
+ BadgeCheckIcon,
600
645
  BellIcon,
601
646
  BookmarkIcon,
602
647
  CalendarIcon,
@@ -607,16 +652,25 @@ export {
607
652
  ChevronLeftIcon,
608
653
  ChevronRightIcon,
609
654
  ChevronUpIcon,
655
+ CircleIcon,
656
+ CompassIcon,
657
+ CopyIcon,
658
+ CrosshairIcon,
610
659
  DownloadIcon,
611
660
  EllipsisIcon,
661
+ EyeIcon,
612
662
  FacebookIcon,
613
663
  FileIcon,
614
664
  GoogleIcon,
665
+ HeartIcon,
615
666
  IconColorProvider,
616
667
  InfoIcon,
617
668
  ImageIcon,
669
+ LinkIcon,
670
+ ListChecksIcon,
618
671
  LockIcon,
619
672
  MaximizeIcon,
673
+ MessageCircleIcon,
620
674
  MicIcon,
621
675
  MinusIcon,
622
676
  MoonIcon,
@@ -628,18 +682,24 @@ export {
628
682
  PlusIcon,
629
683
  PlusSquareIcon,
630
684
  ReceiptIcon,
685
+ RepeatIcon,
631
686
  RotateCcwIcon,
687
+ RotateCwIcon,
632
688
  SearchIcon,
633
689
  SendIcon,
634
690
  ShareNodesIcon,
635
691
  ShieldAlertIcon,
636
692
  ShieldCheckIcon,
693
+ SparklesIcon,
694
+ StarIcon,
637
695
  SunIcon,
638
696
  TrashIcon,
639
697
  UnlockIcon,
640
698
  XIcon,
641
699
  useIconColor,
642
700
  type IconProps,
701
+ type ToggleIconProps,
702
+ type BadgeCheckIconProps,
643
703
  } from './icons';
644
704
 
645
705
  // Hooks
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
  });