diff --git a/src/components/WheelPicker/index.tsx b/src/components/WheelPicker/index.tsx index c6254e8..75e8716 100644 --- a/src/components/WheelPicker/index.tsx +++ b/src/components/WheelPicker/index.tsx @@ -1,33 +1,23 @@ -import React, { useState } from 'react'; +import React, { useLayoutEffect, useMemo } from 'react'; // Global Components import Picker from 'rmc-picker/es/Picker'; import MultiPicker from 'rmc-picker/es/MultiPicker'; // Styles import { GlobalStyle } from './styles'; -// Helpers -import { - convertSelectedDateToObject, - isObjectEmpty, - prefixClassName, -} from '../../helpers'; -import { - convertDateToObject, - daysInMonth as calculateDaysInMonth, - isValid, - pickerData, -} from '../../helpers/date'; // Hooks -import { usePrevious } from '../../hooks/previous'; -// Types -import type { - PickerColumns, - PickerDateModel, - WheelPickerProps, -} from './index.types'; import { usePicker } from '../../hooks/usePicker'; +// Helpers +import { convertSelectedDateToObject, isObjectEmpty } from '../../helpers'; +import { pickerData } from '../../helpers/date'; +// Types +import type { PickerColumns, WheelPickerProps } from './index.types'; +import type { FC } from 'react'; -export const WheelPicker: React.FC = (props) => { +export const WheelPicker: FC = (props) => { const { + prefix, + + daysInMonth, selectedDate, setSelectedDate, defaultPickerValues, @@ -39,16 +29,6 @@ export const WheelPicker: React.FC = (props) => { handlePickerItemTextContent, handlePickerItemClassNames, } = usePicker(props); - // Local States - const [daysInMonth, setDaysInMonth] = useState(29); - - // Hooks - const previousSelectedDate = usePrevious(selectedDate); - // Local Variables - /** - * Picker CSS classnames prefix name - */ - const prefix = prefixClassName(props.prefix!); // Memo list /** @@ -57,7 +37,7 @@ export const WheelPicker: React.FC = (props) => { * @category watchers * @return {PickerColumns} */ - const pickerColumns = React.useMemo(() => { + const pickerColumns = useMemo(() => { return Object.keys(props.config).map((column) => { switch (column) { case 'year': @@ -103,7 +83,7 @@ export const WheelPicker: React.FC = (props) => { /** * Prepare the default value of DatePicker when the Developer has not passed a defaultValue */ - React.useLayoutEffect(() => { + useLayoutEffect(() => { if ( pickerColumns.length && isObjectEmpty(selectedDate) && @@ -116,33 +96,6 @@ export const WheelPicker: React.FC = (props) => { setSelectedDate(defaultDate); } }, [pickerColumns, selectedDate, props.defaultValue]); - /** - * * Local Watchers - */ - // Calculate days in selected months - React.useEffect(() => { - if (!isObjectEmpty(selectedDate)) { - if ( - previousSelectedDate?.month !== selectedDate?.month || - previousSelectedDate?.year !== selectedDate?.year - ) { - setDaysInMonth( - calculateDaysInMonth( - Number(selectedDate.year), - Number(selectedDate.month), - ), - ); - } - } - }, [selectedDate, previousSelectedDate?.year, previousSelectedDate?.month]); - /** - * Derived Selected Date from Prop's defaultValue - */ - React.useEffect(() => { - if (isValid(props.defaultValue!)) { - setSelectedDate(convertDateToObject(props.defaultValue!)); - } - }, [props.defaultValue]); /** * Picker onChange event which includes every columns' selected value @@ -197,6 +150,6 @@ export const WheelPicker: React.FC = (props) => { }; WheelPicker.defaultProps = { - minDecade: 30, // past 30 years - maxDecade: 30, // next 30 years + startYear: 30, // past 30 years + endYear: 30, // next 30 years }; diff --git a/src/components/WheelPicker/index.types.ts b/src/components/WheelPicker/index.types.ts index c544aea..8d7fc92 100644 --- a/src/components/WheelPicker/index.types.ts +++ b/src/components/WheelPicker/index.types.ts @@ -3,25 +3,18 @@ export interface WheelPickerSelectEvent extends PickerDateModel {} export interface WheelPickerProps { // CSS classnames prefix prefix?: string; - // Min Year value - minYear?: number; - // Max Year value - maxYear?: number; - // Min Month value - minMonth?: number; - // Max Month value - maxMonth?: number; - // Min Day value - minDay?: number; - // Max Day value - maxDay?: number; // Default column value defaultValue?: Date; // Title title?: string; // Triggered when the component DOM is generated, the parameter is the component element onRender?: () => void; - // Triggered when the value changes due to scrolling, the parameter is the index value of the entry array and the changed column + /** + * Gets called when value of the picker changes + * + * @param {WheelPickerSelectEvent} selected + * @return {void} + */ onChange?: (selected: WheelPickerSelectEvent) => void; // Triggered when you click OK onSelect?: (selected: WheelPickerSelectEvent) => void; @@ -31,14 +24,43 @@ export interface WheelPickerProps { disabled?: boolean; // Set config to configure year, month, day, hour, minute and seconds config: DateConfig; - // Min Date value + /** + * Specifies the minimum selectable day by user + * + * @default null + * @type {Date} + */ minDate?: Date; - // Max Date value + /** + * Specifies the maximum selectable day by user + * + * @default null + * @type {Date} + */ maxDate?: Date; - // Max decade - maxDecade?: number; - // Min decade - minDecade?: number; + /** + * The Minimum selectable year + * + * @description Picker will calculate the StartYear by this approach: currentYear + startYear + * @default 30 + * @type {number} + */ + endYear?: number; + /** + * The Maximum selectable year + * + * @description Picker will calculate the StartYear by this approach: currentYear + startYear + * @default 30 + * @type {number} + */ + startYear?: number; + /** + * Determines whether to mark weekend days with red or not. (weekend day is Friday) + * + * @default false + * @type {boolean} + */ + highlightWeekends?: boolean; } export type DateConfigTypes = @@ -56,14 +78,23 @@ export type DateConfigFormats = | 'hh' | 'mm' | 'ss'; +export type WeekDayText = + | 'شنبه' + | 'یک‌شنبه' + | 'دو‌شنبه' + | 'سه‌شنبه' + | 'چهار‌شنبه' + | 'پنج‌شنبه' + | 'جمعه'; + export type PickerSelectedDateValue = number; export interface DateConfigValuesModel { caption?: string; formatter?: ( value: PickerSelectedDateValue, ) => PickerSelectedDateValue | string; - classname?: (value: PickerClassNameFormatter) => string | string[]; - shouldRender?: (value: PickerClassNameFormatter) => boolean; + classname?: (value: PickerExtraDateInfo) => string | string[]; + shouldRender?: (value: PickerExtraDateInfo) => boolean; } export type DateConfig = Partial< @@ -82,9 +113,11 @@ export type PickerDateModel = { }; export type RequiredPickerDateModel = Required; -export interface PickerClassNameFormatter extends PickerDateModel { +export interface PickerExtraDateInfo extends PickerDateModel { weekDay?: number; - weekDayName?: WeekDaysName; + weekDayText?: WeekDayText; + monthText?: string; + isLeapYear?: boolean; } export interface PickerItemModel { @@ -93,12 +126,3 @@ export interface PickerItemModel { } export type PickerColumns = Array>>; - -export type WeekDaysName = - | 'شنبه' - | 'یک‌شنبه' - | 'دو‌شنبه' - | 'سه‌شنبه' - | 'چهار‌شنبه' - | 'پنج‌شنبه' - | 'جمعه'; diff --git a/src/helpers/date.ts b/src/helpers/date.ts index 5870beb..b92c85e 100644 --- a/src/helpers/date.ts +++ b/src/helpers/date.ts @@ -21,12 +21,12 @@ import { createAnArrayOfNumbers, generateArrayInRangeOfNumbers } from './'; // Types import type { PickerItemModel, - WeekDaysName, + WeekDayText, } from '../components/WheelPicker/index.types'; import type { PickerDateModel } from '../components/WheelPicker/index.types'; -import { RequiredPickerDateModel } from '../components/WheelPicker/index.types'; +import type { RequiredPickerDateModel } from '../components/WheelPicker/index.types'; -export const weekDays: Record = { +export const weekDays: Record = { 0: 'شنبه', 1: 'یک‌شنبه', 2: 'دو‌شنبه', @@ -119,19 +119,23 @@ export function getWeekDay(year: number, month: number, day: number): number { return (dateGetDay(setDate(year, month, day)) + 1) % 7; } +export function isWeekend(year: number, month: number, day: number): boolean { + return getWeekDay(year, month, day) === 6; +} + /** * Get weekday's name by date * * @param {number} year * @param {number} month * @param {number} day - * @return {WeekDaysName} شنبه،... + * @return {WeekDayText} شنبه،... */ -export function getWeekDayName( +export function getWeekDayText( year: number, month: number, day: number, -): WeekDaysName { +): WeekDayText { const result = getWeekDay(year, month, day); return weekDays[result]; @@ -206,7 +210,7 @@ export function generateYearsRange(min: number, max: number): Array { * @returns {boolean} */ export function isLeapYear(year: number): boolean { - return dateIsLeapYear(year); + return dateIsLeapYear(setDate(year, 1, 1)); } /** diff --git a/src/hooks/usePicker.ts b/src/hooks/usePicker.ts index 466bf23..feb9a29 100644 --- a/src/hooks/usePicker.ts +++ b/src/hooks/usePicker.ts @@ -1,34 +1,61 @@ -import { - DateConfig, - DateConfigTypes, - PickerClassNameFormatter, - PickerDateModel, - PickerItemModel, - PickerSelectedDateValue, - RequiredPickerDateModel, - WheelPickerProps, -} from '../components/WheelPicker/index.types'; -import React, { useState } from 'react'; +import React from 'react'; import { convertDateToObject, convertObjectToDate, currentDateObject, + daysInMonth as calculateDaysInMonth, getCurrentYear, getWeekDay, - getWeekDayName, isAfter, isBefore, isEqual, + isLeapYear, isValid, + isWeekend, jalaliMonths, + weekDays, } from '../helpers/date'; import { convertSelectedDateToAnArray, isObjectEmpty, + prefixClassName, toPositive, } from '../helpers'; +// Hooks +import { useState } from 'react'; +import { usePrevious } from './usePrevious'; +// Types +import type { + DateConfig, + DateConfigTypes, + PickerExtraDateInfo, + PickerDateModel, + PickerItemModel, + PickerSelectedDateValue, + RequiredPickerDateModel, + WheelPickerProps, +} from '../components/WheelPicker/index.types'; export function usePicker(props: WheelPickerProps) { + /** + * Date picker columns config + * + * @returns {Required} + */ + const configs = React.useMemo>(() => { + const config = { ...props.config } as Required; + if (config.month && !config.month.formatter) { + config.month.formatter = (value) => jalaliMonths[value]; + } + + return config; + }, [props.config]); + + /** + * Picker CSS classnames prefix name + */ + const prefix = prefixClassName(props.prefix!); + /** * * Parse and convert [minDate] to an object * @@ -83,19 +110,25 @@ export function usePicker(props: WheelPickerProps) { */ const minYear = React.useMemo(() => { const currentYear = getCurrentYear(); + const startYear = Number(props.startYear); - let year: number = currentYear; + let year: number; if (isMinDateValid) { year = minDateObject.year; } else if (isDefaultValueValid) { - year = defaultValueDateObject.year; - } else if (Number(props.minDecade)) { - year = currentYear + Number(props.minDecade); + year = defaultValueDateObject.year + startYear; + } else { + year = currentYear + startYear; } return toPositive(currentYear - year); - }, [minDateObject, defaultValueDateObject, props.minDecade]); - + }, [ + isMinDateValid, + minDateObject, + isDefaultValueValid, + defaultValueDateObject, + props.startYear, + ]); /** * * Get Max Year of the [Year Column] which should be rendered * @@ -103,39 +136,97 @@ export function usePicker(props: WheelPickerProps) { */ const maxYear = React.useMemo(() => { const currentYear = getCurrentYear(); + const endYear = Number(props.endYear); - let year: number = currentYear; + let year: number; if (isMinDateValid) { year = maxDateObject.year; } else if (isDefaultValueValid) { - year = defaultValueDateObject.year; - } else if (Number(props.maxDecade)) { - year = currentYear + Number(props.maxDecade); + year = defaultValueDateObject.year + endYear; + } else { + year = currentYear + endYear; } return toPositive(currentYear - year); }, [ + isMinDateValid, maxDateObject, defaultValueDateObject, - props.maxDecade, + props.endYear, isDefaultValueValid, ]); + /** + * Get default selected date by [MinDate], [MaxDate], [DefaultValue] or current date + * + * @type {PickerDateModel} + */ const defaultSelectedDate = React.useMemo(() => { if (isDefaultValueValid) { - return convertDateToObject(props.defaultValue!); + const defaultSelectedDateObject = convertDateToObject( + props.defaultValue!, + ); + // Check if defaultValue has overlap with shouldRender which passed by config prop. + if ( + configShouldRender(defaultSelectedDateObject, 'year') && + configShouldRender(defaultSelectedDateObject, 'month') && + configShouldRender(defaultSelectedDateObject, 'day') + ) { + return defaultSelectedDateObject; + } else { + // TODO: should be refactored and check currentDate if it is in range of min and max dates, if it was, it should be the default value + return currentDateObject(); + } + } + if (isMinDateValid) { + return minDateObject; } else if (isMaxDateValid) { return maxDateObject; - } else if (isMinDateValid) { - return minDateObject; } return currentDateObject(); - }, [isMinDateValid, isMaxDateValid, isDefaultValueValid]); + }, [ + isMinDateValid, + maxDateObject, + minDateObject, + isMaxDateValid, + props.defaultValue, + isDefaultValueValid, + ]); // Local States + const [daysInMonth, setDaysInMonth] = useState(29); const [selectedDate, setSelectedDate] = useState(defaultSelectedDate); + // Hooks + const previousSelectedDate = usePrevious(selectedDate); + + // Watchers + /** + * Derived Selected Date from Prop's defaultValue + */ + React.useEffect(() => { + if (isValid(props.defaultValue!)) { + setSelectedDate(convertDateToObject(props.defaultValue!)); + } + }, [props.defaultValue]); + + // Calculate days in selected months + React.useEffect(() => { + if (!isObjectEmpty(selectedDate)) { + if ( + previousSelectedDate?.month !== selectedDate?.month || + previousSelectedDate?.year !== selectedDate?.year + ) { + setDaysInMonth( + calculateDaysInMonth( + Number(selectedDate.year), + Number(selectedDate.month), + ), + ); + } + } + }, [selectedDate, previousSelectedDate?.year, previousSelectedDate?.month]); /** * Default Picker selected columns value which goes from the parent to local changes @@ -147,39 +238,45 @@ export function usePicker(props: WheelPickerProps) { : selectedDate)!, ); }, [props.defaultValue, selectedDate]); - /** - * Date picker columns config - * - * @returns {DateConfig} - */ - const configs = React.useMemo(() => { - const result = { ...props.config } as Required; - if (result.month && !result.month.formatter) { - result.month.formatter = (value) => jalaliMonths[value]; - } - - return result; - }, [props.config]); - // Functions + // Handlers /** * Check if entered Year is in Range of Min and Max * - * @param {number} year + * @param {number} value * @returns {boolean} */ - function shouldRenderYear(year: number): boolean { - if (isMaxDateValid && isMaxDateValid) { - return year >= minDateObject.year && year <= maxDateObject.year; - } else if (isMaxDateValid) { - return year <= maxDateObject.year; + function shouldRenderYear(value: number): boolean { + // Call the Config's shouldRender method to find that we should render this item or not + // User can prevent rendering specific year or a list of years in Picker's Year column + if (!configShouldRender(selectedDate, 'year', value)) return false; + + if (isMinDateValid && isMaxDateValid) { + return value >= minDateObject.year && value <= maxDateObject.year; } else if (isMaxDateValid) { - return year >= minDateObject.year; + return value <= maxDateObject.year; + } else if (isMinDateValid) { + return value >= minDateObject.year; } return true; } + function configShouldRender( + currentSelectedDate: PickerDateModel, + key: DateConfigTypes, + value?: PickerSelectedDateValue, + ) { + return ( + configs?.[key]?.shouldRender?.( + extraDateInfo(currentSelectedDate, { + type: key, + value: value ?? currentSelectedDate[key]!, + }), + ) ?? true + ); + } + /** * * Check if the Month or Day Column's item should be rendered or not * @@ -193,6 +290,11 @@ export function usePicker(props: WheelPickerProps) { ): boolean { // Create new Date object and clone the SelectedDate and assign the entered day to the Object const $date = { ...selectedDate, [key]: value }; + + // Call the Config's shouldRender method to find that we should render this item or not + // User can prevent rendering the weekend's holidays in Date Picker + if (!configShouldRender($date, key, value)) return false; + // Convert to a Date instance const selectedDateValue = convertObjectToDate($date); @@ -206,12 +308,13 @@ export function usePicker(props: WheelPickerProps) { ) return true; - if (isMaxDateValid && isMaxDateValid) { + if (isMinDateValid && isMaxDateValid) { + // Date should be in range of min and max dates return ( isAfter(selectedDateValue, props.minDate!) && isBefore(selectedDateValue, props.maxDate!) ); - } else if (isMaxDateValid) { + } else if (isMinDateValid) { return isAfter(selectedDateValue, props.minDate!); } else if (isMaxDateValid) { return isBefore(selectedDateValue, props.maxDate!); @@ -247,13 +350,14 @@ export function usePicker(props: WheelPickerProps) { }); } + // Picker Config's Formatters /** - * Picker items' text content + * Format the Picker items' text content * * @param {PickerItemModel} pickerItem - * @returns {PickerSelectedDateValue} columns text content + * @returns {PickerSelectedDateValue | string} columns text content */ - function handlePickerItemTextContent( + function pickerItemTextFormatter( pickerItem: PickerItemModel, ): PickerSelectedDateValue | string { return ( @@ -271,32 +375,77 @@ export function usePicker(props: WheelPickerProps) { function handlePickerItemClassNames(pickerItem: PickerItemModel): string { const classNamesFormatter = configs[pickerItem.type]?.classname; if (classNamesFormatter) { - const targetSelectedDate: PickerClassNameFormatter = { ...selectedDate }; - targetSelectedDate[pickerItem.type] = pickerItem.value; - const weekDay = getWeekDay( - targetSelectedDate.year as number, - targetSelectedDate.month as number, - targetSelectedDate.day as number, - ); - if (weekDay >= 0) { - targetSelectedDate.weekDay = weekDay; - targetSelectedDate.weekDayName = getWeekDayName( - targetSelectedDate.year as number, - targetSelectedDate.month as number, - targetSelectedDate.day as number, - ); - } - + const dateValues = extraDateInfo(selectedDate, pickerItem); // Pass to the classname config's formatter - const classNames = classNamesFormatter(targetSelectedDate); + const classNames = classNamesFormatter(dateValues); + const classNameString = Array.isArray(classNames) + ? classNames.join(' ') + : classNames; + // Only add weekend className for the Day items + const highlightWeekendClassNameString = + pickerItem.type === 'day' + ? // Check result safely + highlightWeekendClassName(pickerItem.value) ?? '' + : ''; - return Array.isArray(classNames) ? classNames.join(' ') : classNames; + return [classNameString, highlightWeekendClassNameString] + .filter(Boolean) + .join(' '); + } else if (props.highlightWeekends && pickerItem.type === 'day') { + return highlightWeekendClassName(pickerItem.value) ?? ''; } return ''; } + function highlightWeekendClassName(day: number): string { + const determineDayOfWeek = isWeekend( + selectedDate.year!, + selectedDate.month!, + day, + ); + + return determineDayOfWeek ? prefix('weekend') : ''; + } + + // Utilities + /** + * Find WeekDay and WeekName by a selected date and add to the Date object + * + * @param {PickerDateModel} currentSelectedDate + * @param {PickerItemModel} pickerItem + * @return {PickerExtraDateInfo} + */ + function extraDateInfo( + currentSelectedDate: PickerDateModel, + pickerItem: PickerItemModel, + ): PickerExtraDateInfo { + const targetSelectedDate: PickerExtraDateInfo = { + ...currentSelectedDate, + [pickerItem.type]: pickerItem.value, + }; + // Add Month text if the Picker type is month, just for Month's config object + targetSelectedDate.monthText = jalaliMonths[targetSelectedDate.month!]; + // Check if year is leap year and if it was true just for Year's config object + targetSelectedDate.isLeapYear = isLeapYear(targetSelectedDate.year!); + + const determineDayOfWeek = getWeekDay( + targetSelectedDate.year!, + targetSelectedDate.month!, + targetSelectedDate.day!, + ); + if (determineDayOfWeek >= 0) { + targetSelectedDate.weekDay = determineDayOfWeek; + targetSelectedDate.weekDayText = weekDays[determineDayOfWeek]; + } + + return targetSelectedDate; + } + return { + prefix, + + daysInMonth, selectedDate, setSelectedDate, @@ -316,7 +465,7 @@ export function usePicker(props: WheelPickerProps) { shouldRenderYear, shouldRender, filterAllowedColumnRows, - handlePickerItemTextContent, + handlePickerItemTextContent: pickerItemTextFormatter, handlePickerItemClassNames, }; } diff --git a/src/hooks/previous.ts b/src/hooks/usePrevious.ts similarity index 87% rename from src/hooks/previous.ts rename to src/hooks/usePrevious.ts index 4e0489c..beabf74 100644 --- a/src/hooks/previous.ts +++ b/src/hooks/usePrevious.ts @@ -1,4 +1,3 @@ -// Hooks import { useEffect, useRef } from 'react'; /** @@ -6,9 +5,10 @@ import { useEffect, useRef } from 'react'; * * @see https://usehooks.com/usePrevious/ * - * @param {any} value + * @param {T} value + * @return {T} */ -export function usePrevious(value: T): T { +export function usePrevious(value: T): T { // The ref object is a generic container whose current property is mutable ... // ... and can hold any value, similar to an instance property on a class const ref = useRef(); diff --git a/src/index.tsx b/src/index.tsx index 8e3b2ca..f6d1583 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -36,6 +36,12 @@ const Picker: React.FC = (props) => { minDate={props.minDate} maxDate={props.maxDate} defaultValue={props.defaultValue} + highlightWeekends={props.highlightWeekends} + endYear={props.endYear} + startYear={props.startYear} + onChange={props.onChange} + onRender={props.onRender} + disabled={props.disabled} />