diff --git a/src/components/WheelPicker/index.tsx b/src/components/WheelPicker/index.tsx index e110338..c6254e8 100644 --- a/src/components/WheelPicker/index.tsx +++ b/src/components/WheelPicker/index.tsx @@ -6,42 +6,42 @@ import MultiPicker from 'rmc-picker/es/MultiPicker'; import { GlobalStyle } from './styles'; // Helpers import { - convertSelectedDateToAnArray, convertSelectedDateToObject, isObjectEmpty, prefixClassName, } from '../../helpers'; import { - isValidDate, - jalaliMonths, - pickerData, - daysInMonth as calculateDaysInMonth, - getWeekDay, - getWeekDayName, convertDateToObject, - convertObjectToDate, + daysInMonth as calculateDaysInMonth, isValid, - isAfter, - isBefore, + pickerData, } from '../../helpers/date'; // Hooks import { usePrevious } from '../../hooks/previous'; // Types import type { PickerColumns, - PickerItemModel, - PickerSelectedDateValue, - WheelPickerProps, - DateConfig, PickerDateModel, - DateConfigTypes, - PickerClassNameFormatter, + WheelPickerProps, } from './index.types'; +import { usePicker } from '../../hooks/usePicker'; export const WheelPicker: React.FC = (props) => { + const { + selectedDate, + setSelectedDate, + defaultPickerValues, + + maxYear, + minYear, + + filterAllowedColumnRows, + handlePickerItemTextContent, + handlePickerItemClassNames, + } = usePicker(props); // Local States const [daysInMonth, setDaysInMonth] = useState(29); - const [selectedDate, setSelectedDate] = useState({}); + // Hooks const previousSelectedDate = usePrevious(selectedDate); // Local Variables @@ -49,36 +49,8 @@ export const WheelPicker: React.FC = (props) => { * Picker CSS classnames prefix name */ const prefix = prefixClassName(props.prefix!); - // Memo list - /** - * Default Picker selected columns value which goes from the parent to local changes - */ - const defaultPickerValues = React.useMemo>(() => { - return convertSelectedDateToAnArray( - (isObjectEmpty(selectedDate) ? props.defaultValue : selectedDate)!, - ); - }, [props.defaultValue, selectedDate]); - /** - * parse and convert [minDate] to an object - * - * @type {Required} - */ - const minDateObject = React.useMemo( - () => convertDateToObject(props.minDate!), - [props.minDate], - ); - console.log('minDateObject', minDateObject); - /** - * parse and convert [maxDate] to an object - * - * @type {Required} - */ - const maxDateObject = React.useMemo( - () => convertDateToObject(props.maxDate!), - [props.minDate], - ); - console.log('maxDateObject', maxDateObject); + // Memo list /** * Generate Picker's columns with their values * @@ -86,14 +58,14 @@ export const WheelPicker: React.FC = (props) => { * @return {PickerColumns} */ const pickerColumns = React.useMemo(() => { - let columns = Object.keys(props.config).map((column) => { + return Object.keys(props.config).map((column) => { switch (column) { case 'year': return { type: 'year', value: pickerData.getYears({ - min: 10, - max: 10, + min: minYear, + max: maxYear, }), }; case 'month': @@ -122,12 +94,12 @@ export const WheelPicker: React.FC = (props) => { value: pickerData.getSeconds(), }; default: - throw Error('unknown type'); + throw TypeError( + `[PersianMobileDatePicker] ${column}'s type is not valid. Columns types should be one of [year, month, day]`, + ); } }) as PickerColumns; - - return columns; - }, [props.config, pickerData, daysInMonth]); + }, [props.config, daysInMonth, minYear, maxYear]); /** * Prepare the default value of DatePicker when the Developer has not passed a defaultValue */ @@ -143,7 +115,7 @@ export const WheelPicker: React.FC = (props) => { }); setSelectedDate(defaultDate); } - }, [pickerColumns, selectedDate]); + }, [pickerColumns, selectedDate, props.defaultValue]); /** * * Local Watchers */ @@ -151,8 +123,8 @@ export const WheelPicker: React.FC = (props) => { React.useEffect(() => { if (!isObjectEmpty(selectedDate)) { if ( - previousSelectedDate.month !== selectedDate.month || - previousSelectedDate.year !== selectedDate.year + previousSelectedDate?.month !== selectedDate?.month || + previousSelectedDate?.year !== selectedDate?.year ) { setDaysInMonth( calculateDaysInMonth( @@ -162,82 +134,15 @@ export const WheelPicker: React.FC = (props) => { ); } } - }, [selectedDate]); + }, [selectedDate, previousSelectedDate?.year, previousSelectedDate?.month]); /** * Derived Selected Date from Prop's defaultValue */ React.useEffect(() => { - if ( - isValidDate( - Number(props.defaultValue?.year), - Number(props.defaultValue?.month), - Number(props.defaultValue?.day), - ) - ) { - setSelectedDate(props.defaultValue!); + if (isValid(props.defaultValue!)) { + setSelectedDate(convertDateToObject(props.defaultValue!)); } }, [props.defaultValue]); - /** - * 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]); - // Handlers - /** - * Picker items' text content - * - * @param {PickerItemModel} pickerItem - * @returns {PickerSelectedDateValue} columns text content - */ - function handlePickerItemTextContent( - pickerItem: PickerItemModel, - ): PickerSelectedDateValue { - return ( - configs[pickerItem.type]?.formatter?.(pickerItem.value) ?? - pickerItem.value - ); - } - /** - * Handle every single of columns' row Classname by their type and value - * - * @param pickerItem - * @returns {string} - */ - 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, - ); - } - - // Pass to the classname config's formatter - const classNames = classNamesFormatter(targetSelectedDate); - - return Array.isArray(classNames) ? classNames.join(' ') : classNames; - } - - return ''; - } /** * Picker onChange event which includes every columns' selected value @@ -252,54 +157,6 @@ export const WheelPicker: React.FC = (props) => { props.onChange?.(convertSelectedDate); } - /** - * Allow to render the Column's row if [min] and [max] is passed. - * - * @param {DateConfigTypes} rowType - * @param {PickerSelectedDateValue} rowValue - * @returns {boolean} - */ - const shouldRenderColumnRow = React.useCallback< - (rowType: DateConfigTypes, rowValue: PickerSelectedDateValue) => boolean - >( - (rowType, rowValue) => { - const $selectedDateObject = { ...selectedDate }; - $selectedDateObject[rowType] = rowValue; - const $selectedDate = convertObjectToDate($selectedDateObject); - console.log( - '$selectedDateObject', - $selectedDateObject, - 'isValid', - isValid($selectedDate), - ); - if (!isValid($selectedDate)) return true; - - if (!isObjectEmpty(minDateObject) && !isObjectEmpty(maxDateObject)) { - if (rowType === 'year') { - return ( - rowValue >= (minDateObject[rowType] || rowValue) && - rowValue <= (maxDateObject[rowType] || rowValue) - ); - } - - return ( - isAfter(props.minDate!, $selectedDate) && - isBefore(props.maxDate!, $selectedDate) - ); - } else if (!isObjectEmpty(minDateObject)) { - return isAfter(props.minDate!, $selectedDate); - } else if (!isObjectEmpty(maxDateObject)) { - return isBefore(props.maxDate!, $selectedDate); - } - - return true; - }, - [selectedDate, props.minDate, props.maxDate], - ); - - console.log('selectedDate', selectedDate); - console.log('pickerColumns', pickerColumns); - return ( = (props) => { `indicator ${prefix(`${column.type}-column`)}`, )} > - {column.value.map((pickerItem) => { - return ( - shouldRenderColumnRow(pickerItem.type, pickerItem.value) && ( + {filterAllowedColumnRows(column.value, column.type).map( + (pickerItem) => { + return ( = (props) => { > {handlePickerItemTextContent(pickerItem)} - ) - ); - })} + ); + }, + )} ); })} @@ -340,15 +197,6 @@ export const WheelPicker: React.FC = (props) => { }; WheelPicker.defaultProps = { - minDay: 1, - maxDay: 31, - minMonth: 1, - maxMonth: 12, - minYear: 1300, - maxYear: 1500, - defaultValue: { - year: 1399, - month: 12, - day: 29, - }, + minDecade: 30, // past 30 years + maxDecade: 30, // next 30 years }; diff --git a/src/components/WheelPicker/index.types.ts b/src/components/WheelPicker/index.types.ts index bd4eefc..c544aea 100644 --- a/src/components/WheelPicker/index.types.ts +++ b/src/components/WheelPicker/index.types.ts @@ -16,7 +16,7 @@ export interface WheelPickerProps { // Max Day value maxDay?: number; // Default column value - defaultValue?: PickerDateModel; + defaultValue?: Date; // Title title?: string; // Triggered when the component DOM is generated, the parameter is the component element @@ -35,6 +35,10 @@ export interface WheelPickerProps { minDate?: Date; // Max Date value maxDate?: Date; + // Max decade + maxDecade?: number; + // Min decade + minDecade?: number; } export type DateConfigTypes = @@ -52,10 +56,12 @@ export type DateConfigFormats = | 'hh' | 'mm' | 'ss'; -export type PickerSelectedDateValue = string | number; +export type PickerSelectedDateValue = number; export interface DateConfigValuesModel { caption?: string; - formatter?: (value: PickerSelectedDateValue) => PickerSelectedDateValue; + formatter?: ( + value: PickerSelectedDateValue, + ) => PickerSelectedDateValue | string; classname?: (value: PickerClassNameFormatter) => string | string[]; shouldRender?: (value: PickerClassNameFormatter) => boolean; } @@ -74,6 +80,7 @@ export type PickerDateModel = { minute?: PickerSelectedDateValue; second?: PickerSelectedDateValue; }; +export type RequiredPickerDateModel = Required; export interface PickerClassNameFormatter extends PickerDateModel { weekDay?: number; diff --git a/src/helpers/date.ts b/src/helpers/date.ts index 47b7f8d..5870beb 100644 --- a/src/helpers/date.ts +++ b/src/helpers/date.ts @@ -12,6 +12,7 @@ import { getHours, getMinutes, getSeconds, + isEqual as isEqualFns, isBefore as isBeforeFns, isAfter as isAfterFns, } from 'date-fns-jalali'; @@ -23,6 +24,7 @@ import type { WeekDaysName, } from '../components/WheelPicker/index.types'; import type { PickerDateModel } from '../components/WheelPicker/index.types'; +import { RequiredPickerDateModel } from '../components/WheelPicker/index.types'; export const weekDays: Record = { 0: 'شنبه', @@ -64,12 +66,12 @@ export function setDate( * Convert entered date to an object * * @param {Date} date - * @return {Required} + * @return {RequiredPickerDateModel} */ -export const convertDateToObject = (date: Date): Required => { +export const convertDateToObject = (date: Date): RequiredPickerDateModel => { return { year: getYear(date), - month: getMonth(date), + month: getMonth(date) + 1, day: getDate(date), hour: getHours(date), minute: getMinutes(date), @@ -159,13 +161,27 @@ export function isAfter(currentDate: Date, nextDate: Date): boolean { return isAfterFns(currentDate, nextDate); } +export function isEqual(dateLeft: Date, dateRight: Date): boolean { + return isEqualFns(dateLeft, dateRight); +} + /** * Return the current Year * * @returns {number} */ export function getCurrentYear(): number { - return Number(format(new Date(), 'yyyy')); + return currentDateObject().year; +} + +export function currentDateObject(): Required< + Record, number> +> { + return { + year: Number(format(new Date(), 'yyyy')), + month: Number(format(new Date(), 'M')), + day: Number(format(new Date(), 'd')), + }; } /** diff --git a/src/helpers/index.ts b/src/helpers/index.ts index 0253c65..6d4a456 100644 --- a/src/helpers/index.ts +++ b/src/helpers/index.ts @@ -71,3 +71,7 @@ export function convertSelectedDateToAnArray( export function isObjectEmpty(obj: Object): boolean { return Object.keys(obj).length === 0 && obj.constructor === Object; } + +export function toPositive(n: number): number { + return n < 0 ? n * -1 : n; +} diff --git a/src/hooks/usePicker.ts b/src/hooks/usePicker.ts new file mode 100644 index 0000000..466bf23 --- /dev/null +++ b/src/hooks/usePicker.ts @@ -0,0 +1,322 @@ +import { + DateConfig, + DateConfigTypes, + PickerClassNameFormatter, + PickerDateModel, + PickerItemModel, + PickerSelectedDateValue, + RequiredPickerDateModel, + WheelPickerProps, +} from '../components/WheelPicker/index.types'; +import React, { useState } from 'react'; +import { + convertDateToObject, + convertObjectToDate, + currentDateObject, + getCurrentYear, + getWeekDay, + getWeekDayName, + isAfter, + isBefore, + isEqual, + isValid, + jalaliMonths, +} from '../helpers/date'; +import { + convertSelectedDateToAnArray, + isObjectEmpty, + toPositive, +} from '../helpers'; + +export function usePicker(props: WheelPickerProps) { + /** + * * Parse and convert [minDate] to an object + * + * @type {RequiredPickerDateModel} + */ + const minDateObject = React.useMemo( + () => convertDateToObject(props.minDate!), + [props.minDate], + ); + /** + * * Parse and convert [maxDate] to an object + * + * @type {RequiredPickerDateModel} + */ + const maxDateObject = React.useMemo( + () => convertDateToObject(props.maxDate!), + [props.maxDate], + ); + /** + * * Check if the [Min Date] is valid and has filled + * + * @type {boolean} + */ + const isMinDateValid = isValid(props.minDate!); + /** + * * Check if the [Max Date] is valid and has filled + * + * @type {boolean} + */ + const isMaxDateValid = isValid(props.maxDate!); + + /** + * * Parse and convert the [defaultValue] that filled as a prop to an Object + * + * @type {RequiredPickerDateModel} + */ + const defaultValueDateObject = React.useMemo( + () => convertDateToObject(props.defaultValue!), + [props.defaultValue], + ); + /** + * Check if the Default Value Date is valid and has filled + * + * @type {boolean} + */ + const isDefaultValueValid = isValid(props.defaultValue!); + + /** + * Get Min Year of the Year Column which should be rendered + * + * @type {number} + */ + const minYear = React.useMemo(() => { + const currentYear = getCurrentYear(); + + let year: number = currentYear; + if (isMinDateValid) { + year = minDateObject.year; + } else if (isDefaultValueValid) { + year = defaultValueDateObject.year; + } else if (Number(props.minDecade)) { + year = currentYear + Number(props.minDecade); + } + + return toPositive(currentYear - year); + }, [minDateObject, defaultValueDateObject, props.minDecade]); + + /** + * * Get Max Year of the [Year Column] which should be rendered + * + * @type {number} + */ + const maxYear = React.useMemo(() => { + const currentYear = getCurrentYear(); + + let year: number = currentYear; + if (isMinDateValid) { + year = maxDateObject.year; + } else if (isDefaultValueValid) { + year = defaultValueDateObject.year; + } else if (Number(props.maxDecade)) { + year = currentYear + Number(props.maxDecade); + } + + return toPositive(currentYear - year); + }, [ + maxDateObject, + defaultValueDateObject, + props.maxDecade, + isDefaultValueValid, + ]); + + const defaultSelectedDate = React.useMemo(() => { + if (isDefaultValueValid) { + return convertDateToObject(props.defaultValue!); + } else if (isMaxDateValid) { + return maxDateObject; + } else if (isMinDateValid) { + return minDateObject; + } + + return currentDateObject(); + }, [isMinDateValid, isMaxDateValid, isDefaultValueValid]); + + // Local States + const [selectedDate, setSelectedDate] = + useState(defaultSelectedDate); + + /** + * Default Picker selected columns value which goes from the parent to local changes + */ + const defaultPickerValues = React.useMemo>(() => { + return convertSelectedDateToAnArray( + (isObjectEmpty(selectedDate) + ? convertDateToObject(props.defaultValue!) + : 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 + /** + * Check if entered Year is in Range of Min and Max + * + * @param {number} year + * @returns {boolean} + */ + function shouldRenderYear(year: number): boolean { + if (isMaxDateValid && isMaxDateValid) { + return year >= minDateObject.year && year <= maxDateObject.year; + } else if (isMaxDateValid) { + return year <= maxDateObject.year; + } else if (isMaxDateValid) { + return year >= minDateObject.year; + } + + return true; + } + + /** + * * Check if the Month or Day Column's item should be rendered or not + * + * @param {DateConfigTypes} key + * @param {PickerSelectedDateValue} value + * @returns {boolean} + */ + function shouldRender( + key: DateConfigTypes, + value: PickerSelectedDateValue, + ): boolean { + // Create new Date object and clone the SelectedDate and assign the entered day to the Object + const $date = { ...selectedDate, [key]: value }; + // Convert to a Date instance + const selectedDateValue = convertObjectToDate($date); + + // Date is not valid + if (!isValid(selectedDateValue)) return true; + + // Selected Date is equals to the Min or Max Date + if ( + isEqual(props.minDate!, selectedDateValue) || + isEqual(props.maxDate!, selectedDateValue) + ) + return true; + + if (isMaxDateValid && isMaxDateValid) { + return ( + isAfter(selectedDateValue, props.minDate!) && + isBefore(selectedDateValue, props.maxDate!) + ); + } else if (isMaxDateValid) { + return isAfter(selectedDateValue, props.minDate!); + } else if (isMaxDateValid) { + return isBefore(selectedDateValue, props.maxDate!); + } + + return true; + } + + /** + * * Filter all Columns values and only return the Column's items which should be displayed + * + * @param {Array} pickerList list of Column's items + * @param {DateConfigTypes} type type of column + * @returns {Array} new list of Column's items which should be displayed in the DatePicker + */ + function filterAllowedColumnRows( + pickerList: Array, + type: DateConfigTypes, + ): Array { + return pickerList.filter((pickerItem) => { + // Check if Day or Month is in Range + if ( + (type === 'day' || type === 'month') && + !shouldRender(pickerItem.type, pickerItem.value) + ) { + return false; + // Check if Month is in Range + } else if (type === 'year' && !shouldRenderYear(pickerItem.value)) { + return false; + } + + return true; + }); + } + + /** + * Picker items' text content + * + * @param {PickerItemModel} pickerItem + * @returns {PickerSelectedDateValue} columns text content + */ + function handlePickerItemTextContent( + pickerItem: PickerItemModel, + ): PickerSelectedDateValue | string { + return ( + configs[pickerItem.type]?.formatter?.(pickerItem.value) ?? + pickerItem.value + ); + } + + /** + * Handle every single of columns' row Classname by their type and value + * + * @param {PickerItemModel} pickerItem + * @returns {string} + */ + 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, + ); + } + + // Pass to the classname config's formatter + const classNames = classNamesFormatter(targetSelectedDate); + + return Array.isArray(classNames) ? classNames.join(' ') : classNames; + } + + return ''; + } + + return { + selectedDate, + setSelectedDate, + + defaultSelectedDate, + maxYear, + minYear, + minDateObject, + maxDateObject, + isMinDateValid, + isMaxDateValid, + + defaultValueDateObject, + isDefaultValueValid, + defaultPickerValues, + + // Functions + shouldRenderYear, + shouldRender, + filterAllowedColumnRows, + handlePickerItemTextContent, + handlePickerItemClassNames, + }; +} diff --git a/src/index.tsx b/src/index.tsx index 7627909..8e3b2ca 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -3,50 +3,27 @@ import React from 'react'; import Sheet from 'react-modal-sheet'; // Local Components import { WheelPicker } from './components/WheelPicker'; -// Utilities -import { digitsEnToFa } from '@persian-tools/persian-tools'; // Types -import { PickerProps } from './index.types'; -import { DateConfig } from './components/WheelPicker/index.types'; +import type { PickerProps } from './index.types'; import { SubmitButton, Footer, CancelButton } from './index.styles'; import { setDate } from './helpers/date'; const Picker: React.FC = (props) => { - const [isOpen, setIsOpen] = React.useState(true); - const pickerConfig = React.useMemo( - () => ({ - year: { - caption: 'سال', - formatter(value) { - return value ? digitsEnToFa(value) : value; - }, - shouldRender: (value) => value === 1400, - }, - month: { - caption: 'ماه', - shouldRender: (value) => value >= 1 && value <= 7, - }, - day: { - formatter(value) { - return value ? digitsEnToFa(value) : value; - }, - caption: 'روز', - classname: (value) => { - if (typeof value === 'number' && (value % 7 === 0 || value % 6 === 0)) - return 'holiday'; + const [isOpen, setIsOpen] = React.useState(false); - return ''; - }, - shouldRender: (value) => value >= 10 && value <= 30, - }, - }), - [], - ); + React.useEffect(() => { + setIsOpen(props.isOpen); + }, [props.isOpen]); + + function handleClose() { + setIsOpen(false); + props.onClose?.(); + } return ( setIsOpen(false)} + onClose={() => handleClose()} snapPoints={[350]} initialSnap={0} > @@ -54,10 +31,11 @@ const Picker: React.FC = (props) => {