@cdx-ui/components 0.2.5 → 0.3.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 (75) hide show
  1. package/docs/components/bottom-sheet/content.md +2 -0
  2. package/docs/components/calendar/content.md +25 -1
  3. package/docs/components/month-calendar/content.md +2 -0
  4. package/docs/components/range-calendar/content.md +3 -1
  5. package/docs/llms.txt +1 -0
  6. package/docs/resources/release-notes/0.2.5/content.md +25 -0
  7. package/docs/resources/release-notes/content.md +1 -0
  8. package/lib/commonjs/components/BottomSheet/index.js +25 -1
  9. package/lib/commonjs/components/BottomSheet/index.js.map +1 -1
  10. package/lib/commonjs/components/BottomSheet/styles.js +8 -2
  11. package/lib/commonjs/components/BottomSheet/styles.js.map +1 -1
  12. package/lib/commonjs/components/Calendar/CalendarMonthHeader.js +15 -5
  13. package/lib/commonjs/components/Calendar/CalendarMonthHeader.js.map +1 -1
  14. package/lib/commonjs/components/Calendar/CalendarYearList.js +116 -0
  15. package/lib/commonjs/components/Calendar/CalendarYearList.js.map +1 -0
  16. package/lib/commonjs/components/Calendar/CalendarYearPickerGrid.js +58 -31
  17. package/lib/commonjs/components/Calendar/CalendarYearPickerGrid.js.map +1 -1
  18. package/lib/commonjs/components/Calendar/flashCalendarAdapter.js +22 -3
  19. package/lib/commonjs/components/Calendar/flashCalendarAdapter.js.map +1 -1
  20. package/lib/commonjs/components/Calendar/index.js +6 -2
  21. package/lib/commonjs/components/Calendar/index.js.map +1 -1
  22. package/lib/commonjs/components/Calendar/styles.js +16 -2
  23. package/lib/commonjs/components/Calendar/styles.js.map +1 -1
  24. package/lib/commonjs/components/MonthCalendar/index.js +39 -13
  25. package/lib/commonjs/components/MonthCalendar/index.js.map +1 -1
  26. package/lib/commonjs/components/RangeCalendar/index.js +6 -2
  27. package/lib/commonjs/components/RangeCalendar/index.js.map +1 -1
  28. package/lib/module/components/BottomSheet/index.js +27 -3
  29. package/lib/module/components/BottomSheet/index.js.map +1 -1
  30. package/lib/module/components/BottomSheet/styles.js +8 -2
  31. package/lib/module/components/BottomSheet/styles.js.map +1 -1
  32. package/lib/module/components/Calendar/CalendarMonthHeader.js +15 -5
  33. package/lib/module/components/Calendar/CalendarMonthHeader.js.map +1 -1
  34. package/lib/module/components/Calendar/CalendarYearList.js +112 -0
  35. package/lib/module/components/Calendar/CalendarYearList.js.map +1 -0
  36. package/lib/module/components/Calendar/CalendarYearPickerGrid.js +61 -34
  37. package/lib/module/components/Calendar/CalendarYearPickerGrid.js.map +1 -1
  38. package/lib/module/components/Calendar/flashCalendarAdapter.js +20 -2
  39. package/lib/module/components/Calendar/flashCalendarAdapter.js.map +1 -1
  40. package/lib/module/components/Calendar/index.js +7 -3
  41. package/lib/module/components/Calendar/index.js.map +1 -1
  42. package/lib/module/components/Calendar/styles.js +15 -1
  43. package/lib/module/components/Calendar/styles.js.map +1 -1
  44. package/lib/module/components/MonthCalendar/index.js +42 -15
  45. package/lib/module/components/MonthCalendar/index.js.map +1 -1
  46. package/lib/module/components/RangeCalendar/index.js +7 -3
  47. package/lib/module/components/RangeCalendar/index.js.map +1 -1
  48. package/lib/typescript/components/BottomSheet/index.d.ts +8 -0
  49. package/lib/typescript/components/BottomSheet/index.d.ts.map +1 -1
  50. package/lib/typescript/components/BottomSheet/styles.d.ts +5 -1
  51. package/lib/typescript/components/BottomSheet/styles.d.ts.map +1 -1
  52. package/lib/typescript/components/Calendar/CalendarMonthHeader.d.ts.map +1 -1
  53. package/lib/typescript/components/Calendar/CalendarYearList.d.ts +23 -0
  54. package/lib/typescript/components/Calendar/CalendarYearList.d.ts.map +1 -0
  55. package/lib/typescript/components/Calendar/CalendarYearPickerGrid.d.ts +5 -1
  56. package/lib/typescript/components/Calendar/CalendarYearPickerGrid.d.ts.map +1 -1
  57. package/lib/typescript/components/Calendar/flashCalendarAdapter.d.ts +8 -0
  58. package/lib/typescript/components/Calendar/flashCalendarAdapter.d.ts.map +1 -1
  59. package/lib/typescript/components/Calendar/index.d.ts.map +1 -1
  60. package/lib/typescript/components/Calendar/styles.d.ts +7 -1
  61. package/lib/typescript/components/Calendar/styles.d.ts.map +1 -1
  62. package/lib/typescript/components/MonthCalendar/index.d.ts.map +1 -1
  63. package/lib/typescript/components/RangeCalendar/index.d.ts.map +1 -1
  64. package/lib/typescript/components/ToggleButton/styles.d.ts +1 -1
  65. package/package.json +5 -5
  66. package/src/components/BottomSheet/index.tsx +174 -138
  67. package/src/components/BottomSheet/styles.ts +11 -3
  68. package/src/components/Calendar/CalendarMonthHeader.tsx +29 -22
  69. package/src/components/Calendar/CalendarYearList.tsx +136 -0
  70. package/src/components/Calendar/CalendarYearPickerGrid.tsx +60 -33
  71. package/src/components/Calendar/flashCalendarAdapter.ts +27 -2
  72. package/src/components/Calendar/index.tsx +8 -3
  73. package/src/components/Calendar/styles.ts +17 -1
  74. package/src/components/MonthCalendar/index.tsx +55 -33
  75. package/src/components/RangeCalendar/index.tsx +8 -3
@@ -5,6 +5,7 @@ import React, {
5
5
  useEffect,
6
6
  useId,
7
7
  useImperativeHandle,
8
+ useMemo,
8
9
  useRef,
9
10
  useState,
10
11
  type ReactNode,
@@ -25,15 +26,21 @@ import {
25
26
  type BottomSheetModalProps as GorhomBottomSheetModalProps,
26
27
  } from '@gorhom/bottom-sheet';
27
28
  import { useEscapeKey, useOverlayStackRegistration } from '@cdx-ui/primitives';
28
- import { cn } from '@cdx-ui/utils';
29
+ import { cn, ParentContext, useParentContext, useStyleContext } from '@cdx-ui/utils';
29
30
  import { SheetHeader } from '../sheet-header';
30
31
  import {
31
32
  bottomSheetBackgroundClassName,
32
33
  bottomSheetContentVariants,
33
34
  bottomSheetFooterVariants,
34
35
  bottomSheetModalContainerClassName,
36
+ type BottomSheetContentVariantProps,
35
37
  } from './styles';
36
38
 
39
+ const SCOPE = 'BOTTOM_SHEET';
40
+
41
+ const useBottomSheetStyleContext = () =>
42
+ (useStyleContext(SCOPE) ?? {}) as BottomSheetContentVariantProps;
43
+
37
44
  const ZERO_INSETS: EdgeInsets = { top: 0, bottom: 0, left: 0, right: 0 };
38
45
 
39
46
  function useSafeInsets(): EdgeInsets {
@@ -100,13 +107,19 @@ export interface BottomSheetContentProps {
100
107
  className?: string;
101
108
  /** Adds bottom padding equal to the safe-area inset. */
102
109
  hasSafeAreaInset?: boolean;
110
+ /** Removes horizontal padding. Inherits the parent `BottomSheet.Modal`'s `fullBleed` when omitted. */
111
+ fullBleed?: BottomSheetContentVariantProps['fullBleed'];
103
112
  style?: ViewStyle;
104
113
  }
105
114
 
106
115
  const BottomSheetContent = forwardRef<View, BottomSheetContentProps>(
107
- ({ children, className, hasSafeAreaInset, style }, ref) => {
116
+ ({ children, className, hasSafeAreaInset, fullBleed, style }, ref) => {
108
117
  const insets = useSafeInsets();
109
- const computedClassName = cn(bottomSheetContentVariants(), className);
118
+ const { fullBleed: fullBleedFromContext } = useBottomSheetStyleContext();
119
+ const computedClassName = cn(
120
+ bottomSheetContentVariants({ fullBleed: fullBleed ?? fullBleedFromContext }),
121
+ className,
122
+ );
110
123
  return (
111
124
  <View
112
125
  ref={ref}
@@ -162,6 +175,11 @@ export interface BottomSheetModalProps extends GorhomBottomSheetModalProps {
162
175
  * Pass it through this prop; a footer placed as a child does not animate with the sheet.
163
176
  */
164
177
  footer?: ReactNode;
178
+ /**
179
+ * Removes `BottomSheet.Content`'s horizontal padding so content can span the sheet edge to edge.
180
+ * `BottomSheet.Content` inherits this unless it sets its own `fullBleed`. The footer keeps its padding.
181
+ */
182
+ fullBleed?: BottomSheetContentVariantProps['fullBleed'];
165
183
  }
166
184
 
167
185
  const MAX_DYNAMIC_CONTENT_SIZE = Dimensions.get('window').height * 0.9;
@@ -185,151 +203,169 @@ const CustomBackground = (props: React.ComponentPropsWithoutRef<typeof View>) =>
185
203
  const BottomSheetModalStyled = forwardRef<
186
204
  React.ComponentRef<typeof BottomSheetModal>,
187
205
  BottomSheetModalProps
188
- >(({ handleVisible = true, footer, onDismiss, onChange, onAnimate, ...rest }, ref) => {
189
- const renderFooter = useCallback(
190
- (props: GorhomBottomSheetFooterProps) => (
191
- <GorhomBottomSheetFooter {...props}>{footer}</GorhomBottomSheetFooter>
192
- ),
193
- [footer],
194
- );
195
-
196
- const modalRef = useRef<BottomSheetModalRef | null>(null);
197
- const dismissedRef = useRef(false);
198
- const sheetMountedRef = useRef(false);
199
- const mountPendingRef = useRef(false);
200
- const pendingDismissRef = useRef<Parameters<BottomSheetModalRef['dismiss']> | null>(null);
201
- const [presented, setPresented] = useState(false);
202
-
203
- // Must stay a callback ref: gorhom hands this ref to its modal provider, which
204
- // reads `.current` to minimize and restore stacked sheets. A ref object would
205
- // switch that stacking on.
206
- const setModalRef = useCallback((instance: BottomSheetModalRef | null) => {
207
- modalRef.current = instance;
208
- }, []);
209
-
210
- // gorhom mounts the sheet a frame after `present()` and silently drops a
211
- // `dismiss()` that arrives before then, never reporting `onDismiss`. Holding
212
- // it until the sheet mounts keeps the early Escape/back claim releasable.
213
- const dismissSheet = useCallback((...args: Parameters<BottomSheetModalRef['dismiss']>) => {
214
- if (mountPendingRef.current) {
215
- pendingDismissRef.current = args;
216
- return;
217
- }
218
- modalRef.current?.dismiss(...args);
219
- }, []);
206
+ >(
207
+ (
208
+ { handleVisible = true, footer, fullBleed, children, onDismiss, onChange, onAnimate, ...rest },
209
+ ref,
210
+ ) => {
211
+ const parent = useParentContext();
212
+ const ctx = useMemo(() => ({ ...parent, [SCOPE]: { fullBleed } }), [parent, fullBleed]);
213
+
214
+ const renderFooter = useCallback(
215
+ (props: GorhomBottomSheetFooterProps) => (
216
+ <GorhomBottomSheetFooter {...props}>{footer}</GorhomBottomSheetFooter>
217
+ ),
218
+ [footer],
219
+ );
220
220
 
221
- const markSheetMounted = useCallback(() => {
222
- sheetMountedRef.current = true;
223
- if (!mountPendingRef.current) return;
224
- mountPendingRef.current = false;
225
- const pendingDismiss = pendingDismissRef.current;
226
- pendingDismissRef.current = null;
227
- if (pendingDismiss) modalRef.current?.dismiss(...pendingDismiss);
228
- }, []);
221
+ const modalRef = useRef<BottomSheetModalRef | null>(null);
222
+ const dismissedRef = useRef(false);
223
+ const sheetMountedRef = useRef(false);
224
+ const mountPendingRef = useRef(false);
225
+ const pendingDismissRef = useRef<Parameters<BottomSheetModalRef['dismiss']> | null>(null);
226
+ const [presented, setPresented] = useState(false);
227
+
228
+ // Must stay a callback ref: gorhom hands this ref to its modal provider, which
229
+ // reads `.current` to minimize and restore stacked sheets. A ref object would
230
+ // switch that stacking on.
231
+ const setModalRef = useCallback((instance: BottomSheetModalRef | null) => {
232
+ modalRef.current = instance;
233
+ }, []);
234
+
235
+ // gorhom mounts the sheet a frame after `present()` and silently drops a
236
+ // `dismiss()` that arrives before then, never reporting `onDismiss`. Holding
237
+ // it until the sheet mounts keeps the early Escape/back claim releasable.
238
+ const dismissSheet = useCallback((...args: Parameters<BottomSheetModalRef['dismiss']>) => {
239
+ if (mountPendingRef.current) {
240
+ pendingDismissRef.current = args;
241
+ return;
242
+ }
243
+ modalRef.current?.dismiss(...args);
244
+ }, []);
245
+
246
+ const markSheetMounted = useCallback(() => {
247
+ sheetMountedRef.current = true;
248
+ if (!mountPendingRef.current) return;
249
+ mountPendingRef.current = false;
250
+ const pendingDismiss = pendingDismissRef.current;
251
+ pendingDismissRef.current = null;
252
+ if (pendingDismiss) modalRef.current?.dismiss(...pendingDismiss);
253
+ }, []);
254
+
255
+ // `present()` claims Escape and back immediately; waiting for the first
256
+ // `onChange` would leave the overlay beneath in charge for the whole open animation.
257
+ useImperativeHandle(
258
+ ref,
259
+ (): BottomSheetModalRef => ({
260
+ present: (...args) => {
261
+ dismissedRef.current = false;
262
+ if (!sheetMountedRef.current) mountPendingRef.current = true;
263
+ setPresented(true);
264
+ modalRef.current?.present(...args);
265
+ },
266
+ dismiss: dismissSheet,
267
+ snapToIndex: (...args) => modalRef.current?.snapToIndex(...args),
268
+ snapToPosition: (...args) => modalRef.current?.snapToPosition(...args),
269
+ expand: (...args) => modalRef.current?.expand(...args),
270
+ collapse: (...args) => modalRef.current?.collapse(...args),
271
+ close: (...args) => modalRef.current?.close(...args),
272
+ forceClose: (...args) => modalRef.current?.forceClose(...args),
273
+ }),
274
+ [dismissSheet],
275
+ );
229
276
 
230
- // `present()` claims Escape and back immediately; waiting for the first
231
- // `onChange` would leave the overlay beneath in charge for the whole open animation.
232
- useImperativeHandle(
233
- ref,
234
- (): BottomSheetModalRef => ({
235
- present: (...args) => {
277
+ const handleDismiss = useCallback(() => {
278
+ sheetMountedRef.current = false;
279
+ mountPendingRef.current = false;
280
+ pendingDismissRef.current = null;
281
+ setPresented(false);
282
+ if (dismissedRef.current) return;
283
+ dismissedRef.current = true;
284
+ onDismiss?.();
285
+ }, [onDismiss]);
286
+
287
+ const handleChange = useCallback(
288
+ (...args: Parameters<NonNullable<BottomSheetModalProps['onChange']>>) => {
236
289
  dismissedRef.current = false;
237
- if (!sheetMountedRef.current) mountPendingRef.current = true;
238
- setPresented(true);
239
- modalRef.current?.present(...args);
290
+ setPresented(args[0] >= 0);
291
+ if (args[0] >= 0) markSheetMounted();
292
+ onChange?.(...args);
240
293
  },
241
- dismiss: dismissSheet,
242
- snapToIndex: (...args) => modalRef.current?.snapToIndex(...args),
243
- snapToPosition: (...args) => modalRef.current?.snapToPosition(...args),
244
- expand: (...args) => modalRef.current?.expand(...args),
245
- collapse: (...args) => modalRef.current?.collapse(...args),
246
- close: (...args) => modalRef.current?.close(...args),
247
- forceClose: (...args) => modalRef.current?.forceClose(...args),
248
- }),
249
- [dismissSheet],
250
- );
251
-
252
- const handleDismiss = useCallback(() => {
253
- sheetMountedRef.current = false;
254
- mountPendingRef.current = false;
255
- pendingDismissRef.current = null;
256
- setPresented(false);
257
- if (dismissedRef.current) return;
258
- dismissedRef.current = true;
259
- onDismiss?.();
260
- }, [onDismiss]);
261
-
262
- const handleChange = useCallback(
263
- (...args: Parameters<NonNullable<BottomSheetModalProps['onChange']>>) => {
264
- dismissedRef.current = false;
265
- setPresented(args[0] >= 0);
266
- if (args[0] >= 0) markSheetMounted();
267
- onChange?.(...args);
268
- },
269
- [markSheetMounted, onChange],
270
- );
294
+ [markSheetMounted, onChange],
295
+ );
271
296
 
272
- const handleAnimate = useCallback(
273
- (...args: Parameters<NonNullable<BottomSheetModalProps['onAnimate']>>) => {
274
- if (args[1] >= 0) markSheetMounted();
275
- onAnimate?.(...args);
276
- },
277
- [markSheetMounted, onAnimate],
278
- );
297
+ const handleAnimate = useCallback(
298
+ (...args: Parameters<NonNullable<BottomSheetModalProps['onAnimate']>>) => {
299
+ if (args[1] >= 0) markSheetMounted();
300
+ onAnimate?.(...args);
301
+ },
302
+ [markSheetMounted, onAnimate],
303
+ );
279
304
 
280
- const id = useId();
281
- const overlayId = `bottom-sheet-${id}`;
282
- useEscapeKey(presented, dismissSheet, {
283
- id: overlayId,
284
- groupId: overlayId,
285
- layer: 'modal',
286
- });
287
- const { isTopmost } = useOverlayStackRegistration({
288
- active: presented && Platform.OS !== 'web',
289
- capabilities: ['back'],
290
- id: overlayId,
291
- groupId: overlayId,
292
- layer: 'modal',
293
- });
294
-
295
- useEffect(() => {
296
- // BackHandler is native-only; react-native-web warns on every subscription.
297
- if (!presented || Platform.OS === 'web') return;
298
-
299
- const backHandler = BackHandler.addEventListener('hardwareBackPress', () => {
300
- if (!isTopmost('back')) return false;
301
- dismissSheet();
302
- return true;
305
+ const id = useId();
306
+ const overlayId = `bottom-sheet-${id}`;
307
+ useEscapeKey(presented, dismissSheet, {
308
+ id: overlayId,
309
+ groupId: overlayId,
310
+ layer: 'modal',
311
+ });
312
+ const { isTopmost } = useOverlayStackRegistration({
313
+ active: presented && Platform.OS !== 'web',
314
+ capabilities: ['back'],
315
+ id: overlayId,
316
+ groupId: overlayId,
317
+ layer: 'modal',
303
318
  });
304
319
 
305
- return () => {
306
- backHandler.remove();
307
- };
308
- }, [dismissSheet, isTopmost, presented]);
320
+ useEffect(() => {
321
+ // BackHandler is native-only; react-native-web warns on every subscription.
322
+ if (!presented || Platform.OS === 'web') return;
309
323
 
310
- const sheetContainerStyle = useResolveClassNames(bottomSheetModalContainerClassName);
311
- const handleIndicatorStyle = useResolveClassNames(
312
- 'bg-surface-neutral-subtle w-9 h-[5px] rounded-full',
313
- );
324
+ const backHandler = BackHandler.addEventListener('hardwareBackPress', () => {
325
+ if (!isTopmost('back')) return false;
326
+ dismissSheet();
327
+ return true;
328
+ });
314
329
 
315
- return (
316
- <BottomSheetModal
317
- ref={setModalRef}
318
- enableDynamicSizing
319
- maxDynamicContentSize={MAX_DYNAMIC_CONTENT_SIZE}
320
- style={sheetContainerStyle}
321
- handleComponent={handleVisible ? undefined : null}
322
- handleIndicatorStyle={handleVisible ? handleIndicatorStyle : undefined}
323
- backdropComponent={CustomBackdrop}
324
- backgroundComponent={CustomBackground}
325
- {...rest}
326
- onDismiss={handleDismiss}
327
- onChange={handleChange}
328
- onAnimate={handleAnimate}
329
- {...(footer != null && { footerComponent: renderFooter })}
330
- />
331
- );
332
- });
330
+ return () => {
331
+ backHandler.remove();
332
+ };
333
+ }, [dismissSheet, isTopmost, presented]);
334
+
335
+ const sheetContainerStyle = useResolveClassNames(bottomSheetModalContainerClassName);
336
+ const handleIndicatorStyle = useResolveClassNames(
337
+ 'bg-surface-neutral-subtle w-9 h-[5px] rounded-full',
338
+ );
339
+
340
+ return (
341
+ <BottomSheetModal
342
+ ref={setModalRef}
343
+ enableDynamicSizing
344
+ maxDynamicContentSize={MAX_DYNAMIC_CONTENT_SIZE}
345
+ style={sheetContainerStyle}
346
+ handleComponent={handleVisible ? undefined : null}
347
+ handleIndicatorStyle={handleVisible ? handleIndicatorStyle : undefined}
348
+ backdropComponent={CustomBackdrop}
349
+ backgroundComponent={CustomBackground}
350
+ {...rest}
351
+ onDismiss={handleDismiss}
352
+ onChange={handleChange}
353
+ onAnimate={handleAnimate}
354
+ {...(footer != null && { footerComponent: renderFooter })}
355
+ >
356
+ {typeof children === 'function' ? (
357
+ (props: { data?: unknown }) => (
358
+ <ParentContext.Provider value={ctx}>
359
+ {children(props) as ReactNode}
360
+ </ParentContext.Provider>
361
+ )
362
+ ) : (
363
+ <ParentContext.Provider value={ctx}>{children}</ParentContext.Provider>
364
+ )}
365
+ </BottomSheetModal>
366
+ );
367
+ },
368
+ );
333
369
 
334
370
  BottomSheetModalStyled.displayName = 'BottomSheet.Modal';
335
371
 
@@ -1,4 +1,4 @@
1
- import { cva } from '@cdx-ui/utils';
1
+ import { cva, type VariantProps } from '@cdx-ui/utils';
2
2
 
3
3
  /** Keep gorhom's animated container unclipped so web transforms can settle correctly. */
4
4
  export const bottomSheetModalContainerClassName = 'rounded-t-4xl';
@@ -6,6 +6,14 @@ export const bottomSheetModalContainerClassName = 'rounded-t-4xl';
6
6
  /** Clip the static background for rounded corners without clipping the animated sheet. */
7
7
  export const bottomSheetBackgroundClassName = `${bottomSheetModalContainerClassName} overflow-hidden bg-surface-primary`;
8
8
 
9
- export const bottomSheetContentVariants = cva(['bg-surface-primary', 'px-5 pb-5 gap-3']);
9
+ export const bottomSheetContentVariants = cva(['bg-surface-primary', 'px-4 pb-4 gap-3'], {
10
+ variants: {
11
+ fullBleed: {
12
+ true: 'px-0',
13
+ },
14
+ },
15
+ });
10
16
 
11
- export const bottomSheetFooterVariants = cva(['px-5 pb-5 gap-2', 'bg-surface-primary']);
17
+ export type BottomSheetContentVariantProps = VariantProps<typeof bottomSheetContentVariants>;
18
+
19
+ export const bottomSheetFooterVariants = cva(['px-4 pb-5 gap-2', 'bg-surface-primary']);
@@ -94,28 +94,35 @@ export const CalendarMonthHeader = forwardRef<ComponentRef<typeof View>, Calenda
94
94
  />
95
95
  </Pressable>
96
96
 
97
- {!isYearView ? (
98
- <View className={calendarMonthHeaderNavVariants()}>
99
- <IconButton
100
- as={ChevronLeft}
101
- variant="ghost"
102
- color="neutral"
103
- size="small"
104
- accessibilityLabel={labels.previousMonth}
105
- isDisabled={disabled}
106
- onPress={() => shiftMonth(-1)}
107
- />
108
- <IconButton
109
- as={ChevronRight}
110
- variant="ghost"
111
- color="neutral"
112
- size="small"
113
- accessibilityLabel={labels.nextMonth}
114
- isDisabled={disabled}
115
- onPress={() => shiftMonth(1)}
116
- />
117
- </View>
118
- ) : null}
97
+ {/* Stays mounted in the year view so the header keeps its height and the label does not move. */}
98
+ <View
99
+ className={calendarMonthHeaderNavVariants({ hidden: isYearView })}
100
+ pointerEvents={isYearView ? 'none' : 'auto'}
101
+ accessibilityElementsHidden={isYearView}
102
+ importantForAccessibility={isYearView ? 'no-hide-descendants' : 'auto'}
103
+ {...(isYearView ? { 'aria-hidden': true } : {})}
104
+ >
105
+ <IconButton
106
+ as={ChevronLeft}
107
+ variant="ghost"
108
+ color="neutral"
109
+ size="small"
110
+ accessibilityLabel={labels.previousMonth}
111
+ isDisabled={disabled || isYearView}
112
+ focusable={!isYearView}
113
+ onPress={() => shiftMonth(-1)}
114
+ />
115
+ <IconButton
116
+ as={ChevronRight}
117
+ variant="ghost"
118
+ color="neutral"
119
+ size="small"
120
+ accessibilityLabel={labels.nextMonth}
121
+ isDisabled={disabled || isYearView}
122
+ focusable={!isYearView}
123
+ onPress={() => shiftMonth(1)}
124
+ />
125
+ </View>
119
126
  </View>
120
127
  );
121
128
  },
@@ -0,0 +1,136 @@
1
+ import { Fragment, useCallback, useMemo, useRef, type ReactElement } from 'react';
2
+ import { ScrollView, View } from 'react-native';
3
+ import { ScrollShadow } from '../ScrollShadow';
4
+ import {
5
+ calendarYearPickerListContentVariants,
6
+ calendarYearPickerPlaceholderVariants,
7
+ calendarYearPickerRowVariants,
8
+ } from './styles';
9
+
10
+ /** Years listed before / after today's year on a side with no `minValue` / `maxValue`. */
11
+ const DEFAULT_YEAR_WINDOW = 100;
12
+ const YEAR_COLUMNS = 3;
13
+ /** Pill cell (40px) plus its vertical margins and row gap; fixed so the initial offset is exact. */
14
+ export const YEAR_ROW_HEIGHT = 52;
15
+
16
+ const ROW_STYLE = { height: YEAR_ROW_HEIGHT };
17
+
18
+ export interface YearRange {
19
+ first: number;
20
+ last: number;
21
+ }
22
+
23
+ export function getYearRange(
24
+ minYear: number | undefined,
25
+ maxYear: number | undefined,
26
+ visibleYear: number,
27
+ todayYear: number,
28
+ ): YearRange {
29
+ const first =
30
+ minYear ?? Math.min(todayYear - DEFAULT_YEAR_WINDOW, visibleYear, maxYear ?? Infinity);
31
+ const last = maxYear ?? Math.max(todayYear + DEFAULT_YEAR_WINDOW, visibleYear, first);
32
+ return { first, last: Math.max(first, last) };
33
+ }
34
+
35
+ function clamp(value: number, min: number, max: number): number {
36
+ return Math.min(Math.max(value, min), max);
37
+ }
38
+
39
+ export interface CalendarYearListProps extends YearRange {
40
+ selectedYear: number;
41
+ /** Fixed list height, matched to the sibling view so switching views does not resize. */
42
+ height: number;
43
+ renderYear: (year: number) => ReactElement;
44
+ }
45
+
46
+ /**
47
+ * Three-up year list shared by the Calendar, RangeCalendar, and MonthCalendar year views.
48
+ *
49
+ * A plain `ScrollView` rather than a `FlatList`: the pickers render this inside their popover
50
+ * `ScrollView`, and a nested `VirtualizedList` breaks windowing there. The list is a few hundred
51
+ * light cells at most, so virtualization buys little.
52
+ */
53
+ export function CalendarYearList({
54
+ first,
55
+ last,
56
+ selectedYear,
57
+ height,
58
+ renderYear,
59
+ }: CalendarYearListProps) {
60
+ const scrollRef = useRef<ScrollView>(null);
61
+ const hasAppliedInitialOffset = useRef(false);
62
+
63
+ const rows = useMemo<(number | null)[][]>(() => {
64
+ const count = last - first + 1;
65
+ return Array.from({ length: Math.ceil(count / YEAR_COLUMNS) }, (_, row) =>
66
+ Array.from({ length: YEAR_COLUMNS }, (_, column) => {
67
+ const index = row * YEAR_COLUMNS + column;
68
+ return index < count ? first + index : null;
69
+ }),
70
+ );
71
+ }, [first, last]);
72
+
73
+ const visibleRows = height / YEAR_ROW_HEIGHT;
74
+ const selectedRow = Math.floor((clamp(selectedYear, first, last) - first) / YEAR_COLUMNS);
75
+ const firstVisibleRow = Math.max(0, selectedRow - Math.floor((visibleRows - 1) / 2));
76
+ const maxOffset = Math.max(0, rows.length * YEAR_ROW_HEIGHT - height);
77
+ const initialOffsetY = Math.min(firstVisibleRow * YEAR_ROW_HEIGHT, maxOffset);
78
+
79
+ // `contentOffset` is computed once so later re-renders (a new selection) never yank the
80
+ // scroll position back.
81
+ const initialOffset = useRef({ x: 0, y: initialOffsetY }).current;
82
+ const listStyle = useMemo(() => ({ height }), [height]);
83
+
84
+ // iOS and Android apply `contentOffset` before first paint; React Native Web ignores it, so
85
+ // the same offset is applied once content has a size.
86
+ const handleContentSizeChange = useCallback(() => {
87
+ if (hasAppliedInitialOffset.current) {
88
+ return;
89
+ }
90
+ hasAppliedInitialOffset.current = true;
91
+ if (initialOffset.y > 0) {
92
+ scrollRef.current?.scrollTo({ x: 0, y: initialOffset.y, animated: false });
93
+ }
94
+ }, [initialOffset]);
95
+
96
+ const content = useMemo(
97
+ () =>
98
+ rows.map((cells, row) => (
99
+ <View
100
+ key={first + row * YEAR_COLUMNS}
101
+ className={calendarYearPickerRowVariants()}
102
+ style={ROW_STYLE}
103
+ >
104
+ {cells.map((year, column) =>
105
+ year === null ? (
106
+ <View
107
+ key={`placeholder-${String(column)}`}
108
+ className={calendarYearPickerPlaceholderVariants()}
109
+ accessibilityElementsHidden
110
+ importantForAccessibility="no-hide-descendants"
111
+ aria-hidden
112
+ />
113
+ ) : (
114
+ <Fragment key={year}>{renderYear(year)}</Fragment>
115
+ ),
116
+ )}
117
+ </View>
118
+ )),
119
+ [first, rows, renderYear],
120
+ );
121
+
122
+ return (
123
+ <ScrollShadow surface="primary" size="small">
124
+ <ScrollView
125
+ ref={scrollRef}
126
+ style={listStyle}
127
+ contentContainerClassName={calendarYearPickerListContentVariants()}
128
+ contentOffset={initialOffset}
129
+ onContentSizeChange={handleContentSizeChange}
130
+ nestedScrollEnabled
131
+ >
132
+ {content}
133
+ </ScrollView>
134
+ </ScrollShadow>
135
+ );
136
+ }