diff --git a/example/app.json b/example/app.json index 459d457..8c278d5 100644 --- a/example/app.json +++ b/example/app.json @@ -2,7 +2,9 @@ "expo": { "name": "React Native UI DatePicker", "slug": "react-native-ui-datepicker-example", + "description": "Customizable React Native DateTime Picker component for Android, iOS, and Web. It includes date, time, and datetime modes and supports different locales.", "version": "1.0.0", + "githubUrl": "https://github.com/farhoudshapouran/react-native-ui-datepicker", "orientation": "portrait", "icon": "./assets/icon.png", "userInterfaceStyle": "light", @@ -22,7 +24,9 @@ } }, "web": { - "favicon": "./assets/calendar.png" + "favicon": "./assets/calendar.png", + "themeColor": "#FFFFFF", + "display": "fullscreen" } } } diff --git a/example/src/App.tsx b/example/src/App.tsx index e58e8d5..7143d32 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -58,6 +58,7 @@ export default function App() { ]} onPress={() => setTheme(item)} accessibilityRole="button" + accessibilityLabel="Set Active Theme" /> ))} @@ -73,6 +74,7 @@ export default function App() { ]} onPress={() => setLocale(item)} accessibilityRole="button" + accessibilityLabel={item.toUpperCase()} > ({ calendarView: CalendarViews.day, - selectedDate: Date.now(), - currentDate: Date.now(), + selectedDate: new Date(), + currentDate: new Date(), + currentYear: new Date().getFullYear(), mode: 'datetime', locale: 'en', minimumDate: null, diff --git a/src/DateTimePicker.tsx b/src/DateTimePicker.tsx index 38f30db..99406a5 100644 --- a/src/DateTimePicker.tsx +++ b/src/DateTimePicker.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useReducer } from 'react'; -import { getNow, getFormated, getDate } from './utils'; +import { getFormated, getDate, getDateYear } from './utils'; import CalendarContext from './CalendarContext'; import { CalendarViews, CalendarActionKind } from './enums'; import type { @@ -31,7 +31,7 @@ interface PropTypes extends CalendarTheme, HeaderProps { } const DateTimePicker = ({ - value = getNow(), + value, mode = 'datetime', locale = 'en', minimumDate = null, @@ -97,6 +97,11 @@ const DateTimePicker = ({ ...prevState, currentDate: action.payload, }; + case CalendarActionKind.CHANGE_CURRENT_YEAR: + return { + ...prevState, + currentYear: action.payload, + }; case CalendarActionKind.CHANGE_SELECTED_DATE: return { ...prevState, @@ -106,8 +111,9 @@ const DateTimePicker = ({ }, { calendarView: mode === 'time' ? CalendarViews.time : CalendarViews.day, - selectedDate: value ? getFormated(value) : getNow(), - currentDate: value ? getFormated(value) : getNow(), + selectedDate: value ? getFormated(value) : new Date(), + currentDate: value ? getFormated(value) : new Date(), + currentYear: value ? getDateYear(value) : new Date().getFullYear(), } ); @@ -120,6 +126,10 @@ const DateTimePicker = ({ type: CalendarActionKind.CHANGE_CURRENT_DATE, payload: value, }); + dispatch({ + type: CalendarActionKind.CHANGE_CURRENT_YEAR, + payload: getDateYear(value), + }); }, [value]); useEffect(() => { @@ -173,10 +183,9 @@ const DateTimePicker = ({ }); }, onChangeYear: (year: number) => { - const newDate = getDate(state.currentDate).add(year, 'year'); dispatch({ - type: CalendarActionKind.CHANGE_CURRENT_DATE, - payload: getFormated(newDate), + type: CalendarActionKind.CHANGE_CURRENT_YEAR, + payload: year, }); }, }; diff --git a/src/components/Day.tsx b/src/components/Day.tsx new file mode 100644 index 0000000..ca1e762 --- /dev/null +++ b/src/components/Day.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { View, Text, Pressable, StyleSheet } from 'react-native'; +import { CalendarTheme, IDayObject } from '../types'; +import { CALENDAR_HEIGHT } from '../enums'; + +type Props = { + day?: IDayObject; + theme?: CalendarTheme; + isToday: boolean; + selected: boolean; + onSelectDate: (date: string) => void; +}; + +const Day = ({ day, theme, isToday, selected, onSelectDate }: Props) => { + const dayContainerStyle = + day && day.isCurrentMonth ? theme?.dayContainerStyle : { opacity: 0.3 }; + + const todayItemStyle = isToday + ? { + borderWidth: 2, + borderColor: theme?.selectedItemColor || '#0047FF', + ...theme?.todayContainerStyle, + } + : null; + + const activeItemStyle = selected + ? { + borderColor: theme?.selectedItemColor || '#0047FF', + backgroundColor: theme?.selectedItemColor || '#0047FF', + } + : null; + + const textStyle = selected + ? { color: '#fff', ...theme?.selectedTextStyle } + : isToday + ? { + ...theme?.calendarTextStyle, + color: theme?.selectedItemColor || '#0047FF', + ...theme?.todayTextStyle, + } + : theme?.calendarTextStyle; + + return ( + + {day ? ( + onSelectDate(day.date)} + style={[ + styles.dayContainer, + dayContainerStyle, + todayItemStyle, + activeItemStyle, + day.disabled && styles.disabledDay, + ]} + testID={day.date} + accessibilityRole="button" + accessibilityLabel={day.date} + > + + {day.text} + + + ) : null} + + ); +}; + +const styles = StyleSheet.create({ + dayCell: { + width: '14.2%', + height: CALENDAR_HEIGHT / 7 - 1, + }, + dayContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + margin: 1.5, + borderRadius: 100, + }, + dayTextContainer: { + justifyContent: 'center', + alignItems: 'center', + }, + disabledDay: { + opacity: 0.3, + }, +}); + +export default React.memo(Day); diff --git a/src/components/DaySelector.tsx b/src/components/DaySelector.tsx index f6274bc..12350d0 100644 --- a/src/components/DaySelector.tsx +++ b/src/components/DaySelector.tsx @@ -1,16 +1,14 @@ -import React, { useMemo } from 'react'; -import { Text, View, Pressable, StyleSheet } from 'react-native'; +import React, { useMemo, useCallback } from 'react'; +import { Text, View, StyleSheet } from 'react-native'; import { useCalendarContext } from '../CalendarContext'; -import { CALENDAR_HEIGHT } from '../enums'; +import Day from './Day'; import { getParsedDate, getMonthDays, + getWeekdaysMin, + areDatesOnSameDay, getDate, getFormated, - getWeekdaysMin, - getToday, - getFormatedDate, - dateFormat, } from '../utils'; const DaySelector = () => { @@ -24,24 +22,37 @@ const DaySelector = () => { theme, } = useCalendarContext(); const { year, month, hour, minute } = getParsedDate(currentDate); - const days = useMemo( + + const daysGrid = useMemo( () => { + const today = new Date(); return getMonthDays( currentDate, displayFullDays, minimumDate, maximumDate - ); + ).map((day) => { + const isToday = areDatesOnSameDay(day?.date, today); + const selected = areDatesOnSameDay(day?.date, selectedDate); + return { + ...day, + isToday, + selected, + }; + }); }, // eslint-disable-next-line react-hooks/exhaustive-deps - [month, year, displayFullDays, minimumDate, maximumDate] + [month, year, displayFullDays, minimumDate, maximumDate, selectedDate] ); - const handleSelectDate = (date: string) => { - const newDate = getDate(date).hour(hour).minute(minute); + const handleSelectDate = useCallback( + (date: string) => { + const newDate = getDate(date).hour(hour).minute(minute); - onSelectDate(getFormated(newDate)); - }; + onSelectDate(getFormated(newDate)); + }, + [onSelectDate, hour, minute] + ); return ( @@ -56,62 +67,16 @@ const DaySelector = () => { ))} - {days?.map((day, index) => { - const dayContainerStyle = - day && day.isCurrentMonth - ? theme?.dayContainerStyle - : { opacity: 0.3 }; - - const todayItemStyle = - day && day.date === getToday() - ? { - borderWidth: 2, - borderColor: theme?.selectedItemColor || '#0047FF', - ...theme?.todayContainerStyle, - } - : null; - - const activeItemStyle = - day && day.date === getFormatedDate(selectedDate, dateFormat) - ? { - borderColor: theme?.selectedItemColor || '#0047FF', - backgroundColor: theme?.selectedItemColor || '#0047FF', - } - : null; - - const textStyle = - day && day.date === getFormatedDate(selectedDate, dateFormat) - ? { color: '#fff', ...theme?.selectedTextStyle } - : day && day.date === getToday() - ? { - ...theme?.calendarTextStyle, - color: theme?.selectedItemColor || '#0047FF', - ...theme?.todayTextStyle, - } - : theme?.calendarTextStyle; - + {daysGrid?.map((day, index) => { return ( - - {day ? ( - handleSelectDate(day.date)} - style={[ - styles.dayContainer, - dayContainerStyle, - todayItemStyle, - activeItemStyle, - day.disabled && styles.disabledDay, - ]} - testID={day.date} - accessibilityRole="button" - > - - {day.text} - - - ) : null} - + ); })} @@ -148,24 +113,6 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignContent: 'flex-start', }, - dayCell: { - width: '14.2%', - height: CALENDAR_HEIGHT / 7 - 1, - }, - dayContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - margin: 1.5, - borderRadius: 100, - }, - dayTextContainer: { - justifyContent: 'center', - alignItems: 'center', - }, - disabledDay: { - opacity: 0.3, - }, }); export default DaySelector; diff --git a/src/components/Header.tsx b/src/components/Header.tsx index cf430bf..b5ccada 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,9 +1,10 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import { View, Text, Pressable, StyleSheet, Image } from 'react-native'; import { useCalendarContext } from '../CalendarContext'; import dayjs from 'dayjs'; import { CalendarViews } from '../enums'; import type { HeaderProps } from '../types'; +import { getDateYear, getYearRange, YEAR_PAGE_SIZE } from '../utils'; const arrow_left = require('../assets/images/arrow_left.png'); const arrow_right = require('../assets/images/arrow_right.png'); @@ -12,6 +13,7 @@ const Header = ({ buttonPrevIcon, buttonNextIcon }: HeaderProps) => { const { currentDate, selectedDate, + currentYear, onChangeMonth, onChangeYear, calendarView, @@ -21,6 +23,8 @@ const Header = ({ buttonPrevIcon, buttonNextIcon }: HeaderProps) => { locale, } = useCalendarContext(); + const currentMonthText = dayjs(currentDate).locale(locale).format('MMMM'); + const renderPrevButton = ( { calendarView === CalendarViews.day ? onChangeMonth(-1) : calendarView === CalendarViews.month - ? onChangeYear(-1) - : calendarView === CalendarViews.year && onChangeYear(-12) + ? onChangeYear(currentYear - 1) + : calendarView === CalendarViews.year && + onChangeYear(currentYear - YEAR_PAGE_SIZE) } testID="btn-prev" accessibilityRole="button" + accessibilityLabel="Prev" > { calendarView === CalendarViews.day ? onChangeMonth(1) : calendarView === CalendarViews.month - ? onChangeYear(1) - : calendarView === CalendarViews.year && onChangeYear(12) + ? onChangeYear(currentYear + 1) + : calendarView === CalendarViews.year && + onChangeYear(currentYear + YEAR_PAGE_SIZE) } testID="btn-next" accessibilityRole="button" + accessibilityLabel="Next" > { ); + const yearSelector = useCallback(() => { + const years = getYearRange(currentYear); + return ( + { + setCalendarView( + calendarView === CalendarViews.year + ? CalendarViews.day + : CalendarViews.year + ); + onChangeYear(getDateYear(currentDate)); + }} + testID="btn-year" + accessibilityRole="button" + accessibilityLabel={dayjs(currentDate).format('YYYY')} + > + + + {calendarView === CalendarViews.year + ? `${years.at(0)} - ${years.at(-1)}` + : dayjs(currentDate).format('YYYY')} + + + + ); + }, [ + calendarView, + currentDate, + currentYear, + setCalendarView, + onChangeYear, + theme, + ]); + + const monthSelector = ( + + setCalendarView( + calendarView === CalendarViews.month + ? CalendarViews.day + : CalendarViews.month + ) + } + testID="btn-month" + accessibilityRole="button" + accessibilityLabel={currentMonthText} + > + + + {currentMonthText} + + + + ); + const renderSelectors = ( <> - - setCalendarView( - calendarView === CalendarViews.month - ? CalendarViews.day - : CalendarViews.month - ) - } - testID="btn-month" - accessibilityRole="button" - > - - - {dayjs(currentDate).locale(locale).format('MMMM')} - - - - - - setCalendarView( - calendarView === CalendarViews.year - ? CalendarViews.day - : CalendarViews.year - ) - } - testID="btn-year" - accessibilityRole="button" - > - - - {calendarView === CalendarViews.year - ? dayjs(selectedDate).format('YYYY') - : dayjs(currentDate).format('YYYY')} - - - + {calendarView !== CalendarViews.year ? monthSelector : null} + {yearSelector()} - {mode === 'datetime' ? ( + {mode === 'datetime' && calendarView !== CalendarViews.year ? ( setCalendarView( @@ -132,6 +160,7 @@ const Header = ({ buttonPrevIcon, buttonNextIcon }: HeaderProps) => { ) } accessibilityRole="button" + accessibilityLabel={dayjs(selectedDate).format('HH:mm')} > diff --git a/src/components/MonthSelector.tsx b/src/components/MonthSelector.tsx index dbf95c4..7d2b6b4 100644 --- a/src/components/MonthSelector.tsx +++ b/src/components/MonthSelector.tsx @@ -30,6 +30,7 @@ const MonthSelector = () => { style={styles.monthCell} onPress={() => onSelectMonth(index)} accessibilityRole="button" + accessibilityLabel={item} > { - const { currentDate, selectedDate, onSelectYear, theme } = + const { currentDate, currentYear, selectedDate, onSelectYear, theme } = useCalendarContext(); - const currentYear = getDateYear(currentDate); const selectedYear = getDateYear(selectedDate); - const rowArray = [1, 2, 3]; - const colArray = [1, 2, 3, 4]; - let year = 12 * Math.ceil(currentYear / 12) - 12; - if (year < 0) year = 0; - - function generateColumns() { - const column = rowArray.map(() => { - const cellYear = year++; - const activeItemStyle = - cellYear === selectedYear + const generateCells = useCallback(() => { + const years = getYearRange(currentYear); + const activeYear = getDateYear(currentDate); + const column = years.map((year) => { + const activeItemStyle: ViewStyle = + year === selectedYear ? { borderColor: theme?.selectedItemColor || '#0047FF', backgroundColor: theme?.selectedItemColor || '#0047FF', } - : null; + : year === activeYear + ? { + borderColor: theme?.selectedItemColor || '#0047FF', + } + : {}; - const textStyle = - cellYear === selectedYear + const textStyle: TextStyle = + year === selectedYear ? { color: '#fff', ...theme?.selectedTextStyle } - : theme?.calendarTextStyle; + : year === activeYear + ? { + color: theme?.selectedItemColor || '#0047FF', + fontWeight: 'bold', + } + : { ...theme?.calendarTextStyle }; return ( onSelectYear(cellYear)} + key={year} + onPress={() => onSelectYear(year)} style={styles.yearCell} accessibilityRole="button" + accessibilityLabel={year.toString()} > - - {cellYear} + + {year} ); }); return column; - } + }, [onSelectYear, selectedYear, currentYear, currentDate, theme]); return ( - {colArray.map((index) => ( - - {generateColumns()} - - ))} + {generateCells()} ); }; @@ -72,8 +80,9 @@ const styles = StyleSheet.create({ yearCell: { width: '33.3%', }, - yearsRow: { + years: { flexDirection: 'row', + flexWrap: 'wrap', width: '100%', }, year: { diff --git a/src/enums.ts b/src/enums.ts index c2712c7..6a9a8bc 100644 --- a/src/enums.ts +++ b/src/enums.ts @@ -8,6 +8,7 @@ export enum CalendarViews { export enum CalendarActionKind { SET_CALENDAR_VIEW = 'SET_CALENDAR_VIEW', CHANGE_CURRENT_DATE = 'CHANGE_CURRENT_DATE', + CHANGE_CURRENT_YEAR = 'CHANGE_CURRENT_YEAR', CHANGE_SELECTED_DATE = 'CHANGE_SELECTED_DATE', } diff --git a/src/types.ts b/src/types.ts index 7569734..7783b3f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -12,7 +12,8 @@ export type HeaderButtonPositions = 'around' | 'right' | 'left'; export type CalendarState = { calendarView: CalendarViews; selectedDate: DateType; - currentDate: DateType; + currentDate: DateType; // used for latest state of calendar based on Month and Year + currentYear: number; // used for pagination in YearSelector }; export type CalendarAction = { @@ -46,3 +47,11 @@ export type HeaderProps = { buttonPrevIcon?: ReactNode; buttonNextIcon?: ReactNode; }; + +export interface IDayObject { + text: string; + day: number; + date: string; + disabled: boolean; + isCurrentMonth: boolean; +} diff --git a/src/utils.ts b/src/utils.ts index 7807d9a..8c1c07a 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,16 +1,9 @@ import dayjs from 'dayjs'; -import type { DateType } from './types'; - -export interface IDayObject { - text: string; - day: number; - date: string; - disabled: boolean; - isCurrentMonth: boolean; -} +import type { DateType, IDayObject } from './types'; -export const calendarFormat = 'YYYY-MM-DD HH:mm'; -export const dateFormat = 'YYYY-MM-DD'; +export const CALENDAR_FORMAT = 'YYYY-MM-DD HH:mm'; +export const DATE_FORMAT = 'YYYY-MM-DD'; +export const YEAR_PAGE_SIZE = 12; export const getMonths = () => dayjs.months(); @@ -23,25 +16,44 @@ export const getWeekdaysShort = () => dayjs.weekdaysShort(); export const getWeekdaysMin = () => dayjs.weekdaysMin(); export const getFormated = (date: DateType) => - dayjs(date).format(calendarFormat); - -export const getNow = () => dayjs().format(calendarFormat); + dayjs(date).format(CALENDAR_FORMAT); export const getDateMonth = (date: DateType) => dayjs(date).month(); export const getDateYear = (date: DateType) => dayjs(date).year(); -export const getToday = () => dayjs().format(dateFormat); +export const getToday = () => dayjs().format(DATE_FORMAT); + +export function areDatesOnSameDay(a: DateType, b: DateType) { + if (!a || !b) { + return false; + } + + const date_a = dayjs(a).format(DATE_FORMAT); + const date_b = dayjs(b).format(DATE_FORMAT); + + return date_a === date_b; +} export const getFormatedDate = (date: DateType, format: string) => dayjs(date).format(format); -export const getDate = (date: DateType) => dayjs(date, calendarFormat); +export const getDate = (date: DateType) => dayjs(date, CALENDAR_FORMAT); + +export const getYearRange = (year: number) => { + const endYear = YEAR_PAGE_SIZE * Math.ceil(year / YEAR_PAGE_SIZE); + let startYear = endYear === year ? endYear : endYear - YEAR_PAGE_SIZE; + + if (startYear < 0) startYear = 0; + return Array.from({ length: YEAR_PAGE_SIZE }, (_, i) => startYear + i); +}; /** * Get detailed date object + * * @param date Get detailed date object - * @returns + * + * @returns parsed date object */ export const getParsedDate = (date: DateType) => { return { @@ -55,11 +67,12 @@ export const getParsedDate = (date: DateType) => { /** * Calculate month days array based on current date * - * @param {DateType} datetime - The current date that selected - * @param {boolean} displayFullDays - * @param {DateType} minimumDate - min selectable date - * @param {DateType} maximumDate - max selectable date - * @returns {IDayObject[]} days array based on current date + * @param datetime - The current date that selected + * @param displayFullDays + * @param minimumDate - min selectable date + * @param maximumDate - max selectable date + * + * @returns days array based on current date */ export const getMonthDays = ( datetime: DateType = dayjs(), @@ -106,12 +119,13 @@ export const getMonthDays = ( /** * Generate day object for displaying inside day cell * - * @param {number} day - number of day - * @param {dayjs.Dayjs} date - calculated date based on day, month, and year - * @param {DateType} minDate - min selectable date - * @param {DateType} maxDate - max selectable date - * @param {boolean} isCurrentMonth - define the day is in the current month - * @returns {IDayObject} days object based on current date + * @param day - number of day + * @param date - calculated date based on day, month, and year + * @param minDate - min selectable date + * @param maxDate - max selectable date + * @param isCurrentMonth - define the day is in the current month + * + * @returns days object based on current date */ const generateDayObject = ( day: number, @@ -130,7 +144,7 @@ const generateDayObject = ( return { text: day.toString(), day: day, - date: getFormatedDate(date, dateFormat), + date: getFormatedDate(date, DATE_FORMAT), disabled, isCurrentMonth, };