Custom date range selection.

This commit is contained in:
Mike Cao 2020-09-13 01:26:54 -07:00
parent 7a8ab94bba
commit 4e103152b2
19 changed files with 545 additions and 40 deletions

View file

@ -13,6 +13,8 @@ export default function Button({
className,
tooltip,
tooltipId,
disabled = false,
onClick = () => {},
...props
}) {
return (
@ -27,7 +29,10 @@ export default function Button({
[styles.xsmall]: size === 'xsmall',
[styles.action]: variant === 'action',
[styles.danger]: variant === 'danger',
[styles.disabled]: disabled,
})}
disabled={disabled}
onClick={!disabled ? onClick : null}
{...props}
>
{icon && <Icon icon={icon} size={size} />}

View file

@ -14,7 +14,7 @@
}
.button:hover {
background: #eaeaea;
background: var(--gray200);
}
.button:active {
@ -38,19 +38,32 @@
}
.action {
color: var(--gray50) !important;
background: var(--gray900) !important;
color: var(--gray50);
background: var(--gray900);
}
.action:hover {
background: var(--gray800) !important;
background: var(--gray800);
}
.danger {
color: var(--gray50) !important;
background: var(--red500) !important;
color: var(--gray50);
background: var(--red500);
}
.danger:hover {
background: var(--red400) !important;
background: var(--red400);
}
.button:disabled {
color: var(--gray500);
background: var(--gray75);
}
.button:disabled:active {
color: var(--gray500);
}
.button:disabled:hover {
background: var(--gray75);
}

View file

@ -0,0 +1,258 @@
import React, { useState } from 'react';
import classNames from 'classnames';
import {
startOfWeek,
startOfMonth,
startOfYear,
endOfMonth,
addDays,
subDays,
addYears,
subYears,
addMonths,
setMonth,
setYear,
isSameDay,
isBefore,
isAfter,
} from 'date-fns';
import Button from './Button';
import useLocale from 'hooks/useLocale';
import { dateFormat } from 'lib/lang';
import { chunk } from 'lib/array';
import Chevron from 'assets/chevron-down.svg';
import Cross from 'assets/times.svg';
import styles from './Calendar.module.css';
import Icon from './Icon';
export default function Calendar({ date, minDate, maxDate, onChange }) {
const [locale] = useLocale();
const [selectMonth, setSelectMonth] = useState(false);
const [selectYear, setSelectYear] = useState(false);
const month = dateFormat(date, 'MMMM', locale);
const year = date.getFullYear();
function toggleMonthSelect() {
setSelectYear(false);
setSelectMonth(state => !state);
}
function toggleYearSelect() {
setSelectMonth(false);
setSelectYear(state => !state);
}
function handleChange(value) {
setSelectMonth(false);
setSelectYear(false);
if (value) {
onChange(value);
}
}
return (
<div className={styles.calendar}>
<div className={styles.header}>
<div>{date.getDate()}</div>
<div
className={classNames(styles.selector, { [styles.open]: selectMonth })}
onClick={toggleMonthSelect}
>
{month}
<Icon className={styles.icon} icon={selectMonth ? <Cross /> : <Chevron />} size="small" />
</div>
<div
className={classNames(styles.selector, { [styles.open]: selectYear })}
onClick={toggleYearSelect}
>
{year}
<Icon className={styles.icon} icon={selectYear ? <Cross /> : <Chevron />} size="small" />
</div>
</div>
{!selectMonth && !selectYear && (
<DaySelector
date={date}
minDate={minDate}
maxDate={maxDate}
locale={locale}
onSelect={handleChange}
/>
)}
{selectMonth && (
<MonthSelector
date={date}
minDate={minDate}
maxDate={maxDate}
locale={locale}
onSelect={handleChange}
onClose={toggleMonthSelect}
/>
)}
{selectYear && (
<YearSelector
date={date}
minDate={minDate}
maxDate={maxDate}
onSelect={handleChange}
onClose={toggleYearSelect}
/>
)}
</div>
);
}
const DaySelector = ({ date, minDate, maxDate, locale, onSelect }) => {
const startWeek = startOfWeek(date);
const startMonth = startOfMonth(date);
const startDay = subDays(startMonth, startMonth.getDay() + 1);
const month = date.getMonth();
const year = date.getFullYear();
const daysOfWeek = [];
for (let i = 0; i < 7; i++) {
daysOfWeek.push(addDays(startWeek, i));
}
const days = [];
for (let i = 0; i < 35; i++) {
days.push(addDays(startDay, i));
}
return (
<table>
<thead>
<tr>
{daysOfWeek.map((day, i) => (
<th key={i} className={locale}>
{dateFormat(day, 'EEE', locale)}
</th>
))}
</tr>
</thead>
<tbody>
{chunk(days, 7).map((week, i) => (
<tr key={i}>
{week.map((day, j) => {
const disabled = isBefore(day, minDate) || isAfter(day, maxDate);
return (
<td
key={j}
className={classNames({
[styles.selected]: isSameDay(date, day),
[styles.faded]: day.getMonth() !== month || day.getFullYear() !== year,
[styles.disabled]: disabled,
})}
onClick={!disabled ? () => onSelect(day) : null}
>
{day.getDate()}
</td>
);
})}
</tr>
))}
</tbody>
</table>
);
};
const MonthSelector = ({ date, minDate, maxDate, locale, onSelect }) => {
const start = startOfYear(date);
const months = [];
for (let i = 0; i < 12; i++) {
months.push(endOfMonth(addMonths(start, i)));
}
function handleSelect(value) {
onSelect(setMonth(date, value));
}
return (
<table>
<tbody>
{chunk(months, 3).map((row, i) => (
<tr key={i}>
{row.map((month, j) => {
const disabled = isBefore(month, minDate) || isAfter(month, maxDate);
return (
<td
key={j}
className={classNames(locale, {
[styles.selected]: month.getMonth() === date.getMonth(),
[styles.disabled]: disabled,
})}
onClick={!disabled ? () => handleSelect(month.getMonth()) : null}
>
{dateFormat(month, 'MMMM', locale)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
);
};
const YearSelector = ({ date, minDate, maxDate, onSelect }) => {
const [currentDate, setCurrentDate] = useState(date);
const year = date.getFullYear();
const currentYear = currentDate.getFullYear();
const minYear = minDate.getFullYear();
const maxYear = maxDate.getFullYear();
const years = [];
for (let i = 0; i < 15; i++) {
years.push(currentYear - 7 + i);
}
function handleSelect(value) {
onSelect(setYear(date, value));
}
function handlePrevClick() {
setCurrentDate(state => subYears(state, 15));
}
function handleNextClick() {
setCurrentDate(state => addYears(state, 15));
}
return (
<div className={styles.pager}>
<Button
icon={<Chevron />}
size="xsmall"
className={styles.left}
onClick={handlePrevClick}
disabled={years[0] <= minYear}
/>
<table>
<tbody>
{chunk(years, 5).map((row, i) => (
<tr key={i}>
{row.map((n, j) => (
<td
key={j}
className={classNames({
[styles.selected]: n === year,
[styles.disabled]: n < minYear || n > maxYear,
})}
onClick={() => (n < minYear || n > maxYear ? null : handleSelect(n))}
>
{n}
</td>
))}
</tr>
))}
</tbody>
</table>
<Button
icon={<Chevron />}
size="xsmall"
className={styles.right}
onClick={handleNextClick}
disabled={years[years.length - 1] > maxYear}
/>
</div>
);
};

View file

@ -0,0 +1,84 @@
.calendar {
display: flex;
flex-direction: column;
font-size: var(--font-size-small);
flex: 1;
}
.calendar table {
flex: 1;
}
.calendar td {
color: var(--gray800);
cursor: pointer;
text-align: center;
vertical-align: center;
height: 40px;
min-width: 40px;
border-radius: 5px;
}
.calendar td:hover {
background: var(--gray100);
}
.calendar td.faded {
color: var(--gray500);
}
.calendar td.selected {
font-weight: 600;
border: 1px solid var(--gray600);
}
.calendar td.selected:hover {
background: transparent;
}
.calendar td.disabled {
color: var(--gray300);
background: var(--gray75);
}
.calendar td.disabled:hover {
cursor: default;
background: var(--gray75);
}
.calendar td.faded.disabled {
color: var(--gray200);
}
.header {
display: flex;
justify-content: space-evenly;
align-items: center;
font-weight: 700;
line-height: 40px;
font-size: var(--font-size-normal);
}
.selector {
cursor: pointer;
}
.pager {
display: flex;
}
.pager button {
align-self: center;
}
.left svg {
transform: rotate(90deg);
}
.right svg {
transform: rotate(-90deg);
}
.icon {
margin-left: 10px;
}

View file

@ -1,7 +1,12 @@
import React from 'react';
import { getDateRange } from 'lib/date';
import DropDown from './DropDown';
import React, { useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { endOfYear } from 'date-fns';
import Modal from './Modal';
import DropDown from './DropDown';
import DatePickerForm from 'components/forms/DatePickerForm';
import useLocale from 'hooks/useLocale';
import { getDateRange } from 'lib/date';
import { dateFormat } from 'lib/lang';
const filterOptions = [
{
@ -35,14 +40,53 @@ const filterOptions = [
value: '1month',
},
{ label: <FormattedMessage id="label.this-year" defaultMessage="This year" />, value: '1year' },
{
label: <FormattedMessage id="label.custom-range" defaultMessage="Custom range" />,
value: 'custom',
},
];
export default function DateFilter({ value, onChange, className }) {
export default function DateFilter({ value, startDate, endDate, onChange, className }) {
const [locale] = useLocale();
const [showPicker, setShowPicker] = useState(false);
const displayValue =
value === 'custom'
? `${dateFormat(startDate, 'd LLL y', locale)}${dateFormat(endDate, 'd LLL y', locale)}`
: value;
function handleChange(value) {
if (value === 'custom') {
setShowPicker(true);
return;
}
onChange(getDateRange(value));
}
function handlePickerChange(value) {
setShowPicker(false);
onChange(value);
}
return (
<DropDown className={className} value={value} options={filterOptions} onChange={handleChange} />
<>
<DropDown
className={className}
value={displayValue}
options={filterOptions}
onChange={handleChange}
/>
{showPicker && (
<Modal>
<DatePickerForm
startDate={startDate}
endDate={endDate}
minDate={new Date(2000, 0, 1)}
maxDate={endOfYear(new Date())}
onChange={handlePickerChange}
onClose={() => setShowPicker(false)}
/>
</Modal>
)}
</>
);
}

View file

@ -23,9 +23,8 @@ export default function DropDown({
function handleSelect(selected, e) {
e.stopPropagation();
setShowMenu(false);
if (selected !== value) {
onChange(selected);
}
onChange(selected);
}
useDocumentClick(e => {
@ -37,7 +36,7 @@ export default function DropDown({
return (
<div ref={ref} className={classNames(styles.dropdown, className)} onClick={handleShowMenu}>
<div className={styles.value}>
{options.find(e => e.value === value)?.label}
<div>{options.find(e => e.value === value)?.label || value}</div>
<Icon icon={<Chevron />} size="small" />
</div>
{showMenu && (

View file

@ -1,16 +1,15 @@
.dropdown {
flex: 1;
position: relative;
font-size: var(--font-size-small);
min-width: 140px;
border: 1px solid var(--gray500);
border-radius: 4px;
cursor: pointer;
}
.value {
display: flex;
justify-content: space-between;
white-space: nowrap;
position: relative;
font-size: var(--font-size-small);
min-width: 140px;
padding: 4px 16px;
border: 1px solid var(--gray500);
border-radius: 4px;
cursor: pointer;
}

View file

@ -2,7 +2,7 @@ import React from 'react';
import classNames from 'classnames';
import styles from './Icon.module.css';
export default function Icon({ icon, className, size = 'medium' }) {
export default function Icon({ icon, className, size = 'medium', ...props }) {
return (
<div
className={classNames(styles.icon, className, {
@ -12,6 +12,7 @@ export default function Icon({ icon, className, size = 'medium' }) {
[styles.small]: size === 'small',
[styles.xsmall]: size === 'xsmall',
})}
{...props}
>
{icon}
</div>

View file

@ -35,7 +35,7 @@ export default function LanguageButton({ menuPosition = 'bottom', menuAlign = 'l
rel="stylesheet"
/>
)}
{locale === 'jp-JP' && (
{locale === 'ja-JP' && (
<link
href="https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400;500;700&display=swap"
rel="stylesheet"

View file

@ -1,5 +1,5 @@
.modal {
position: absolute;
position: fixed;
top: 0;
left: 0;
right: 0;
@ -28,6 +28,7 @@
background: var(--gray50);
min-width: 400px;
min-height: 100px;
max-width: 100vw;
z-index: 1;
border: 1px solid var(--gray300);
padding: 30px;