UNPKG

@spark-web/date-picker

Version:

--- title: DatePicker storybookPath: forms-date-picker isExperimentalPackage: true ---

569 lines (544 loc) 18.1 kB
import _objectSpread from '@babel/runtime/helpers/esm/objectSpread2'; import _slicedToArray from '@babel/runtime/helpers/esm/slicedToArray'; import _objectWithoutProperties from '@babel/runtime/helpers/esm/objectWithoutProperties'; import { Stack } from '@spark-web/stack'; import { forwardRef, useMemo, useState, useCallback, useEffect, useRef } from 'react'; import { usePopper } from 'react-popper'; import { ChevronRightIcon, ChevronLeftIcon, CalendarIcon } from '@spark-web/icon'; import { DayPicker } from 'react-day-picker'; import FocusLock from 'react-focus-lock'; import { css } from '@emotion/css'; import { useFocusRing, visuallyHiddenStyles } from '@spark-web/a11y'; import { Box } from '@spark-web/box'; import { useButtonStyles, BaseButton } from '@spark-web/button'; import { useHeading } from '@spark-web/heading'; import { useText } from '@spark-web/text'; import { useTheme } from '@spark-web/theme'; import { jsx, jsxs } from 'react/jsx-runtime'; import { useFieldContext } from '@spark-web/field'; import { TextInput, InputAdornment } from '@spark-web/text-input'; import { format, parse, isValid, isBefore, isAfter, isDate as isDate$1 } from 'date-fns'; function CalendarContainer(_ref) { var children = _ref.children; var dayPickerStyles = useDayPickerStyles(); return /*#__PURE__*/jsx(Box, { background: "surface", border: "standard", borderRadius: "medium", display: "inline-block", padding: "small", position: "relative", shadow: "medium", className: css(dayPickerStyles), children: children }); } function useDayPickerStyles() { var theme = useTheme(); var cellSize = theme.sizing.medium; var _useHeading = useHeading({ level: '3', align: 'left' }), _useHeading2 = _slicedToArray(_useHeading, 2), typographyHeadingStyles = _useHeading2[0], responsiveHeadingStyles = _useHeading2[1]; var _useText = useText({ baseline: true, tone: 'neutral', size: 'small', weight: 'regular' }), _useText2 = _slicedToArray(_useText, 2), typographyTextStyles = _useText2[0], responsiveTextStyles = _useText2[1]; var _useButtonStyles = useButtonStyles({ iconOnly: false, prominence: 'none', size: 'medium', tone: 'primary' }), _useButtonStyles2 = _slicedToArray(_useButtonStyles, 2), buttonStyles = _useButtonStyles2[1]; var focusStyles = useFocusRing({ always: true }); return { '.rdp-vhidden': visuallyHiddenStyles, // Base button '.rdp-button_reset': { appearance: 'none', background: 'none', border: 'none', margin: 0, padding: 0, cursor: 'pointer', color: 'inherit', font: 'inherit' }, // Header '.rdp-caption': { display: 'flex', alignItems: 'center', justifyContent: 'center', height: theme.sizing.medium, position: 'relative' }, '.rdp-caption_label': _objectSpread(_objectSpread(_objectSpread({}, typographyHeadingStyles), responsiveHeadingStyles), {}, { margin: 0, whiteSpace: 'nowrap' }), // Left / right arrows '.rdp-nav': { position: 'absolute', top: 0, bottom: 0, left: 0, right: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingLeft: theme.spacing.medium - theme.spacing.small, paddingRight: theme.spacing.medium - theme.spacing.small }, '.rdp-nav_button': _objectSpread(_objectSpread({}, buttonStyles), {}, { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', height: theme.sizing.small, width: theme.sizing.small, borderRadius: theme.border.radius.full }), '.rdp-nav_button:focus': _objectSpread(_objectSpread({}, focusStyles), {}, { position: 'relative', backgroundColor: theme.backgroundInteractions.primaryLowHover }), // Days of week '.rdp-head_cell': _objectSpread(_objectSpread(_objectSpread({}, typographyTextStyles), responsiveTextStyles), {}, { fontWeight: theme.typography.fontWeight.semibold, margin: 0, padding: 0, textAlign: 'center', verticalAlign: 'middle', height: cellSize, width: cellSize }), // Day button '.rdp-day': _objectSpread(_objectSpread(_objectSpread(_objectSpread({}, typographyTextStyles), responsiveTextStyles), buttonStyles), {}, { borderRadius: theme.border.radius.small }), '.rdp-day:focus': _objectSpread(_objectSpread({}, focusStyles), {}, { position: 'relative', backgroundColor: theme.backgroundInteractions.primaryLowHover }), ".rdp-button:disabled, .rdp-button[aria-disabled='true']": { color: theme.color.foreground.disabled, pointerEvents: 'none', userSelect: 'none' }, '.rdp-weeknumber, .rdp-day': { display: 'flex', justifyContent: 'center', alignItems: 'center', width: cellSize, height: cellSize }, // Table '.rdp-months': { display: 'flex' }, '.rdp-month:first-of-type': { marginLeft: 0 }, '.rdp-month:last-of-type': { marginRight: 0 }, '.rdp-table': { margin: 0, maxWidth: "calc(".concat(cellSize, " * 7)"), borderCollapse: 'collapse' }, '.rdp-tbody': { border: 0 }, '.rdp-cell': { width: cellSize, height: cellSize, padding: 0, textAlign: 'center' }, ".rdp-day_selected:not([aria-disabled='true']), .rdp-day_selected:focus:not([aria-disabled='true']), .rdp-day_selected:active:not([aria-disabled='true']), .rdp-day_selected:hover:not([aria-disabled='true']), .rdp-day_selected:hover:not([aria-disabled='true'])": { backgroundColor: theme.color.background.primary, color: theme.color.foreground.neutralInverted } }; } function CalendarSingle(props) { return /*#__PURE__*/jsx(FocusLock, { autoFocus: false, returnFocus: true, children: /*#__PURE__*/jsx(CalendarContainer, { children: /*#__PURE__*/jsx(DayPicker, _objectSpread(_objectSpread({}, props), {}, { mode: "single", components: calendarComponents })) }) }); } var calendarComponents = { IconRight: function IconRight() { return /*#__PURE__*/jsx(ChevronRightIcon, { size: "xsmall" }); }, IconLeft: function IconLeft() { return /*#__PURE__*/jsx(ChevronLeftIcon, { size: "xsmall" }); } }; /** Date format is not configurable. */ var dateFormat = 'dd/MM/yyyy'; /** Formats a date to 'dd/MM/yyyy'. */ function formatDate(date) { return format(new Date(date), dateFormat); } /** Formats a date object into a more human readable form. */ function formatHumanReadableDate(date) { return format(date, 'eeee MMMM do, yyyy'); } /** Checks whether a value is a Date. */ function isDate(value) { return isDate$1(value); } /** * Returns a date parsed from a string that is in 'dd/MM/yyyy' format. * * @see https://github.com/date-fns/date-fns/issues/942 */ function parseDate(value) { if (value.length !== dateFormat.length) { return undefined; } var parsedDate = parse(value, dateFormat, new Date()); if (isDate(parsedDate) && isValid(parsedDate)) { return parsedDate; } return undefined; } /** * Constrains a date to be within a range: * * @see * [min](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/date#min) * and [max](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/date#max). */ function constrainDate(date, minDate, maxDate) { if (!date) { return date; } if (minDate && isBefore(date, minDate)) { return minDate; } if (maxDate && isAfter(date, maxDate)) { return maxDate; } return date; } /** * Returns the indexes of the separators in the date format */ function getSeparatorIndexes() { var indexes = []; for (var i = 0; i < dateFormat.length; i++) { if (dateFormat[i] === '/') { indexes.push(i); } } return indexes; } /** * This is used to format the date as the user types based on the given format * This should be used in the `onInputChange` event of the input field to allow formatting the date on type change even if the date is incomplete. * @param date * @returns string */ function formatDateOnChange(date, inputValue, cursorPosition) { var indexes = getSeparatorIndexes(); var format = dateFormat.toUpperCase(); var dateFormatParts = format.split('/'); var dateParts = date.split('/'); var newDate = []; // If the date is empty, defaults to format if (!inputValue || !date) return format; if (indexes.includes(cursorPosition)) { return date.slice(0, cursorPosition) + inputValue.slice(cursorPosition); } dateFormatParts.forEach(function (part, index) { var cleanValue = part; var cleanDate = dateParts[index].replace(/\D/g, ''); var length = part.length; if (Boolean(cleanDate)) { var _char = part.charAt(0); var formattedDate = cleanDate.padEnd(length, _char).slice(0, length); cleanValue = formattedDate; } newDate.push(cleanValue); }); return newDate.join('/'); } var _excluded$1 = ["buttonRef", "buttonOnClick", "value"]; var DateInput = /*#__PURE__*/forwardRef(function DateInput(_ref, forwardedRef) { var buttonRef = _ref.buttonRef, buttonOnClick = _ref.buttonOnClick, value = _ref.value, consumerProps = _objectWithoutProperties(_ref, _excluded$1); var _useIconButtonStyles = useIconButtonStyles(), _useIconButtonStyles2 = _slicedToArray(_useIconButtonStyles, 2), boxProps = _useIconButtonStyles2[0], buttonStyles = _useIconButtonStyles2[1]; var _useFieldContext = useFieldContext(), _useFieldContext2 = _slicedToArray(_useFieldContext, 1), disabled = _useFieldContext2[0].disabled; var buttonLabel = useMemo(function () { if (typeof value !== 'string') { return 'Choose date'; } var parsed = parseDate(value); if (!parsed) { return 'Choose date'; } return "Change Date, ".concat(formatHumanReadableDate(parsed)); }, [value]); return /*#__PURE__*/jsx(TextInput, _objectSpread(_objectSpread({}, consumerProps), {}, { ref: forwardedRef, value: value, children: /*#__PURE__*/jsx(InputAdornment, { placement: "end", children: /*#__PURE__*/jsx(BaseButton, _objectSpread(_objectSpread({}, boxProps), {}, { "aria-label": buttonLabel, onClick: buttonOnClick, ref: buttonRef, disabled: disabled, className: css(buttonStyles) // The input is not keyboard navigable when disabled and so we are // also removing the button from the tab index to make it less // confusing to keyboard and assistive technology users. , tabIndex: disabled ? -1 : undefined, children: /*#__PURE__*/jsx(CalendarIcon, { tone: disabled ? 'disabled' : 'neutral' }) })) }) })); }); function useIconButtonStyles() { var _useButtonStyles = useButtonStyles({ iconOnly: false, prominence: 'none', size: 'medium', tone: 'neutral' }), _useButtonStyles2 = _slicedToArray(_useButtonStyles, 2), buttonStyles = _useButtonStyles2[1]; return [{ alignItems: 'center', borderRadius: 'full', cursor: 'pointer', display: 'inline-flex', gap: 'small', height: 'small', justifyContent: 'center', paddingX: 'xsmall', position: 'relative', width: 'small' }, buttonStyles]; } //////////////////////////////////////////////////////////////////////////////// /** * Useful for situations where you have separate buttons to open/close * or expand/collapse an element. */ function useTernaryState(initialValue) { var _useState = useState(initialValue), _useState2 = _slicedToArray(_useState, 2), state = _useState2[0], setState = _useState2[1]; var setTrue = useCallback(function () { return setState(true); }, []); var setFalse = useCallback(function () { return setState(false); }, []); return [state, setTrue, setFalse]; } //////////////////////////////////////////////////////////////////////////////// /** * Calls the provided handler function if a click is detected outside of a * specified element. */ function useClickOutside(ref, handler) { useEffect(function () { function listener(event) { var element = ref === null || ref === void 0 ? void 0 : ref.current; // Do nothing if clicking ref's element or descendent elements if (!element || element.contains(event.target)) { return; } handler(event); } window.addEventListener('mousedown', listener); return function () { return window.removeEventListener('mousedown', listener); }; }, [handler, ref]); } var _excluded = ["data", "initialMonth", "maxDate", "minDate", "onChange", "value"]; var DatePicker = /*#__PURE__*/forwardRef(function DatePicker(_ref, forwardedRef) { var data = _ref.data, initialMonth = _ref.initialMonth, maxDate = _ref.maxDate, minDate = _ref.minDate, onChange = _ref.onChange, value = _ref.value, consumerProps = _objectWithoutProperties(_ref, _excluded); var _useTernaryState = useTernaryState(false), _useTernaryState2 = _slicedToArray(_useTernaryState, 3), isCalendarOpen = _useTernaryState2[0], openCalendar = _useTernaryState2[1], closeCalendar = _useTernaryState2[2]; // Popper state var triggerRef = useRef(null); var _useState = useState(null), _useState2 = _slicedToArray(_useState, 2), refEl = _useState2[0], setRefEl = _useState2[1]; var _useState3 = useState(null), _useState4 = _slicedToArray(_useState3, 2), popperEl = _useState4[0], setPopperEl = _useState4[1]; var _usePopper = usePopper(refEl, popperEl, { placement: 'bottom-start', modifiers: [{ name: 'offset', options: { offset: [0, 8] } }] }), styles = _usePopper.styles, attributes = _usePopper.attributes; var defaultValue = dateFormat.toUpperCase(); var _useState5 = useState(''), _useState6 = _slicedToArray(_useState5, 2), inputValue = _useState6[0], setInputValue = _useState6[1]; var onSelect = useCallback(function (_, selectedDay, modifiers) { // If the day is disabled, do nothing if (modifiers.disabled) { return; } // Update the input field with the selected day setInputValue(formatDate(selectedDay)); // Trigger the callback onChange(selectedDay); // Close the calendar and focus the calendar icon closeCalendar(); }, [onChange, closeCalendar]); var onInputChange = useCallback(function (event) { var _event$target$selecti; var indexes = getSeparatorIndexes(); var eventValue = event.target.value; var startPos = (_event$target$selecti = event.target.selectionStart) !== null && _event$target$selecti !== void 0 ? _event$target$selecti : 0; var formattedDate = formatDateOnChange(eventValue, inputValue, startPos); var nextPos = startPos; // to fix issue where cursor jumps to end of input when formatting value if (indexes.includes(startPos) && eventValue.length > inputValue.length) { nextPos = startPos + 1; } setInputValue(formattedDate); setCursorPosition(event, nextPos); var parsedDate = parseDate(formattedDate); var constrainedDate = constrainDate(parsedDate, minDate, maxDate); onChange(constrainedDate); }, [maxDate, minDate, onChange, inputValue]); // Update the text inputs when the value updates useEffect(function () { if (value) { setInputValue(formatDate(value)); } }, [value]); // Close the calendar when the user clicks outside var clickOutsideRef = useRef(popperEl); clickOutsideRef.current = popperEl; var handleClickOutside = useCallback(function () { if (isCalendarOpen) { closeCalendar(); } }, [isCalendarOpen, closeCalendar]); useClickOutside(clickOutsideRef, handleClickOutside); // Close the calendar when the user presses escape var handleEscape = useCallback(function (event) { if (isCalendarOpen && event.code === 'Escape') { event.preventDefault(); event.stopPropagation(); // Close the calendar and focus the calendar icon closeCalendar(); } }, [isCalendarOpen, closeCalendar]); var disabledCalendarDays = useMemo(function () { if (!(minDate || maxDate)) { return; } return [minDate ? { before: minDate } : undefined, maxDate ? { after: maxDate } : undefined].filter(function (x) { return Boolean(x); }); }, [minDate, maxDate]); // sets the next cursor position var setCursorPosition = function setCursorPosition(event, position) { setTimeout(function () { event.target.setSelectionRange(position, position); }, 0); }; return /*#__PURE__*/jsxs(Stack, { ref: setRefEl, onKeyDown: handleEscape, data: data, width: "full", children: [/*#__PURE__*/jsx(DateInput, _objectSpread(_objectSpread({}, consumerProps), {}, { buttonOnClick: openCalendar, buttonRef: triggerRef, onChange: onInputChange, ref: forwardedRef, value: inputValue, placeholder: defaultValue, onFocus: function onFocus(e) { if (!inputValue) setInputValue(defaultValue); setCursorPosition(e, 0); }, onBlur: function onBlur() { if (inputValue === defaultValue) setInputValue(''); } })), isCalendarOpen && /*#__PURE__*/jsx("div", _objectSpread(_objectSpread({}, attributes.popper), {}, { ref: setPopperEl, style: _objectSpread(_objectSpread({}, styles.popper), {}, { zIndex: 1 }), children: /*#__PURE__*/jsx(CalendarSingle, { defaultMonth: value || initialMonth, disabled: disabledCalendarDays, initialFocus: true, numberOfMonths: 1, onSelect: onSelect, selected: value }) }))] }); }); export { DatePicker };