@cdx-ui/components 0.2.4 → 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 (165) hide show
  1. package/docs/components/accordion/content.md +2 -2
  2. package/docs/components/alert/content.md +1 -1
  3. package/docs/components/badge/content.md +5 -15
  4. package/docs/components/bottom-sheet/content.md +18 -14
  5. package/docs/components/box/content.md +141 -0
  6. package/docs/components/breadcrumbs/content.md +9 -9
  7. package/docs/components/button/content.md +19 -19
  8. package/docs/components/calendar/content.md +25 -1
  9. package/docs/components/card/content.md +2 -2
  10. package/docs/components/carousel/content.md +9 -8
  11. package/docs/components/chip/content.md +1 -1
  12. package/docs/components/color-area/content.md +3 -5
  13. package/docs/components/color-field/content.md +3 -5
  14. package/docs/components/color-picker/content.md +5 -4
  15. package/docs/components/color-slider/content.md +3 -5
  16. package/docs/components/color-swatch/content.md +3 -5
  17. package/docs/components/color-swatch-picker/content.md +3 -5
  18. package/docs/components/content.md +10 -9
  19. package/docs/components/data-grid/content.md +10 -11
  20. package/docs/components/drawer/content.md +17 -25
  21. package/docs/components/full-screen-dialog/content.md +2 -2
  22. package/docs/components/icon/content.md +1 -1
  23. package/docs/components/icon-button/content.md +3 -3
  24. package/docs/components/input/content.md +65 -0
  25. package/docs/components/list-item/content.md +9 -9
  26. package/docs/components/menu/content.md +57 -50
  27. package/docs/components/month-calendar/content.md +2 -0
  28. package/docs/components/range-calendar/content.md +3 -1
  29. package/docs/components/reorderable-list/content.md +5 -3
  30. package/docs/components/scoped-theme/content.md +1 -1
  31. package/docs/components/scroll-shadow/content.md +43 -36
  32. package/docs/components/select/content.md +80 -7
  33. package/docs/components/skeleton/content.md +7 -6
  34. package/docs/components/table/content.md +2 -2
  35. package/docs/components/tabs/content.md +14 -9
  36. package/docs/components/tile/content.md +9 -10
  37. package/docs/components/toggle-button/content.md +1 -1
  38. package/docs/components/tooltip/content.md +9 -9
  39. package/docs/components/virtualized-list/content.md +14 -14
  40. package/docs/getting-started/developers/content.md +2 -2
  41. package/docs/getting-started/developers/styling/content.md +20 -18
  42. package/docs/llms.txt +12 -9
  43. package/docs/patterns/authentication/content.md +4 -6
  44. package/docs/patterns/dashboard/content.md +8 -13
  45. package/docs/patterns/empty-states/content.md +7 -6
  46. package/docs/patterns/forms/content.md +6 -7
  47. package/docs/patterns/onboarding/content.md +4 -4
  48. package/docs/patterns/settings/content.md +5 -5
  49. package/docs/resources/release-notes/0.2.4/content.md +17 -0
  50. package/docs/resources/release-notes/0.2.5/content.md +25 -0
  51. package/docs/resources/release-notes/content.md +2 -0
  52. package/lib/commonjs/components/BottomSheet/index.js +25 -1
  53. package/lib/commonjs/components/BottomSheet/index.js.map +1 -1
  54. package/lib/commonjs/components/BottomSheet/styles.js +8 -2
  55. package/lib/commonjs/components/BottomSheet/styles.js.map +1 -1
  56. package/lib/commonjs/components/Box/Box.js +9 -1
  57. package/lib/commonjs/components/Box/Box.js.map +1 -1
  58. package/lib/commonjs/components/Breadcrumbs/index.js +28 -3
  59. package/lib/commonjs/components/Breadcrumbs/index.js.map +1 -1
  60. package/lib/commonjs/components/Breadcrumbs/styles.js +27 -0
  61. package/lib/commonjs/components/Breadcrumbs/styles.js.map +1 -1
  62. package/lib/commonjs/components/Calendar/CalendarMonthHeader.js +15 -5
  63. package/lib/commonjs/components/Calendar/CalendarMonthHeader.js.map +1 -1
  64. package/lib/commonjs/components/Calendar/CalendarYearList.js +116 -0
  65. package/lib/commonjs/components/Calendar/CalendarYearList.js.map +1 -0
  66. package/lib/commonjs/components/Calendar/CalendarYearPickerGrid.js +58 -31
  67. package/lib/commonjs/components/Calendar/CalendarYearPickerGrid.js.map +1 -1
  68. package/lib/commonjs/components/Calendar/flashCalendarAdapter.js +22 -3
  69. package/lib/commonjs/components/Calendar/flashCalendarAdapter.js.map +1 -1
  70. package/lib/commonjs/components/Calendar/index.js +6 -2
  71. package/lib/commonjs/components/Calendar/index.js.map +1 -1
  72. package/lib/commonjs/components/Calendar/styles.js +16 -2
  73. package/lib/commonjs/components/Calendar/styles.js.map +1 -1
  74. package/lib/commonjs/components/MonthCalendar/index.js +39 -13
  75. package/lib/commonjs/components/MonthCalendar/index.js.map +1 -1
  76. package/lib/commonjs/components/RangeCalendar/index.js +6 -2
  77. package/lib/commonjs/components/RangeCalendar/index.js.map +1 -1
  78. package/lib/commonjs/components/Stack/HStack.js +6 -2
  79. package/lib/commonjs/components/Stack/HStack.js.map +1 -1
  80. package/lib/commonjs/components/Stack/VStack.js +6 -2
  81. package/lib/commonjs/components/Stack/VStack.js.map +1 -1
  82. package/lib/commonjs/index.js +10 -10
  83. package/lib/commonjs/index.js.map +1 -1
  84. package/lib/module/components/BottomSheet/index.js +27 -3
  85. package/lib/module/components/BottomSheet/index.js.map +1 -1
  86. package/lib/module/components/BottomSheet/styles.js +8 -2
  87. package/lib/module/components/BottomSheet/styles.js.map +1 -1
  88. package/lib/module/components/Box/Box.js +9 -1
  89. package/lib/module/components/Box/Box.js.map +1 -1
  90. package/lib/module/components/Breadcrumbs/index.js +29 -4
  91. package/lib/module/components/Breadcrumbs/index.js.map +1 -1
  92. package/lib/module/components/Breadcrumbs/styles.js +26 -0
  93. package/lib/module/components/Breadcrumbs/styles.js.map +1 -1
  94. package/lib/module/components/Calendar/CalendarMonthHeader.js +15 -5
  95. package/lib/module/components/Calendar/CalendarMonthHeader.js.map +1 -1
  96. package/lib/module/components/Calendar/CalendarYearList.js +112 -0
  97. package/lib/module/components/Calendar/CalendarYearList.js.map +1 -0
  98. package/lib/module/components/Calendar/CalendarYearPickerGrid.js +61 -34
  99. package/lib/module/components/Calendar/CalendarYearPickerGrid.js.map +1 -1
  100. package/lib/module/components/Calendar/flashCalendarAdapter.js +20 -2
  101. package/lib/module/components/Calendar/flashCalendarAdapter.js.map +1 -1
  102. package/lib/module/components/Calendar/index.js +7 -3
  103. package/lib/module/components/Calendar/index.js.map +1 -1
  104. package/lib/module/components/Calendar/styles.js +15 -1
  105. package/lib/module/components/Calendar/styles.js.map +1 -1
  106. package/lib/module/components/MonthCalendar/index.js +42 -15
  107. package/lib/module/components/MonthCalendar/index.js.map +1 -1
  108. package/lib/module/components/RangeCalendar/index.js +7 -3
  109. package/lib/module/components/RangeCalendar/index.js.map +1 -1
  110. package/lib/module/components/Stack/HStack.js +6 -2
  111. package/lib/module/components/Stack/HStack.js.map +1 -1
  112. package/lib/module/components/Stack/VStack.js +6 -2
  113. package/lib/module/components/Stack/VStack.js.map +1 -1
  114. package/lib/module/index.js +6 -1
  115. package/lib/module/index.js.map +1 -1
  116. package/lib/typescript/animation/hooks/usePopupDialogContentAnimation.d.ts +2 -2
  117. package/lib/typescript/animation/hooks/usePopupOverlayAnimation.d.ts +2 -2
  118. package/lib/typescript/components/BottomSheet/index.d.ts +8 -0
  119. package/lib/typescript/components/BottomSheet/index.d.ts.map +1 -1
  120. package/lib/typescript/components/BottomSheet/styles.d.ts +5 -1
  121. package/lib/typescript/components/BottomSheet/styles.d.ts.map +1 -1
  122. package/lib/typescript/components/Box/Box.d.ts +4 -0
  123. package/lib/typescript/components/Box/Box.d.ts.map +1 -1
  124. package/lib/typescript/components/Breadcrumbs/index.d.ts +3 -1
  125. package/lib/typescript/components/Breadcrumbs/index.d.ts.map +1 -1
  126. package/lib/typescript/components/Breadcrumbs/styles.d.ts +2 -0
  127. package/lib/typescript/components/Breadcrumbs/styles.d.ts.map +1 -1
  128. package/lib/typescript/components/Calendar/CalendarMonthHeader.d.ts.map +1 -1
  129. package/lib/typescript/components/Calendar/CalendarYearList.d.ts +23 -0
  130. package/lib/typescript/components/Calendar/CalendarYearList.d.ts.map +1 -0
  131. package/lib/typescript/components/Calendar/CalendarYearPickerGrid.d.ts +5 -1
  132. package/lib/typescript/components/Calendar/CalendarYearPickerGrid.d.ts.map +1 -1
  133. package/lib/typescript/components/Calendar/flashCalendarAdapter.d.ts +8 -0
  134. package/lib/typescript/components/Calendar/flashCalendarAdapter.d.ts.map +1 -1
  135. package/lib/typescript/components/Calendar/index.d.ts.map +1 -1
  136. package/lib/typescript/components/Calendar/styles.d.ts +7 -1
  137. package/lib/typescript/components/Calendar/styles.d.ts.map +1 -1
  138. package/lib/typescript/components/MonthCalendar/index.d.ts.map +1 -1
  139. package/lib/typescript/components/RangeCalendar/index.d.ts.map +1 -1
  140. package/lib/typescript/components/Stack/HStack.d.ts +3 -2
  141. package/lib/typescript/components/Stack/HStack.d.ts.map +1 -1
  142. package/lib/typescript/components/Stack/VStack.d.ts +3 -2
  143. package/lib/typescript/components/Stack/VStack.d.ts.map +1 -1
  144. package/lib/typescript/components/Toast/animation.d.ts +3 -3
  145. package/lib/typescript/components/Toast/animation.d.ts.map +1 -1
  146. package/lib/typescript/components/ToggleButton/styles.d.ts +1 -1
  147. package/lib/typescript/index.d.ts +6 -1
  148. package/lib/typescript/index.d.ts.map +1 -1
  149. package/package.json +8 -8
  150. package/src/components/BottomSheet/index.tsx +174 -138
  151. package/src/components/BottomSheet/styles.ts +11 -3
  152. package/src/components/Box/Box.tsx +18 -4
  153. package/src/components/Breadcrumbs/index.tsx +28 -4
  154. package/src/components/Breadcrumbs/styles.ts +30 -0
  155. package/src/components/Calendar/CalendarMonthHeader.tsx +29 -22
  156. package/src/components/Calendar/CalendarYearList.tsx +136 -0
  157. package/src/components/Calendar/CalendarYearPickerGrid.tsx +60 -33
  158. package/src/components/Calendar/flashCalendarAdapter.ts +27 -2
  159. package/src/components/Calendar/index.tsx +8 -3
  160. package/src/components/Calendar/styles.ts +17 -1
  161. package/src/components/MonthCalendar/index.tsx +55 -33
  162. package/src/components/RangeCalendar/index.tsx +8 -3
  163. package/src/components/Stack/HStack.tsx +8 -4
  164. package/src/components/Stack/VStack.tsx +8 -4
  165. package/src/index.ts +8 -1
@@ -19,7 +19,10 @@ import {
19
19
  type ILinkProps,
20
20
  } from '@cdx-ui/primitives';
21
21
  import { cn } from '@cdx-ui/utils';
22
+ import { Icon } from '../Icon';
23
+ import { Text as ForgeText } from '../Text';
22
24
  import {
25
+ breadcrumbContentColor,
23
26
  breadcrumbItemVariants,
24
27
  breadcrumbDisabledCursor,
25
28
  breadcrumbItemRootVariants,
@@ -35,7 +38,8 @@ type BreadcrumbItemState = 'current' | 'ancestor' | 'disabled';
35
38
  export interface BreadcrumbsProps extends IBreadcrumbsProps {
36
39
  className?: string;
37
40
  /**
38
- * Separator rendered between breadcrumb items.
41
+ * Separator rendered between breadcrumb items. A direct `Icon` or `Text` child takes the
42
+ * separator color unless it sets `color`.
39
43
  * @default `/`
40
44
  */
41
45
  separator?: ReactNode;
@@ -48,6 +52,7 @@ interface BreadcrumbItemInternalProps {
48
52
 
49
53
  export interface BreadcrumbsItemProps extends Omit<ILinkProps, 'children' | 'disabled'> {
50
54
  className?: string;
55
+ /** Item content. A direct `Icon` or `Text` child takes the item's state color unless it sets `color`. */
51
56
  children?: ReactNode;
52
57
  }
53
58
 
@@ -65,6 +70,23 @@ function isTextOnlyChild(children: ReactNode): children is string | number {
65
70
  return typeof children === 'string' || typeof children === 'number';
66
71
  }
67
72
 
73
+ // `Icon` and `Text` default to `text-content-primary` and native has no color cascade, so
74
+ // direct children that leave `color` unset take the content color through `className`.
75
+ function withContentColor(children: ReactNode, colorClassName: string): ReactNode {
76
+ return Children.map(children, (child) => {
77
+ if (!isValidElement<{ className?: string; color?: unknown }>(child)) {
78
+ return child;
79
+ }
80
+ if (child.type !== Icon && child.type !== ForgeText) {
81
+ return child;
82
+ }
83
+ if (child.props.color !== undefined) {
84
+ return child;
85
+ }
86
+ return cloneElement(child, { className: cn(colorClassName, child.props.className) });
87
+ });
88
+ }
89
+
68
90
  function BreadcrumbItemContent({
69
91
  children,
70
92
  className,
@@ -74,6 +96,8 @@ function BreadcrumbItemContent({
74
96
  className: string;
75
97
  state: BreadcrumbItemState;
76
98
  }) {
99
+ const { color } = useBreadcrumbsContext();
100
+
77
101
  if (isTextOnlyChild(children)) {
78
102
  return (
79
103
  <Text className={className} {...dataAttributes({ state })}>
@@ -84,13 +108,13 @@ function BreadcrumbItemContent({
84
108
 
85
109
  return (
86
110
  <View className={cn('flex-row items-center gap-1', className)} {...dataAttributes({ state })}>
87
- {children}
111
+ {withContentColor(children, breadcrumbContentColor(state, color))}
88
112
  </View>
89
113
  );
90
114
  }
91
115
 
92
116
  function BreadcrumbsSeparator({ separator }: { separator: ReactNode }) {
93
- const { isDisabled } = useBreadcrumbsContext();
117
+ const { isDisabled, color } = useBreadcrumbsContext();
94
118
  const disabledClassName = isDisabled ? breadcrumbDisabledCursor : undefined;
95
119
  const slotProps = mergeDataAttributes({}, dataAttributes({ slot: 'breadcrumbs-separator' }));
96
120
  const a11yProps = {
@@ -121,7 +145,7 @@ function BreadcrumbsSeparator({ separator }: { separator: ReactNode }) {
121
145
  {...a11yProps}
122
146
  {...slotProps}
123
147
  >
124
- {separator}
148
+ {withContentColor(separator, breadcrumbContentColor('separator', color))}
125
149
  </View>
126
150
  );
127
151
  }
@@ -13,6 +13,36 @@ export const breadcrumbDisabledCursor = 'web:cursor-not-allowed';
13
13
 
14
14
  export const breadcrumbSeparatorVariants = cva(['body-md text-content-tertiary select-none px-1']);
15
15
 
16
+ const breadcrumbAncestorContentColor = {
17
+ neutral: 'text-content-secondary',
18
+ action: 'text-content-action',
19
+ danger: 'text-content-danger',
20
+ warning: 'text-content-warning',
21
+ success: 'text-content-success',
22
+ info: 'text-content-info',
23
+ } satisfies Record<BreadcrumbColor, string>;
24
+
25
+ // Color for an `Icon` or `Text` placed directly in item or separator content. Web inherits
26
+ // the CSS color of the content element, so hover follows it; native has no color cascade,
27
+ // so it resolves the state's token.
28
+ export function breadcrumbContentColor(
29
+ state: 'current' | 'ancestor' | 'disabled' | 'separator',
30
+ color: BreadcrumbColor,
31
+ ): string {
32
+ if (Platform.OS === 'web') {
33
+ return 'text-inherit';
34
+ }
35
+ switch (state) {
36
+ case 'current':
37
+ return 'text-content-primary';
38
+ case 'ancestor':
39
+ return breadcrumbAncestorContentColor[color];
40
+ case 'disabled':
41
+ case 'separator':
42
+ return 'text-content-tertiary';
43
+ }
44
+ }
45
+
16
46
  export const breadcrumbItemRootVariants = cva([
17
47
  'self-start',
18
48
  'web:outline-none web:focus:outline-none web:focus-visible:outline-none',
@@ -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
+ }
@@ -1,27 +1,32 @@
1
- import { forwardRef, useCallback, useMemo, type ComponentRef } from 'react';
1
+ import { forwardRef, useCallback, useMemo, useState, type ComponentRef } from 'react';
2
2
  import { Pressable, Text, View, type ViewProps } from 'react-native';
3
3
  import {
4
4
  formatMonthId,
5
5
  formatSelectYear,
6
+ parseDateId,
6
7
  parseMonthId,
7
8
  resolveDateComponentLabels,
8
9
  type DateComponentLabels,
10
+ type DateId,
9
11
  type MonthId,
10
12
  } from '@cdx-ui/formatters';
11
13
  import { cn } from '@cdx-ui/utils';
14
+ import { CalendarYearList, getYearRange } from './CalendarYearList';
12
15
  import type { CalendarNavigationView } from './CalendarMonthHeader';
16
+ import { getFlashCalendarGridHeight } from './flashCalendarAdapter';
13
17
  import {
14
18
  calendarPillCellTextVariants,
15
19
  calendarPillCellVariants,
16
- calendarYearPickerGridVariants,
20
+ calendarYearPickerListVariants,
17
21
  } from './styles';
18
22
 
19
- const YEAR_RANGE_BEFORE = 9;
20
- const YEAR_RANGE_AFTER = 11;
21
-
22
23
  export interface CalendarYearPickerGridProps extends ViewProps {
23
24
  monthId: MonthId;
24
25
  onMonthIdChange: (monthId: MonthId) => void;
26
+ /** Earliest selectable date; its year is the first year listed. */
27
+ minValue?: DateId;
28
+ /** Latest selectable date; its year is the last year listed. */
29
+ maxValue?: DateId;
25
30
  disabled?: boolean;
26
31
  onViewChange: (view: CalendarNavigationView) => void;
27
32
  labels?: Partial<DateComponentLabels>;
@@ -35,6 +40,8 @@ export const CalendarYearPickerGrid = forwardRef<
35
40
  {
36
41
  monthId,
37
42
  onMonthIdChange,
43
+ minValue,
44
+ maxValue,
38
45
  disabled = false,
39
46
  onViewChange,
40
47
  labels: labelsProp,
@@ -45,54 +52,74 @@ export const CalendarYearPickerGrid = forwardRef<
45
52
  ref,
46
53
  ) => {
47
54
  const labels = useMemo(() => resolveDateComponentLabels(labelsProp), [labelsProp]);
55
+ const [todayYear] = useState(() => new Date().getFullYear());
48
56
  const parts = parseMonthId(monthId);
49
- const currentYear = parts?.year ?? new Date().getFullYear();
57
+ const currentYear = parts?.year ?? todayYear;
50
58
  const currentMonth = parts?.month ?? 1;
59
+ const minParts = minValue ? parseDateId(minValue) : null;
60
+ const maxParts = maxValue ? parseDateId(maxValue) : null;
61
+ const minYear = minParts?.year;
62
+ const minMonth = minParts?.month;
63
+ const maxYear = maxParts?.year;
64
+ const maxMonth = maxParts?.month;
51
65
 
52
- const years = useMemo(
53
- () =>
54
- Array.from(
55
- { length: YEAR_RANGE_BEFORE + YEAR_RANGE_AFTER + 1 },
56
- (_, i) => currentYear - YEAR_RANGE_BEFORE + i,
57
- ),
58
- [currentYear],
59
- );
66
+ const { first, last } = getYearRange(minYear, maxYear, currentYear, todayYear);
67
+
68
+ // Matches the day grid so switching views does not change the Calendar's height.
69
+ const height = getFlashCalendarGridHeight(monthId);
60
70
 
61
71
  const selectYear = useCallback(
62
72
  (year: number) => {
63
73
  if (disabled) {
64
74
  return;
65
75
  }
66
- onMonthIdChange(formatMonthId({ year, month: currentMonth }));
76
+ let month = currentMonth;
77
+ if (minYear === year && minMonth !== undefined) {
78
+ month = Math.max(month, minMonth);
79
+ }
80
+ if (maxYear === year && maxMonth !== undefined) {
81
+ month = Math.min(month, maxMonth);
82
+ }
83
+ onMonthIdChange(formatMonthId({ year, month }));
67
84
  onViewChange('month');
68
85
  },
69
- [currentMonth, disabled, onMonthIdChange, onViewChange],
86
+ [currentMonth, disabled, maxMonth, maxYear, minMonth, minYear, onMonthIdChange, onViewChange],
87
+ );
88
+
89
+ const renderYear = useCallback(
90
+ (year: number) => {
91
+ const selected = year === currentYear;
92
+ return (
93
+ <Pressable
94
+ accessibilityRole="button"
95
+ accessibilityState={{ selected, disabled }}
96
+ accessibilityLabel={formatSelectYear(labels, year)}
97
+ disabled={disabled}
98
+ onPress={() => selectYear(year)}
99
+ className={calendarPillCellVariants({ selected, disabled })}
100
+ >
101
+ <Text className={calendarPillCellTextVariants({ selected })}>{String(year)}</Text>
102
+ </Pressable>
103
+ );
104
+ },
105
+ [currentYear, disabled, labels, selectYear],
70
106
  );
71
107
 
72
108
  return (
73
109
  <View
74
110
  ref={ref}
75
- className={cn(calendarYearPickerGridVariants(), className)}
111
+ className={cn(calendarYearPickerListVariants(), className)}
76
112
  style={style}
77
113
  accessibilityRole="none"
78
114
  {...props}
79
115
  >
80
- {years.map((year) => {
81
- const selected = year === currentYear;
82
- return (
83
- <Pressable
84
- key={year}
85
- accessibilityRole="button"
86
- accessibilityState={{ selected, disabled }}
87
- accessibilityLabel={formatSelectYear(labels, year)}
88
- disabled={disabled}
89
- onPress={() => selectYear(year)}
90
- className={calendarPillCellVariants({ selected, disabled })}
91
- >
92
- <Text className={calendarPillCellTextVariants({ selected })}>{String(year)}</Text>
93
- </Pressable>
94
- );
95
- })}
116
+ <CalendarYearList
117
+ first={first}
118
+ last={last}
119
+ selectedYear={currentYear}
120
+ height={height}
121
+ renderYear={renderYear}
122
+ />
96
123
  </View>
97
124
  );
98
125
  },
@@ -6,6 +6,9 @@ import { formatDateId, parseMonthId, type DateId, type MonthId } from '@cdx-ui/f
6
6
  const DAY_SIZE = 36;
7
7
  const DAY_RADIUS = DAY_SIZE / 2;
8
8
 
9
+ /** Bottom padding the theme adds under the weekday header row (`rowWeek.container`). */
10
+ const WEEK_HEADER_PADDING_BOTTOM = 4;
11
+
9
12
  const CALENDAR_COLOR_VARS = [
10
13
  '--color-surface-action-strong',
11
14
  '--color-content-action-on-strong',
@@ -159,7 +162,7 @@ export function useFlashCalendarTheme(selectedDateId?: DateId): CalendarTheme {
159
162
  },
160
163
  rowWeek: {
161
164
  container: {
162
- paddingBottom: 4,
165
+ paddingBottom: WEEK_HEADER_PADDING_BOTTOM,
163
166
  },
164
167
  },
165
168
  itemWeekName: {
@@ -249,7 +252,7 @@ export function useFlashCalendarRangeTheme(range?: {
249
252
  },
250
253
  rowWeek: {
251
254
  container: {
252
- paddingBottom: 4,
255
+ paddingBottom: WEEK_HEADER_PADDING_BOTTOM,
253
256
  },
254
257
  },
255
258
  itemWeekName: {
@@ -348,6 +351,28 @@ export const FLASH_CALENDAR_RANGE_DAY_SPACING = 0;
348
351
 
349
352
  export const FLASH_CALENDAR_DAY_HEIGHT = DAY_SIZE;
350
353
 
354
+ export const FLASH_CALENDAR_WEEK_HEADER_HEIGHT = 28;
355
+
356
+ export const FLASH_CALENDAR_ROW_VERTICAL_SPACING = 4;
357
+
358
+ /**
359
+ * Rendered height of the Flash Calendar month grid for `monthId`, derived from the same layout
360
+ * values passed to Flash Calendar (zero-height month row, weekday header, one row per week with
361
+ * Sunday as the first day). Lets the year view match the day view without measuring.
362
+ */
363
+ export function getFlashCalendarGridHeight(monthId: MonthId): number {
364
+ const parts = parseMonthId(monthId);
365
+ const firstWeekday = parts ? new Date(parts.year, parts.month - 1, 1).getDay() : 0;
366
+ const daysInMonth = parts ? new Date(parts.year, parts.month, 0).getDate() : 31;
367
+ const weekCount = Math.ceil((firstWeekday + daysInMonth) / 7);
368
+ return (
369
+ FLASH_CALENDAR_WEEK_HEADER_HEIGHT +
370
+ WEEK_HEADER_PADDING_BOTTOM +
371
+ weekCount * FLASH_CALENDAR_DAY_HEIGHT +
372
+ (weekCount + 1) * FLASH_CALENDAR_ROW_VERTICAL_SPACING
373
+ );
374
+ }
375
+
351
376
  export function formatFlashCalendarWeekdayShort(date: Date, locale: string): string {
352
377
  return new Intl.DateTimeFormat(locale, { weekday: 'short' }).format(date);
353
378
  }
@@ -15,6 +15,8 @@ import { CalendarMonthHeader } from './CalendarMonthHeader';
15
15
  import { CalendarYearPickerGrid } from './CalendarYearPickerGrid';
16
16
  import {
17
17
  FLASH_CALENDAR_DAY_HEIGHT,
18
+ FLASH_CALENDAR_ROW_VERTICAL_SPACING,
19
+ FLASH_CALENDAR_WEEK_HEADER_HEIGHT,
18
20
  formatFlashCalendarWeekdayShort,
19
21
  getMonthDateIds,
20
22
  toCalendarDateId,
@@ -143,8 +145,8 @@ const CalendarGrid = forwardRef<View, ViewProps>(({ className, style, ...props }
143
145
  calendarDisabledDateIds={unavailableDateIds}
144
146
  calendarMonthHeaderHeight={0}
145
147
  calendarDayHeight={FLASH_CALENDAR_DAY_HEIGHT}
146
- calendarWeekHeaderHeight={28}
147
- calendarRowVerticalSpacing={4}
148
+ calendarWeekHeaderHeight={FLASH_CALENDAR_WEEK_HEADER_HEIGHT}
149
+ calendarRowVerticalSpacing={FLASH_CALENDAR_ROW_VERTICAL_SPACING}
148
150
  calendarRowHorizontalSpacing={4}
149
151
  getCalendarMonthFormat={() => ''}
150
152
  getCalendarWeekDayFormat={formatWeekday}
@@ -158,12 +160,15 @@ const CalendarGrid = forwardRef<View, ViewProps>(({ className, style, ...props }
158
160
  CalendarGrid.displayName = 'Calendar.Grid';
159
161
 
160
162
  const CalendarYearPicker = forwardRef<View, ViewProps>(({ className, style, ...props }, ref) => {
161
- const { monthId, onMonthIdChange, disabled, setView, labels } = useCalendarContext();
163
+ const { monthId, onMonthIdChange, minValue, maxValue, disabled, setView, labels } =
164
+ useCalendarContext();
162
165
  return (
163
166
  <CalendarYearPickerGrid
164
167
  ref={ref}
165
168
  monthId={monthId}
166
169
  onMonthIdChange={onMonthIdChange}
170
+ minValue={minValue}
171
+ maxValue={maxValue}
167
172
  disabled={disabled}
168
173
  onViewChange={setView}
169
174
  labels={labels}
@@ -31,12 +31,28 @@ export const calendarMonthHeaderIconVariants = cva('size-5 shrink-0', {
31
31
  defaultVariants: { isYearView: false },
32
32
  });
33
33
 
34
- export const calendarMonthHeaderNavVariants = cva('flex-row items-center gap-1');
34
+ export const calendarMonthHeaderNavVariants = cva('flex-row items-center gap-1', {
35
+ variants: {
36
+ hidden: {
37
+ true: 'opacity-0',
38
+ false: '',
39
+ },
40
+ },
41
+ defaultVariants: { hidden: false },
42
+ });
35
43
 
36
44
  export const calendarYearPickerGridVariants = cva(
37
45
  'flex-row flex-wrap justify-between gap-y-2 px-1 py-1',
38
46
  );
39
47
 
48
+ export const calendarYearPickerListVariants = cva('w-full');
49
+
50
+ export const calendarYearPickerListContentVariants = cva('px-1');
51
+
52
+ export const calendarYearPickerRowVariants = cva('flex-row items-center justify-between');
53
+
54
+ export const calendarYearPickerPlaceholderVariants = cva('w-[31%]');
55
+
40
56
  export const calendarPillCellVariants = cva(
41
57
  'my-0.5 w-[31%] items-center justify-center rounded-[var(--border-radius-round)] py-2.5',
42
58
  {