UNPKG

18.1 kBJavaScriptView Raw
1import _objectSpread from '@babel/runtime/helpers/esm/objectSpread2';
2import _slicedToArray from '@babel/runtime/helpers/esm/slicedToArray';
3import _objectWithoutProperties from '@babel/runtime/helpers/esm/objectWithoutProperties';
4import { Stack } from '@spark-web/stack';
5import { forwardRef, useMemo, useState, useCallback, useEffect, useRef } from 'react';
6import { usePopper } from 'react-popper';
7import { ChevronRightIcon, ChevronLeftIcon, CalendarIcon } from '@spark-web/icon';
8import { DayPicker } from 'react-day-picker';
9import FocusLock from 'react-focus-lock';
10import { css } from '@emotion/css';
11import { useFocusRing, visuallyHiddenStyles } from '@spark-web/a11y';
12import { Box } from '@spark-web/box';
13import { useButtonStyles, BaseButton } from '@spark-web/button';
14import { useHeading } from '@spark-web/heading';
15import { useText } from '@spark-web/text';
16import { useTheme } from '@spark-web/theme';
17import { jsx, jsxs } from 'react/jsx-runtime';
18import { useFieldContext } from '@spark-web/field';
19import { TextInput, InputAdornment } from '@spark-web/text-input';
20import { format, parse, isValid, isBefore, isAfter, isDate as isDate$1 } from 'date-fns';
21
22function CalendarContainer(_ref) {
23 var children = _ref.children;
24 var dayPickerStyles = useDayPickerStyles();
25 return /*#__PURE__*/jsx(Box, {
26 background: "surface",
27 border: "standard",
28 borderRadius: "medium",
29 display: "inline-block",
30 padding: "small",
31 position: "relative",
32 shadow: "medium",
33 className: css(dayPickerStyles),
34 children: children
35 });
36}
37function useDayPickerStyles() {
38 var theme = useTheme();
39 var cellSize = theme.sizing.medium;
40 var _useHeading = useHeading({
41 level: '3',
42 align: 'left'
43 }),
44 _useHeading2 = _slicedToArray(_useHeading, 2),
45 typographyHeadingStyles = _useHeading2[0],
46 responsiveHeadingStyles = _useHeading2[1];
47 var _useText = useText({
48 baseline: true,
49 tone: 'neutral',
50 size: 'small',
51 weight: 'regular'
52 }),
53 _useText2 = _slicedToArray(_useText, 2),
54 typographyTextStyles = _useText2[0],
55 responsiveTextStyles = _useText2[1];
56 var _useButtonStyles = useButtonStyles({
57 iconOnly: false,
58 prominence: 'none',
59 size: 'medium',
60 tone: 'primary'
61 }),
62 _useButtonStyles2 = _slicedToArray(_useButtonStyles, 2),
63 buttonStyles = _useButtonStyles2[1];
64 var focusStyles = useFocusRing({
65 always: true
66 });
67 return {
68 '.rdp-vhidden': visuallyHiddenStyles,
69 // Base button
70 '.rdp-button_reset': {
71 appearance: 'none',
72 background: 'none',
73 border: 'none',
74 margin: 0,
75 padding: 0,
76 cursor: 'pointer',
77 color: 'inherit',
78 font: 'inherit'
79 },
80 // Header
81 '.rdp-caption': {
82 display: 'flex',
83 alignItems: 'center',
84 justifyContent: 'center',
85 height: theme.sizing.medium,
86 position: 'relative'
87 },
88 '.rdp-caption_label': _objectSpread(_objectSpread(_objectSpread({}, typographyHeadingStyles), responsiveHeadingStyles), {}, {
89 margin: 0,
90 whiteSpace: 'nowrap'
91 }),
92 // Left / right arrows
93 '.rdp-nav': {
94 position: 'absolute',
95 top: 0,
96 bottom: 0,
97 left: 0,
98 right: 0,
99 display: 'flex',
100 alignItems: 'center',
101 justifyContent: 'space-between',
102 paddingLeft: theme.spacing.medium - theme.spacing.small,
103 paddingRight: theme.spacing.medium - theme.spacing.small
104 },
105 '.rdp-nav_button': _objectSpread(_objectSpread({}, buttonStyles), {}, {
106 display: 'inline-flex',
107 alignItems: 'center',
108 justifyContent: 'center',
109 cursor: 'pointer',
110 height: theme.sizing.small,
111 width: theme.sizing.small,
112 borderRadius: theme.border.radius.full
113 }),
114 '.rdp-nav_button:focus': _objectSpread(_objectSpread({}, focusStyles), {}, {
115 position: 'relative',
116 backgroundColor: theme.backgroundInteractions.primaryLowHover
117 }),
118 // Days of week
119 '.rdp-head_cell': _objectSpread(_objectSpread(_objectSpread({}, typographyTextStyles), responsiveTextStyles), {}, {
120 fontWeight: theme.typography.fontWeight.semibold,
121 margin: 0,
122 padding: 0,
123 textAlign: 'center',
124 verticalAlign: 'middle',
125 height: cellSize,
126 width: cellSize
127 }),
128 // Day button
129 '.rdp-day': _objectSpread(_objectSpread(_objectSpread(_objectSpread({}, typographyTextStyles), responsiveTextStyles), buttonStyles), {}, {
130 borderRadius: theme.border.radius.small
131 }),
132 '.rdp-day:focus': _objectSpread(_objectSpread({}, focusStyles), {}, {
133 position: 'relative',
134 backgroundColor: theme.backgroundInteractions.primaryLowHover
135 }),
136 ".rdp-button:disabled, .rdp-button[aria-disabled='true']": {
137 color: theme.color.foreground.disabled,
138 pointerEvents: 'none',
139 userSelect: 'none'
140 },
141 '.rdp-weeknumber, .rdp-day': {
142 display: 'flex',
143 justifyContent: 'center',
144 alignItems: 'center',
145 width: cellSize,
146 height: cellSize
147 },
148 // Table
149 '.rdp-months': {
150 display: 'flex'
151 },
152 '.rdp-month:first-of-type': {
153 marginLeft: 0
154 },
155 '.rdp-month:last-of-type': {
156 marginRight: 0
157 },
158 '.rdp-table': {
159 margin: 0,
160 maxWidth: "calc(".concat(cellSize, " * 7)"),
161 borderCollapse: 'collapse'
162 },
163 '.rdp-tbody': {
164 border: 0
165 },
166 '.rdp-cell': {
167 width: cellSize,
168 height: cellSize,
169 padding: 0,
170 textAlign: 'center'
171 },
172 ".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'])": {
173 backgroundColor: theme.color.background.primary,
174 color: theme.color.foreground.neutralInverted
175 }
176 };
177}
178
179function CalendarSingle(props) {
180 return /*#__PURE__*/jsx(FocusLock, {
181 autoFocus: false,
182 returnFocus: true,
183 children: /*#__PURE__*/jsx(CalendarContainer, {
184 children: /*#__PURE__*/jsx(DayPicker, _objectSpread(_objectSpread({}, props), {}, {
185 mode: "single",
186 components: calendarComponents
187 }))
188 })
189 });
190}
191var calendarComponents = {
192 IconRight: function IconRight() {
193 return /*#__PURE__*/jsx(ChevronRightIcon, {
194 size: "xsmall"
195 });
196 },
197 IconLeft: function IconLeft() {
198 return /*#__PURE__*/jsx(ChevronLeftIcon, {
199 size: "xsmall"
200 });
201 }
202};
203
204/** Date format is not configurable. */
205var dateFormat = 'dd/MM/yyyy';
206
207/** Formats a date to 'dd/MM/yyyy'. */
208function formatDate(date) {
209 return format(new Date(date), dateFormat);
210}
211
212/** Formats a date object into a more human readable form. */
213function formatHumanReadableDate(date) {
214 return format(date, 'eeee MMMM do, yyyy');
215}
216
217/** Checks whether a value is a Date. */
218function isDate(value) {
219 return isDate$1(value);
220}
221
222/**
223 * Returns a date parsed from a string that is in 'dd/MM/yyyy' format.
224 *
225 * @see https://github.com/date-fns/date-fns/issues/942
226 */
227function parseDate(value) {
228 if (value.length !== dateFormat.length) {
229 return undefined;
230 }
231 var parsedDate = parse(value, dateFormat, new Date());
232 if (isDate(parsedDate) && isValid(parsedDate)) {
233 return parsedDate;
234 }
235 return undefined;
236}
237
238/**
239 * Constrains a date to be within a range:
240 *
241 * @see
242 * [min](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/date#min)
243 * and [max](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/date#max).
244 */
245function constrainDate(date, minDate, maxDate) {
246 if (!date) {
247 return date;
248 }
249 if (minDate && isBefore(date, minDate)) {
250 return minDate;
251 }
252 if (maxDate && isAfter(date, maxDate)) {
253 return maxDate;
254 }
255 return date;
256}
257
258/**
259 * Returns the indexes of the separators in the date format
260 */
261function getSeparatorIndexes() {
262 var indexes = [];
263 for (var i = 0; i < dateFormat.length; i++) {
264 if (dateFormat[i] === '/') {
265 indexes.push(i);
266 }
267 }
268 return indexes;
269}
270
271/**
272 * This is used to format the date as the user types based on the given format
273 * 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.
274 * @param date
275 * @returns string
276 */
277function formatDateOnChange(date, inputValue, cursorPosition) {
278 var indexes = getSeparatorIndexes();
279 var format = dateFormat.toUpperCase();
280 var dateFormatParts = format.split('/');
281 var dateParts = date.split('/');
282 var newDate = [];
283
284 // If the date is empty, defaults to format
285 if (!inputValue || !date) return format;
286 if (indexes.includes(cursorPosition)) {
287 return date.slice(0, cursorPosition) + inputValue.slice(cursorPosition);
288 }
289 dateFormatParts.forEach(function (part, index) {
290 var cleanValue = part;
291 var cleanDate = dateParts[index].replace(/\D/g, '');
292 var length = part.length;
293 if (Boolean(cleanDate)) {
294 var _char = part.charAt(0);
295 var formattedDate = cleanDate.padEnd(length, _char).slice(0, length);
296 cleanValue = formattedDate;
297 }
298 newDate.push(cleanValue);
299 });
300 return newDate.join('/');
301}
302
303var _excluded$1 = ["buttonRef", "buttonOnClick", "value"];
304var DateInput = /*#__PURE__*/forwardRef(function DateInput(_ref, forwardedRef) {
305 var buttonRef = _ref.buttonRef,
306 buttonOnClick = _ref.buttonOnClick,
307 value = _ref.value,
308 consumerProps = _objectWithoutProperties(_ref, _excluded$1);
309 var _useIconButtonStyles = useIconButtonStyles(),
310 _useIconButtonStyles2 = _slicedToArray(_useIconButtonStyles, 2),
311 boxProps = _useIconButtonStyles2[0],
312 buttonStyles = _useIconButtonStyles2[1];
313 var _useFieldContext = useFieldContext(),
314 _useFieldContext2 = _slicedToArray(_useFieldContext, 1),
315 disabled = _useFieldContext2[0].disabled;
316 var buttonLabel = useMemo(function () {
317 if (typeof value !== 'string') {
318 return 'Choose date';
319 }
320 var parsed = parseDate(value);
321 if (!parsed) {
322 return 'Choose date';
323 }
324 return "Change Date, ".concat(formatHumanReadableDate(parsed));
325 }, [value]);
326 return /*#__PURE__*/jsx(TextInput, _objectSpread(_objectSpread({}, consumerProps), {}, {
327 ref: forwardedRef,
328 value: value,
329 children: /*#__PURE__*/jsx(InputAdornment, {
330 placement: "end",
331 children: /*#__PURE__*/jsx(BaseButton, _objectSpread(_objectSpread({}, boxProps), {}, {
332 "aria-label": buttonLabel,
333 onClick: buttonOnClick,
334 ref: buttonRef,
335 disabled: disabled,
336 className: css(buttonStyles)
337 // The input is not keyboard navigable when disabled and so we are
338 // also removing the button from the tab index to make it less
339 // confusing to keyboard and assistive technology users.
340 ,
341 tabIndex: disabled ? -1 : undefined,
342 children: /*#__PURE__*/jsx(CalendarIcon, {
343 tone: disabled ? 'disabled' : 'neutral'
344 })
345 }))
346 })
347 }));
348});
349function useIconButtonStyles() {
350 var _useButtonStyles = useButtonStyles({
351 iconOnly: false,
352 prominence: 'none',
353 size: 'medium',
354 tone: 'neutral'
355 }),
356 _useButtonStyles2 = _slicedToArray(_useButtonStyles, 2),
357 buttonStyles = _useButtonStyles2[1];
358 return [{
359 alignItems: 'center',
360 borderRadius: 'full',
361 cursor: 'pointer',
362 display: 'inline-flex',
363 gap: 'small',
364 height: 'small',
365 justifyContent: 'center',
366 paddingX: 'xsmall',
367 position: 'relative',
368 width: 'small'
369 }, buttonStyles];
370}
371
372////////////////////////////////////////////////////////////////////////////////
373
374/**
375 * Useful for situations where you have separate buttons to open/close
376 * or expand/collapse an element.
377 */
378function useTernaryState(initialValue) {
379 var _useState = useState(initialValue),
380 _useState2 = _slicedToArray(_useState, 2),
381 state = _useState2[0],
382 setState = _useState2[1];
383 var setTrue = useCallback(function () {
384 return setState(true);
385 }, []);
386 var setFalse = useCallback(function () {
387 return setState(false);
388 }, []);
389 return [state, setTrue, setFalse];
390}
391////////////////////////////////////////////////////////////////////////////////
392
393/**
394 * Calls the provided handler function if a click is detected outside of a
395 * specified element.
396 */
397function useClickOutside(ref, handler) {
398 useEffect(function () {
399 function listener(event) {
400 var element = ref === null || ref === void 0 ? void 0 : ref.current;
401
402 // Do nothing if clicking ref's element or descendent elements
403 if (!element || element.contains(event.target)) {
404 return;
405 }
406 handler(event);
407 }
408 window.addEventListener('mousedown', listener);
409 return function () {
410 return window.removeEventListener('mousedown', listener);
411 };
412 }, [handler, ref]);
413}
414
415var _excluded = ["data", "initialMonth", "maxDate", "minDate", "onChange", "value"];
416var DatePicker = /*#__PURE__*/forwardRef(function DatePicker(_ref, forwardedRef) {
417 var data = _ref.data,
418 initialMonth = _ref.initialMonth,
419 maxDate = _ref.maxDate,
420 minDate = _ref.minDate,
421 onChange = _ref.onChange,
422 value = _ref.value,
423 consumerProps = _objectWithoutProperties(_ref, _excluded);
424 var _useTernaryState = useTernaryState(false),
425 _useTernaryState2 = _slicedToArray(_useTernaryState, 3),
426 isCalendarOpen = _useTernaryState2[0],
427 openCalendar = _useTernaryState2[1],
428 closeCalendar = _useTernaryState2[2];
429
430 // Popper state
431 var triggerRef = useRef(null);
432 var _useState = useState(null),
433 _useState2 = _slicedToArray(_useState, 2),
434 refEl = _useState2[0],
435 setRefEl = _useState2[1];
436 var _useState3 = useState(null),
437 _useState4 = _slicedToArray(_useState3, 2),
438 popperEl = _useState4[0],
439 setPopperEl = _useState4[1];
440 var _usePopper = usePopper(refEl, popperEl, {
441 placement: 'bottom-start',
442 modifiers: [{
443 name: 'offset',
444 options: {
445 offset: [0, 8]
446 }
447 }]
448 }),
449 styles = _usePopper.styles,
450 attributes = _usePopper.attributes;
451 var defaultValue = dateFormat.toUpperCase();
452 var _useState5 = useState(''),
453 _useState6 = _slicedToArray(_useState5, 2),
454 inputValue = _useState6[0],
455 setInputValue = _useState6[1];
456 var onSelect = useCallback(function (_, selectedDay, modifiers) {
457 // If the day is disabled, do nothing
458 if (modifiers.disabled) {
459 return;
460 }
461 // Update the input field with the selected day
462 setInputValue(formatDate(selectedDay));
463 // Trigger the callback
464 onChange(selectedDay);
465 // Close the calendar and focus the calendar icon
466 closeCalendar();
467 }, [onChange, closeCalendar]);
468 var onInputChange = useCallback(function (event) {
469 var _event$target$selecti;
470 var indexes = getSeparatorIndexes();
471 var eventValue = event.target.value;
472 var startPos = (_event$target$selecti = event.target.selectionStart) !== null && _event$target$selecti !== void 0 ? _event$target$selecti : 0;
473 var formattedDate = formatDateOnChange(eventValue, inputValue, startPos);
474 var nextPos = startPos;
475
476 // to fix issue where cursor jumps to end of input when formatting value
477 if (indexes.includes(startPos) && eventValue.length > inputValue.length) {
478 nextPos = startPos + 1;
479 }
480 setInputValue(formattedDate);
481 setCursorPosition(event, nextPos);
482 var parsedDate = parseDate(formattedDate);
483 var constrainedDate = constrainDate(parsedDate, minDate, maxDate);
484 onChange(constrainedDate);
485 }, [maxDate, minDate, onChange, inputValue]);
486
487 // Update the text inputs when the value updates
488 useEffect(function () {
489 if (value) {
490 setInputValue(formatDate(value));
491 }
492 }, [value]);
493
494 // Close the calendar when the user clicks outside
495 var clickOutsideRef = useRef(popperEl);
496 clickOutsideRef.current = popperEl;
497 var handleClickOutside = useCallback(function () {
498 if (isCalendarOpen) {
499 closeCalendar();
500 }
501 }, [isCalendarOpen, closeCalendar]);
502 useClickOutside(clickOutsideRef, handleClickOutside);
503
504 // Close the calendar when the user presses escape
505 var handleEscape = useCallback(function (event) {
506 if (isCalendarOpen && event.code === 'Escape') {
507 event.preventDefault();
508 event.stopPropagation();
509 // Close the calendar and focus the calendar icon
510 closeCalendar();
511 }
512 }, [isCalendarOpen, closeCalendar]);
513 var disabledCalendarDays = useMemo(function () {
514 if (!(minDate || maxDate)) {
515 return;
516 }
517 return [minDate ? {
518 before: minDate
519 } : undefined, maxDate ? {
520 after: maxDate
521 } : undefined].filter(function (x) {
522 return Boolean(x);
523 });
524 }, [minDate, maxDate]);
525
526 // sets the next cursor position
527 var setCursorPosition = function setCursorPosition(event, position) {
528 setTimeout(function () {
529 event.target.setSelectionRange(position, position);
530 }, 0);
531 };
532 return /*#__PURE__*/jsxs(Stack, {
533 ref: setRefEl,
534 onKeyDown: handleEscape,
535 data: data,
536 width: "full",
537 children: [/*#__PURE__*/jsx(DateInput, _objectSpread(_objectSpread({}, consumerProps), {}, {
538 buttonOnClick: openCalendar,
539 buttonRef: triggerRef,
540 onChange: onInputChange,
541 ref: forwardedRef,
542 value: inputValue,
543 placeholder: defaultValue,
544 onFocus: function onFocus(e) {
545 if (!inputValue) setInputValue(defaultValue);
546 setCursorPosition(e, 0);
547 },
548 onBlur: function onBlur() {
549 if (inputValue === defaultValue) setInputValue('');
550 }
551 })), isCalendarOpen && /*#__PURE__*/jsx("div", _objectSpread(_objectSpread({}, attributes.popper), {}, {
552 ref: setPopperEl,
553 style: _objectSpread(_objectSpread({}, styles.popper), {}, {
554 zIndex: 1
555 }),
556 children: /*#__PURE__*/jsx(CalendarSingle, {
557 defaultMonth: value || initialMonth,
558 disabled: disabledCalendarDays,
559 initialFocus: true,
560 numberOfMonths: 1,
561 onSelect: onSelect,
562 selected: value
563 })
564 }))]
565 });
566});
567
568export { DatePicker };