@butternutbox/pawprint-native 0.0.1 → 0.1.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 (182) hide show
  1. package/.turbo/turbo-build.log +15 -15
  2. package/CHANGELOG.md +16 -0
  3. package/COMPONENT_GUIDELINES.md +111 -4
  4. package/dist/index.cjs +12370 -1455
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +1110 -11
  7. package/dist/index.d.ts +1110 -11
  8. package/dist/index.js +12324 -1455
  9. package/dist/index.js.map +1 -1
  10. package/package.json +28 -9
  11. package/src/__mocks__/asset-stub.ts +1 -0
  12. package/src/__mocks__/emotion-native.tsx +18 -0
  13. package/src/__mocks__/react-native-gesture-handler.tsx +41 -0
  14. package/src/__mocks__/react-native-reanimated.tsx +79 -0
  15. package/src/__mocks__/react-native-safe-area-context.tsx +6 -0
  16. package/src/__mocks__/react-native-svg.tsx +27 -0
  17. package/src/__mocks__/react-native-worklets.tsx +11 -0
  18. package/src/__mocks__/react-native.tsx +338 -0
  19. package/src/__mocks__/rn-primitives/avatar.tsx +24 -0
  20. package/src/__mocks__/rn-primitives/checkbox.tsx +19 -0
  21. package/src/__mocks__/rn-primitives/select.tsx +116 -0
  22. package/src/__mocks__/rn-primitives/slider.tsx +40 -0
  23. package/src/__mocks__/rn-primitives/slot.tsx +30 -0
  24. package/src/__mocks__/rn-primitives/switch.tsx +24 -0
  25. package/src/__mocks__/rn-primitives/toggle.tsx +16 -0
  26. package/src/components/atoms/Avatar/Avatar.stories.tsx +57 -49
  27. package/src/components/atoms/Avatar/Avatar.test.tsx +269 -0
  28. package/src/components/atoms/Avatar/Avatar.tsx +68 -22
  29. package/src/components/atoms/Avatar/index.ts +1 -6
  30. package/src/components/atoms/Badge/Badge.stories.tsx +5 -29
  31. package/src/components/atoms/Badge/Badge.test.tsx +90 -0
  32. package/src/components/atoms/Button/Button.test.tsx +123 -0
  33. package/src/components/atoms/Button/Button.tsx +1 -1
  34. package/src/components/atoms/CarouselControls/CarouselControls.stories.tsx +217 -0
  35. package/src/components/atoms/CarouselControls/CarouselControls.tsx +127 -0
  36. package/src/components/atoms/CarouselControls/index.ts +2 -0
  37. package/src/components/atoms/Hint/Hint.test.tsx +36 -0
  38. package/src/components/atoms/Icon/Icon.test.tsx +98 -0
  39. package/src/components/atoms/Icon/Icon.tsx +5 -1
  40. package/src/components/atoms/IconButton/IconButton.test.tsx +101 -0
  41. package/src/components/atoms/Illustration/Illustration.test.tsx +55 -0
  42. package/src/components/atoms/Input/Input.stories.tsx +129 -86
  43. package/src/components/atoms/Input/Input.test.tsx +306 -0
  44. package/src/components/atoms/Input/Input.tsx +9 -1
  45. package/src/components/atoms/Input/InputField.tsx +226 -74
  46. package/src/components/atoms/Link/Link.test.tsx +89 -0
  47. package/src/components/atoms/Logo/Logo.registry.ts +30 -5
  48. package/src/components/atoms/Logo/Logo.stories.tsx +108 -0
  49. package/src/components/atoms/Logo/Logo.test.tsx +56 -0
  50. package/src/components/atoms/Logo/assets/BCorp.tsx +113 -0
  51. package/src/components/atoms/Logo/assets/ButternutFavicon.tsx +33 -0
  52. package/src/components/atoms/Logo/assets/ButternutPrimary.tsx +294 -0
  53. package/src/components/atoms/Logo/assets/ButternutTabbedBottom.tsx +294 -0
  54. package/src/components/atoms/Logo/assets/ButternutTabbedTop.tsx +294 -0
  55. package/src/components/atoms/Logo/assets/ButternutWordmark.tsx +274 -0
  56. package/src/components/atoms/Logo/assets/PsiBufetFavicon.tsx +45 -0
  57. package/src/components/atoms/Logo/assets/PsiBufetPrimary.tsx +218 -0
  58. package/src/components/atoms/Logo/assets/PsiBufetTabbedBottom.tsx +218 -0
  59. package/src/components/atoms/Logo/assets/PsiBufetTabbedTop.tsx +218 -0
  60. package/src/components/atoms/Logo/assets/PsiBufetWordmark.tsx +195 -0
  61. package/src/components/atoms/Logo/assets/index.ts +11 -0
  62. package/src/components/atoms/NumberInput/NumberInput.stories.tsx +183 -0
  63. package/src/components/atoms/NumberInput/NumberInput.test.tsx +261 -0
  64. package/src/components/atoms/NumberInput/NumberInput.tsx +129 -0
  65. package/src/components/atoms/NumberInput/NumberInputField.tsx +77 -0
  66. package/src/components/atoms/NumberInput/index.ts +4 -0
  67. package/src/components/atoms/Spinner/Spinner.test.tsx +46 -0
  68. package/src/components/atoms/Spinner/Spinner.tsx +14 -5
  69. package/src/components/atoms/Switch/Switch.test.tsx +92 -0
  70. package/src/components/atoms/Switch/Switch.tsx +16 -13
  71. package/src/components/atoms/Tag/Tag.test.tsx +70 -0
  72. package/src/components/atoms/TextArea/TextArea.stories.tsx +303 -0
  73. package/src/components/atoms/TextArea/TextArea.test.tsx +416 -0
  74. package/src/components/atoms/TextArea/TextArea.tsx +171 -0
  75. package/src/components/atoms/TextArea/TextAreaField.tsx +304 -0
  76. package/src/components/atoms/TextArea/TextAreaLabel.tsx +103 -0
  77. package/src/components/atoms/TextArea/index.ts +6 -0
  78. package/src/components/atoms/Typography/Typography.test.tsx +94 -0
  79. package/src/components/atoms/index.ts +3 -0
  80. package/src/components/molecules/Accordion/Accordion.stories.tsx +177 -0
  81. package/src/components/molecules/Accordion/Accordion.test.tsx +185 -0
  82. package/src/components/molecules/Accordion/Accordion.tsx +284 -0
  83. package/src/components/molecules/Accordion/index.ts +6 -0
  84. package/src/components/molecules/Animated/Animated.stories.tsx +254 -0
  85. package/src/components/molecules/Animated/Animated.tsx +283 -0
  86. package/src/components/molecules/Animated/index.ts +10 -0
  87. package/src/components/molecules/ButtonDock/ButtonDock.test.tsx +83 -0
  88. package/src/components/molecules/ButtonGroup/ButtonGroup.stories.tsx +8 -14
  89. package/src/components/molecules/ButtonGroup/ButtonGroup.test.tsx +73 -0
  90. package/src/components/molecules/ButtonGroup/ButtonGroup.tsx +25 -3
  91. package/src/components/molecules/Checkbox/Checkbox.stories.tsx +72 -0
  92. package/src/components/molecules/Checkbox/Checkbox.test.tsx +117 -0
  93. package/src/components/molecules/Checkbox/Checkbox.tsx +101 -95
  94. package/src/components/molecules/CopyField/CopyField.stories.tsx +313 -0
  95. package/src/components/molecules/CopyField/CopyField.test.tsx +431 -0
  96. package/src/components/molecules/CopyField/CopyField.tsx +156 -0
  97. package/src/components/molecules/CopyField/CopyFieldInput.tsx +127 -0
  98. package/src/components/molecules/CopyField/hooks/index.ts +1 -0
  99. package/src/components/molecules/CopyField/hooks/useCopyField.ts +25 -0
  100. package/src/components/molecules/CopyField/index.ts +4 -0
  101. package/src/components/molecules/DatePicker/DatePicker.stories.tsx +298 -0
  102. package/src/components/molecules/DatePicker/DatePicker.test.tsx +201 -0
  103. package/src/components/molecules/DatePicker/DatePicker.tsx +590 -0
  104. package/src/components/molecules/DatePicker/index.ts +2 -0
  105. package/src/components/molecules/Drawer/Drawer.stories.tsx +285 -0
  106. package/src/components/molecules/Drawer/Drawer.test.tsx +180 -0
  107. package/src/components/molecules/Drawer/Drawer.tsx +187 -0
  108. package/src/components/molecules/Drawer/DrawerBody.tsx +80 -0
  109. package/src/components/molecules/Drawer/DrawerClose.tsx +76 -0
  110. package/src/components/molecules/Drawer/DrawerContent.tsx +339 -0
  111. package/src/components/molecules/Drawer/DrawerContext.ts +19 -0
  112. package/src/components/molecules/Drawer/DrawerDescription.tsx +31 -0
  113. package/src/components/molecules/Drawer/DrawerDragContext.ts +11 -0
  114. package/src/components/molecules/Drawer/DrawerFooter.tsx +49 -0
  115. package/src/components/molecules/Drawer/DrawerFooterContext.ts +6 -0
  116. package/src/components/molecules/Drawer/DrawerGrabber.tsx +62 -0
  117. package/src/components/molecules/Drawer/DrawerHeader.tsx +244 -0
  118. package/src/components/molecules/Drawer/DrawerHeaderContext.ts +13 -0
  119. package/src/components/molecules/Drawer/DrawerOverlay.tsx +53 -0
  120. package/src/components/molecules/Drawer/DrawerTitle.tsx +32 -0
  121. package/src/components/molecules/Drawer/index.ts +12 -0
  122. package/src/components/molecules/FilterTab/FilterTab.stories.tsx +210 -0
  123. package/src/components/molecules/FilterTab/FilterTab.tsx +310 -0
  124. package/src/components/molecules/FilterTab/index.ts +2 -0
  125. package/src/components/molecules/MessageCard/MessageCard.stories.tsx +169 -0
  126. package/src/components/molecules/MessageCard/MessageCard.tsx +362 -0
  127. package/src/components/molecules/MessageCard/index.ts +10 -0
  128. package/src/components/molecules/Notification/Notification.stories.tsx +219 -0
  129. package/src/components/molecules/Notification/Notification.tsx +426 -0
  130. package/src/components/molecules/Notification/index.ts +2 -0
  131. package/src/components/molecules/NumberField/NumberField.stories.tsx +231 -0
  132. package/src/components/molecules/NumberField/NumberField.tsx +186 -0
  133. package/src/components/molecules/NumberField/NumberFieldInput.tsx +287 -0
  134. package/src/components/molecules/NumberField/index.ts +2 -0
  135. package/src/components/molecules/PasswordField/PasswordField.stories.tsx +362 -0
  136. package/src/components/molecules/PasswordField/PasswordField.test.tsx +369 -0
  137. package/src/components/molecules/PasswordField/PasswordField.tsx +194 -0
  138. package/src/components/molecules/PasswordField/PasswordFieldError.tsx +52 -0
  139. package/src/components/molecules/PasswordField/PasswordFieldInput.tsx +73 -0
  140. package/src/components/molecules/PasswordField/PasswordFieldRequirements.tsx +92 -0
  141. package/src/components/molecules/PasswordField/hooks/index.ts +2 -0
  142. package/src/components/molecules/PasswordField/hooks/usePasswordField.ts +113 -0
  143. package/src/components/molecules/PasswordField/index.ts +10 -0
  144. package/src/components/molecules/PictureSelector/PictureSelector.stories.tsx +243 -0
  145. package/src/components/molecules/PictureSelector/PictureSelector.tsx +313 -0
  146. package/src/components/molecules/PictureSelector/index.ts +5 -0
  147. package/src/components/molecules/Progress/Progress.stories.tsx +145 -0
  148. package/src/components/molecules/Progress/Progress.tsx +184 -0
  149. package/src/components/molecules/Progress/index.ts +2 -0
  150. package/src/components/molecules/Radio/Radio.test.tsx +104 -0
  151. package/src/components/molecules/Radio/Radio.tsx +1 -2
  152. package/src/components/molecules/SearchField/SearchField.stories.tsx +242 -0
  153. package/src/components/molecules/SearchField/SearchField.test.tsx +318 -0
  154. package/src/components/molecules/SearchField/SearchField.tsx +143 -0
  155. package/src/components/molecules/SearchField/SearchFieldInput.tsx +63 -0
  156. package/src/components/molecules/SearchField/hooks/index.ts +1 -0
  157. package/src/components/molecules/SearchField/hooks/useSearchField.ts +56 -0
  158. package/src/components/molecules/SearchField/index.ts +4 -0
  159. package/src/components/molecules/SegmentedControl/SegmentedControl.stories.tsx +31 -8
  160. package/src/components/molecules/SegmentedControl/SegmentedControl.test.tsx +141 -0
  161. package/src/components/molecules/SegmentedControl/SegmentedControl.tsx +237 -23
  162. package/src/components/molecules/SelectField/SelectField.stories.tsx +320 -0
  163. package/src/components/molecules/SelectField/SelectField.test.tsx +254 -0
  164. package/src/components/molecules/SelectField/SelectField.tsx +236 -0
  165. package/src/components/molecules/SelectField/SelectFieldContent.tsx +85 -0
  166. package/src/components/molecules/SelectField/SelectFieldItem.tsx +133 -0
  167. package/src/components/molecules/SelectField/SelectFieldTrigger.tsx +170 -0
  168. package/src/components/molecules/SelectField/SelectFieldValue.tsx +31 -0
  169. package/src/components/molecules/SelectField/hooks/index.ts +2 -0
  170. package/src/components/molecules/SelectField/hooks/useSelectField.ts +84 -0
  171. package/src/components/molecules/SelectField/index.ts +10 -0
  172. package/src/components/molecules/Slider/Slider.test.tsx +102 -0
  173. package/src/components/molecules/Slider/Slider.tsx +293 -180
  174. package/src/components/molecules/Tooltip/Tooltip.stories.tsx +168 -0
  175. package/src/components/molecules/Tooltip/Tooltip.tsx +326 -0
  176. package/src/components/molecules/Tooltip/index.ts +2 -0
  177. package/src/components/molecules/index.ts +15 -0
  178. package/src/test-utils.tsx +20 -0
  179. package/tsconfig.json +1 -1
  180. package/tsup.config.ts +16 -2
  181. package/vitest.config.ts +114 -0
  182. package/vitest.setup.ts +16 -0
@@ -0,0 +1,590 @@
1
+ import React, { useCallback, useMemo, useState } from "react"
2
+ import { format, isBefore, isSameDay, isToday, type Locale } from "date-fns"
3
+ import { View, Pressable, ViewProps } from "react-native"
4
+ import styled from "@emotion/native"
5
+ import { useTheme } from "@emotion/react"
6
+ import {
7
+ KeyboardArrowLeft,
8
+ KeyboardArrowRight
9
+ } from "@butternutbox/pawprint-icons/core"
10
+ import Reanimated, {
11
+ Easing,
12
+ FadeInLeft,
13
+ FadeInRight,
14
+ FadeOutLeft,
15
+ FadeOutRight
16
+ } from "react-native-reanimated"
17
+ import { IconButton } from "../../atoms/IconButton"
18
+ import { Typography } from "../../atoms/Typography"
19
+
20
+ /**
21
+ * Visual state for a calendar date cell.
22
+ *
23
+ * - `previous` — Dates before today. Non-interactive, shown in muted text.
24
+ * - `current` — Today's date. Bold + underlined, no indicator fill.
25
+ * - `default` — A future date with no scheduled delivery. Plain text.
26
+ * - `nextBox` — The next scheduled box delivery date. Filled dark indicator.
27
+ * When selected (`selectedDate` matches): dashed-border indicator.
28
+ * When disabled (`isDateDisabled` returns true): shown at reduced opacity (undeliverable — e.g. bank holiday).
29
+ * - `futureBox` — A future recurring delivery slot (cadence-based, typically weekly).
30
+ * Beige indicator by default.
31
+ * When selected: dashed-border indicator (projected future delivery after user's chosen date).
32
+ * When disabled: shown at reduced opacity (undeliverable — e.g. bank holiday).
33
+ */
34
+ export type DateItemState =
35
+ | "default"
36
+ | "previous"
37
+ | "current"
38
+ | "nextBox"
39
+ | "futureBox"
40
+
41
+ const parseTokenValue = (value: string): number => parseFloat(value)
42
+
43
+ const slideEaseOut = Easing.out(Easing.quad)
44
+
45
+ const MONTH_SLIDE_ENTERING = {
46
+ forward: FadeInRight.duration(250).easing(slideEaseOut),
47
+ backward: FadeInLeft.duration(250).easing(slideEaseOut)
48
+ } as const
49
+
50
+ const MONTH_SLIDE_EXITING = {
51
+ forward: FadeOutLeft.duration(200),
52
+ backward: FadeOutRight.duration(200)
53
+ } as const
54
+
55
+ const DEFAULT_WEEK_LABELS: [
56
+ string,
57
+ string,
58
+ string,
59
+ string,
60
+ string,
61
+ string,
62
+ string
63
+ ] = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
64
+
65
+ function buildCalendarGrid(year: number, month: number): (Date | null)[][] {
66
+ const firstDay = new Date(year, month, 1)
67
+ const lastDay = new Date(year, month + 1, 0)
68
+ const startDayOfWeek = (firstDay.getDay() + 6) % 7 // Mon=0, Sun=6
69
+
70
+ const rows: (Date | null)[][] = []
71
+ let currentRow: (Date | null)[] = Array(startDayOfWeek).fill(null)
72
+
73
+ for (let day = 1; day <= lastDay.getDate(); day++) {
74
+ currentRow.push(new Date(year, month, day))
75
+ if (currentRow.length === 7) {
76
+ rows.push(currentRow)
77
+ currentRow = []
78
+ }
79
+ }
80
+
81
+ if (currentRow.length > 0) {
82
+ while (currentRow.length < 7) currentRow.push(null)
83
+ rows.push(currentRow)
84
+ }
85
+
86
+ return rows
87
+ }
88
+
89
+ function toDateKey(date: Date): string {
90
+ return format(date, "yyyy-MM-dd")
91
+ }
92
+
93
+ function defaultGetDateState(date: Date): DateItemState {
94
+ if (isToday(date)) return "current"
95
+ if (isBefore(date, new Date())) return "previous"
96
+ return "default"
97
+ }
98
+
99
+ function getDefaultMonthLabel(
100
+ year: number,
101
+ month: number,
102
+ locale?: Locale
103
+ ): string {
104
+ return format(new Date(year, month, 1), "MMMM yyyy", { locale })
105
+ }
106
+
107
+ type DatePickerOwnProps = {
108
+ year: number
109
+ month: number
110
+ selectedDate?: Date | null
111
+ selectedDates?: Date[]
112
+ onDateSelect?: (date: Date) => void
113
+ onPrevMonth?: () => void
114
+ onNextMonth?: () => void
115
+ onMonthChange?: (year: number, month: number) => void
116
+ showWeekHeader?: boolean
117
+ showPrevControl?: boolean
118
+ showNextControl?: boolean
119
+ locale?: Locale
120
+ monthLabel?: string
121
+ weekDayLabels?: [string, string, string, string, string, string, string]
122
+ getDateState?: (date: Date) => DateItemState
123
+ isDateDisabled?: (date: Date) => boolean
124
+ minDate?: Date
125
+ maxDate?: Date
126
+ }
127
+
128
+ export type DatePickerProps = DatePickerOwnProps &
129
+ Omit<ViewProps, keyof DatePickerOwnProps>
130
+
131
+ const StyledContainer = styled(View)<{
132
+ containerGap: number
133
+ containerMaxWidth: number
134
+ }>(({ containerGap, containerMaxWidth }) => ({
135
+ flexDirection: "column",
136
+ gap: containerGap,
137
+ maxWidth: containerMaxWidth
138
+ }))
139
+
140
+ const StyledMonthHeader = styled(View)<{ headerGap: number }>(
141
+ ({ headerGap }) => ({
142
+ flexDirection: "row",
143
+ alignItems: "center",
144
+ gap: headerGap
145
+ })
146
+ )
147
+
148
+ const StyledWeekRow = styled(View)({
149
+ flexDirection: "row"
150
+ })
151
+
152
+ const StyledWeekCell = styled(View)<{ cellHeight: number }>(
153
+ ({ cellHeight }) => ({
154
+ flex: 1,
155
+ alignItems: "center",
156
+ justifyContent: "center",
157
+ height: cellHeight
158
+ })
159
+ )
160
+
161
+ const StyledDatesGrid = styled(View)<{ rowGap: number }>(({ rowGap }) => ({
162
+ flexDirection: "column",
163
+ gap: rowGap
164
+ }))
165
+
166
+ const StyledDateRow = styled(View)({
167
+ flexDirection: "row"
168
+ })
169
+
170
+ const StyledDateCell = styled(View)<{ cellHeight: number }>(
171
+ ({ cellHeight }) => ({
172
+ flex: 1,
173
+ height: cellHeight,
174
+ alignItems: "center",
175
+ justifyContent: "center"
176
+ })
177
+ )
178
+
179
+ const StyledIndicator = styled(View)<{
180
+ indicatorSize: number
181
+ indicatorBorderRadius: number
182
+ indicatorBg: string
183
+ indicatorBorderWidth: number
184
+ indicatorBorderColor: string
185
+ indicatorBorderStyle: "solid" | "dashed"
186
+ }>(
187
+ ({
188
+ indicatorSize,
189
+ indicatorBorderRadius,
190
+ indicatorBg,
191
+ indicatorBorderWidth,
192
+ indicatorBorderColor,
193
+ indicatorBorderStyle
194
+ }) => ({
195
+ width: indicatorSize,
196
+ height: indicatorSize,
197
+ borderRadius: indicatorBorderRadius,
198
+ backgroundColor: indicatorBg,
199
+ borderWidth: indicatorBorderWidth,
200
+ borderColor: indicatorBorderColor,
201
+ borderStyle: indicatorBorderStyle,
202
+ alignItems: "center",
203
+ justifyContent: "center"
204
+ })
205
+ )
206
+
207
+ /**
208
+ * A controlled calendar date-picker showing a single month grid.
209
+ *
210
+ * Supports date states: default, previous, current, nextBox, futureBox.
211
+ * Prev/next month navigation and the week-day header are optional.
212
+ *
213
+ * Note: Unlike the web version, there is no hover state (not available on mobile).
214
+ *
215
+ * @param {number} year - Full year (e.g. 2026).
216
+ * @param {number} month - Zero-based month index (0–11).
217
+ * @param {Date | null} [selectedDate] - Currently selected date.
218
+ * @param {(date: Date) => void} [onDateSelect] - Called when the user selects a date.
219
+ * @param {() => void} [onPrevMonth] - Called when the prev-month control is pressed.
220
+ * @param {() => void} [onNextMonth] - Called when the next-month control is pressed.
221
+ * @param {(year: number, month: number) => void} [onMonthChange] - Called with the new year and month (0-based) whenever the month changes via prev/next.
222
+ * @param {boolean} [showWeekHeader=true] - Show the Mon–Sun column headers.
223
+ * @param {boolean} [showPrevControl=true] - Show the left chevron control.
224
+ * @param {boolean} [showNextControl=true] - Show the right chevron control.
225
+ * @param {string} [monthLabel] - Override the month/year label (defaults to Intl formatting).
226
+ * @param {[string,string,string,string,string,string,string]} [weekDayLabels] - Day-column labels Mon→Sun.
227
+ * @param {(date: Date) => DateItemState} [getDateState] - Returns the visual state for a date.
228
+ * Defaults to: past="previous", today="current", future="default".
229
+ * @param {(date: Date) => boolean} [isDateDisabled] - Returns true to disable a date.
230
+ * @param {Date} [minDate] - Dates before this (exclusive of time) will be disabled.
231
+ * @param {Date} [maxDate] - Dates after this (exclusive of time) will be disabled.
232
+ *
233
+ * @example
234
+ * ```tsx
235
+ * import { DatePicker } from "@butternutbox/pawprint-native"
236
+ *
237
+ * const [month, setMonth] = React.useState(new Date().getMonth())
238
+ * const [year, setYear] = React.useState(new Date().getFullYear())
239
+ * const [selected, setSelected] = React.useState<Date | null>(null)
240
+ *
241
+ * <DatePicker
242
+ * year={year}
243
+ * month={month}
244
+ * selectedDate={selected}
245
+ * onDateSelect={setSelected}
246
+ * onPrevMonth={() => setMonth(m => m === 0 ? (setYear(y => y - 1), 11) : m - 1)}
247
+ * onNextMonth={() => setMonth(m => m === 11 ? (setYear(y => y + 1), 0) : m + 1)}
248
+ * />
249
+ * ```
250
+ */
251
+ export const DatePicker = React.forwardRef<View, DatePickerProps>(
252
+ (
253
+ {
254
+ year,
255
+ month,
256
+ selectedDate,
257
+ selectedDates,
258
+ onDateSelect,
259
+ onPrevMonth,
260
+ onNextMonth,
261
+ onMonthChange,
262
+ showWeekHeader = true,
263
+ showPrevControl = true,
264
+ showNextControl = true,
265
+ locale,
266
+ monthLabel,
267
+ weekDayLabels = DEFAULT_WEEK_LABELS,
268
+ getDateState,
269
+ isDateDisabled,
270
+ minDate,
271
+ maxDate,
272
+ ...rest
273
+ },
274
+ ref
275
+ ) => {
276
+ const theme = useTheme()
277
+ const { datePicker } = theme.tokens.components
278
+ const dt = datePicker.dateItem
279
+
280
+ const [slideDir, setSlideDir] = useState<"forward" | "backward">("forward")
281
+
282
+ const {
283
+ containerGap,
284
+ containerMaxWidth,
285
+ headerGap,
286
+ cellHeight,
287
+ indicatorSize,
288
+ indicatorBorderRadius,
289
+ selectedBorderWidth,
290
+ disabledOpacity,
291
+ rowGap
292
+ } = useMemo(
293
+ () => ({
294
+ containerGap: parseTokenValue(datePicker.spacing.gap),
295
+ containerMaxWidth: parseTokenValue(dt.size.width) * 7,
296
+ headerGap: parseTokenValue(datePicker.month.spacing.gap),
297
+ cellHeight: parseTokenValue(dt.size.height),
298
+ indicatorSize: parseTokenValue(dt.size.indicator),
299
+ indicatorBorderRadius: parseTokenValue(dt.borderRadius.indicator),
300
+ selectedBorderWidth: parseTokenValue(dt.borderWidth.indicator.selected),
301
+ disabledOpacity: parseTokenValue(dt.opacity.disabled),
302
+ rowGap: parseTokenValue(datePicker.dates.spacing.gap)
303
+ }),
304
+ [datePicker, dt]
305
+ )
306
+
307
+ const resolvedGetDateState = useCallback(
308
+ (date: Date) => getDateState?.(date) ?? defaultGetDateState(date),
309
+ [getDateState]
310
+ )
311
+
312
+ const resolvedIsDateDisabled = useCallback(
313
+ (date: Date) => {
314
+ if (isDateDisabled?.(date)) return true
315
+ const d = new Date(date.getFullYear(), date.getMonth(), date.getDate())
316
+ if (minDate) {
317
+ const min = new Date(
318
+ minDate.getFullYear(),
319
+ minDate.getMonth(),
320
+ minDate.getDate()
321
+ )
322
+ if (d < min) return true
323
+ }
324
+ if (maxDate) {
325
+ const max = new Date(
326
+ maxDate.getFullYear(),
327
+ maxDate.getMonth(),
328
+ maxDate.getDate()
329
+ )
330
+ if (d > max) return true
331
+ }
332
+ return false
333
+ },
334
+ [isDateDisabled, minDate, maxDate]
335
+ )
336
+
337
+ const resolvedMonthLabel =
338
+ monthLabel ?? getDefaultMonthLabel(year, month, locale)
339
+
340
+ const calendarRows = useMemo(
341
+ () => buildCalendarGrid(year, month),
342
+ [year, month]
343
+ )
344
+
345
+ const selectedDatesSet = useMemo(
346
+ () => (selectedDates ? new Set(selectedDates.map(toDateKey)) : null),
347
+ [selectedDates]
348
+ )
349
+
350
+ const resolveDateCellConfig = useCallback(
351
+ (date: Date) => {
352
+ const state = resolvedGetDateState(date)
353
+ const isSelectedPrimary =
354
+ selectedDate != null && isSameDay(date, selectedDate)
355
+ const isSelectedSecondary =
356
+ selectedDatesSet?.has(toDateKey(date)) ?? false
357
+ const isSelected = isSelectedPrimary || isSelectedSecondary
358
+
359
+ let indicatorBg = "transparent"
360
+ let indicatorBorderColor = "transparent"
361
+ let textColor: string = dt.colour.text.default
362
+ let typography:
363
+ | typeof dt.typography.default
364
+ | typeof dt.typography.currentDate = dt.typography.default
365
+ let textDecoration: "underline" | "line-through" | undefined
366
+
367
+ if (isSelectedPrimary) {
368
+ indicatorBg = dt.indicator.colour.background.nextBox.selected
369
+ textColor = dt.colour.text.nextBox.selected
370
+ indicatorBorderColor = dt.colour.border.nextBoxSelected
371
+ } else if (isSelectedSecondary) {
372
+ indicatorBg = "transparent"
373
+ textColor = dt.colour.text.futureBox.selected
374
+ indicatorBorderColor = dt.colour.border.futureBoxSelected
375
+ } else if (state === "current") {
376
+ textColor = dt.colour.text.default
377
+ typography = dt.typography.currentDate
378
+ textDecoration = "underline"
379
+ } else if (state === "previous") {
380
+ textColor = dt.colour.text.previous
381
+ textDecoration = "line-through"
382
+ } else if (state === "nextBox") {
383
+ indicatorBg = dt.indicator.colour.background.nextBox.default
384
+ textColor = dt.colour.text.nextBox.default
385
+ } else if (state === "futureBox") {
386
+ indicatorBg = dt.indicator.colour.background.futureBox.default
387
+ textColor = dt.colour.text.futureBox.default
388
+ }
389
+
390
+ return {
391
+ state,
392
+ indicatorBg,
393
+ indicatorBorderColor,
394
+ textColor,
395
+ typography,
396
+ textDecoration,
397
+ isSelected
398
+ }
399
+ },
400
+ [resolvedGetDateState, selectedDate, selectedDatesSet, dt]
401
+ )
402
+
403
+ const handlePrevMonth = useCallback(() => {
404
+ setSlideDir("backward")
405
+ onPrevMonth?.()
406
+ if (onMonthChange) {
407
+ const newMonth = month === 0 ? 11 : month - 1
408
+ const newYear = month === 0 ? year - 1 : year
409
+ onMonthChange(newYear, newMonth)
410
+ }
411
+ }, [onPrevMonth, onMonthChange, year, month])
412
+
413
+ const handleNextMonth = useCallback(() => {
414
+ setSlideDir("forward")
415
+ onNextMonth?.()
416
+ if (onMonthChange) {
417
+ const newMonth = month === 11 ? 0 : month + 1
418
+ const newYear = month === 11 ? year + 1 : year
419
+ onMonthChange(newYear, newMonth)
420
+ }
421
+ }, [onNextMonth, onMonthChange, year, month])
422
+
423
+ return (
424
+ <StyledContainer
425
+ ref={ref}
426
+ containerGap={containerGap}
427
+ containerMaxWidth={containerMaxWidth}
428
+ {...rest}
429
+ >
430
+ {showWeekHeader && (
431
+ <StyledWeekRow accessibilityElementsHidden>
432
+ {weekDayLabels.map((label) => (
433
+ <StyledWeekCell key={label} cellHeight={cellHeight}>
434
+ <Typography
435
+ token={datePicker.week.typography}
436
+ align="center"
437
+ color={datePicker.week.colour.text.default}
438
+ >
439
+ {label}
440
+ </Typography>
441
+ </StyledWeekCell>
442
+ ))}
443
+ </StyledWeekRow>
444
+ )}
445
+
446
+ <StyledMonthHeader headerGap={headerGap}>
447
+ <View style={{ opacity: showPrevControl ? 1 : 0 }}>
448
+ <IconButton
449
+ icon={KeyboardArrowLeft}
450
+ variant="filled"
451
+ size="sm"
452
+ colour="primary"
453
+ aria-label="Previous month"
454
+ onPress={showPrevControl ? handlePrevMonth : undefined}
455
+ accessible={showPrevControl}
456
+ />
457
+ </View>
458
+
459
+ <View style={{ flex: 1 }}>
460
+ <Typography
461
+ token={datePicker.month.typography}
462
+ align="center"
463
+ color={datePicker.month.colour.text.default}
464
+ >
465
+ {resolvedMonthLabel}
466
+ </Typography>
467
+ </View>
468
+
469
+ <View style={{ opacity: showNextControl ? 1 : 0 }}>
470
+ <IconButton
471
+ icon={KeyboardArrowRight}
472
+ variant="filled"
473
+ size="sm"
474
+ colour="primary"
475
+ aria-label="Next month"
476
+ onPress={showNextControl ? handleNextMonth : undefined}
477
+ accessible={showNextControl}
478
+ />
479
+ </View>
480
+ </StyledMonthHeader>
481
+
482
+ <Reanimated.View
483
+ key={`${year}-${month}`}
484
+ entering={MONTH_SLIDE_ENTERING[slideDir]}
485
+ exiting={MONTH_SLIDE_EXITING[slideDir]}
486
+ >
487
+ <StyledDatesGrid
488
+ rowGap={rowGap}
489
+ accessible
490
+ accessibilityLabel={resolvedMonthLabel}
491
+ >
492
+ {calendarRows.map((row, rowIdx) => (
493
+ <StyledDateRow key={rowIdx}>
494
+ {row.map((date, colIdx) =>
495
+ !date ? (
496
+ <StyledDateCell
497
+ key={`empty-${rowIdx}-${colIdx}`}
498
+ cellHeight={cellHeight}
499
+ accessibilityElementsHidden
500
+ />
501
+ ) : (
502
+ <StyledDateCell
503
+ key={toDateKey(date)}
504
+ cellHeight={cellHeight}
505
+ >
506
+ {(() => {
507
+ const isDisabled = resolvedIsDateDisabled(date)
508
+ const {
509
+ state,
510
+ indicatorBg,
511
+ indicatorBorderColor,
512
+ textColor,
513
+ typography,
514
+ textDecoration,
515
+ isSelected
516
+ } = resolveDateCellConfig(date)
517
+
518
+ // Previous/Undeliverable: disabled default dates get muted text +
519
+ // strikethrough; opacity is reserved for disabled nextBox/futureBox.
520
+ const isUndeliverable =
521
+ isDisabled && state === "default"
522
+ const cellOpacity =
523
+ isDisabled && !isUndeliverable ? disabledOpacity : 1
524
+ const cellTextDecoration = isUndeliverable
525
+ ? "line-through"
526
+ : textDecoration
527
+ const cellTextColor = isUndeliverable
528
+ ? dt.colour.text.previous
529
+ : textColor
530
+
531
+ return (
532
+ <Pressable
533
+ onPress={
534
+ !isDisabled
535
+ ? () => onDateSelect?.(date)
536
+ : undefined
537
+ }
538
+ disabled={isDisabled}
539
+ accessible
540
+ accessibilityRole="button"
541
+ accessibilityLabel={`${date.getDate()} ${resolvedMonthLabel}`}
542
+ accessibilityState={{
543
+ selected: isSelected,
544
+ disabled: isDisabled
545
+ }}
546
+ accessibilityHint={
547
+ state === "current" ? "Today" : undefined
548
+ }
549
+ style={{ opacity: cellOpacity }}
550
+ >
551
+ <StyledIndicator
552
+ indicatorSize={indicatorSize}
553
+ indicatorBorderRadius={indicatorBorderRadius}
554
+ indicatorBg={indicatorBg}
555
+ indicatorBorderWidth={
556
+ indicatorBorderColor !== "transparent"
557
+ ? selectedBorderWidth
558
+ : 0
559
+ }
560
+ indicatorBorderColor={indicatorBorderColor}
561
+ indicatorBorderStyle={
562
+ indicatorBorderColor !== "transparent"
563
+ ? "dashed"
564
+ : "solid"
565
+ }
566
+ >
567
+ <Typography
568
+ color={cellTextColor as any}
569
+ token={typography}
570
+ textDecoration={cellTextDecoration}
571
+ >
572
+ {date.getDate().toString()}
573
+ </Typography>
574
+ </StyledIndicator>
575
+ </Pressable>
576
+ )
577
+ })()}
578
+ </StyledDateCell>
579
+ )
580
+ )}
581
+ </StyledDateRow>
582
+ ))}
583
+ </StyledDatesGrid>
584
+ </Reanimated.View>
585
+ </StyledContainer>
586
+ )
587
+ }
588
+ )
589
+
590
+ DatePicker.displayName = "DatePicker"
@@ -0,0 +1,2 @@
1
+ export { DatePicker } from "./DatePicker"
2
+ export type { DatePickerProps, DateItemState } from "./DatePicker"