From f633e22fdaa0ec222f0559b377a2fd73c37f063b Mon Sep 17 00:00:00 2001 From: hazrid93 Date: Mon, 20 Jul 2026 10:28:26 +0000 Subject: [PATCH] feat: add DatePicker component Addresses #4256. Adds a Material 3 calendar date picker that lets the user pick a date from a monthly grid. Controlled by a value Date, calls onChange with the selected date when a day is pressed. Renders a header with the month and year plus prev and next navigation that moves one month at a time and wraps the year at the December and January boundary, a weekday label row, and a full six week 42 cell grid of days including leading days from the previous month and trailing days from the next month. Highlights the selected day and disables days before an optional min date and after an optional max date. Each day exposes a button accessibility role and an accessibility label. The component is exported from the public surface. This covers the date picker half of the linked issue, with a companion time picker proposed separately. --- src/components/DatePicker/DatePicker.tsx | 281 +++++++++++++++++++++++ src/components/DatePicker/types.ts | 22 ++ src/index.tsx | 2 + 3 files changed, 305 insertions(+) create mode 100644 src/components/DatePicker/DatePicker.tsx create mode 100644 src/components/DatePicker/types.ts diff --git a/src/components/DatePicker/DatePicker.tsx b/src/components/DatePicker/DatePicker.tsx new file mode 100644 index 0000000000..ac713e29c1 --- /dev/null +++ b/src/components/DatePicker/DatePicker.tsx @@ -0,0 +1,281 @@ +import * as React from 'react'; +import { Pressable, StyleSheet, Text, View, ViewStyle } from 'react-native'; + +import type { DatePickerProps } from './types'; +import { useInternalTheme } from '../../core/theming'; + +const MONTHS = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', +]; + +const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + +const daysInMonth = (year: number, month: number) => + new Date(year, month + 1, 0).getDate(); + +const firstWeekday = (year: number, month: number) => + new Date(year, month, 1).getDay(); + +const sameDay = (a: Date, b: Date) => + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate(); + +const styles = StyleSheet.create({ + container: { + padding: 8, + width: 320, + } as ViewStyle, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: 8, + } as ViewStyle, + navLabel: { + fontSize: 16, + fontWeight: '600', + } as ViewStyle, + navButton: { + width: 40, + height: 40, + alignItems: 'center', + justifyContent: 'center', + } as ViewStyle, + navButtonText: { + fontSize: 20, + } as ViewStyle, + grid: { + flexDirection: 'row', + flexWrap: 'wrap', + } as ViewStyle, + cell: { + width: '14.285714285714286%', + height: 40, + alignItems: 'center', + justifyContent: 'center', + } as ViewStyle, + weekday: { + fontSize: 12, + fontWeight: '500', + } as ViewStyle, + day: { + width: 32, + height: 32, + borderRadius: 16, + alignItems: 'center', + justifyContent: 'center', + } as ViewStyle, + dayText: { + fontSize: 14, + } as ViewStyle, + outOfMonth: { + opacity: 0.35, + } as ViewStyle, +}); + +/** + * Material 3 calendar date picker (month view). + * + * Shows a single-month grid with prev/next navigation and a selectable day + * cell for each day. The selected day is highlighted; days outside the + * `min`/`max` range are disabled. + * + * @param props + */ +function DatePicker({ + value, + onChange, + min, + max, + weekStartsOn = 0, + style, + theme: themeOverrides, + testID = 'date-picker', +}: DatePickerProps) { + const theme = useInternalTheme(themeOverrides); + const [view, setView] = React.useState({ + year: value.getFullYear(), + month: value.getMonth(), + }); + + const primary = theme.isV3 ? theme.colors.primary : theme.colors.accent; + const onSurface = theme.isV3 ? theme.colors.onSurface : '#000'; + const surface = theme.isV3 ? theme.colors.surface : '#fff'; + const outline = theme.isV3 ? theme.colors.outline : '#999'; + + const orderedWeekdays = [ + ...WEEKDAYS.slice(weekStartsOn), + ...WEEKDAYS.slice(0, weekStartsOn), + ]; + + const total = daysInMonth(view.year, view.month); + const firstDow = (firstWeekday(view.year, view.month) - weekStartsOn + 7) % 7; + + // Leading days from the previous month to fill the first row. + const prevMonthDays = daysInMonth( + view.month === 0 ? view.year - 1 : view.year, + view.month === 0 ? 11 : view.month - 1 + ); + const cells: Array<{ + day: number; + monthOffset: number; // -1 prev, 0 current, +1 next + date: Date; + }> = []; + + for (let i = 0; i < firstDow; i++) { + const day = prevMonthDays - firstDow + 1 + i; + const date = new Date( + view.month === 0 ? view.year - 1 : view.year, + view.month === 0 ? 11 : view.month - 1, + day + ); + cells.push({ day, monthOffset: -1, date }); + } + for (let d = 1; d <= total; d++) { + cells.push({ + day: d, + monthOffset: 0, + date: new Date(view.year, view.month, d), + }); + } + // Trailing days to fill a full 6-week (42-cell) grid. + let next = 1; + while (cells.length < 42) { + const date = new Date( + view.month === 11 ? view.year + 1 : view.year, + view.month === 11 ? 0 : view.month + 1, + next + ); + cells.push({ day: next, monthOffset: 1, date }); + next += 1; + } + + const isDisabled = (date: Date) => { + if ( + min && + date < new Date(min.getFullYear(), min.getMonth(), min.getDate()) + ) { + return true; + } + if ( + max && + date > new Date(max.getFullYear(), max.getMonth(), max.getDate()) + ) { + return true; + } + return false; + }; + + const prevMonth = () => { + setView((v) => + v.month === 0 + ? { year: v.year - 1, month: 11 } + : { year: v.year, month: v.month - 1 } + ); + }; + const nextMonth = () => { + setView((v) => + v.month === 11 + ? { year: v.year + 1, month: 0 } + : { year: v.year, month: v.month + 1 } + ); + }; + + const label = `${MONTHS[view.month]} ${view.year}`; + + return ( + + + + + + + {label} + + + + + + + + {orderedWeekdays.map((wd, i) => ( + + {wd} + + ))} + + + + {cells.map((cell, i) => { + const selected = sameDay(cell.date, value); + const disabled = isDisabled(cell.date); + const outOfMonth = cell.monthOffset !== 0; + return ( + + { + if (!disabled) onChange(cell.date); + }} + disabled={disabled} + accessibilityRole="button" + accessibilityLabel={cell.date.toDateString()} + style={[ + styles.day, + selected + ? { backgroundColor: primary } + : { backgroundColor: 'transparent' }, + ]} + > + + {cell.day} + + + + ); + })} + + + ); +} + +export default DatePicker; diff --git a/src/components/DatePicker/types.ts b/src/components/DatePicker/types.ts new file mode 100644 index 0000000000..88d9a2f3b6 --- /dev/null +++ b/src/components/DatePicker/types.ts @@ -0,0 +1,22 @@ +import type { StyleProp, ViewStyle } from 'react-native'; + +import type { ThemeProp } from '../../types'; + +export type DatePickerProps = { + /** Currently selected date (controlled). */ + value: Date; + /** Called when the user selects a day. */ + onChange: (date: Date) => void; + /** Earliest selectable date. Days before this are disabled. */ + min?: Date; + /** Latest selectable date. Days after this are disabled. */ + max?: Date; + /** 0 = Sunday .. 6 = Saturday. @default 0 */ + weekStartsOn?: number; + /** Style applied to the container. */ + style?: StyleProp; + /** @optional */ + theme?: ThemeProp; + /** TestID for testing. */ + testID?: string; +}; diff --git a/src/index.tsx b/src/index.tsx index 1b20528787..aa84854e20 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -32,6 +32,7 @@ export { default as Banner } from './components/Banner'; export { default as BottomNavigation } from './components/BottomNavigation/BottomNavigation'; export { default as Button } from './components/Button/Button'; export { default as Card } from './components/Card/Card'; +export { default as DatePicker } from './components/DatePicker/DatePicker'; export { default as Checkbox } from './components/Checkbox'; export { default as Chip } from './components/Chip/Chip'; export { default as DataTable } from './components/DataTable/DataTable'; @@ -86,6 +87,7 @@ export type { } from './components/BottomNavigation/BottomNavigation'; export type { Props as ButtonProps } from './components/Button/Button'; export type { Props as CardProps } from './components/Card/Card'; +export type { DatePickerProps } from './components/DatePicker/types'; export type { Props as CardActionsProps } from './components/Card/CardActions'; export type { Props as CardContentProps } from './components/Card/CardContent'; export type { Props as CardCoverProps } from './components/Card/CardCover';