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
@@ -1,5 +1,5 @@
1
1
  import { forwardRef, type ReactNode } from 'react';
2
- import type { View } from 'react-native';
2
+ import { View } from 'react-native';
3
3
  import { tv, type VariantProps } from 'tailwind-variants';
4
4
  import { useCSSVariable } from 'uniwind';
5
5
  import {
@@ -8,7 +8,7 @@ import {
8
8
  } from '../../primitives/animated-pressable';
9
9
  import { Text, textChildren } from '../../primitives/text';
10
10
  import { IconColorProvider } from '../../icons';
11
- import { getNativeUI } from '../../native';
11
+ import { getNativeUI, getSwiftUIModifiers } from '../../native';
12
12
  import { Spinner } from '../spinner';
13
13
 
14
14
  const buttonVariants = tv({
@@ -118,8 +118,29 @@ export interface ButtonProps
118
118
  * A native button **sizes itself to its label**, the way a platform button
119
119
  * is supposed to. It does not stretch to fill its container, and `fullWidth`
120
120
  * has no effect on it.
121
+ *
122
+ * **Give it a string, not elements, unless `size` is `icon`.** A string
123
+ * becomes the platform's own label. Anything else has to be hosted inside
124
+ * the native tree, and a hosted view only measures where something above it
125
+ * is definite on *both* axes — which is true of an icon button, because it
126
+ * is a square this component sizes, and false of a labelled one, whose width
127
+ * is its text's and known to nobody in advance. Host a label without a width
128
+ * and the two layout systems ask each other the same question until the app
129
+ * dies, in native code, where a `try` here has nothing to catch.
121
130
  */
122
131
  native?: boolean;
132
+ /**
133
+ * Draw the native button in the platform's Liquid Glass material — the one
134
+ * iOS 26 uses for its own floating controls. Requires `native`, and iOS 26 or
135
+ * later; anywhere else it is ignored and the button keeps its ordinary
136
+ * platform style rather than failing.
137
+ *
138
+ * `primary` and `destructive` take the prominent variant, which keeps the
139
+ * accent tint a filled button is supposed to have; every other variant takes
140
+ * the plain one. An icon button is drawn round rather than in the platform's
141
+ * default capsule.
142
+ */
143
+ glass?: boolean;
123
144
  }
124
145
 
125
146
  /**
@@ -140,6 +161,51 @@ const NATIVE_HEIGHT: Record<NonNullable<ButtonVariantProps['size']>, number> = {
140
161
  icon: 44,
141
162
  };
142
163
 
164
+ /**
165
+ * Room around the glyph in a native icon button, applied to the glyph itself.
166
+ *
167
+ * Not to the button, and that distinction is the whole thing. SwiftUI's
168
+ * `padding` on a Button pads *outside* its background — the chrome stays the
169
+ * size it was and moves inward — and `frame` only sets the layout box, leaving
170
+ * a glyph-sized background centred in a larger invisible one. Neither grows
171
+ * the button. What the background is drawn around is the *label*, and the
172
+ * label here is a React Native view we host ourselves, so padding it in React
173
+ * is both the simplest lever and the only one that works.
174
+ */
175
+ const NATIVE_ICON_PADDING = 6;
176
+
177
+ /**
178
+ * The frame a native icon button is given, which must contain the padded glyph
179
+ * above with room to spare.
180
+ *
181
+ * It exists to end a measurement, not to set a look. The chain is
182
+ * `Host(matchContents)` → platform button → `RNHostView(matchContents)` → our
183
+ * view: every link sized by its contents, and nothing anywhere with a size of
184
+ * its own. Something has to be definite or the two layout systems ask each
185
+ * other the same question forever, and that fails down in the platform, where
186
+ * there is nothing for a JavaScript `try` to catch.
187
+ *
188
+ */
189
+ const NATIVE_ICON_FRAME = 44;
190
+
191
+ /**
192
+ * Our sizes on the platform's own control scale, which is how a native button
193
+ * is made bigger: it scales the room the style leaves around the label, and the
194
+ * label with it.
195
+ *
196
+ * Only ever sent for a *string* label. A hosted label is the case that has
197
+ * taken this app down twice, and while the cause turned out to be a missing
198
+ * frame rather than this modifier, a labelled button has no hosted view and no
199
+ * measurement to get wrong — so there is no reason to find out on both at once.
200
+ */
201
+ const NATIVE_CONTROL_SIZE = {
202
+ sm: 'small',
203
+ md: 'regular',
204
+ lg: 'large',
205
+ icon: 'regular',
206
+ } as const;
207
+
208
+
143
209
  /** PanelUI variants mapped onto the platform button styles. */
144
210
  const NATIVE_VARIANT: Record<
145
211
  NonNullable<ButtonVariantProps['variant']>,
@@ -167,6 +233,7 @@ export const Button = forwardRef<View, ButtonProps>(
167
233
  startContent,
168
234
  endContent,
169
235
  native,
236
+ glass = false,
170
237
  ...props
171
238
  },
172
239
  ref
@@ -198,6 +265,39 @@ export const Button = forwardRef<View, ButtonProps>(
198
265
  if (nativeUI) {
199
266
  const { Host, Button: NativeButton, RNHostView } = nativeUI;
200
267
  const isStringLabel = typeof children === 'string';
268
+ const prominent = variant === 'primary' || variant === 'destructive';
269
+
270
+ /*
271
+ * Looks the portable props cannot ask for.
272
+ *
273
+ * `buttonStyle` because Liquid Glass has no cross-platform variant to
274
+ * map onto. `glassProminent` is the tinted one — it keeps the accent
275
+ * fill a prominent button is supposed to have, which drawing the
276
+ * material by hand over a plain button throws away. A supplied
277
+ * `buttonStyle` replaces the one the variant would have set, so this is
278
+ * a substitution rather than a layer on top.
279
+ *
280
+ * `buttonBorderShape` because an icon button is round and the platform's
281
+ * default capsule is not — a lone glyph in a capsule reads as a text
282
+ * button somebody forgot to label. It shapes the glass too.
283
+ */
284
+ const swiftUI = getSwiftUIModifiers();
285
+ const nativeModifiers = swiftUI
286
+ ? [
287
+ glass ? swiftUI.buttonStyle(prominent ? 'glassProminent' : 'glass') : null,
288
+ size === 'icon' ? swiftUI.buttonBorderShape('circle') : null,
289
+ isStringLabel ? swiftUI.controlSize(NATIVE_CONTROL_SIZE[size ?? 'md']) : null,
290
+ ].filter(Boolean)
291
+ : [];
292
+
293
+ /*
294
+ * The platform paints the background, not the theme — so a hosted icon
295
+ * cannot read its colour from a token the way it does in the styled
296
+ * button. On a tinted button that is white, whatever the theme thinks its
297
+ * own primary foreground is; on the rest, including plain glass, the
298
+ * material is clear enough to leave the page's own foreground legible.
299
+ */
300
+ const nativeContent = prominent ? '#ffffff' : contentColor;
201
301
 
202
302
  return (
203
303
  <Host matchContents>
@@ -205,7 +305,22 @@ export const Button = forwardRef<View, ButtonProps>(
205
305
  label={isStringLabel ? children : undefined}
206
306
  variant={NATIVE_VARIANT[variant ?? 'primary']}
207
307
  disabled={isDisabled}
208
- style={{ height: NATIVE_HEIGHT[size ?? 'md'] }}
308
+ /*
309
+ * A square for an icon button, a height for a labelled one — and
310
+ * in both cases a definite number, which is what stops the
311
+ * measurement chain above from having no fixed point in it.
312
+ *
313
+ * The frame does not decide how big the *button* looks; the padded
314
+ * label does, because that is what the platform draws its
315
+ * background around. The frame only has to be large enough to hold
316
+ * the result.
317
+ */
318
+ style={
319
+ size === 'icon'
320
+ ? { width: NATIVE_ICON_FRAME, height: NATIVE_ICON_FRAME }
321
+ : { height: NATIVE_HEIGHT[size ?? 'md'] }
322
+ }
323
+ modifiers={nativeModifiers.length ? nativeModifiers : undefined}
209
324
  onPress={props.onPress}
210
325
  >
211
326
  {/* Non-string children are React Native views, and the native
@@ -215,11 +330,28 @@ export const Button = forwardRef<View, ButtonProps>(
215
330
  bare text once it is in there. */}
216
331
  {isStringLabel ? undefined : (
217
332
  <RNHostView matchContents>
218
- <>
219
- {textChildren(children, (text) => (
220
- <Text className={label({ className: labelClassName })}>{text}</Text>
221
- ))}
222
- </>
333
+ {/* Padding the hosted label is what grows the button, because
334
+ the platform draws its background around the label. The
335
+ centring is not redundant with it: a view lays a child out
336
+ from its leading edge, so an icon narrower than the line it
337
+ sits on ends up off to one side of a circle that is not. */}
338
+ <View
339
+ style={
340
+ size === 'icon'
341
+ ? {
342
+ padding: NATIVE_ICON_PADDING,
343
+ alignItems: 'center',
344
+ justifyContent: 'center',
345
+ }
346
+ : undefined
347
+ }
348
+ >
349
+ <IconColorProvider color={nativeContent}>
350
+ {textChildren(children, (text) => (
351
+ <Text className={label({ className: labelClassName })}>{text}</Text>
352
+ ))}
353
+ </IconColorProvider>
354
+ </View>
223
355
  </RNHostView>
224
356
  )}
225
357
  </NativeButton>
@@ -39,11 +39,12 @@ import {
39
39
  createContext,
40
40
  useCallback,
41
41
  useContext,
42
+ useEffect,
42
43
  useMemo,
43
44
  useRef,
44
45
  useState,
45
46
  } from 'react';
46
- import { Pressable, ScrollView, View, type ViewProps } from 'react-native';
47
+ import { AppState, Pressable, ScrollView, View, type ViewProps } from 'react-native';
47
48
  import { tv } from 'tailwind-variants';
48
49
  import { useCSSVariable } from 'uniwind';
49
50
  import {
@@ -57,17 +58,20 @@ import { cn } from '../../utils/cn';
57
58
  import { Popover } from '../popover';
58
59
  import {
59
60
  addCalendarMonths,
60
- addDays,
61
61
  calendarDayNumber,
62
62
  calendarLongDate,
63
63
  calendarMonthLabel,
64
64
  calendarMonthNames,
65
65
  calendarParts,
66
+ clampDate,
66
67
  isAfter,
67
68
  isBefore,
68
69
  isSameCalendarMonth,
69
70
  isSameDay,
70
71
  isWithin,
72
+ localeWeekStart,
73
+ monthGrid,
74
+ normalizeWeekStart,
71
75
  resolveCalendar,
72
76
  startOfCalendarMonth,
73
77
  startOfDay,
@@ -184,6 +188,35 @@ function useCalendar(component: string): CalendarContextValue {
184
188
  return context;
185
189
  }
186
190
 
191
+ /**
192
+ * Today, kept current.
193
+ *
194
+ * "Today" is not a constant, and a calendar is exactly the kind of thing left
195
+ * on screen across the boundary that changes it — a picker open in a sheet, an
196
+ * app resumed the next morning. Recomputed when the app comes back to the
197
+ * foreground, which is when a stale answer would otherwise become visible, and
198
+ * held as state so the marker actually moves when it does.
199
+ */
200
+ function useToday(): Date {
201
+ const [today, setToday] = useState(() => startOfDay(new Date()));
202
+
203
+ useEffect(() => {
204
+ const refresh = () => {
205
+ const now = startOfDay(new Date());
206
+ // A new object every resume would invalidate every memo below it for a
207
+ // date that has not changed.
208
+ setToday((current) => (current.getTime() === now.getTime() ? current : now));
209
+ };
210
+
211
+ const subscription = AppState.addEventListener('change', (state) => {
212
+ if (state === 'active') refresh();
213
+ });
214
+ return () => subscription.remove();
215
+ }, []);
216
+
217
+ return today;
218
+ }
219
+
187
220
  /** Whether a rule, a list or a span rules this day out. */
188
221
  function isDisabled(
189
222
  date: Date,
@@ -245,8 +278,17 @@ export interface CalendarProps<Mode extends CalendarMode = 'single'>
245
278
  startMonth?: Date;
246
279
  /** Latest month the caption's pickers offer. Defaults to `maxDate`, else ten years on. */
247
280
  endMonth?: Date;
248
- /** `0` is Sunday. */
249
- weekStartsOn?: number;
281
+ /**
282
+ * Which day the week's first column is. `0` is Sunday.
283
+ *
284
+ * `auto` asks the locale, which is what most of the world wants — `en-GB` and
285
+ * `fr-FR` start on Monday, and a calendar that says otherwise is wrong in a
286
+ * way its reader notices immediately. It is not the default only because
287
+ * changing an existing calendar's columns out from under it is not something
288
+ * a version bump should do. Out-of-range numbers wrap rather than producing
289
+ * a blank column.
290
+ */
291
+ weekStartsOn?: number | 'auto';
250
292
  /** Draw the neighbouring months' days rather than leaving the cells blank. */
251
293
  showOutsideDays?: boolean;
252
294
  /**
@@ -291,7 +333,7 @@ function CalendarRoot<Mode extends CalendarMode = 'single'>({
291
333
  maxDate,
292
334
  startMonth,
293
335
  endMonth,
294
- weekStartsOn = 0,
336
+ weekStartsOn: weekStartsOnProp = 0,
295
337
  showOutsideDays = true,
296
338
  selectOutsideDays = false,
297
339
  bordered = true,
@@ -302,6 +344,16 @@ function CalendarRoot<Mode extends CalendarMode = 'single'>({
302
344
  // Settled once here rather than at each call site, so every part of the
303
345
  // grid is certainly reading the same calendar.
304
346
  const system = useMemo(() => resolveCalendar(calendar, locale), [calendar, locale]);
347
+ // Settled once too, and normalised: everything below indexes an array of
348
+ // seven with it, where a negative or out-of-range value is a blank column
349
+ // rather than an error anyone would trace back to here.
350
+ const weekStartsOn = useMemo(
351
+ () =>
352
+ weekStartsOnProp === 'auto'
353
+ ? localeWeekStart(locale)
354
+ : normalizeWeekStart(weekStartsOnProp),
355
+ [weekStartsOnProp, locale]
356
+ );
305
357
  const [internalSelected, setInternalSelected] = useState<CalendarSelection[Mode] | undefined>(
306
358
  defaultSelected
307
359
  );
@@ -328,15 +380,52 @@ function CalendarRoot<Mode extends CalendarMode = 'single'>({
328
380
  }, []);
329
381
 
330
382
  const [internalMonth, setInternalMonth] = useState(initialMonth);
331
- const month = monthProp ? startOfCalendarMonth(monthProp, system, locale) : internalMonth;
383
+ /*
384
+ * Memoised on the *instant*, not on the prop. A controlled caller almost
385
+ * always passes a fresh `new Date(…)`, and deriving a new month object from
386
+ * it on every render invalidates the grid memo and the context memo below —
387
+ * so a calendar that has not moved rebuilds forty-two cells per keystroke
388
+ * somewhere else on the screen.
389
+ */
390
+ const controlledMonth = useMemo(
391
+ () => (monthProp ? startOfCalendarMonth(monthProp, system, locale) : undefined),
392
+ // eslint-disable-next-line react-hooks/exhaustive-deps
393
+ [monthProp?.getTime(), system, locale]
394
+ );
395
+ const month = controlledMonth ?? internalMonth;
396
+
397
+ /*
398
+ * How far the calendar may be paged, as the first day of the outermost month
399
+ * either way. `startMonth`/`endMonth` win where they are given, because they
400
+ * are the props about *navigation*; `minDate`/`maxDate` are about which days
401
+ * can be picked, and stand in when the navigation bounds are not stated.
402
+ */
403
+ const monthBounds = useMemo(() => {
404
+ const lower = startMonth ?? minDate;
405
+ const upper = endMonth ?? maxDate;
406
+ return {
407
+ from: lower ? startOfCalendarMonth(lower, system, locale) : undefined,
408
+ to: upper ? startOfCalendarMonth(upper, system, locale) : undefined,
409
+ };
410
+ }, [startMonth, minDate, endMonth, maxDate, system, locale]);
332
411
 
333
412
  const setMonth = useCallback(
334
413
  (next: Date) => {
335
- const settled = startOfCalendarMonth(next, system, locale);
414
+ /*
415
+ * Clamped here rather than only at the arrows. The arrows guard
416
+ * themselves, but the caption's dropdowns and a controlled caller both
417
+ * reach this by other routes — and a month list that offers a year is a
418
+ * poor place to discover that the year was out of bounds.
419
+ */
420
+ const settled = clampDate(
421
+ startOfCalendarMonth(next, system, locale),
422
+ monthBounds.from,
423
+ monthBounds.to
424
+ );
336
425
  if (!monthProp) setInternalMonth(settled);
337
426
  onMonthChange?.(settled);
338
427
  },
339
- [monthProp, onMonthChange, system, locale]
428
+ [monthProp, onMonthChange, system, locale, monthBounds]
340
429
  );
341
430
 
342
431
  const commit = useCallback(
@@ -351,6 +440,14 @@ function CalendarRoot<Mode extends CalendarMode = 'single'>({
351
440
  (date: Date) => {
352
441
  const day = startOfDay(date);
353
442
 
443
+ /*
444
+ * A tap on a neighbouring month's cell — which only happens with
445
+ * `selectOutsideDays` — commits a date the grid is not showing. Page to
446
+ * it, or the selection lands in a greyed-out cell of the month you are
447
+ * still looking at and reads as having missed.
448
+ */
449
+ if (!isSameCalendarMonth(day, month, system, locale)) setMonth(day);
450
+
354
451
  if (mode === 'multiple') {
355
452
  const current = (value as Date[] | undefined) ?? [];
356
453
  const without = current.filter((picked) => !isSameDay(picked, day));
@@ -379,7 +476,7 @@ function CalendarRoot<Mode extends CalendarMode = 'single'>({
379
476
  const current = value as Date | undefined;
380
477
  commit((isSameDay(current, day) ? undefined : day) as CalendarSelection[Mode]);
381
478
  },
382
- [mode, value, commit]
479
+ [mode, value, commit, month, system, locale, setMonth]
383
480
  );
384
481
 
385
482
  const context = useMemo(
@@ -563,10 +660,30 @@ function CalendarDropdowns({ month, offset }: { month: Date; offset: number }) {
563
660
  */
564
661
  const first = startMonth ?? minDate;
565
662
  const last = endMonth ?? maxDate;
663
+ const firstParts = first ? calendarParts(first, system, locale) : null;
664
+ const lastParts = last ? calendarParts(last, system, locale) : null;
566
665
  const bounds = {
567
- from: first ? calendarParts(first, system, locale).year : current.year - 100,
568
- to: last ? calendarParts(last, system, locale).year : current.year + 10,
666
+ from: firstParts ? firstParts.year : current.year - 100,
667
+ to: lastParts ? lastParts.year : current.year + 10,
569
668
  };
669
+ /*
670
+ * The month list is bounded too, and only the year list used to be — so a
671
+ * calendar starting in March 1925 still offered January and February of 1925,
672
+ * and picking one navigated somewhere the calendar had said it would not go.
673
+ * Only the outermost year of each bound loses months; every year between them
674
+ * has all twelve.
675
+ */
676
+ const monthOptions = months
677
+ .map((label, index) => ({ label, month: index + 1 }))
678
+ .filter(({ month }) => {
679
+ if (firstParts && current.year === firstParts.year && month < firstParts.month) {
680
+ return false;
681
+ }
682
+ if (lastParts && current.year === lastParts.year && month > lastParts.month) {
683
+ return false;
684
+ }
685
+ return true;
686
+ });
570
687
  /*
571
688
  * Oldest first, the way a year runs. The list is long — a century by default
572
689
  * — so it is opened scrolled to the year already in the caption rather than
@@ -594,10 +711,10 @@ function CalendarDropdowns({ month, offset }: { month: Date; offset: number }) {
594
711
  <CaptionDropdown
595
712
  label={months[current.month - 1] ?? String(current.month)}
596
713
  accessibilityLabel="Month"
597
- options={months}
598
- active={current.month - 1}
714
+ options={monthOptions.map((option) => option.label)}
715
+ active={monthOptions.findIndex((option) => option.month === current.month)}
599
716
  width={MONTH_LIST_WIDTH}
600
- onSelect={(index) => choose(index - (current.month - 1))}
717
+ onSelect={(index) => choose((monthOptions[index]?.month ?? current.month) - current.month)}
601
718
  />
602
719
  <CaptionDropdown
603
720
  label={String(current.year)}
@@ -787,16 +904,12 @@ function CalendarGrid({
787
904
  * boundaries and the day numbers differ — so the walk itself is the same
788
905
  * either way and only where it starts moves.
789
906
  */
790
- const weeks = useMemo(() => {
791
- const first = startOfCalendarMonth(month, system, locale);
792
- const lead = (first.getDay() - weekStartsOn + 7) % 7;
793
- const start = addDays(first, -lead);
794
- return Array.from({ length: 6 }, (_unusedWeek, week) =>
795
- Array.from({ length: 7 }, (_unusedDay, day) => addDays(start, week * 7 + day))
796
- );
797
- }, [month, weekStartsOn, system, locale]);
907
+ const weeks = useMemo(
908
+ () => monthGrid(month, weekStartsOn, system, locale),
909
+ [month, weekStartsOn, system, locale]
910
+ );
798
911
  const headings = useMemo(() => weekdayNames(locale, weekStartsOn), [locale, weekStartsOn]);
799
- const today = startOfDay(new Date());
912
+ const today = useToday();
800
913
 
801
914
  const range = mode === 'range' ? (value as DateRange | undefined) : undefined;
802
915
  const multiple = mode === 'multiple' ? ((value as Date[] | undefined) ?? []) : [];