Skip to content

Instantly share code, notes, and snippets.

@trinhvanminh
Last active August 18, 2023 04:25
Show Gist options
  • Save trinhvanminh/6b0e68c2abc964fd823ec36e9556186b to your computer and use it in GitHub Desktop.
Save trinhvanminh/6b0e68c2abc964fd823ec36e9556186b to your computer and use it in GitHub Desktop.
custom mui date range picker (mobile and desktop)
============================================components/PickersActionBar.js============================================
import { Button, Stack } from '@mui/material';
import { useLocaleText } from '@mui/x-date-pickers/internals';
export default function PickersActionBar({ onCancel, onAccept, disabled }) {
const { cancelButtonLabel, okButtonLabel } = useLocaleText();
return (
<Stack
flexDirection="row"
justifyContent="flex-end"
zIndex={1}
p={2}
borderTop={1}
borderColor="divider"
bgcolor="#fff"
>
<Button onClick={onCancel}>{cancelButtonLabel}</Button>
<Button onClick={onAccept} disabled={disabled} sx={{ ml: 2 }}>
{okButtonLabel}
</Button>
</Stack>
);
}
============================================components/PickersShortcuts.js============================================
import { Box, List, ListItemButton, ListItemText } from '@mui/material';
import { useRef } from 'react';
function ShortcutItem({ label, getValue, isValid, onChange, onDoubleClick, disableClear }) {
const timeoutRef = useRef();
const [from, to] = getValue();
const isValidDate = from || to ? isValid(from) || isValid(to) : !disableClear;
const handleOnClick = () => {
timeoutRef.current && clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
onChange([from, to]);
}, 50);
};
const handleOnDoubleClick = () => {
timeoutRef.current && clearTimeout(timeoutRef.current);
onDoubleClick([from, to]);
};
return (
<ListItemButton key={label} disabled={!isValidDate} onClick={handleOnClick} onDoubleClick={handleOnDoubleClick}>
<ListItemText primary={label} />
</ListItemButton>
);
}
export default function PickersShortcuts({ items, className, isValid, onChange, onDoubleClick, disableClear }) {
return (
<Box
className={className}
sx={{
borderRight: 1,
borderColor: 'divider',
}}
>
<List>
{items.map((item, iKey) => (
<ShortcutItem
key={iKey}
{...item}
isValid={isValid}
onChange={onChange}
onDoubleClick={onDoubleClick}
disableClear={disableClear}
/>
))}
</List>
</Box>
);
}
============================================components/PickersToolbar.js============================================
import { Box } from '@mui/material';
import {
PickersToolbar as MuiPickersToolbar,
PickersToolbarButton,
useLocaleText,
} from '@mui/x-date-pickers/internals';
import { EN_DASH } from '../enhance';
const PickersToolbar = (props) => {
const localeText = useLocaleText();
const { start, end, dateRangePickerToolbarTitle } = localeText;
// eslint-disable-next-line no-unused-vars
const { children, value, toolbarPlaceholder, toolbarTitle, ...restProps } = props;
const [from, to] = value || {};
const fromDateValue = from ? from.format('DD MMM') : start;
const toDateValue = to ? to.format('DD MMM') : end;
const fromDateSelected = !from || (from && to);
return (
<MuiPickersToolbar {...restProps} toolbarTitle={toolbarTitle || dateRangePickerToolbarTitle}>
<Box>
<PickersToolbarButton value={fromDateValue} variant="h5" disabled selected={fromDateSelected} />
{` ${toolbarPlaceholder || EN_DASH} `}
<PickersToolbarButton value={toDateValue} variant="h5" disabled selected={!fromDateSelected} />
</Box>
</MuiPickersToolbar>
);
};
export default PickersToolbar;
============================================components/PickersDay.js============================================
import { Box } from '@mui/material';
import { PickersDay as MuiPickersDay } from '@mui/x-date-pickers';
import { useRef } from 'react';
import { ThemeExtensions } from 'styles/theme';
// disable onKeydown events
// eslint-disable-next-line no-unused-vars
export default function PickersDay({ value, onChange, onDoubleClick, onKeyDown, ...restProps }) {
const timeoutRef = useRef();
const { day, outsideCurrentMonth } = restProps;
const [fromDate, toDate] = value;
if (outsideCurrentMonth) {
return <MuiPickersDay {...restProps} />;
}
const isDateInScope = fromDate && toDate && day.isBetween(fromDate, toDate, 'day', '[]');
const isSelectedFromDate = fromDate && day.isSame(fromDate, 'day');
const isSelectedToDate = toDate && day.isSame(toDate, 'day');
const isSelected = isSelectedFromDate || isSelectedToDate;
const handleOnDaySelect = (newDay) => {
restProps.onDaySelect(newDay);
timeoutRef.current && clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
onChange(newDay);
}, 50);
};
const handleOnDoubleClick = (newDay) => {
timeoutRef.current && clearTimeout(timeoutRef.current);
onDoubleClick(newDay);
};
return (
<Box
sx={{
backgroundColor: () => toDate && isDateInScope && ThemeExtensions.getHoverColor('primary'),
...(isSelectedFromDate && {
borderTopLeftRadius: '50%',
borderBottomLeftRadius: '50%',
}),
...(isSelectedToDate && {
borderTopRightRadius: '50%',
borderBottomRightRadius: '50%',
}),
'&:first-of-type': {
borderTopLeftRadius: '50%',
borderBottomLeftRadius: '50%',
},
'&:last-of-type': {
borderTopRightRadius: '50%',
borderBottomRightRadius: '50%',
},
}}
onDoubleClick={() => handleOnDoubleClick(day)}
>
<MuiPickersDay {...restProps} onDaySelect={handleOnDaySelect} selected={isSelected} />
</Box>
);
}
============================================enhance/index.js============================================
import moment from 'moment';
import LabelResources from 'resources/LabelResources';
export const sxProps = {
toDate: {
'.MuiPickersCalendarHeader-root': {
cursor: 'default',
'.MuiPickersCalendarHeader-labelContainer': {
flex: 1,
justifyContent: 'center',
cursor: 'default',
'.MuiPickersFadeTransitionGroup-root': {
mr: -5.5,
cursor: 'text',
},
},
'.MuiPickersArrowSwitcher-root': {
userSelect: 'none',
},
},
},
fromDate: {
'.MuiPickersCalendarHeader-root': {
cursor: 'default',
flexDirection: 'row-reverse',
pl: 1.5,
pr: 3,
'.MuiPickersCalendarHeader-labelContainer': {
flex: 1,
cursor: 'default',
justifyContent: 'center',
'.MuiPickersFadeTransitionGroup-root': {
ml: -4.25,
cursor: 'text',
},
},
'.MuiPickersArrowSwitcher-root': {
userSelect: 'none',
},
},
},
popper: {
zIndex: 'modal',
mt: (theme) => `${theme.spacing(0.5)} !important`,
mb: (theme) => `${theme.spacing(1)} !important`,
},
dateRangePickerWrapper: {
boxShadow: 8,
borderRadius: 1,
overflow: 'hidden',
transformOrigin: 'center top',
},
mobileDatePickerWrapper: {
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
outline: 'none',
'.MuiPickersLayout-root': {
outline: 'none',
borderRadius: 1,
},
},
};
export const shortcutItems = [
{
label: LabelResources.TODAY,
getValue: () => {
return [moment().startOf('day'), moment().endOf('day')];
},
},
{
label: LabelResources.YESTERDAY,
getValue: () => {
return [moment().subtract(1, 'day').startOf('day'), moment().subtract(1, 'day').endOf('day')];
},
},
{
label: LabelResources.THIS_WEEK,
getValue: () => {
return [moment().startOf('week'), moment().endOf('week')];
},
},
{
label: LabelResources.LAST_WEEK,
getValue: () => {
return [moment().subtract(1, 'week').startOf('week'), moment().subtract(1, 'week').endOf('week')];
},
},
{
label: LabelResources.THIS_MONTH,
getValue: () => {
return [moment().startOf('month'), moment().endOf('month')];
},
},
{
label: LabelResources.LAST_MONTH,
getValue: () => {
return [moment().subtract(1, 'month').startOf('month'), moment().subtract(1, 'month').endOf('month')];
},
},
{
label: LabelResources.RESET,
getValue: () => {
return [null, null];
},
},
];
export const DASH = '-';
export const EN_DASH = '\u2013'; // also dash but longer --> '–'
export const DEFAULT_TIMEMOUT = 329;
============================================MobileDateRangePicker.js============================================
import { Box, Modal } from '@mui/material';
import { StaticDatePicker } from '@mui/x-date-pickers';
import moment from 'moment';
import { memo } from 'react';
import PickersDay from './components/PickersDay';
import PickersToolbar from './components/PickersToolbar';
import { sxProps } from './enhance';
const MobileDateRangePicker = ({
// commonProps.custom props
open,
viewMonth,
tempValue,
onAccept,
onCancel,
onChange,
onNextMonthClick,
onPreviousMonthClick,
//
onClose,
onDoubleClick,
...rest
}) => {
const dateRangeSlotProps = {
day: {
value: tempValue,
onChange,
onDoubleClick,
},
actionBar: {
onAccept,
onCancel,
},
toolbar: {
value: tempValue,
},
previousIconButton: {
onClick: onPreviousMonthClick,
},
nextIconButton: {
onClick: onNextMonthClick,
},
};
return (
<Modal open={open} onClose={onClose}>
<Box sx={sxProps.mobileDatePickerWrapper}>
<StaticDatePicker
{...rest}
slotProps={dateRangeSlotProps}
slots={{ day: PickersDay, toolbar: PickersToolbar }}
value={viewMonth || moment()}
/>
</Box>
</Modal>
);
};
export default memo(MobileDateRangePicker);
============================================DesktopDateRangePicker.js============================================
import { Box, Grow, Popper, Stack } from '@mui/material';
import { StaticDatePicker } from '@mui/x-date-pickers';
import moment from 'moment';
import { memo } from 'react';
import PickersActionBar from './components/PickersActionBar';
import PickersDay from './components/PickersDay';
import PickersShortcuts from './components/PickersShortcuts';
import { DEFAULT_TIMEMOUT, shortcutItems, sxProps } from './enhance';
const DesktopDateRangePicker = ({
// commonProps.custom props
open,
anchorEl,
viewMonth,
tempValue,
onAccept,
onCancel,
onChange,
onNextMonthClick,
onPreviousMonthClick,
//
onShortcutsChange,
onDoubleClick,
disableClear,
...rest
}) => {
const commonDaySlotProps = {
value: tempValue,
onChange,
onDoubleClick,
};
const fromDateSlotProps = {
shortcuts: {
items: shortcutItems,
onChange: onShortcutsChange,
onDoubleClick,
disableClear,
},
day: commonDaySlotProps,
previousIconButton: { onClick: onPreviousMonthClick },
};
const fromDateSlots = {
shortcuts: PickersShortcuts,
day: PickersDay,
nextIconButton: () => null,
};
const toDateSlotProps = {
day: commonDaySlotProps,
nextIconButton: { onClick: onNextMonthClick },
};
const toDateSlots = {
day: PickersDay,
previousIconButton: () => null,
};
return (
<Popper placement="bottom-start" open={open} sx={sxProps.popper} anchorEl={anchorEl} transition>
{({ TransitionProps }) => (
<Grow {...TransitionProps} timeout={DEFAULT_TIMEMOUT}>
<Box sx={sxProps.dateRangePickerWrapper}>
<Stack flexDirection="row">
<StaticDatePicker
sx={sxProps.fromDate}
slots={fromDateSlots}
slotProps={fromDateSlotProps}
{...rest}
displayStaticWrapperAs="desktop"
value={viewMonth || moment()}
/>
<StaticDatePicker
sx={sxProps.toDate}
slots={toDateSlots}
slotProps={toDateSlotProps}
{...rest}
displayStaticWrapperAs="desktop"
value={viewMonth ? moment(viewMonth).add(1, 'month') : moment().add(1, 'month')}
defaultCalendarMonth={moment().add(1, 'months')}
/>
</Stack>
{/* Action Bar */}
<PickersActionBar onCancel={onCancel} onAccept={onAccept} disabled={!tempValue[0] || !tempValue[1]} />
</Box>
</Grow>
)}
</Popper>
);
};
export default memo(DesktopDateRangePicker);
============================================index.js============================================
import { Close as CloseIcon, InsertInvitation as InsertInvitationIcon } from '@mui/icons-material';
import { Box, ClickAwayListener, IconButton, InputAdornment, TextField } from '@mui/material';
import { DEFAULT_DESKTOP_MODE_MEDIA_QUERY } from '@mui/x-date-pickers';
import withMediaQuery from 'hocs/withMediaQuery';
import moment from 'moment';
import PropTypes from 'prop-types';
import { PureComponent, createRef } from 'react';
import Resources from 'resources';
import DesktopDateRangePicker from './DesktopDateRangePicker';
import MobileDateRangePicker from './MobileDateRangePicker';
import { DASH } from './enhance';
const initState = {
tempValue: [null, null],
viewMonth: null,
open: false,
focused: false,
};
class DateRangePicker extends PureComponent {
state = initState;
_anchorRef = createRef();
_inputRef = createRef();
componentDidMount() {
const { value } = this.props;
this.setState({ tempValue: value });
}
componentDidUpdate(prevProps, prevState) {
const { value } = this.props;
const { tempValue, open } = this.state;
if (prevState.tempValue[0] !== tempValue[0] || prevState.tempValue[1] !== tempValue[1]) {
if (tempValue[0] !== null) this.setState({ viewMonth: tempValue[0] });
}
if (prevProps.value[0] !== value[0] || prevState.open !== open) {
this.setState({ tempValue: value, viewMonth: value[0] });
}
if (prevState.open !== open) {
if (open) {
window.addEventListener('keydown', this._handleEscPressed);
} else {
window.removeEventListener('keydown', this._handleEscPressed);
}
}
}
render() {
const {
label,
fullWidth,
required,
disableFuture,
disablePast,
helperText,
value,
localeText,
error,
format,
disableClear,
matches: isDesktop,
} = this.props;
const { tempValue, viewMonth, focused, open } = this.state;
const [minDate, maxDate] = this._getSelectableRange(tempValue);
const isExistedValue =
(tempValue?.length && (tempValue[0] || tempValue[1])) || (value?.length && (value[0] || value[1]));
const isClearable = isExistedValue && !disableClear;
const InputProps = {
endAdornment: isDesktop ? (
<InputAdornment position="end">
{isClearable ? (
<IconButton edge="end" onClick={this._handleClearIconClick}>
<CloseIcon />
</IconButton>
) : (
<IconButton edge="end" onClick={this._handleCalendarIconClick}>
<InsertInvitationIcon />
</IconButton>
)}
</InputAdornment>
) : null,
};
const commonProps = {
// mui date picker props (rest)
views: ['day'],
minDate,
maxDate,
disablePast,
disableFuture,
// custom props
open,
viewMonth,
tempValue,
onCancel: this._handleCancel,
onAccept: this._handleAccept,
onChange: this._handleDateChange,
onPreviousMonthClick: this._handlePreviousMonthClick,
onNextMonthClick: this._handleNextMonthClick,
};
const toLocaleText = isDesktop ? localeText.to : DASH;
return (
<ClickAwayListener onClickAway={this._handleClickAway}>
<Box ref={this._anchorRef}>
<TextField
readOnly
required={required}
fullWidth={fullWidth}
label={label}
inputRef={this._inputRef}
onClick={this._handleTextFieldClick}
helperText={helperText}
error={error}
InputProps={InputProps}
value={this._renderDisplayText(toLocaleText)}
placeholder={`${format} ${toLocaleText} ${format}`}
sx={{ caretColor: 'transparent' }}
focused={focused}
autoComplete="off"
/>
{isDesktop ? (
<DesktopDateRangePicker
{...commonProps}
anchorEl={this._anchorRef.current}
onShortcutsChange={this._handleShortcutsChange}
onDoubleClick={this._handleDoubleClickToAccept}
disableClear={disableClear}
/>
) : (
<MobileDateRangePicker {...commonProps} onClose={this._handleClickAway} />
)}
</Box>
</ClickAwayListener>
);
}
_handleTextFieldClick = (e) => {
const { open } = this.state;
const isBubbleTriggered = this._inputRef.current !== e.target;
if (isBubbleTriggered) return;
this.setState({
open: !open,
focused: true,
});
};
_handleCalendarIconClick = () => {
const { open } = this.state;
this.setState({ open: !open, focused: !open });
};
_handleEscPressed = (event) => {
if (event.key === 'Escape') {
event.preventDefault();
this._handleClickAway();
}
};
_handleClose = () => {
this.setState({
open: false,
});
};
_handleClickAway = () => {
if (this.state.open) {
this._handleAccept(null, true);
const activeElement = document.activeElement;
if (
(activeElement.tagName === 'INPUT' || activeElement.tagName === 'BUTTON') &&
activeElement !== this._inputRef.current
) {
this.setState({
focused: false,
});
}
} else {
this.setState({ focused: false });
}
};
_handleClearValue = () => {
this.setState({
tempValue: [null, null],
});
};
_handleClearIconClick = (e) => {
const { onChange } = this.props;
e?.stopPropagation();
this._handleClearValue();
onChange?.([null, null]);
};
_handleCancel = () => {
this.setState(initState);
};
_handleDoubleClickToAccept = () => {
this._handleAccept();
};
_handleAccept = (newValueOrEvent, isClickAway) => {
const { tempValue } = this.state;
const newValue = Array.isArray(newValueOrEvent) && newValueOrEvent;
const { onChange } = this.props;
const valueToAccept = newValue || tempValue;
const isReadyToAccept = (Boolean(valueToAccept[0]) ^ Boolean(valueToAccept[1])) === 0; // => return 0 if (false ^ false) or (true ^ true)
if (!isReadyToAccept && isClickAway) {
this._handleCancel();
}
if (typeof onChange !== 'function' || !isReadyToAccept) return;
this._handleClose();
onChange(valueToAccept);
};
_handlePreviousMonthClick = () => {
const { viewMonth } = this.state;
this.setState({
viewMonth: moment(viewMonth || moment()).subtract(1, 'month'),
});
};
_renderDisplayText = (toLocaleText) => {
const { format, value } = this.props;
const { tempValue } = this.state;
const displayValue = tempValue[0] ? tempValue : value;
const [from, to] = displayValue;
const fromText = from ? moment(from).format(format) : '';
const toText = to ? moment(to).format(format) : '';
if (from) {
return `${fromText} ${toLocaleText} ${toText}`.trim();
}
return '';
};
_handleNextMonthClick = () => {
const { viewMonth } = this.state;
this.setState({
viewMonth: moment(viewMonth || moment()).add(1, 'month'),
});
};
_handleShortcutsChange = (newValue) => {
const { onChange, acceptOnShortcutClick } = this.props;
const [fromValue, toValue] = newValue;
// "shortcut item is [Reset]"
if (!fromValue || !toValue) {
acceptOnShortcutClick ? onChange?.([null, null]) : this._handleClearValue();
}
// other shortcuts
else {
const [minDate, maxDate] = this._getSelectableRange(newValue);
const from = moment.max([minDate, fromValue].filter(Boolean));
const minToDate = moment.min([toValue, maxDate].filter(Boolean));
const to = minToDate.isBefore(from, 'day') ? from : minToDate;
const startDate = moment(from).startOf('day');
const endDate = moment(to).endOf('day');
acceptOnShortcutClick
? onChange?.([startDate, endDate])
: this.setState({
tempValue: [startDate, endDate],
});
}
};
_getRange = () => {
const { minDate, maxDate, disableFuture, disablePast } = this.props;
const now = moment();
let validMinDate = minDate;
let validMaxDate = maxDate;
if (disableFuture) {
validMaxDate = maxDate ? moment.min(maxDate, now) : now;
}
if (disablePast) {
validMinDate = minDate ? moment.max(minDate, now) : now;
}
return [validMinDate, validMaxDate];
};
_getSelectableRange = (value) => {
const { maxRangeLength } = this.props;
const [fromDate, toDate] = value || [];
const [lowerBound, upperBound] = this._getRange();
if (fromDate && !toDate && maxRangeLength) {
const startOfRange = moment(fromDate).subtract(maxRangeLength - 1, 'day');
const minRangeDate = moment.max([lowerBound, startOfRange].filter(Boolean));
const selectedDateInRange = moment.max([minRangeDate, fromDate].filter(Boolean)).endOf('day');
const endOfRange = moment(selectedDateInRange).add(maxRangeLength - 1, 'day');
const maxRangeDate = moment.min([endOfRange, upperBound].filter(Boolean));
return [minRangeDate?.startOf('day'), maxRangeDate?.endOf('day')];
}
return [lowerBound?.startOf('day'), upperBound?.endOf('day')];
};
_handleDateChange = (date) => {
const { matches: isDesktop } = this.props;
const { tempValue } = this.state;
const [fromDate, toDate] = tempValue;
const newValue = [fromDate, toDate];
if (!fromDate) {
newValue[0] = date;
} else if (!toDate) {
if (date.isBefore(fromDate.startOf('d'))) {
newValue[0] = date;
} else {
newValue[1] = date;
}
} else {
newValue[0] = date;
newValue[1] = null;
}
this.setState(
{
tempValue: newValue,
},
isDesktop ? () => this._handleAccept(newValue) : null // accept when existed fromDate and toDate is selected
);
};
}
DateRangePicker.propTypes = {
fullWidth: PropTypes.bool,
label: PropTypes.string,
helperText: PropTypes.string,
error: PropTypes.bool,
required: PropTypes.bool,
format: PropTypes.string,
disableClear: PropTypes.bool,
value: PropTypes.arrayOf(PropTypes.instanceOf(moment)),
onChange: PropTypes.func.isRequired,
minDate: PropTypes.oneOfType([PropTypes.string, PropTypes.instanceOf(moment)]),
maxDate: PropTypes.oneOfType([PropTypes.string, PropTypes.instanceOf(moment)]),
disableFuture: PropTypes.bool,
disablePast: PropTypes.bool,
maxRangeLength: PropTypes.number,
acceptOnShortcutClick: PropTypes.bool,
localeText: PropTypes.shape({
to: PropTypes.string,
}),
};
DateRangePicker.defaultProps = {
helperText: '',
error: false,
required: false,
disableClear: false,
format: Resources.FORMAT_DATE_DEFAULT,
minDate: moment('01/01/1900', Resources.FORMAT_DATE_DEFAULT),
maxDate: moment('01/01/2100', Resources.FORMAT_DATE_DEFAULT),
acceptOnShortcutClick: true,
localeText: {
to: 'đến',
},
};
export default withMediaQuery(DateRangePicker, {
mediaQueryString: DEFAULT_DESKTOP_MODE_MEDIA_QUERY,
defaultMatches: true,
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment