Skip to content

Commit

Permalink
Datepicker Date Range availability (#508)
Browse files Browse the repository at this point in the history
* Add DateRange to Datepicker

* Add Day range validation to DatePicker

* Add intialValue to DatePicker story

* Update the Year check for NextMonth in datePicker

* Updated design for disabled/invalid ranges in DatePicker

* Update DatePicker story to have a closer example with projects
  • Loading branch information
isacoder authored Nov 22, 2024
1 parent 77d4059 commit 3b86465
Show file tree
Hide file tree
Showing 4 changed files with 151 additions and 19 deletions.
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from 'react';
import styled from 'styled-components';
import { select, text } from '@storybook/addon-knobs';
import { boolean, object, select, text } from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions';
import { DatePicker, FilterDropdownContainer } from 'scorer-ui-kit';

Expand All @@ -24,29 +24,56 @@ const exampleCallback = <T extends Function>(fn: T): T => {
return fn;
};

const START_RANGE: Date = new Date();
START_RANGE.setDate(0);
START_RANGE.setDate(START_RANGE.getDate() - 20);
const END_RANGE: Date = new Date();
END_RANGE.setDate(1);
END_RANGE.setDate(END_RANGE.getDate() + 60);


const TODAY: Date = new Date();
const TWO_WEEKS_BEFORE: Date = new Date();
TWO_WEEKS_BEFORE.setDate(TODAY.getDate() - 15);


const initialValue = {
start: TWO_WEEKS_BEFORE,
end: TODAY
}
const datesRange = {
start: START_RANGE,
end: END_RANGE
}

export const _DatePicker = () => {
const language = select('Language', { English: 'en', Japanese: 'ja' }, 'ja');
const initialValueObj = object('Initial Value', initialValue);
const dateMode = select('Date Mode', { single: 'single', interval: 'interval' }, 'interval');
const timeMode = select('Time Mode', { off: 'off', single: 'single', interval: 'interval' }, 'interval');
const dateTimeTextUpper = text('Date Time Text Upper', 'From');
const dateTimeTextLower = text('Date Time Text Lower', 'To');
const timeZoneTitle = text('Time Zone Title', 'Timezone');
const timeZoneValueTitle = text('Time Zone Value', 'JST');
const updateCallback = action('Date / Time Updated');
const sendRange = boolean('Send Available Range', true);
const availableRangeDates = object('Available Range', datesRange);

return (
<Container>
<FilterDropdownContainer>
<DatePicker {...{
timeMode,
dateMode,
timeZoneValueTitle
timeZoneValueTitle,
}}
updateCallback={exampleCallback(updateCallback)}
dateTimeTextUpper={language === 'ja' ? 'から' : dateTimeTextUpper}
dateTimeTextLower={language === 'ja' ? 'まで' : dateTimeTextLower}
timeZoneTitle={language === 'ja' ? '時間帯' : timeZoneTitle}
lang={language}
initialValue={initialValueObj}
availableRange={sendRange ? availableRangeDates : undefined}
/>
</FilterDropdownContainer>
</Container>);
Expand Down
5 changes: 3 additions & 2 deletions packages/ui-lib/src/Filters/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import DatePicker, { DateInterval, isDateInterval } from './molecules/DatePicker';
import DatePicker, { DateInterval, isDateInterval, DateRange } from './molecules/DatePicker';
import FilterDropdownContainer from './atoms/FilterDropdownContainer';
import FilterButton from './atoms/FilterButton';
import FilterDropdown from './molecules/FilterDropdown';
Expand Down Expand Up @@ -47,5 +47,6 @@ export type {
IFilterValue,
DateInterval,
IFilterDatePicker,
FilterButtonDesign
FilterButtonDesign,
DateRange
};
130 changes: 116 additions & 14 deletions packages/ui-lib/src/Filters/molecules/DatePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import DateTimeBlock from '../atoms/DateTimeBlock';

import { format, startOfMonth, endOfMonth, eachDayOfInterval, isAfter, eachWeekOfInterval, addMonths, endOfWeek, intervalToDuration, isSameMonth, isSameDay, isToday, startOfDay, endOfDay, isWithinInterval, set, add, isEqual } from 'date-fns';
import { ja, enUS } from 'date-fns/locale';
import { resetButtonStyles } from '../../common';

/**
* Convert a single days duration to an interval.
Expand Down Expand Up @@ -132,14 +133,14 @@ const PaginateMonth = styled.button`
align-items: center;
transition: color var(--speed-fast) var(--easing-primary-in-out);
${IconWrap}{
svg * {
transition: stroke var(--speed-fast) var(--easing-primary-in-out);
}
}
&:hover {
&:hover:enabled {
color: var(--grey-12);
${IconWrap}{
Expand All @@ -148,6 +149,12 @@ const PaginateMonth = styled.button`
}
}
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
`;

const CalBody = styled.div`
Expand All @@ -167,7 +174,8 @@ const CalHRow = styled(CalRow)`
border-bottom: var(--grey-6) 1px solid;
`;

const CalCell = styled.div`
const CalCell = styled.button`
${resetButtonStyles};
display: flex;
text-align: center;
justify-content: center;
Expand Down Expand Up @@ -203,7 +211,7 @@ const CalCellB = styled(CalCell) <{ thisMonth?: boolean, isToday?: boolean, stat
`}
${({ state }) => (state !== 'single' && state !== 'start' && state !== 'end') && css`
&:hover {
&:hover:enabled {
background: var(--primary-a6);
color: var(--white-1);
}
Expand Down Expand Up @@ -253,6 +261,26 @@ const CalCellB = styled(CalCell) <{ thisMonth?: boolean, isToday?: boolean, stat
}
`}
&:disabled {
color: var(--grey-6);
cursor: not-allowed;
${({ state }) => (state === 'single' || state === 'start' || state === 'end') && css`
color: var(--white-1);
background: var(--red-a9);
`}
${({ state }) => (state === 'inside') && css`
color: var(--white-1);
background: var(--red-a7);
&:nth-child(7n+1), &:nth-child(7n){
&::after {
background: var(--red-a7);
}
}
`};
}
`;

const enDayGuide: string[] = [
Expand Down Expand Up @@ -281,6 +309,12 @@ export interface DateInterval {
start: Date;
end: Date;
}

export interface DateRange {
start: Date | null;
end: Date | null;
}

export interface IDatePicker {
initialValue?: Date | DateInterval
dateMode?: DateMode
Expand All @@ -290,6 +324,7 @@ export interface IDatePicker {
dateTimeTextLower?: string
timeZoneTitle?: string
timeZoneValueTitle?: string
availableRange?: DateRange
updateCallback?: (data: DateInterval | Date) => void
lang?: 'en' | 'ja'
}
Expand All @@ -304,6 +339,7 @@ const DatePicker: React.FC<IDatePicker> = ({
hasEmptyValue = false,
updateCallback = () => { },
initialValue,
availableRange,
lang = 'en'
}) => {

Expand Down Expand Up @@ -390,18 +426,18 @@ const DatePicker: React.FC<IDatePicker> = ({
useEffect(() => {
const { start, end } = selectedRange ? selectedRange : TODAY_INTERVAL;

if((timeMode ==='interval' && isAfter(add(start, { minutes: 1 }), end))){
if(isEqual(end, endOfDay(start)) && end.getSeconds() > 0) { // Midnight exception
if ((timeMode === 'interval' && isAfter(add(start, { minutes: 1 }), end))) {
if (isEqual(end, endOfDay(start)) && end.getSeconds() > 0) { // Midnight exception
setIsTimeRangeValid(true);
}else {
} else {
setIsTimeRangeValid(false);
}

} else {
setIsTimeRangeValid(true);
}

},[selectedRange, timeMode]);
}, [selectedRange, timeMode]);

const updateStartDate = useCallback((start: Date) => {
const { end } = selectedRange ? selectedRange : TODAY_INTERVAL;
Expand All @@ -419,8 +455,8 @@ const DatePicker: React.FC<IDatePicker> = ({
<Container>

<DateTimeArea>
<DateTimeBlock {...{isTimeRangeValid}} title={dateTimeTextUpper} hasDate hasTime={timeMode !== 'off'} date={selectedRange ? selectedRange.start : TODAY_INTERVAL.start} setDateCallback={updateStartDate} />
<DateTimeBlock {...{isTimeRangeValid}} title={dateTimeTextLower} hasDate={dateMode === 'interval'} hasTime={timeMode === 'interval'} date={selectedRange ? selectedRange.end : TODAY_INTERVAL.end} allowAfterMidnight setDateCallback={updateEndDate} />
<DateTimeBlock {...{ isTimeRangeValid }} title={dateTimeTextUpper} hasDate hasTime={timeMode !== 'off'} date={selectedRange ? selectedRange.start : TODAY_INTERVAL.start} setDateCallback={updateStartDate} />
<DateTimeBlock {...{ isTimeRangeValid }} title={dateTimeTextLower} hasDate={dateMode === 'interval'} hasTime={timeMode === 'interval'} date={selectedRange ? selectedRange.end : TODAY_INTERVAL.end} allowAfterMidnight setDateCallback={updateEndDate} />

<TimeZoneOption>
<TimeZoneLabel>{timeZoneTitle}</TimeZoneLabel>
Expand All @@ -432,7 +468,7 @@ const DatePicker: React.FC<IDatePicker> = ({
<CalendarArea>
<CalendarHeader>

<PaginateMonth type='button' onClick={() => setFocusedMonth(addMonths(focusedMonth, -1))}>
<PaginateMonth type='button' disabled={isPrevMonthOutOfRange(focusedMonth, availableRange)} onClick={() => setFocusedMonth(addMonths(focusedMonth, -1))}>
<IconWrap><Icon icon='Left' color='dimmed' size={10} /></IconWrap>
{format(addMonths(focusedMonth, -1), "MMM", { locale: lang === 'ja' ? ja : enUS })}
</PaginateMonth>
Expand All @@ -442,7 +478,7 @@ const DatePicker: React.FC<IDatePicker> = ({
{format(focusedMonth, "MMMM", { locale: lang === 'ja' ? ja : enUS })}
</CurrentMonth>

<PaginateMonth type='button' onClick={() => setFocusedMonth(addMonths(focusedMonth, 1))}>
<PaginateMonth type='button' disabled={isNextMonthOutOfRange(focusedMonth, availableRange)} onClick={() => setFocusedMonth(addMonths(focusedMonth, 1))}>
{format(addMonths(focusedMonth, 1), "MMM", { locale: lang === 'ja' ? ja : enUS })}
<IconWrap><Icon icon='Right' color='dimmed' size={10} /></IconWrap>
</PaginateMonth>
Expand All @@ -465,7 +501,16 @@ const DatePicker: React.FC<IDatePicker> = ({
return (
<CalRow key={index}>
{days.map((day, index) => {
return <CalCellB key={index} onClick={() => onCellClick(day)} state={cellState(day, selectedRange)} thisMonth={isSameMonth(day, focusedMonth)} isToday={isToday(day)}>{format(day, "d")}</CalCellB>;
return (
<CalCellB
key={index}
disabled={isDayOutOfRange(day, availableRange)}
onClick={() => onCellClick(day)}
state={cellState(day, selectedRange)}
thisMonth={isSameMonth(day, focusedMonth)}
isToday={isToday(day)}>{format(day, "d")}
</CalCellB>
);
})}
</CalRow>
);
Expand Down Expand Up @@ -541,4 +586,61 @@ const getInitialValue = (hasEmptyValue: boolean, initialValue?: Date | DateInter
const validInitial = initialValue ? initialValue : initializeInterval(startOfDay(new Date()));

return (validInitial instanceof Date) ? initializeInterval(validInitial) : validInitial;
};
};

const isPrevMonthOutOfRange = (focusedMonth: Date, availableRange?: DateRange): boolean => {
if (!availableRange?.start) return false;

try {
const startYear = availableRange.start.getFullYear();
const startMonth = availableRange.start.getMonth();

if (focusedMonth.getFullYear() < startYear ||
(focusedMonth.getFullYear() === startYear && focusedMonth.getMonth() <= startMonth)) {
return true;
}
} catch (error) {
console.warn('Invalid available range:', availableRange, error);
}

return false;
};

const isNextMonthOutOfRange = (focusedMonth: Date, availableRange?: DateRange): boolean => {
if (!availableRange?.end) return false;

try {
const endYear = availableRange.end.getFullYear();
const endMonth = availableRange.end.getMonth();

if (focusedMonth.getFullYear() > endYear ||
(focusedMonth.getFullYear() === endYear && focusedMonth.getMonth() >= endMonth)) {
return true;
}
} catch (error) {
console.warn('Invalid available range:', availableRange, error);
}

return false;
};


const isDayOutOfRange = (currentDay: Date, availableRange?: DateRange): boolean => {
if (!availableRange) return false;

const { start, end } = availableRange;

try {
if (start && currentDay < start && !isSameDay(currentDay, start)) {
return true;
}

if (end && currentDay > end && !isSameDay(currentDay, end)) {
return true;
}
} catch (error) {
console.warn('Invalid available range:', availableRange, error);
}

return false;
};
4 changes: 3 additions & 1 deletion packages/ui-lib/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import {
DatePicker,
DateInterval,
isDateInterval,
DateRange,
IFilterDatePicker,
FilterDropdownContainer,
FilterButton,
Expand Down Expand Up @@ -417,5 +418,6 @@ export type {
ISplitLayoutHandles,
AlertType,
ITooltipType,
FilterButtonDesign
FilterButtonDesign,
DateRange
};

0 comments on commit 3b86465

Please sign in to comment.