@adyen/bento-mcp 0.5.2 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/assets/components/alert/alert.docs.mdx +17 -17
  3. package/dist/assets/components/alert/alert.vue +1 -1
  4. package/dist/assets/components/country/country.vue +1 -1
  5. package/dist/assets/components/dashed-underline/dashed-underline.vue +1 -1
  6. package/dist/assets/components/data-grid/components/data-grid-row/data-grid-row.types.ts +1 -1
  7. package/dist/assets/components/data-grid/components/data-grid-row/data-grid-row.vue +1 -1
  8. package/dist/assets/components/data-grid/data-grid.docs.mdx +3 -0
  9. package/dist/assets/components/data-grid/data-grid.vue +1 -1
  10. package/dist/assets/components/date/date.vue +1 -1
  11. package/dist/assets/components/date-picker/composables/use-date-picker-single-calendar-text.types.ts +1 -1
  12. package/dist/assets/components/date-picker/date-picker.docs.mdx +6 -0
  13. package/dist/assets/components/date-picker/date-picker.stories.ts +1 -1
  14. package/dist/assets/components/date-picker/date-picker.types.ts +1 -1
  15. package/dist/assets/components/date-picker/date-picker.vue +1 -1
  16. package/dist/assets/components/date-range-picker/components/date-range-picker-calendar/date-range-picker-calendar.vue +1 -1
  17. package/dist/assets/components/date-range-picker/components/date-time-input/date-time-input.types.ts +1 -0
  18. package/dist/assets/components/date-range-picker/components/date-time-input/date-time-input.vue +1 -0
  19. package/dist/assets/components/date-range-picker/date-range-picker.docs.mdx +6 -0
  20. package/dist/assets/components/date-range-picker/date-range-picker.stories.ts +1 -1
  21. package/dist/assets/components/date-range-picker/date-range-picker.types.ts +1 -1
  22. package/dist/assets/components/date-range-picker/date-range-picker.vue +1 -1
  23. package/dist/assets/components/date-time-picker/date-time-picker.docs.mdx +129 -0
  24. package/dist/assets/components/date-time-picker/date-time-picker.stories.ts +1 -0
  25. package/dist/assets/components/date-time-picker/date-time-picker.types.ts +1 -0
  26. package/dist/assets/components/date-time-picker/date-time-picker.vue +1 -0
  27. package/dist/assets/components/draggable/draggable.vue +1 -1
  28. package/dist/assets/components/dropdown/components/dropdown-base-textbox/dropdown-base-textbox.vue +1 -1
  29. package/dist/assets/components/dropdown/components/dropdown-options-container/dropdown-options-container.vue +1 -1
  30. package/dist/assets/components/dropdown/components/dropdown-small-textbox/dropdown-small-textbox.vue +1 -1
  31. package/dist/assets/components/dropdown/components/variants/dropdown-country/dropdown-country.types.ts +1 -1
  32. package/dist/assets/components/dropdown/dropdown.types.ts +1 -1
  33. package/dist/assets/components/dropdown/dropdown.vue +1 -1
  34. package/dist/assets/components/file-uploader/file-uploader.vue +1 -1
  35. package/dist/assets/components/filter-bar/components/checkbox-group-filter/checkbox-group-filter.vue +1 -1
  36. package/dist/assets/components/filter-bar/components/date-filter/date-filter.vue +1 -1
  37. package/dist/assets/components/filter-bar/components/date-range-filter/date-range-filter.vue +1 -1
  38. package/dist/assets/components/filter-bar/filter-bar.docs.mdx +8 -2
  39. package/dist/assets/components/filter-bar/filter-bar.stories.ts +1 -1
  40. package/dist/assets/components/filter-bar/filter-bar.types.ts +1 -1
  41. package/dist/assets/components/filter-bar/filter-bar.vue +1 -1
  42. package/dist/assets/components/input-field/input-field.vue +1 -1
  43. package/dist/assets/components/input-field-password/input-field-password.vue +1 -1
  44. package/dist/assets/components/internal/calendar/calendar.stories.ts +1 -1
  45. package/dist/assets/components/internal/calendar/calendar.types.ts +1 -1
  46. package/dist/assets/components/internal/calendar/calendar.vue +1 -1
  47. package/dist/assets/components/internal/calendar/components/calendar-month/calendar-month.types.ts +1 -1
  48. package/dist/assets/components/internal/calendar/components/calendar-month/calendar-month.vue +1 -1
  49. package/dist/assets/components/internal/calendar/components/calendar-month/components/calendar-month-day/calendar-month-day.vue +1 -1
  50. package/dist/assets/components/internal/calendar/components/calendar-year/calendar-year.types.ts +1 -1
  51. package/dist/assets/components/internal/calendar/components/calendar-year/calendar-year.vue +1 -1
  52. package/dist/assets/components/internal/calendar/components/calendar-year-selector/calendar-year-selector.types.ts +1 -0
  53. package/dist/assets/components/internal/calendar/components/calendar-year-selector/calendar-year-selector.vue +1 -0
  54. package/dist/assets/components/internal/header-meta/header-meta.vue +1 -1
  55. package/dist/assets/components/internal/info-icon-with-popover/info-icon-with-popover.vue +1 -1
  56. package/dist/assets/components/internal/listbox/components/listbox-multi-select/listbox-multi-select.vue +1 -1
  57. package/dist/assets/components/internal/listbox/components/listbox-single-select/listbox-single-select.vue +1 -1
  58. package/dist/assets/components/link/link.vue +1 -1
  59. package/dist/assets/components/list/components/list-item/list-item.types.ts +1 -1
  60. package/dist/assets/components/list/components/list-item/list-item.vue +1 -1
  61. package/dist/assets/components/list/list.docs.mdx +62 -0
  62. package/dist/assets/components/list/list.stories.ts +1 -1
  63. package/dist/assets/components/list/list.types.ts +1 -1
  64. package/dist/assets/components/list/list.vue +1 -1
  65. package/dist/assets/components/navigation-menu/components/navigation-menu-group/navigation-menu-group.stories.ts +1 -1
  66. package/dist/assets/components/navigation-menu/components/navigation-menu-group/navigation-menu-group.vue +1 -1
  67. package/dist/assets/components/navigation-menu/components/navigation-menu-item/navigation-menu-item.stories.ts +1 -1
  68. package/dist/assets/components/navigation-menu/components/navigation-menu-item/navigation-menu-item.vue +1 -1
  69. package/dist/assets/components/navigation-menu/navigation-menu.docs.mdx +7 -43
  70. package/dist/assets/components/navigation-menu/navigation-menu.stories.ts +1 -1
  71. package/dist/assets/components/navigation-menu/navigation-menu.types.ts +1 -1
  72. package/dist/assets/components/navigation-menu/navigation-menu.vue +1 -1
  73. package/dist/assets/components/popover/popover.docs.mdx +78 -15
  74. package/dist/assets/components/popover/popover.stories.ts +1 -1
  75. package/dist/assets/components/selection-card/components/selection-card-group/selection-card-group.types.ts +1 -1
  76. package/dist/assets/components/summary-grid/summary-grid.vue +1 -1
  77. package/dist/assets/components/tabs/tabs.docs.mdx +6 -1
  78. package/dist/assets/components.json +1 -0
  79. package/dist/assets/composables/use-bento-delayed-hover/use-bento-delayed-hover.docs.mdx +77 -0
  80. package/dist/assets/composables/use-bento-delayed-hover/use-bento-delayed-hover.stories.ts +1 -0
  81. package/dist/assets/deprecations.md +156 -156
  82. package/dist/assets/index.ts +1 -1
  83. package/dist/assets/usage.json +75 -74
  84. package/dist/main.js +135 -48
  85. package/package.json +1 -1
@@ -1 +1 @@
1
- <!-- TODO: Remove aria props as these are part of JSX elements /vue/types/jsx.d.ts --> <!-- eslint-disable vue/attribute-hyphenation --> <template> <div v-bento-click-outside-directive="closeDatePicker" class="b-date-picker" @keydown.space.capture="onSpaceBarOrEnter" @keydown.enter.capture="onSpaceBarOrEnter" > <!-- Label --> <field-label v-if="label" :id="labelId" :label="label" :tooltip-text="tooltipText" :optional="optional" :required="required" /> <!-- Input --> <div> <bento-input-field v-if="isTouchDevice" ref="inputContainerRef" :type="isMonthVariant ? 'month' : 'date'" :aria-labelledby="label ? labelId : $attrs?.['aria-labelledby']" :aria-label="label || $attrs?.['aria-labelledby'] ? null : popoverAriaLabel" :aria-describedby="ariaDescribedBy" :aria-required="required" :disabled="disabled" :error="!!errorMessage || !!dateErrorText" :model-value="nativeValue" :readonly="isReadOnly" :min="formattedMin" :max="formattedMax" @update:model-value="onNativeDateInput" /> <dropdown-input-default v-else ref="inputContainerRef" always-combobox-is-input :ariaControls="datePickerContainerId" :ariaExpanded="isDatePickerOpen" :aria-labelledby="label ? labelId : $attrs?.['aria-labelledby']" :aria-label="label ? null : popoverAriaLabel" :aria-describedby="ariaDescribedBy" :aria-required="required" :disabled="disabled" dynamic-filtering :is-invalid="!!errorMessage || !!dateErrorText" :open="isDatePickerOpen" :value="textInputValue" :display-value="dateDisplayValue" :debounce-time="0" :readonly="isReadOnly" @input="onDateInputChange" @open="openDatePicker" @keydown="onDateKeyDown" @clear="clearDatePickerValue" @close="closeDatePicker" /> </div> <!-- Error Messages --> <error-message v-if="dateErrorText" :id="dateErrorId" :error-message="dateErrorText" class="b-date-picker__error-message" /> <error-message v-if="errorMessage" :id="errorId" :error-message="errorMessage" class="b-date-picker__error-message" /> <!-- Description --> <bento-typography v-if="description" class="b-date-picker__description" :class="descriptionConditionalClasses" el="span" > {{ description }} </bento-typography> <!-- Calendar container --> <bento-popover v-if="inputContainerRef && !isTouchDevice" :id="datePickerContainerId" class="b-date-picker__container" role="dialog" :open="isDatePickerOpen" :target-element="inputContainerRef" :aria-label="popoverAriaLabel" :aria-modal="true" position="bottom-start" fit-content trap-all :trap-all-options="computedTrapAllOptions" @keydown.esc.native="closeDatePicker" > <calendar ref="calendarContainerRef" :default-display-month="defaultDisplayMonth" :first-day-of-week="resolvedFirstDayOfWeek" :is-date-disabled="isDateDisabled" :number-of-months="1" :value="internalModelValue" :min="min" :max="max" :variant="variant" @input="onDateSelected" /> </bento-popover> </div> </template> <script setup lang="ts"> import { computed, nextTick, ref, toRef, watch } from 'vue'; import { startOfMonth } from 'date-fns/startOfMonth'; // Components import { BentoPopover } from '@/components/popover'; import { BentoTypography } from '../typography'; import { Calendar, type CalendarSingleDateValue, ErrorMessage, FieldLabel } from '@/internal'; import { DropdownInputDefault } from '@/components/dropdown/components'; import { BentoInputField } from '@/components/input-field'; // Utils import { debounce } from '@/utils/ts/debounce'; import { generateUid } from '@/core/utils/ts'; import { useI18n } from '@/utils/ts/i18n'; import { dateToNativeDateString, dateToNativeMonthString, parseNativeMonthString } from '@/utils/ts/format-date'; import { useFormFieldEmits } from '@/utils/ts/form-field/form-field'; import { deprecate } from '@/utils/ts/deprecate'; // Composables import { useDatePickerSingleCalendarText } from './composables/use-date-picker-single-calendar-text'; import { useDateInputFormatter, useFirstDayOfWeek, useFormLayoutFieldLoading, useTouchDevice } from '@/composables'; // Directive import { BentoClickOutsideDirective as vBentoClickOutsideDirective } from '@/directives'; // Types import { type BentoDatePickerEmits, type BentoDatePickerProps } from './date-picker.types'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const DEBOUNCE_TIME = 1000; const props = withDefaults(defineProps<BentoDatePickerProps>(), { defaultDisplayMonth: null, description: undefined, disabled: false, errorMessage: null, firstDayOfWeek: undefined, isDateDisabled: undefined, label: undefined, max: null, min: null, modelValue: null, optional: false, placeholder: null, readonly: false, required: false, tooltipText: null, value: null, variant: undefined, // Default will be set by @/internal/calendar }); const emit = defineEmits<BentoDatePickerEmits>(); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const datePickerContainerId = generateUid('bento-date-picker-container'); const labelId = generateUid('bento-date-picker-label'); const descriptionId = generateUid('bento-date-picker-description'); const errorId = generateUid('bento-date-picker-error'); const dateErrorId = generateUid('bento-date-picker-date-error'); const popoverAriaLabel = computed(() => props.label || t('datePicker', { name: datePickerContainerId })); // Refs const calendarContainerRef = ref(null); const inputContainerRef = ref(null); // Values const isDateIncorrect = ref(false); const isDatePickerOpen = ref(false); const initialFocusOnDatePickerOpen = ref(undefined); const internalModelValue = ref<CalendarSingleDateValue | null>(props.modelValue); // to be removed when `props.value` is removed const { isReadOnly } = useFormLayoutFieldLoading(toRef(props, 'readonly')); const { emitValue } = useFormFieldEmits<CalendarSingleDateValue>(emit); const { resolvedFirstDayOfWeek } = useFirstDayOfWeek(toRef(props, 'firstDayOfWeek')); const { isFormatValid, parseDate, onKeyDown, autoFormat } = useDateInputFormatter(); const { isTouchDevice } = useTouchDevice(); const computedTrapAllOptions = computed(() => ({ initialFocus: initialFocusOnDatePickerOpen.value, additionalContainers: [inputContainerRef.value.$el], })); watch( () => props.disabled, disabled => { // Close date picker popover if it's opened when it's disabled if (disabled && isDatePickerOpen.value) { isDatePickerOpen.value = false; } } ); watch( [() => props.value, () => props.modelValue], ([newValue, newModelValue]) => { if (newValue) { deprecate( 'BentoDatePicker "value" property', `The use of "value" prop in "BentoDatePicker" is no longer supported. Use the "v-model" or "model-value" property instead.`, '2.0.0' ); } internalModelValue.value = newModelValue ?? newValue; }, { immediate: true } ); const isMonthVariant = computed(() => props.variant === 'month'); const { dateDisplayValue, textInputValue, formatTextInputDisplayValue } = useDatePickerSingleCalendarText({ datePickerValue: internalModelValue, isDateIncorrect, isMonthVariant, placeholder: toRef(props, 'placeholder'), }); const formatNativeDate = (date: Date): string => { if (!date) { return ''; } return isMonthVariant.value ? dateToNativeMonthString(date) : dateToNativeDateString(date); }; const nativeValue = computed(() => formatNativeDate(internalModelValue.value)); const onNativeDateInput = (value: string) => { if (!value) { emitValue(null); return; } if (isMonthVariant.value) { emitValue(parseNativeMonthString(value)); return; } const date = parseDate(value); if (date) { emitValue(date); } }; const dateErrorText = computed(() => { if ( props.isDateDisabled && textInputValue.value && isFormatValid(textInputValue.value) && props.isDateDisabled(parseDate(textInputValue.value)) && !isDatePickerOpen.value ) { return t('selectedDateIsNotAvailable'); } return isDateIncorrect.value ? t('provideDateInAFormat') : null; }); const descriptionConditionalClasses = computed(() => ({ 'b-date-picker__description--error': props.errorMessage || dateErrorText.value, })); const ariaDescribedBy = computed( () => `${props.description ? descriptionId : ''} ${props.errorMessage ? errorId : ''} ${ dateErrorText.value ? dateErrorId : '' }`.trim() || null ); const formattedMin = computed(() => formatNativeDate(props.min)); const formattedMax = computed(() => formatNativeDate(props.max)); const isDateEnabled = (date: Date) => { // Check if date is between min/max bounds, if there are any if ((props.min && date <= props.min) || (props.max && date >= props.max)) { return false; } // Check if function is set, otherwise all dates are enabled if (props.isDateDisabled) { return !props.isDateDisabled(date); } return true; }; const closeDatePicker = () => { isDatePickerOpen.value = false; }; const openDatePicker = () => { isDatePickerOpen.value = true; formatTextInputDisplayValue(); }; const onDateSelected = (selectedDate: Date) => { // Reset the error state when a valid date is selected isDateIncorrect.value = false; closeDatePicker(); emitValue(selectedDate); }; const clearDatePickerValue = async () => { if (isDateIncorrect.value) { isDateIncorrect.value = false; textInputValue.value = null; } emitValue(null); }; const onDateKeyDown = (event: KeyboardEvent) => { onKeyDown(event); }; const validateDate = (stringDate: string) => { // Validate only if the date is complete if (stringDate.length < 10) { return; } const isValidDate = isFormatValid(stringDate) && isDateEnabled(parseDate(stringDate)); if (isValidDate) { isDateIncorrect.value = false; emitValue(parseDate(stringDate)); } else if (stringDate) { // Only set to true if the value exists (not null/undefined) isDateIncorrect.value = true; emitValue(null); } }; const validateDateDebounced = debounce(validateDate, DEBOUNCE_TIME); const onDateInputChange = (stringDate: string) => { textInputValue.value = autoFormat(stringDate); // If we are closing the date picker, we should validate (and emit date inputs) immediately so the user doesn't have to wait for the debounce time. // On touch devices with native input, we also want immediate feedback. if (isDatePickerOpen.value || isTouchDevice.value) { validateDate(textInputValue.value); } else { validateDateDebounced(textInputValue.value); } }; /** * Space bar and Enter keyDown event handler. * * * Opens the date picker dialog on the first keydown. * * Move focus to selected date, i.e., the date displayed in the date input text field. If no date has been selected, places focus on the current date. * @param {KeyboardEvent} event Event being triggered */ const onSpaceBarOrEnter = async (event: KeyboardEvent) => { if (isDatePickerOpen.value) { return; } event.preventDefault(); openDatePicker(); // Wait for the date picker dialog to open await nextTick(); // Set current date (today) if no date is selected if (!internalModelValue.value) { // Format the opened date picker date to YYYY-MM-DD formatTextInputDisplayValue(new Date()); emitValue(new Date()); } // Set initial focus initialFocusOnDatePickerOpen.value = calendarContainerRef.value?.$refs.calendarRef[0].focusOnDate( props.variant === 'month' ? startOfMonth(internalModelValue.value) : internalModelValue.value, true ) ?? undefined; }; </script> <script lang="ts"> /** * Date picker selector. * * @example * import { BentoDatePicker } from '@adyen/bento-vue2'; * * export default { * components: { BentoDatePicker }, * template: ` * <bento-date-picker * label="Label" * description="Supporting text" * required * optional * disabled * @input="onDateChanged" * v-model="selectedDate" * /> * `, * setup() { * const selectedDate = ref(new Date()); // reactive({ startDate: new Date(), endDate: new Date() }) * return { * selectedDate, * } * } * } */ export default { i18n: { messages }, name: 'bento-date-picker', model: { prop: 'modelValue' }, }; </script> <style lang="scss" scoped src="./date-picker.scss" />
1
+ <!-- TODO: Remove aria props as these are part of JSX elements /vue/types/jsx.d.ts --> <!-- eslint-disable vue/attribute-hyphenation --> <template> <div v-bento-click-outside-directive="closeDatePicker" class="b-date-picker" @keydown.space.capture="onSpaceBarOrEnter" @keydown.enter.capture="onSpaceBarOrEnter" > <!-- Label --> <field-label v-if="label" :id="labelId" :label="label" :tooltip-text="tooltipText" :optional="optional" :required="required" /> <!-- Input --> <div> <bento-input-field v-if="isTouchDevice" ref="inputContainerRef" :type="isMonthVariant ? 'month' : 'date'" :aria-labelledby="label ? labelId : $attrs?.['aria-labelledby']" :aria-label="label || $attrs?.['aria-labelledby'] ? null : popoverAriaLabel" :aria-describedby="ariaDescribedBy" :aria-required="required" :disabled="disabled" :error="!!errorMessage || !!dateErrorText" :model-value="nativeValue" :readonly="isReadOnly" :min="formattedMin" :max="formattedMax" @update:model-value="onNativeDateInput" /> <dropdown-input-default v-else ref="inputContainerRef" always-combobox-is-input :ariaControls="datePickerContainerId" :ariaExpanded="isDatePickerOpen" :aria-labelledby="label ? labelId : $attrs?.['aria-labelledby']" :aria-label="label ? null : popoverAriaLabel" :aria-describedby="ariaDescribedBy" :aria-required="required" :disabled="disabled" dynamic-filtering :is-invalid="!!errorMessage || !!dateErrorText" :open="isDatePickerOpen" :value="textInputValue" :display-value="dateDisplayValue" :debounce-time="0" :readonly="isReadOnly" @input="onDateInputChange" @open="openDatePicker" @keydown="onDateKeyDown" @clear="clearDatePickerValue" @close="closeDatePicker" /> </div> <!-- Error Messages --> <error-message v-if="dateErrorText && !errorMessage" :id="dateErrorId" :error-message="dateErrorText" class="b-date-picker__error-message" /> <error-message v-if="errorMessage" :id="errorId" :error-message="errorMessage" class="b-date-picker__error-message" /> <!-- Description --> <bento-typography v-if="description" class="b-date-picker__description" :class="descriptionConditionalClasses" el="span" > {{ description }} </bento-typography> <!-- Calendar container --> <bento-popover v-if="inputContainerRef && !isTouchDevice" :id="datePickerContainerId" class="b-date-picker__container" role="dialog" :open="isDatePickerOpen" :target-element="inputContainerRef" :aria-label="popoverAriaLabel" :aria-modal="true" position="bottom-start" fit-content trap-all overflow-visible :trap-all-options="computedTrapAllOptions" @keydown.esc.native="closeDatePicker" > <calendar ref="calendarContainerRef" :default-display-month="defaultDisplayMonth" :first-day-of-week="resolvedFirstDayOfWeek" :is-date-disabled="isDateDisabled" :number-of-months="1" :value="internalModelValue" :min="min" :max="max" :variant="variant" :year-range="yearRange" @input="onDateSelected" /> </bento-popover> </div> </template> <script setup lang="ts"> import { computed, nextTick, ref, toRef, watch } from 'vue'; import { startOfMonth } from 'date-fns/startOfMonth'; // Components import { BentoPopover } from '@/components/popover'; import { BentoTypography } from '../typography'; import { Calendar, type CalendarSingleDateValue, ErrorMessage, FieldLabel } from '@/internal'; import { DropdownInputDefault } from '@/components/dropdown/components'; import { BentoInputField } from '@/components/input-field'; // Utils import { debounce } from '@/utils/ts/debounce'; import { generateUid } from '@/core/utils/ts'; import { useI18n } from '@/utils/ts/i18n'; import { dateToNativeDateString, dateToNativeMonthString, parseNativeDateString } from '@/utils/ts/format-date'; import { useFormFieldEmits } from '@/utils/ts/form-field/form-field'; import { deprecate } from '@/utils/ts/deprecate'; // Composables import { useDatePickerSingleCalendarText } from './composables/use-date-picker-single-calendar-text'; import { useDateInputFormatter, useFirstDayOfWeek, useFormLayoutFieldLoading, useTouchDevice } from '@/composables'; // Directive import { BentoClickOutsideDirective as vBentoClickOutsideDirective } from '@/directives'; // Types import { type BentoDatePickerEmits, type BentoDatePickerProps } from './date-picker.types'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const DEBOUNCE_TIME = 1000; const props = withDefaults(defineProps<BentoDatePickerProps>(), { defaultDisplayMonth: null, description: undefined, disabled: false, errorMessage: null, firstDayOfWeek: undefined, isDateDisabled: undefined, label: undefined, max: null, min: null, modelValue: null, optional: false, placeholder: null, readonly: false, required: false, tooltipText: null, value: null, variant: undefined, // Default will be set by @/internal/calendar yearRange: undefined, }); const emit = defineEmits<BentoDatePickerEmits>(); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const datePickerContainerId = generateUid('bento-date-picker-container'); const labelId = generateUid('bento-date-picker-label'); const descriptionId = generateUid('bento-date-picker-description'); const errorId = generateUid('bento-date-picker-error'); const dateErrorId = generateUid('bento-date-picker-date-error'); const popoverAriaLabel = computed(() => props.label || t('datePicker', { name: datePickerContainerId })); // Refs const calendarContainerRef = ref(null); const inputContainerRef = ref(null); // Values const dateError = ref<keyof MessageSchema | null>(null); const isDatePickerOpen = ref(false); const initialFocusOnDatePickerOpen = ref(undefined); const internalModelValue = ref<CalendarSingleDateValue | null>(props.modelValue); // to be removed when `props.value` is removed const { isReadOnly } = useFormLayoutFieldLoading(toRef(props, 'readonly')); const { emitValue } = useFormFieldEmits<CalendarSingleDateValue>(emit); const { resolvedFirstDayOfWeek } = useFirstDayOfWeek(toRef(props, 'firstDayOfWeek')); const { isFormatValid, parseDate, onKeyDown, autoFormat } = useDateInputFormatter(); const { isTouchDevice } = useTouchDevice(); const computedTrapAllOptions = computed(() => ({ initialFocus: initialFocusOnDatePickerOpen.value, additionalContainers: [inputContainerRef.value.$el], })); watch( () => props.disabled, disabled => { // Close date picker popover if it's opened when it's disabled if (disabled && isDatePickerOpen.value) { isDatePickerOpen.value = false; } } ); watch( [() => props.value, () => props.modelValue], ([newValue, newModelValue]) => { if (newValue) { deprecate( 'BentoDatePicker "value" property', `The use of "value" prop in "BentoDatePicker" is no longer supported. Use the "v-model" or "model-value" property instead.`, '2.0.0' ); } internalModelValue.value = newModelValue ?? newValue; }, { immediate: true } ); const isMonthVariant = computed(() => props.variant === 'month'); const { dateDisplayValue, textInputValue, formatTextInputDisplayValue } = useDatePickerSingleCalendarText({ datePickerValue: internalModelValue, dateError, isMonthVariant, placeholder: toRef(props, 'placeholder'), }); const formatNativeDate = (date: Date): string => { if (!date) { return ''; } return isMonthVariant.value ? dateToNativeMonthString(date) : dateToNativeDateString(date); }; const nativeValue = computed(() => formatNativeDate(internalModelValue.value)); const onNativeDateInput = (value: string) => { if (!value) { dateError.value = null; emitValue(null); return; } const date = parseNativeDateString(value, isMonthVariant.value ? 'month' : 'date'); dateError.value = getDateErrorReason(date); if (dateError.value) { // Emit null to match the non-touch validation behavior. // Note: on repeated invalid selections, the native input may still display // the selected date because Vue skips re-rendering when the model stays null. emitValue(null); return; } emitValue(date); }; const formattedMin = computed(() => formatNativeDate(props.min)); const formattedMax = computed(() => formatNativeDate(props.max)); const getDateErrorReason = (date: Date | null): keyof MessageSchema | null => { if (!date || isNaN(date.getTime())) { return 'invalidDateFormat'; } // Check if date is between min/max bounds, if there are any (inclusive) if ((props.min && date < props.min) || (props.max && date > props.max)) { return 'invalidDateFormat'; } // Check if function is set, otherwise all dates are enabled if (props.isDateDisabled && props.isDateDisabled(date)) { return 'selectedDateIsNotAvailable'; } return null; }; const dateErrorText = computed(() => (dateError.value ? t(dateError.value) : null)); watch( [isDatePickerOpen, textInputValue, () => props.isDateDisabled, () => props.min, () => props.max], ([isOpen, textValue]) => { // Re-validate the current input when the picker closes, validation props change or text input change if (!isOpen && textValue && isFormatValid(textValue)) { dateError.value = getDateErrorReason(parseDate(textValue)); } } ); const descriptionConditionalClasses = computed(() => ({ 'b-date-picker__description--error': props.errorMessage || dateErrorText.value, })); const ariaDescribedBy = computed( () => `${props.description ? descriptionId : ''} ${props.errorMessage ? errorId : ''} ${ dateErrorText.value ? dateErrorId : '' }`.trim() || null ); const closeDatePicker = () => { isDatePickerOpen.value = false; }; const openDatePicker = () => { isDatePickerOpen.value = true; formatTextInputDisplayValue(); }; const onDateSelected = (selectedDate: Date) => { // Reset the error state when a valid date is selected dateError.value = null; closeDatePicker(); emitValue(selectedDate); }; const clearDatePickerValue = async () => { if (dateError.value) { dateError.value = null; textInputValue.value = null; } emitValue(null); }; const onDateKeyDown = (event: KeyboardEvent) => { onKeyDown(event); }; const validateDate = (stringDate: string) => { // Validate only if the date is complete if (stringDate.length < 10) { return; } const parsedDate = isFormatValid(stringDate) ? parseDate(stringDate) : null; const errorKey = getDateErrorReason(parsedDate); if (!errorKey && parsedDate) { dateError.value = null; emitValue(parsedDate); } else if (stringDate) { dateError.value = errorKey || 'invalidDateFormat'; emitValue(null); } }; const validateDateDebounced = debounce(validateDate, DEBOUNCE_TIME); const onDateInputChange = (stringDate: string) => { dateError.value = null; textInputValue.value = autoFormat(stringDate); // If we are closing the date picker, we should validate (and emit date inputs) immediately so the user doesn't have to wait for the debounce time. // On touch devices with native input, we also want immediate feedback. if (isDatePickerOpen.value || isTouchDevice.value) { validateDate(textInputValue.value); } else { validateDateDebounced(textInputValue.value); } }; /** * Space bar and Enter keyDown event handler. * * * Opens the date picker dialog on the first keydown. * * Move focus to selected date, i.e., the date displayed in the date input text field. If no date has been selected, places focus on the current date. * @param {KeyboardEvent} event Event being triggered */ const onSpaceBarOrEnter = async (event: KeyboardEvent) => { if (isDatePickerOpen.value) { return; } event.preventDefault(); openDatePicker(); // Wait for the date picker dialog to open await nextTick(); // Set current date (today) if no date is selected if (!internalModelValue.value) { // Format the opened date picker date to YYYY-MM-DD formatTextInputDisplayValue(new Date()); emitValue(new Date()); } // Set initial focus initialFocusOnDatePickerOpen.value = calendarContainerRef.value?.$refs.calendarRef[0].focusOnDate( props.variant === 'month' ? startOfMonth(internalModelValue.value) : internalModelValue.value, true ) ?? undefined; }; </script> <script lang="ts"> /** * Date picker selector. * * @example * import { BentoDatePicker } from '@adyen/bento-vue2'; * * export default { * components: { BentoDatePicker }, * template: ` * <bento-date-picker * label="Label" * description="Supporting text" * required * optional * disabled * @input="onDateChanged" * v-model="selectedDate" * /> * `, * setup() { * const selectedDate = ref(new Date()); // reactive({ startDate: new Date(), endDate: new Date() }) * return { * selectedDate, * } * } * } */ export default { i18n: { messages }, name: 'bento-date-picker', model: { prop: 'modelValue' }, }; </script> <style lang="scss" scoped src="./date-picker.scss" />
@@ -1 +1 @@
1
- <template> <div class="b-date-range-picker-calendar"> <!-- Form --> <div class="b-date-range-picker-calendar__form-container" :class="computedFormContainerClasses" data-testid="date-range-picker-calendar-form-container" > <div class="b-date-range-picker-calendar__form"> <template v-if="hasSlot('title')"> <slot name="title" /> </template> <bento-dropdown v-if="quickSelectRanges" :aria-label="t('customRange')" :items="quickSelectRangeDefaultItems" :model-value="selectedRangeSelectorValue" @update:model-value="onRangeSelectorInput" /> <bento-segmented-control v-if="granularities" :items="granularityItems" :model-value="internalRangeDate.granularity" full-width @update:model-value="onGranularityInput" > </bento-segmented-control> <bento-alert v-if="maxRangeMessage" variant="tip"> <template #description> {{ maxRangeMessage }} </template> </bento-alert> <div> <bento-typography stronger el="span" class="b-date-range-picker-calendar__label"> From </bento-typography> <div :class="formInputWrapperConditionalClasses"> <bento-input-field :aria-label="t('dateFrom')" :model-value="startDateValue" :error="startDateError" class="b-date-range-picker-calendar__form-input" :type="dateInputType" @update:model-value="onStartDateInput" @keydown="onDateInputKeyDown" > <template v-if="!isTouchDevice" #description>{{ t(dateFormat) }}</template> </bento-input-field> <bento-input-time v-if="allowTimeInput" :aria-label="t('timeFrom')" :model-value="dateTextInput.startTime" class="b-date-range-picker-calendar__form-input" @update:model-value="onStartTimeInput" @input:error="onStartTimeError" @input:valid="onStartTimeValid" /> </div> </div> <div> <bento-typography stronger el="span" class="b-date-range-picker-calendar__label"> To </bento-typography> <div :class="formInputWrapperConditionalClasses"> <bento-input-field class="b-date-range-picker-calendar__form-input" :aria-label="t('dateTo')" :model-value="endDateValue" :error="endDateError" :type="dateInputType" @update:model-value="onEndDateInput" @keydown="onDateInputKeyDown" > <template v-if="!isTouchDevice" #description>{{ t(dateFormat) }}</template> </bento-input-field> <bento-input-time v-if="allowTimeInput" :aria-label="t('timeTo')" :model-value="dateTextInput.endTime" class="b-date-range-picker-calendar__form-input" @update:model-value="onEndTimeInput" @input:error="onEndTimeError" @input:valid="onEndTimeValid" /> </div> </div> </div> <div v-if="hasSlot('actions')"> <slot name="actions" /> </div> </div> <!-- Calendar --> <div v-if="!hasNoCalendars && !isTouchDevice" class="b-date-range-picker-calendar__calendars-container" data-testid="date-range-picker-calendar-calendar-container" > <calendar :value="internalRangeDate" :first-day-of-week="firstDayOfWeek" :is-date-disabled="isDateDisabled" :max-range="maxRange" :min="computedMinMax.min" :max="computedMinMax.max" :number-of-months="numberOfMonths" :show-end-date-on-open="showEndDateOnOpen" :granularity="selectedGranularity" :variant="variant" is-range @input="onDateInput" @start-date-selected="onStartDateSelected" @end-date-selected="onEndDateSelected" /> </div> </div> </template> <script setup lang="ts"> import { computed, onMounted, type PropType, reactive, ref, toRaw, useSlots } from 'vue'; import { Calendar, type CalendarGranularityType } from '@/internal'; import { BentoAlert } from '@/components/alert'; import { BentoTypography } from '@/components/typography'; import { BentoInputField } from '@/components/input-field'; import { BentoInputTime } from '@/components/input-time'; import BentoDropdown from '@/components/dropdown/dropdown.vue'; import { BentoSegmentedControl, type BentoSegmentedControlItem } from '@/components/segmented-control'; import { useI18n } from '@/utils/ts/i18n'; import { debounce } from '@/utils/ts/debounce'; import { useDateInputFormatter, useHasSlot, useTouchDevice } from '@/composables'; import { useGranularityAdjustments } from '@/components/internal/calendar/composables/use-granularity-adjustments'; import { useGranularMinMaxDate } from '@/components/internal/calendar/components/calendar-month/composables/granular-min-max-date'; import { useGranularityMemory } from '../../composables/use-granularity-memory'; import { DateRangePickerCalendarEvent, type DateRangePickerCalendarFormData, type DateRangePickerCalendarRangeSelectorItems, } from './date-range-picker-calendar.types'; import type { BentoDateRangePickerGranularityConfig, BentoDateRangePickerValue, } from '../../date-range-picker.types'; import { endOfDay } from 'date-fns/endOfDay'; import { setHours } from 'date-fns/setHours'; import { setMinutes } from 'date-fns/setMinutes'; import { setSeconds } from 'date-fns/setSeconds'; import { startOfDay } from 'date-fns/startOfDay'; import { isSameSecond } from 'date-fns/isSameSecond'; import { isSameDay } from 'date-fns/isSameDay'; import { isEqual } from 'date-fns/isEqual'; import { isToday } from 'date-fns/isToday'; import { setMilliseconds } from 'date-fns/setMilliseconds'; import { dateToNativeDateString, dateToNativeMonthString, dateToTimeInputString, parseNativeMonthString, } from '@/utils/ts/format-date/format-date'; import { isValidTimeString } from '@/utils/ts/time-input'; import messages from './messages.json'; const FORM_INPUT_DEBOUNCE_TIME = 300; const RANGE_SELECTOR_CUSTOM_RANGE_KEY = 'customRange'; type MessageSchema = (typeof messages)['en-US']; const maxRangeMessageKeyMap: Record<CalendarGranularityType, keyof MessageSchema> = { daily: 'maxRangeDays', weekly: 'maxRangeWeeks', monthly: 'maxRangeMonths', quarterly: 'maxRangeQuarters', }; const props = defineProps({ /** * Allows user to enter time values in the form */ allowTimeInput: { type: Boolean, default: false }, /** * Ranges form persistance data */ dateFormData: { type: Object as PropType<DateRangePickerCalendarFormData>, default: () => ({ startDate: undefined, endDate: undefined, startTime: undefined, endTime: undefined }), }, /** * Allows you to set the first day of the week. 0 is sunday, 1 is monday, 6 is saturday */ firstDayOfWeek: Calendar.props.firstDayOfWeek, /** * A list of available granularities. * If present, the date picker will display a segmented control to change granularity. */ granularities: { type: Array as PropType<Array<BentoDateRangePickerGranularityConfig>>, default: null, }, /** * Indicate if a date should be disabled or not */ isDateDisabled: Calendar.props.isDateDisabled, /** * Set a maximum number of dates to be selectable by the range. */ maxRange: Calendar.props.maxRange, /** * Sets a minimum limit for selectable dates - dates before `min` will not be selectable */ min: Calendar.props.min, /** * Sets a maximum limit for selectable dates - dates after `max` will not be selectable */ max: Calendar.props.max, /** * Number of months rendered on pane */ numberOfMonths: { type: Calendar.props.numberOfMonths.type, default: Calendar.props.numberOfMonths.default, validator: (n: number) => n >= 0, }, /** * Displays the end date's month when the calendar is opened. Defaults to false i.e. the start date's month is displayed. */ showEndDateOnOpen: { type: Boolean, default: false }, /** * Enables the custom range selector. * If provided, must be an array that sets the custom range dropdown items. Items are of the structure: * `label` - label of custom range item. * `value` - a unique key of the custom range item. * `data` - an object `{ startDate: Date; endDate: Date }` to set the date picker range to upon selecting. */ quickSelectRanges: { type: Array as PropType<DateRangePickerCalendarRangeSelectorItems>, default: undefined, }, /** * Selected date */ value: { type: Object as PropType<BentoDateRangePickerValue>, default: undefined }, /** * The type of calendar to display. Defaults to showing days. */ variant: Calendar.props.variant, }); const emit = defineEmits([ DateRangePickerCalendarEvent.CUSTOM_RANGE, DateRangePickerCalendarEvent.INPUT, DateRangePickerCalendarEvent.ERROR, DateRangePickerCalendarEvent.FORM_DATE, DateRangePickerCalendarEvent.START_DATE_SELECTED, ]); const slots = useSlots(); const hasSlot = useHasSlot(slots); const { t, tc } = useI18n<{ message: MessageSchema }>({ messages }); const { isTouchDevice } = useTouchDevice(); const { dateFormat, isFormatValid, parseDate, onKeyDown, autoFormat, formatDate: formatDateUtil, } = useDateInputFormatter(); const calculatedIsRelativeToNowQuickSelectRanges = ref({}); const defaultGranularity = computed(() => (props.variant === 'month' ? 'monthly' : 'daily')); const hasNoCalendars = computed(() => props.numberOfMonths === 0); const internalRangeDate = reactive<BentoDateRangePickerValue>({ startDate: props.value?.startDate, endDate: props.value?.endDate, range: props.value?.range, ...(props.granularities ? { granularity: props.value?.granularity || defaultGranularity.value, } : {}), }); /** * Currently range selector dropdown item that is selected. * The logic will try and find in each range item: * - If an endDate does not exist, then only check if the startDate day matches and if today's day matches. * - if time input has been enabled, then check if the seconds match. * - if an endDate does exist then just check if the startDate day and the endDate day match. * * Note: All date comparisons use local time consistently. Both sides of each comparison * originate from the same timezone context, so no UTC normalization is needed. */ const selectedRangeSelectorValue = computed(() => { return ( // eslint-disable-next-line consistent-return props?.quickSelectRanges?.find(({ value, data }) => { if (!data.endDate) { // Check if this quick select range is explicitly marked as "relative to now" if (data.isRelativeToNow) { // Retrieve previously calculated start/end dates for this relative range. // We use these if the user has already clicked on this time of quick range const newlyCalculatedQuickSelectDates = calculatedIsRelativeToNowQuickSelectRanges.value?.[value]; return newlyCalculatedQuickSelectDates ? isEqual(newlyCalculatedQuickSelectDates.startDate, internalRangeDate.startDate) && isEqual(newlyCalculatedQuickSelectDates.endDate, internalRangeDate.endDate) : // If not stored calculated dates, fall back to comparing the quick select item's value // with the internal range's stored range identifier. value === internalRangeDate.range; } // This block handles quick select ranges that do not have an endDate // AND are NOT explicitly marked as 'isRelativeToNow'. // This means the endDate will be today at the end of the day. // Uses isEqual for startDate to prevent false matches (e.g. same-day selection // accidentally matching a "This week" range). When time input is enabled, // also verify the end time is at end of day so that manual time changes // correctly fall back to "Custom range". return ( isEqual(data.startDate, internalRangeDate.startDate) && isToday(internalRangeDate.endDate) && (!props.allowTimeInput || isSameSecond(internalRangeDate.endDate, endOfDay(internalRangeDate.endDate))) && (props.granularities ? data.granularity === internalRangeDate.granularity : true) ); } if (props.allowTimeInput) { const res = isSameSecond(data.startDate, internalRangeDate.startDate) && isSameSecond(data.endDate, internalRangeDate.endDate) && (props.granularities ? data.granularity === internalRangeDate.granularity : true); return res; } if (data.endDate) { return ( isSameDay(data.startDate, internalRangeDate.startDate) && isSameDay(data.endDate, internalRangeDate.endDate) && (props.granularities ? data.granularity === internalRangeDate.granularity : true) ); } })?.value ?? RANGE_SELECTOR_CUSTOM_RANGE_KEY ); }); const formInputWrapperConditionalClasses = computed(() => ({ 'b-date-range-picker-calendar__form-input-wrapper': props.allowTimeInput, })); const quickSelectRangeDefaultItems = computed(() => [ { label: t('customRange'), value: RANGE_SELECTOR_CUSTOM_RANGE_KEY }, ...(props.quickSelectRanges ? props.quickSelectRanges : []), ]); const selectedGranularity = computed( () => props.granularities?.find(({ type }) => type === internalRangeDate.granularity) || null ); const granularityItems = computed<Array<BentoSegmentedControlItem>>( () => props.granularities?.map(({ type, disabled }) => ({ label: t(type), value: type, disabled, })) ?? [] ); const { getGranularCalendarLimits } = useGranularMinMaxDate(props); const computedMinMax = computed(() => props.granularities ? getGranularCalendarLimits(selectedGranularity.value) : { min: props.min ? startOfDay(props.min) : null, max: props.max ? endOfDay(props.max) : null } ); const { adjustDateForQuarterly, adjustDateForMonthly, adjustDateForWeekly, adjustStartDateInputForWeekly, adjustEndDateInputForWeekly, adjustStartDateInputForMonthly, adjustEndDateInputForMonthly, adjustStartDateInputForQuarterly, adjustEndDateInputForQuarterly, } = useGranularityAdjustments(props.firstDayOfWeek); const { savedRanges, saveRange, resetSavedRanges } = useGranularityMemory(); /** * Format date to a string value in current format * @param dateToFormat - Date to be formatted */ const formatDate = (dateToFormat?: Date) => { if (!dateToFormat) { return ''; } return formatDateUtil(dateToFormat); }; // Form error flags const startDateError = ref(false); const endDateError = ref(false); // Form input text used in range variant const dateTextInput = reactive({ startDate: props.dateFormData.startDate, endDate: props.dateFormData.endDate, startTime: props.dateFormData.startTime, endTime: props.dateFormData.endTime, }); /** * Sets the time of a date object from a time string. * @param dateToSetTimeIn The date object to set the time in. * @param time The time string in HH:MM:SS format. * @returns A new Date object with the time set. */ const setTimeInDateObject = (dateToSetTimeIn: Date, time: string) => { let dateWithTime = new Date(dateToSetTimeIn); const [hours, minutes, seconds] = time.split(':'); dateWithTime = setHours(dateWithTime, parseInt(hours, 10)); dateWithTime = setMinutes(dateWithTime, parseInt(minutes, 10)); dateWithTime = setSeconds(dateWithTime, parseInt(seconds, 10)); return dateWithTime; }; /** * Checks if a time string is valid and within the min/max bounds if a date is provided. * @param timeText The time string to validate. * @param date The date string to check the time against. * @returns True if the time is valid, false otherwise. */ const isValidTime = (timeText: string, date?: string) => { const isTimeAllowed = () => { if (date && isValidDate(date)) { const dateToCheckWithTime = setTimeInDateObject(parseDate(date), timeText); // Check if date is between min/max bounds, if there are any if ( (props.min && dateToCheckWithTime < setMilliseconds(props.min, 0)) || (props.max && dateToCheckWithTime > setMilliseconds(props.max, 0)) ) { return false; } } return true; }; return isValidTimeString(timeText) && isTimeAllowed(); }; const isMonthVariant = computed(() => props.variant === 'month'); const formatNativeDate = (date: Date): string => { if (!date) { return ''; } return isMonthVariant.value ? dateToNativeMonthString(date) : dateToNativeDateString(date); }; const nativeStartDate = computed(() => formatNativeDate(internalRangeDate.startDate)); const nativeEndDate = computed(() => formatNativeDate(internalRangeDate.endDate)); const dateInputType = computed(() => (isTouchDevice.value ? (isMonthVariant.value ? 'month' : 'date') : 'text')); const startDateValue = computed(() => (isTouchDevice.value ? nativeStartDate.value : dateTextInput.startDate)); const endDateValue = computed(() => (isTouchDevice.value ? nativeEndDate.value : dateTextInput.endDate)); /** * Sets the time in a date object if the time is valid and the variant is not 'month'. * @param date The date object to modify. * @param time The time string to set. * @returns The modified date object or the original if time is not set. */ const setTimeInDateRef = (date: Date, time: string) => { // Do not set time if month variant if (!time || !isValidTimeString(time) || props.variant === 'month') { return date; } return setTimeInDateObject(new Date(date), time); }; const onDateInput = (selectedRange: BentoDateRangePickerValue) => { dateTextInput.startTime = dateToTimeInputString( props.min && props.allowTimeInput && isSameDay(props.min, selectedRange.startDate) ? props.min : startOfDay(selectedRange.startDate) ); dateTextInput.endTime = dateToTimeInputString( props.max && props.allowTimeInput && isSameDay(props.max, selectedRange.endDate) ? props.max : endOfDay(selectedRange.endDate) ); internalRangeDate.startDate = setTimeInDateRef(selectedRange.startDate, dateTextInput.startTime); internalRangeDate.endDate = setTimeInDateRef(selectedRange.endDate, dateTextInput.endTime); // Resetting internal range date when selecting a custom range through the calendar internalRangeDate.range = null; if (props.granularities) { // Reset saved ranges on input resetSavedRanges(); } emit(DateRangePickerCalendarEvent.INPUT, { ...internalRangeDate, range: selectedRangeSelectorValue.value, }); }; /** * Checks if a date string is in current format and if the date is enabled. * @param dateText The date string to validate. * @returns True if the date is valid and enabled, false otherwise. */ const isValidDate = (dateText: string) => { if (!dateText) { return false; } // Is date selectable const isDateEnabled = (date: Date) => { // Check if date is between or equal to min/max bounds // Set the time to the start of the day to disregard time if ( (props.min && startOfDay(date) < startOfDay(props.min)) || (props.max && startOfDay(date) > startOfDay(props.max)) ) { return false; } // Check if function is set, otherwise all dates are enabled if (props.isDateDisabled) { return !props.isDateDisabled(startOfDay(date)); } return true; }; // Date matches text regex and is not disabled return isFormatValid(dateText) && isDateEnabled(parseDate(dateText)); }; /** * When mounted, set the input error states to true if the values are not valid */ onMounted(() => { // Start date validation startDateError.value = !!(props.dateFormData.startDate && !isValidDate(props.dateFormData.startDate)); // Start time validation is delegated to bento-input-time via events // End date validation endDateError.value = !!(props.dateFormData.endDate && !isValidDate(props.dateFormData.endDate)); // End time validation is delegated to bento-input-time via events }); const onStartDateSelected = (newStartDate: Date) => { dateTextInput.startDate = formatDate(newStartDate); if (!dateTextInput.startTime) { // Set the time to the beginning of the day dateTextInput.startTime = dateToTimeInputString(newStartDate); } startDateError.value = false; emit(DateRangePickerCalendarEvent.START_DATE_SELECTED, newStartDate); }; const onEndDateSelected = (newEndDate: Date) => { dateTextInput.endDate = formatDate(newEndDate); if (!dateTextInput.endTime) { // Set the time to the end of the day dateTextInput.endTime = dateToTimeInputString(endOfDay(newEndDate)); } endDateError.value = false; }; const getDate = (keyName: keyof BentoDateRangePickerValue) => { const dateProp = (props.value as BentoDateRangePickerValue)[keyName]; if (dateProp) { return dateProp; } if (props.dateFormData[keyName] && isValidDate(props.dateFormData[keyName])) { return parseDate(props.dateFormData[keyName]); } return undefined; }; const validateStartDate = (startDateText: string) => { if (isValidDate(startDateText)) { let dateText = startDateText; startDateError.value = false; switch (selectedGranularity.value?.type) { case 'weekly': dateText = adjustStartDateInputForWeekly( startDateText, formatDate, computedMinMax.value.min, computedMinMax.value.max ); dateTextInput.startDate = dateText; break; case 'monthly': dateText = adjustStartDateInputForMonthly( startDateText, formatDate, computedMinMax.value.min, computedMinMax.value.max ); dateTextInput.startDate = dateText; break; case 'quarterly': dateText = adjustStartDateInputForQuarterly( startDateText, formatDate, computedMinMax.value.min, computedMinMax.value.max ); dateTextInput.startDate = dateText; break; case 'daily': default: break; } const startDate = startOfDay(parseDate(dateText)); internalRangeDate.startDate = setTimeInDateRef(startDate, dateTextInput.startTime); internalRangeDate.endDate = setTimeInDateRef( startOfDay(parseDate(dateTextInput.endDate)), dateTextInput.endTime ); if (isValidDate(dateTextInput.endDate)) { internalRangeDate.endDate = setTimeInDateRef( startOfDay(parseDate(dateTextInput.endDate)), dateTextInput.endTime ); } const endDate = getDate('endDate'); // Resetting internal range date when selecting a custom range through the calendar internalRangeDate.range = null; emit(DateRangePickerCalendarEvent.FORM_DATE, { startDate, endDate, }); emit(DateRangePickerCalendarEvent.INPUT, { ...internalRangeDate, range: selectedRangeSelectorValue.value, }); } else { // Indicate form error startDateError.value = true; // Emit Date picker calendar error emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); } }; const validateStartDateDebounced = debounce(validateStartDate, FORM_INPUT_DEBOUNCE_TIME); const onStartDateInput = (startDateText: string) => { // Set the date as string when input through the form // Bypassing autoFormat for native month input since it doesn't support YYYY-MM dateTextInput.startDate = isTouchDevice.value && isMonthVariant.value ? startDateText : autoFormat(startDateText); if (isTouchDevice.value) { if (isMonthVariant.value && startDateText) { const startDate = parseNativeMonthString(startDateText); internalRangeDate.startDate = setTimeInDateRef(startDate, dateTextInput.startTime); internalRangeDate.range = null; emit(DateRangePickerCalendarEvent.INPUT, { ...internalRangeDate, range: selectedRangeSelectorValue.value, }); } else { validateStartDate(dateTextInput.startDate); } } else { validateStartDateDebounced(dateTextInput.startDate); } }; const validateEndDate = (endDateText: string) => { if (isValidDate(endDateText)) { let dateText = endDateText; endDateError.value = false; switch (selectedGranularity.value?.type) { case 'weekly': dateText = adjustEndDateInputForWeekly( endDateText, formatDate, computedMinMax.value.min, computedMinMax.value.max ); dateTextInput.endDate = dateText; break; case 'monthly': dateText = adjustEndDateInputForMonthly( endDateText, formatDate, computedMinMax.value.min, computedMinMax.value.max ); dateTextInput.endDate = dateText; break; case 'quarterly': dateText = adjustEndDateInputForQuarterly( endDateText, formatDate, computedMinMax.value.min, computedMinMax.value.max ); dateTextInput.endDate = dateText; break; case 'daily': default: break; } // Format date string to Date object const endDate = startOfDay(parseDate(dateText)); internalRangeDate.endDate = setTimeInDateRef(endDate, dateTextInput.endTime); if (isValidDate(dateTextInput.startDate)) { internalRangeDate.startDate = setTimeInDateRef( startOfDay(parseDate(dateTextInput.startDate)), dateTextInput.startTime ); } const startDate = getDate('startDate'); // Resetting internal range date when selecting a custom range through the calendar internalRangeDate.range = null; emit(DateRangePickerCalendarEvent.FORM_DATE, { startDate, endDate, }); emit(DateRangePickerCalendarEvent.INPUT, { ...internalRangeDate, range: selectedRangeSelectorValue.value, }); } else { // Indicate form error endDateError.value = true; // Emit Date picker calendar error emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); } }; const validateEndDateDebounced = debounce(validateEndDate, FORM_INPUT_DEBOUNCE_TIME); const onEndDateInput = (endDateText: string) => { // Set the date as string when input through the form // Bypassing autoFormat for native month input since it doesn't support YYYY-MM dateTextInput.endDate = isTouchDevice.value && isMonthVariant.value ? endDateText : autoFormat(endDateText); if (isTouchDevice.value) { if (isMonthVariant.value && endDateText) { const endDate = parseNativeMonthString(endDateText); internalRangeDate.endDate = setTimeInDateRef(endDate, dateTextInput.endTime); internalRangeDate.range = null; emit(DateRangePickerCalendarEvent.INPUT, { ...internalRangeDate, range: selectedRangeSelectorValue.value, }); } else { validateEndDate(dateTextInput.endDate); } } else { validateEndDateDebounced(dateTextInput.endDate); } }; const onDateInputKeyDown = (event: KeyboardEvent) => { onKeyDown(event); }; const onStartTimeError = () => { emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); }; const onStartTimeValid = () => { const startTimeText = dateTextInput.startTime; if (!isValidTime(startTimeText, dateTextInput.startDate)) { emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); return; } internalRangeDate.startDate = setTimeInDateRef(internalRangeDate.startDate, startTimeText); const endDate = getDate('endDate'); emit(DateRangePickerCalendarEvent.FORM_DATE, { startDate: internalRangeDate.startDate, endDate, startTime: startTimeText, endTime: dateTextInput.endTime ?? props.dateFormData.endTime, }); emit(DateRangePickerCalendarEvent.INPUT, { ...internalRangeDate, range: selectedRangeSelectorValue.value, }); }; const onEndTimeError = () => { emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); }; const onEndTimeValid = () => { const endTimeText = dateTextInput.endTime; if (!isValidTime(endTimeText, dateTextInput.endDate)) { emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); return; } internalRangeDate.endDate = setTimeInDateRef(internalRangeDate.endDate, endTimeText); emit(DateRangePickerCalendarEvent.FORM_DATE, { startDate: internalRangeDate.startDate, endDate: internalRangeDate.endDate, startTime: dateTextInput.startTime ?? props.dateFormData.startTime, endTime: endTimeText, }); emit(DateRangePickerCalendarEvent.INPUT, { ...internalRangeDate, range: selectedRangeSelectorValue.value, }); }; const onStartTimeInput = (startTimeText: string) => { dateTextInput.startTime = startTimeText; }; const onEndTimeInput = (endTimeText: string) => { dateTextInput.endTime = endTimeText; }; const setInternalRange = ( startDate: Date, endDate: Date, granularity?: CalendarGranularityType, range?: string ) => { internalRangeDate.startDate = startDate; internalRangeDate.endDate = endDate; if (granularity) { internalRangeDate.granularity = granularity; } if (range) { internalRangeDate.range = range; } }; const onRangeSelectorInput = (newCustomRangeValue: string) => { const foundResult = props.quickSelectRanges?.find(({ value }) => value === newCustomRangeValue); if (foundResult) { resetSavedRanges(); const date = foundResult.data; const newEndDate = date.endDate || (date.isRelativeToNow ? new Date(Date.now()) : endOfDay(new Date(Date.now()))); // If the time difference between start and end dates is available, // calculating start date dynamically const newStartDate = date.isRelativeToNow && date.timeDifference ? new Date(newEndDate.getTime() - date.timeDifference) : date.startDate; // Set Form input Dates dateTextInput.endDate = formatDate(newEndDate); dateTextInput.startDate = formatDate(newStartDate); setInternalRange(newStartDate, newEndDate, null, newCustomRangeValue); if (props.granularities) { internalRangeDate.granularity = date.granularity || defaultGranularity.value; } // Set Form input time to quick select range time dateTextInput.startTime = dateToTimeInputString(newStartDate); dateTextInput.endTime = dateToTimeInputString(newEndDate); // Set calendar selection internalRangeDate.startDate = newStartDate; internalRangeDate.endDate = newEndDate; if (date.isRelativeToNow) { // Store this calculated start and end dates to be used in the selectedRangeSelectorValue logic calculatedIsRelativeToNowQuickSelectRanges.value = { ...calculatedIsRelativeToNowQuickSelectRanges.value, [newCustomRangeValue]: { startDate: newStartDate, endDate: newEndDate, }, }; } // Emit everytime the date range changes emit(DateRangePickerCalendarEvent.CUSTOM_RANGE, { ...foundResult.data, startDate: newStartDate, endDate: newEndDate, range: foundResult.value, }); } }; const adjustDate = { weekly: adjustDateForWeekly, monthly: adjustDateForMonthly, quarterly: adjustDateForQuarterly, }; const onGranularityInput = (value: CalendarGranularityType) => { const previousValue = internalRangeDate.granularity; internalRangeDate.granularity = value; // Save previously selected range if (previousValue) { saveRange(previousValue, structuredClone(toRaw(internalRangeDate))); } if (internalRangeDate.startDate && internalRangeDate.endDate) { // Adjust date range based on new granularity switch (value) { case 'quarterly': case 'monthly': case 'weekly': { if (savedRanges[value]) { setInternalRange(savedRanges[value].startDate, savedRanges[value].endDate, value); break; } const { startDate, endDate } = adjustDate[value]( internalRangeDate.startDate, internalRangeDate.endDate, computedMinMax.value.min, computedMinMax.value.max ); setInternalRange(startDate, endDate, value); break; } case 'daily': if (savedRanges.daily) { setInternalRange(savedRanges.daily.startDate, savedRanges.daily.endDate, value); } break; default: break; } dateTextInput.startDate = formatDate(internalRangeDate.startDate); dateTextInput.endDate = formatDate(internalRangeDate.endDate); emit(DateRangePickerCalendarEvent.INPUT, { ...internalRangeDate, range: selectedRangeSelectorValue.value, }); } }; const computedFormContainerClasses = computed(() => ({ 'b-date-range-picker-calendar__form-container--no-calendars': hasNoCalendars.value, })); const maxRangeMessage = computed(() => { const granularity = selectedGranularity.value; const count = granularity?.maxRange || props.maxRange; if (!count) { return null; } const key = maxRangeMessageKeyMap[granularity?.type ?? 'daily']; return tc(key, count, { count }); }); </script> <script lang="ts"> /** * Calendar for range dates. Contains the logic to handle the range forms. * * @example * import DatePickerCalendarRange from './date-range-picker-calendar.vue'; * * export default { * components: { DatePickerCalendarRange }, * template: ` * <date-range-picker-calendar * :value="{startDate: new Date(), endDate: new Date() }" * :first-day-of-week="BentoDatePickerFirstDayOfWeek.MONDAY" * :is-date-disabled="(date: Date) => boolean" * :number-of-months="2" * @input="({ startDate, endDate }) => void" * /> * ` * } */ export default { name: 'date-range-picker-calendar', i18n: { messages }, }; </script> <style lang="scss" scoped src="./date-range-picker-calendar.scss" />
1
+ <template> <div class="b-date-range-picker-calendar"> <!-- Form --> <div class="b-date-range-picker-calendar__form-container" :class="computedFormContainerClasses" data-testid="date-range-picker-calendar-form-container" > <div class="b-date-range-picker-calendar__form"> <template v-if="hasSlot('title')"> <slot name="title" /> </template> <bento-dropdown v-if="quickSelectRanges" :aria-label="t('customRange')" :items="quickSelectRangeDefaultItems" :model-value="selectedRangeSelectorValue" @update:model-value="onRangeSelectorInput" /> <bento-segmented-control v-if="granularities" :items="granularityItems" :model-value="internalRangeDate.granularity" full-width @update:model-value="onGranularityInput" > </bento-segmented-control> <bento-alert v-if="maxRangeMessage" variant="tip"> <template #description> {{ maxRangeMessage }} </template> </bento-alert> <div> <bento-typography stronger el="span" class="b-date-range-picker-calendar__label"> From </bento-typography> <date-time-input :date-model-value="startDateValue" :date-props="{ ariaLabel: t('dateFrom'), type: dateInputType, description: !isTouchDevice ? t(dateFormat) : undefined, errorMessage: startDateErrorText, }" :error-message="startSharedError" :show-time-input="allowTimeInput" :time-model-value="dateTextInput.startTime" :time-props="{ ariaLabel: t('timeFrom'), errorMessage: startSharedError, }" class="b-date-range-picker-calendar__form-input" @update:date-model-value="onStartDateInput" @date:keydown="onDateInputKeyDown" @update:time-model-value="onStartTimeInput" @time:error="onStartTimeError" @time:valid="onStartTimeValid" /> </div> <div> <bento-typography stronger el="span" class="b-date-range-picker-calendar__label"> To </bento-typography> <date-time-input :date-model-value="endDateValue" :date-props="{ ariaLabel: t('dateTo'), type: dateInputType, description: !isTouchDevice ? t(dateFormat) : undefined, errorMessage: endDateErrorText, }" :error-message="endSharedError" :show-time-input="allowTimeInput" :time-model-value="dateTextInput.endTime" :time-props="{ ariaLabel: t('timeTo'), errorMessage: endSharedError, }" class="b-date-range-picker-calendar__form-input" @update:date-model-value="onEndDateInput" @date:keydown="onDateInputKeyDown" @update:time-model-value="onEndTimeInput" @time:error="onEndTimeError" @time:valid="onEndTimeValid" /> </div> </div> <div v-if="hasSlot('actions')"> <slot name="actions" /> </div> </div> <!-- Calendar --> <div v-if="!hasNoCalendars && !isTouchDevice" class="b-date-range-picker-calendar__calendars-container" data-testid="date-range-picker-calendar-calendar-container" > <calendar :value="internalRangeDate" :first-day-of-week="firstDayOfWeek" :is-date-disabled="isDateDisabled" :max-range="maxRange" :min="computedMinMax.min" :max="computedMinMax.max" :number-of-months="numberOfMonths" :show-end-date-on-open="showEndDateOnOpen" :granularity="selectedGranularity" :variant="variant" :year-range="yearRange" is-range @input="onDateInput" @start-date-selected="onStartDateSelected" @end-date-selected="onEndDateSelected" /> </div> </div> </template> <script setup lang="ts"> import { computed, onMounted, type PropType, reactive, ref, toRaw, useSlots } from 'vue'; import { Calendar, type CalendarGranularityType } from '@/internal'; import { DateTimeInput } from '../date-time-input'; import { BentoAlert } from '@/components/alert'; import { BentoTypography } from '@/components/typography'; import BentoDropdown from '@/components/dropdown/dropdown.vue'; import { BentoSegmentedControl, type BentoSegmentedControlItem } from '@/components/segmented-control'; import { useI18n } from '@/utils/ts/i18n'; import { debounce } from '@/utils/ts/debounce'; import { useDateInputFormatter, useHasSlot, useTouchDevice } from '@/composables'; import { useGranularityAdjustments } from '@/components/internal/calendar/composables/use-granularity-adjustments'; import { useGranularMinMaxDate } from '@/components/internal/calendar/components/calendar-month/composables/granular-min-max-date'; import { useGranularityMemory } from '../../composables/use-granularity-memory'; import { DateRangePickerCalendarEvent, type DateRangePickerCalendarFormData, type DateRangePickerCalendarRangeSelectorItems, } from './date-range-picker-calendar.types'; import type { BentoDateRangePickerGranularityConfig, BentoDateRangePickerValue, } from '../../date-range-picker.types'; import { endOfDay } from 'date-fns/endOfDay'; import { startOfDay } from 'date-fns/startOfDay'; import { isSameSecond } from 'date-fns/isSameSecond'; import { isSameDay } from 'date-fns/isSameDay'; import { isEqual } from 'date-fns/isEqual'; import { isToday } from 'date-fns/isToday'; import { setMilliseconds } from 'date-fns/setMilliseconds'; import { dateToNativeDateString, dateToNativeMonthString, dateToTimeInputString, parseNativeMonthString, } from '@/utils/ts/format-date/format-date'; import { isValidTimeString } from '@/utils/ts/time-input'; import { setTimeInDateObject } from '@/utils/ts/format-date'; import messages from './messages.json'; const FORM_INPUT_DEBOUNCE_TIME = 300; const RANGE_SELECTOR_CUSTOM_RANGE_KEY = 'customRange'; type MessageSchema = (typeof messages)['en-US']; const maxRangeMessageKeyMap: Record<CalendarGranularityType, keyof MessageSchema> = { daily: 'maxRangeDays', weekly: 'maxRangeWeeks', monthly: 'maxRangeMonths', quarterly: 'maxRangeQuarters', }; const props = defineProps({ /** * Allows user to enter time values in the form */ allowTimeInput: { type: Boolean, default: false }, /** * Ranges form persistance data */ dateFormData: { type: Object as PropType<DateRangePickerCalendarFormData>, default: () => ({ startDate: undefined, endDate: undefined, startTime: undefined, endTime: undefined }), }, /** * Allows you to set the first day of the week. 0 is sunday, 1 is monday, 6 is saturday */ firstDayOfWeek: Calendar.props.firstDayOfWeek, /** * A list of available granularities. * If present, the date picker will display a segmented control to change granularity. */ granularities: { type: Array as PropType<Array<BentoDateRangePickerGranularityConfig>>, default: null, }, /** * Indicate if a date should be disabled or not */ isDateDisabled: Calendar.props.isDateDisabled, /** * Set a maximum number of dates to be selectable by the range. */ maxRange: Calendar.props.maxRange, /** * Sets a minimum limit for selectable dates - dates before `min` will not be selectable */ min: Calendar.props.min, /** * Sets a maximum limit for selectable dates - dates after `max` will not be selectable */ max: Calendar.props.max, /** * Number of months rendered on pane */ numberOfMonths: { type: Calendar.props.numberOfMonths.type, default: Calendar.props.numberOfMonths.default, validator: (n: number) => n >= 0, }, /** * Displays the end date's month when the calendar is opened. Defaults to false i.e. the start date's month is displayed. */ showEndDateOnOpen: { type: Boolean, default: false }, /** * Enables the custom range selector. * If provided, must be an array that sets the custom range dropdown items. Items are of the structure: * `label` - label of custom range item. * `value` - a unique key of the custom range item. * `data` - an object `{ startDate: Date; endDate: Date }` to set the date picker range to upon selecting. */ quickSelectRanges: { type: Array as PropType<DateRangePickerCalendarRangeSelectorItems>, default: undefined, }, /** * Selected date */ value: { type: Object as PropType<BentoDateRangePickerValue>, default: undefined }, /** * The type of calendar to display. Defaults to showing days. */ variant: Calendar.props.variant, /** * Range of years to be selectable from the year dropdown */ yearRange: Calendar.props.yearRange, }); const emit = defineEmits([ DateRangePickerCalendarEvent.CUSTOM_RANGE, DateRangePickerCalendarEvent.INPUT, DateRangePickerCalendarEvent.ERROR, DateRangePickerCalendarEvent.FORM_DATE, DateRangePickerCalendarEvent.START_DATE_SELECTED, ]); const slots = useSlots(); const hasSlot = useHasSlot(slots); const { t, tc } = useI18n<{ message: MessageSchema }>({ messages }); const { isTouchDevice } = useTouchDevice(); const { dateFormat, isFormatValid, parseDate, onKeyDown, autoFormat, formatDate: formatDateUtil, } = useDateInputFormatter(); const calculatedIsRelativeToNowQuickSelectRanges = ref({}); const defaultGranularity = computed(() => (props.variant === 'month' ? 'monthly' : 'daily')); const hasNoCalendars = computed(() => props.numberOfMonths === 0); const internalRangeDate = reactive<BentoDateRangePickerValue>({ startDate: props.value?.startDate, endDate: props.value?.endDate, range: props.value?.range, ...(props.granularities ? { granularity: props.value?.granularity || defaultGranularity.value, } : {}), }); /** * Currently range selector dropdown item that is selected. * The logic will try and find in each range item: * - If an endDate does not exist, then only check if the startDate day matches and if today's day matches. * - if time input has been enabled, then check if the seconds match. * - if an endDate does exist then just check if the startDate day and the endDate day match. * * Note: All date comparisons use local time consistently. Both sides of each comparison * originate from the same timezone context, so no UTC normalization is needed. */ const selectedRangeSelectorValue = computed(() => { return ( // eslint-disable-next-line consistent-return props?.quickSelectRanges?.find(({ value, data }) => { if (!data.endDate) { // Check if this quick select range is explicitly marked as "relative to now" if (data.isRelativeToNow) { // Retrieve previously calculated start/end dates for this relative range. // We use these if the user has already clicked on this time of quick range const newlyCalculatedQuickSelectDates = calculatedIsRelativeToNowQuickSelectRanges.value?.[value]; return newlyCalculatedQuickSelectDates ? isEqual(newlyCalculatedQuickSelectDates.startDate, internalRangeDate.startDate) && isEqual(newlyCalculatedQuickSelectDates.endDate, internalRangeDate.endDate) : // If not stored calculated dates, fall back to comparing the quick select item's value // with the internal range's stored range identifier. value === internalRangeDate.range; } // This block handles quick select ranges that do not have an endDate // AND are NOT explicitly marked as 'isRelativeToNow'. // This means the endDate will be today at the end of the day. // Uses isEqual for startDate to prevent false matches (e.g. same-day selection // accidentally matching a "This week" range). When time input is enabled, // also verify the end time is at end of day so that manual time changes // correctly fall back to "Custom range". return ( isEqual(data.startDate, internalRangeDate.startDate) && isToday(internalRangeDate.endDate) && (!props.allowTimeInput || isSameSecond(internalRangeDate.endDate, endOfDay(internalRangeDate.endDate))) && (props.granularities ? data.granularity === internalRangeDate.granularity : true) ); } if (props.allowTimeInput) { const res = isSameSecond(data.startDate, internalRangeDate.startDate) && isSameSecond(data.endDate, internalRangeDate.endDate) && (props.granularities ? data.granularity === internalRangeDate.granularity : true); return res; } if (data.endDate) { return ( isSameDay(data.startDate, internalRangeDate.startDate) && isSameDay(data.endDate, internalRangeDate.endDate) && (props.granularities ? data.granularity === internalRangeDate.granularity : true) ); } })?.value ?? RANGE_SELECTOR_CUSTOM_RANGE_KEY ); }); const quickSelectRangeDefaultItems = computed(() => [ { label: t('customRange'), value: RANGE_SELECTOR_CUSTOM_RANGE_KEY }, ...(props.quickSelectRanges ? props.quickSelectRanges : []), ]); const selectedGranularity = computed( () => props.granularities?.find(({ type }) => type === internalRangeDate.granularity) || null ); const granularityItems = computed<Array<BentoSegmentedControlItem>>( () => props.granularities?.map(({ type, disabled }) => ({ label: t(type), value: type, disabled, })) ?? [] ); const { getGranularCalendarLimits } = useGranularMinMaxDate(props); const computedMinMax = computed(() => props.granularities ? getGranularCalendarLimits(selectedGranularity.value) : { min: props.min ? startOfDay(props.min) : null, max: props.max ? endOfDay(props.max) : null } ); const { adjustDateForQuarterly, adjustDateForMonthly, adjustDateForWeekly, adjustStartDateInputForWeekly, adjustEndDateInputForWeekly, adjustStartDateInputForMonthly, adjustEndDateInputForMonthly, adjustStartDateInputForQuarterly, adjustEndDateInputForQuarterly, } = useGranularityAdjustments(props.firstDayOfWeek); const { savedRanges, saveRange, resetSavedRanges } = useGranularityMemory(); /** * Format date to a string value in current format * @param dateToFormat - Date to be formatted */ const formatDate = (dateToFormat?: Date) => { if (!dateToFormat) { return ''; } return formatDateUtil(dateToFormat); }; // Form error keys const startDateError = ref<keyof MessageSchema | null>(null); const endDateError = ref<keyof MessageSchema | null>(null); // Translated error messages for the template const startDateErrorText = computed(() => (startDateError.value ? t(startDateError.value) : null)); const endDateErrorText = computed(() => (endDateError.value ? t(endDateError.value) : null)); // Shared errors exclude format errors since those only concern the date input const startSharedError = computed(() => startDateError.value === 'invalidDateFormat' ? null : startDateErrorText.value ); const endSharedError = computed(() => (endDateError.value === 'invalidDateFormat' ? null : endDateErrorText.value)); /** * Returns a translated error message for a given date string, or null if valid. * @param dateText The date string to validate. * @param counterpartDate Optional date to compare against for range order validation. * @param role Whether the date being validated is the 'start' or 'end' of the range. */ const getDateErrorMessage = ( dateText: string, counterpartDate?: Date, role?: 'start' | 'end', resolvedDateTime?: Date ): keyof MessageSchema | null => { if (!dateText || !isFormatValid(dateText)) { return 'invalidDateFormat'; } const date = parseDate(dateText); const dayDate = startOfDay(date); if ((props.min && dayDate < startOfDay(props.min)) || (props.max && dayDate > startOfDay(props.max))) { return 'selectedDateIsNotAvailable'; } if (props.isDateDisabled && props.isDateDisabled(dayDate)) { return 'selectedDateIsNotAvailable'; } if (counterpartDate) { // When time input is enabled and we have a resolved datetime, compare full datetimes; // otherwise fall back to day-level comparison. const current = resolvedDateTime ? setMilliseconds(resolvedDateTime, 0).getTime() : dayDate.getTime(); const counterpart = resolvedDateTime ? setMilliseconds(counterpartDate, 0).getTime() : startOfDay(counterpartDate).getTime(); const isInvalid = role === 'start' ? current >= counterpart : current <= counterpart; if (isInvalid) { return 'startDateMustPrecedeEndDate'; } } return null; }; // Form input text used in range variant const dateTextInput = reactive({ startDate: props.dateFormData.startDate, endDate: props.dateFormData.endDate, startTime: props.dateFormData.startTime, endTime: props.dateFormData.endTime, }); /** * Checks if a time string is valid and within the min/max bounds if a date is provided. * @param timeText The time string to validate. * @param date The date string to check the time against. * @returns True if the time is valid, false otherwise. */ const isValidTime = (timeText: string, date?: string) => { const isTimeAllowed = () => { if (date && isValidDate(date)) { const dateToCheckWithTime = setTimeInDateObject(parseDate(date), timeText); // Check if date is between min/max bounds, if there are any if ( (props.min && dateToCheckWithTime < setMilliseconds(props.min, 0)) || (props.max && dateToCheckWithTime > setMilliseconds(props.max, 0)) ) { return false; } } return true; }; return isValidTimeString(timeText) && isTimeAllowed(); }; const isMonthVariant = computed(() => props.variant === 'month'); const formatNativeDate = (date: Date): string => { if (!date) { return ''; } return isMonthVariant.value ? dateToNativeMonthString(date) : dateToNativeDateString(date); }; const nativeStartDate = computed(() => formatNativeDate(internalRangeDate.startDate)); const nativeEndDate = computed(() => formatNativeDate(internalRangeDate.endDate)); const dateInputType = computed(() => (isTouchDevice.value ? (isMonthVariant.value ? 'month' : 'date') : 'text')); const startDateValue = computed(() => (isTouchDevice.value ? nativeStartDate.value : dateTextInput.startDate)); const endDateValue = computed(() => (isTouchDevice.value ? nativeEndDate.value : dateTextInput.endDate)); /** * Sets the time in a date object if the time is valid and the variant is not 'month'. * @param date The date object to modify. * @param time The time string to set. * @returns The modified date object or the original if time is not set. */ const setTimeInDateRef = (date: Date, time: string) => { // Do not set time if month variant if (!time || !isValidTimeString(time) || props.variant === 'month') { return date; } return setTimeInDateObject(new Date(date), time); }; const onDateInput = (selectedRange: BentoDateRangePickerValue) => { dateTextInput.startTime = dateToTimeInputString( props.min && props.allowTimeInput && isSameDay(props.min, selectedRange.startDate) ? props.min : startOfDay(selectedRange.startDate) ); dateTextInput.endTime = dateToTimeInputString( props.max && props.allowTimeInput && isSameDay(props.max, selectedRange.endDate) ? props.max : endOfDay(selectedRange.endDate) ); internalRangeDate.startDate = setTimeInDateRef(selectedRange.startDate, dateTextInput.startTime); internalRangeDate.endDate = setTimeInDateRef(selectedRange.endDate, dateTextInput.endTime); // Resetting internal range date when selecting a custom range through the calendar internalRangeDate.range = null; if (props.granularities) { // Reset saved ranges on input resetSavedRanges(); } emit(DateRangePickerCalendarEvent.INPUT, { ...internalRangeDate, range: selectedRangeSelectorValue.value, }); }; /** * Checks if a date string is in current format and if the date is enabled. * @param dateText The date string to validate. * @returns True if the date is valid and enabled, false otherwise. */ const isValidDate = (dateText: string): boolean => !!dateText && !getDateErrorMessage(dateText); /** * When mounted, set the input error states to true if the values are not valid */ onMounted(() => { // Start date validation startDateError.value = props.dateFormData.startDate ? getDateErrorMessage(props.dateFormData.startDate) : null; // Start time validation is delegated to bento-input-time via events // End date validation endDateError.value = props.dateFormData.endDate ? getDateErrorMessage(props.dateFormData.endDate) : null; // End time validation is delegated to bento-input-time via events }); const onStartDateSelected = (newStartDate: Date) => { dateTextInput.startDate = formatDate(newStartDate); if (!dateTextInput.startTime) { // Set the time to the beginning of the day dateTextInput.startTime = dateToTimeInputString(newStartDate); } startDateError.value = null; if (endDateError.value === 'startDateMustPrecedeEndDate') { endDateError.value = null; } emit(DateRangePickerCalendarEvent.START_DATE_SELECTED, newStartDate); }; const onEndDateSelected = (newEndDate: Date) => { dateTextInput.endDate = formatDate(newEndDate); if (!dateTextInput.endTime) { // Set the time to the end of the day dateTextInput.endTime = dateToTimeInputString(endOfDay(newEndDate)); } endDateError.value = null; if (startDateError.value === 'startDateMustPrecedeEndDate') { startDateError.value = null; } }; const getDate = (keyName: 'startDate' | 'endDate') => { const dateProp = props.value[keyName]; if (dateProp) { return dateProp; } if (props.dateFormData[keyName] && isValidDate(props.dateFormData[keyName])) { return parseDate(props.dateFormData[keyName]); } return undefined; }; const validateStartDate = (startDateText: string) => { if (isValidDate(startDateText)) { let dateText = startDateText; startDateError.value = null; switch (selectedGranularity.value?.type) { case 'weekly': dateText = adjustStartDateInputForWeekly( startDateText, formatDate, computedMinMax.value.min, computedMinMax.value.max ); dateTextInput.startDate = dateText; break; case 'monthly': dateText = adjustStartDateInputForMonthly( startDateText, formatDate, computedMinMax.value.min, computedMinMax.value.max ); dateTextInput.startDate = dateText; break; case 'quarterly': dateText = adjustStartDateInputForQuarterly( startDateText, formatDate, computedMinMax.value.min, computedMinMax.value.max ); dateTextInput.startDate = dateText; break; case 'daily': default: break; } const startDate = startOfDay(parseDate(dateText)); internalRangeDate.startDate = setTimeInDateRef(startDate, dateTextInput.startTime); if (isValidDate(dateTextInput.endDate)) { internalRangeDate.endDate = setTimeInDateRef( startOfDay(parseDate(dateTextInput.endDate)), dateTextInput.endTime ); } const endDate = getDate('endDate'); const rangeError = getDateErrorMessage( dateText, props.allowTimeInput ? internalRangeDate.endDate : endDate, 'start', props.allowTimeInput ? internalRangeDate.startDate : undefined ); if (rangeError) { startDateError.value = rangeError; emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); return; } if (endDateError.value === 'startDateMustPrecedeEndDate') { endDateError.value = null; } // Resetting internal range date when selecting a custom range through the calendar internalRangeDate.range = null; emit(DateRangePickerCalendarEvent.FORM_DATE, { startDate, endDate, }); emit(DateRangePickerCalendarEvent.INPUT, { ...internalRangeDate, range: selectedRangeSelectorValue.value, }); } else { // Indicate form error startDateError.value = getDateErrorMessage(startDateText); // Emit Date picker calendar error emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); } }; const validateStartDateDebounced = debounce(validateStartDate, FORM_INPUT_DEBOUNCE_TIME); const onStartDateInput = (startDateText: string) => { // Set the date as string when input through the form // Bypassing autoFormat for native month input since it doesn't support YYYY-MM dateTextInput.startDate = isTouchDevice.value && isMonthVariant.value ? startDateText : autoFormat(startDateText); if (isTouchDevice.value) { if (isMonthVariant.value && startDateText) { const startDate = parseNativeMonthString(startDateText); internalRangeDate.startDate = setTimeInDateRef(startDate, dateTextInput.startTime); internalRangeDate.range = null; emit(DateRangePickerCalendarEvent.INPUT, { ...internalRangeDate, range: selectedRangeSelectorValue.value, }); } else if (dateTextInput.startDate) { validateStartDate(dateTextInput.startDate); } else { // on touch devices, skip the any intermediate values e.g. pressing zero first startDateError.value = null; } } else { validateStartDateDebounced(dateTextInput.startDate); } }; const validateEndDate = (endDateText: string) => { if (isValidDate(endDateText)) { let dateText = endDateText; endDateError.value = null; switch (selectedGranularity.value?.type) { case 'weekly': dateText = adjustEndDateInputForWeekly( endDateText, formatDate, computedMinMax.value.min, computedMinMax.value.max ); dateTextInput.endDate = dateText; break; case 'monthly': dateText = adjustEndDateInputForMonthly( endDateText, formatDate, computedMinMax.value.min, computedMinMax.value.max ); dateTextInput.endDate = dateText; break; case 'quarterly': dateText = adjustEndDateInputForQuarterly( endDateText, formatDate, computedMinMax.value.min, computedMinMax.value.max ); dateTextInput.endDate = dateText; break; case 'daily': default: break; } // Format date string to Date object const endDate = startOfDay(parseDate(dateText)); internalRangeDate.endDate = setTimeInDateRef(endDate, dateTextInput.endTime); if (isValidDate(dateTextInput.startDate)) { internalRangeDate.startDate = setTimeInDateRef( startOfDay(parseDate(dateTextInput.startDate)), dateTextInput.startTime ); } const startDate = getDate('startDate'); const rangeError = getDateErrorMessage( dateText, props.allowTimeInput ? internalRangeDate.startDate : startDate, 'end', props.allowTimeInput ? internalRangeDate.endDate : undefined ); if (rangeError) { endDateError.value = rangeError; emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); return; } if (startDateError.value === 'startDateMustPrecedeEndDate') { startDateError.value = null; } // Resetting internal range date when selecting a custom range through the calendar internalRangeDate.range = null; emit(DateRangePickerCalendarEvent.FORM_DATE, { startDate, endDate, }); emit(DateRangePickerCalendarEvent.INPUT, { ...internalRangeDate, range: selectedRangeSelectorValue.value, }); } else { // Indicate form error endDateError.value = getDateErrorMessage(endDateText); // Emit Date picker calendar error emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); } }; const validateEndDateDebounced = debounce(validateEndDate, FORM_INPUT_DEBOUNCE_TIME); const onEndDateInput = (endDateText: string) => { // Set the date as string when input through the form // Bypassing autoFormat for native month input since it doesn't support YYYY-MM dateTextInput.endDate = isTouchDevice.value && isMonthVariant.value ? endDateText : autoFormat(endDateText); if (isTouchDevice.value) { if (isMonthVariant.value && endDateText) { const endDate = parseNativeMonthString(endDateText); internalRangeDate.endDate = setTimeInDateRef(endDate, dateTextInput.endTime); internalRangeDate.range = null; emit(DateRangePickerCalendarEvent.INPUT, { ...internalRangeDate, range: selectedRangeSelectorValue.value, }); } else if (dateTextInput.endDate) { validateEndDate(dateTextInput.endDate); } else { // on touch devices, skip the any intermediate values e.g. pressing zero first endDateError.value = null; } } else { validateEndDateDebounced(dateTextInput.endDate); } }; const onDateInputKeyDown = (event: KeyboardEvent) => { onKeyDown(event); }; const onStartTimeError = () => { emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); }; const onStartTimeValid = () => { const startTimeText = dateTextInput.startTime; if (!isValidTime(startTimeText, dateTextInput.startDate)) { emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); return; } internalRangeDate.startDate = setTimeInDateRef(internalRangeDate.startDate, startTimeText); // Validate range order with updated time if (dateTextInput.startDate && internalRangeDate.endDate) { const rangeError = getDateErrorMessage( dateTextInput.startDate, internalRangeDate.endDate, 'start', internalRangeDate.startDate ); if (rangeError) { startDateError.value = rangeError; emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); return; } } startDateError.value = null; if (endDateError.value === 'startDateMustPrecedeEndDate') { endDateError.value = null; } const endDate = getDate('endDate'); emit(DateRangePickerCalendarEvent.FORM_DATE, { startDate: internalRangeDate.startDate, endDate, startTime: startTimeText, endTime: dateTextInput.endTime ?? props.dateFormData.endTime, }); emit(DateRangePickerCalendarEvent.INPUT, { ...internalRangeDate, range: selectedRangeSelectorValue.value, }); }; const onEndTimeError = () => { emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); }; const onEndTimeValid = () => { const endTimeText = dateTextInput.endTime; if (!isValidTime(endTimeText, dateTextInput.endDate)) { emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); return; } internalRangeDate.endDate = setTimeInDateRef(internalRangeDate.endDate, endTimeText); // Validate range order with updated time if (dateTextInput.endDate && internalRangeDate.startDate) { const rangeError = getDateErrorMessage( dateTextInput.endDate, internalRangeDate.startDate, 'end', internalRangeDate.endDate ); if (rangeError) { endDateError.value = rangeError; emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); return; } } endDateError.value = null; if (startDateError.value === 'startDateMustPrecedeEndDate') { startDateError.value = null; } emit(DateRangePickerCalendarEvent.FORM_DATE, { startDate: internalRangeDate.startDate, endDate: internalRangeDate.endDate, startTime: dateTextInput.startTime ?? props.dateFormData.startTime, endTime: endTimeText, }); emit(DateRangePickerCalendarEvent.INPUT, { ...internalRangeDate, range: selectedRangeSelectorValue.value, }); }; const onStartTimeInput = (startTimeText: string) => { dateTextInput.startTime = startTimeText; }; const onEndTimeInput = (endTimeText: string) => { dateTextInput.endTime = endTimeText; }; const setInternalRange = ( startDate: Date, endDate: Date, granularity?: CalendarGranularityType, range?: string ) => { internalRangeDate.startDate = startDate; internalRangeDate.endDate = endDate; if (granularity) { internalRangeDate.granularity = granularity; } if (range) { internalRangeDate.range = range; } }; const onRangeSelectorInput = (newCustomRangeValue: string) => { const foundResult = props.quickSelectRanges?.find(({ value }) => value === newCustomRangeValue); if (foundResult) { resetSavedRanges(); const date = foundResult.data; const newEndDate = date.endDate || (date.isRelativeToNow ? new Date(Date.now()) : endOfDay(new Date(Date.now()))); // If the time difference between start and end dates is available, // calculating start date dynamically const newStartDate = date.isRelativeToNow && date.timeDifference ? new Date(newEndDate.getTime() - date.timeDifference) : date.startDate; // Set Form input Dates dateTextInput.endDate = formatDate(newEndDate); dateTextInput.startDate = formatDate(newStartDate); setInternalRange(newStartDate, newEndDate, null, newCustomRangeValue); if (props.granularities) { internalRangeDate.granularity = date.granularity || defaultGranularity.value; } // Set Form input time to quick select range time dateTextInput.startTime = dateToTimeInputString(newStartDate); dateTextInput.endTime = dateToTimeInputString(newEndDate); // Set calendar selection internalRangeDate.startDate = newStartDate; internalRangeDate.endDate = newEndDate; if (date.isRelativeToNow) { // Store this calculated start and end dates to be used in the selectedRangeSelectorValue logic calculatedIsRelativeToNowQuickSelectRanges.value = { ...calculatedIsRelativeToNowQuickSelectRanges.value, [newCustomRangeValue]: { startDate: newStartDate, endDate: newEndDate, }, }; } // Emit everytime the date range changes emit(DateRangePickerCalendarEvent.CUSTOM_RANGE, { ...foundResult.data, startDate: newStartDate, endDate: newEndDate, range: foundResult.value, }); } }; const adjustDate = { weekly: adjustDateForWeekly, monthly: adjustDateForMonthly, quarterly: adjustDateForQuarterly, }; const onGranularityInput = (value: CalendarGranularityType) => { const previousValue = internalRangeDate.granularity; internalRangeDate.granularity = value; // Save previously selected range if (previousValue) { saveRange(previousValue, structuredClone(toRaw(internalRangeDate))); } if (internalRangeDate.startDate && internalRangeDate.endDate) { // Adjust date range based on new granularity switch (value) { case 'quarterly': case 'monthly': case 'weekly': { if (savedRanges[value]) { setInternalRange(savedRanges[value].startDate, savedRanges[value].endDate, value); break; } const { startDate, endDate } = adjustDate[value]( internalRangeDate.startDate, internalRangeDate.endDate, computedMinMax.value.min, computedMinMax.value.max ); setInternalRange(startDate, endDate, value); break; } case 'daily': if (savedRanges.daily) { setInternalRange(savedRanges.daily.startDate, savedRanges.daily.endDate, value); } break; default: break; } dateTextInput.startDate = formatDate(internalRangeDate.startDate); dateTextInput.endDate = formatDate(internalRangeDate.endDate); emit(DateRangePickerCalendarEvent.INPUT, { ...internalRangeDate, range: selectedRangeSelectorValue.value, }); } }; const computedFormContainerClasses = computed(() => ({ 'b-date-range-picker-calendar__form-container--no-calendars': hasNoCalendars.value, })); const maxRangeMessage = computed(() => { const granularity = selectedGranularity.value; const count = granularity?.maxRange || props.maxRange; if (!count) { return null; } const key = maxRangeMessageKeyMap[granularity?.type ?? 'daily']; return tc(key, count, { count }); }); </script> <script lang="ts"> /** * Calendar for range dates. Contains the logic to handle the range forms. * * @example * import DatePickerCalendarRange from './date-range-picker-calendar.vue'; * * export default { * components: { DatePickerCalendarRange }, * template: ` * <date-range-picker-calendar * :value="{startDate: new Date(), endDate: new Date() }" * :first-day-of-week="BentoDatePickerFirstDayOfWeek.MONDAY" * :is-date-disabled="(date: Date) => boolean" * :number-of-months="2" * @input="({ startDate, endDate }) => void" * /> * ` * } */ export default { name: 'date-range-picker-calendar', i18n: { messages }, }; </script> <style lang="scss" scoped src="./date-range-picker-calendar.scss" />
@@ -0,0 +1 @@
1
+ import type { BentoInputFieldProps, BentoInputFieldValue } from '@/components/input-field'; import type { BentoInputTimeProps } from '@/components/input-time'; export interface BentoDateTimeInputEmits { /** * Emitted when the date input value changes. */ (e: 'update:date-model-value', value: BentoInputFieldValue): void; /** * Emitted on keydown within the date input. */ (e: 'date:keydown', event: KeyboardEvent): void; /** * Emitted when the time input value changes. */ (e: 'update:time-model-value', value: BentoInputFieldValue): void; /** * Emitted when the time input has a validation error. */ (e: 'time:error'): void; /** * Emitted when the time input value passes validation. */ (e: 'time:valid'): void; } export interface BentoDateTimeInputProps { /** * Model value for the date input field. * @default '' */ dateModelValue?: string; /** * Props passed directly to the inner `bento-input-field`. * Accepts all `BentoInputFieldProps` except `modelValue` and `value`. * @default {} */ dateProps?: Omit<BentoInputFieldProps, 'modelValue' | 'value'>; /** * Shared error message displayed below the input group. * When set, individual error messages are visually hidden and a single error spans the group. * @default null */ errorMessage?: string | null; /** * Whether to display the time input alongside the date input. * @default false */ showTimeInput?: boolean; /** * Model value for the time input field. * @default '' */ timeModelValue?: string; /** * Props passed directly to the inner `bento-input-time`. * Accepts all `BentoInputTimeProps` except `modelValue`. * @default {} */ timeProps?: Omit<BentoInputTimeProps, 'modelValue'>; }
@@ -0,0 +1 @@
1
+ <template> <bento-form-layout-group :layout="showTimeInput ? '50-50' : '100'" :error-message="errorMessage"> <bento-input-field v-bind="resolvedDateProps" :model-value="dateModelValue" @update:model-value="$emit('update:date-model-value', $event)" @keydown="$emit('date:keydown', $event)" /> <bento-input-time v-if="showTimeInput" v-bind="resolvedTimeProps" :model-value="timeModelValue" @update:model-value="$emit('update:time-model-value', $event)" @input:error="$emit('time:error')" @input:valid="$emit('time:valid')" /> </bento-form-layout-group> </template> <script setup lang="ts"> import { computed } from 'vue'; import { BentoFormLayoutGroup } from '@/components/form-layout'; import { BentoInputField } from '@/components/input-field'; import { BentoInputTime } from '@/components/input-time'; import type { BentoDateTimeInputEmits, BentoDateTimeInputProps } from './date-time-input.types'; const props = withDefaults(defineProps<BentoDateTimeInputProps>(), { dateModelValue: '', dateProps: () => ({}), errorMessage: null, showTimeInput: false, timeModelValue: '', timeProps: () => ({}), }); defineEmits<BentoDateTimeInputEmits>(); const resolvedDateProps = computed(() => ({ errorMessage: props.errorMessage, ...props.dateProps, })); const resolvedTimeProps = computed(() => ({ errorMessage: props.errorMessage, ...props.timeProps, })); </script> <script lang="ts"> /** * Internal component that combines a date input field and an optional time input * within a form layout group, providing a shared error message pattern. * * @example * import { DateTimeInput } from '@/internal'; * * export default { * components: { DateTimeInput }, * template: ` * <date-time-input * date-aria-label="Date from" * :date-model-value="dateValue" * :show-time-input="true" * time-aria-label="Time from" * :time-model-value="timeValue" * :error-message="errorMsg" * @update:date-model-value="onDateChange" * @update:time-model-value="onTimeChange" * /> * ` * } */ export default { name: 'date-time-input', }; </script>
@@ -41,6 +41,12 @@ Shows calendar(s) by month/year instead of day/month. Useful for full month rang
41
41
 
42
42
  ## Modifiers
43
43
 
44
+ ### Year range
45
+
46
+ Allows defining a specific range of selectable years in the calendar header dropdown. The `yearRange` property accepts
47
+ an object with `minYear` and `maxYear`. If not provided, a default range of years relative to the current year is
48
+ displayed.
49
+
44
50
  ### Quick select ranges
45
51
 
46
52
  <Canvas withSource="closed">
@@ -1 +1 @@
1
- import { type Meta, type StoryObj } from '@storybook/vue'; import { action } from '@storybook/addon-actions'; import { ref } from 'vue'; import { isVue2 } from 'vue-demi'; import { format } from 'date-fns/format'; import { addMonths } from 'date-fns/addMonths'; import { endOfMonth } from 'date-fns/endOfMonth'; import { endOfWeek } from 'date-fns/endOfWeek'; import { lastDayOfQuarter } from 'date-fns/lastDayOfQuarter'; import { startOfQuarter } from 'date-fns/startOfQuarter'; import { startOfWeek } from 'date-fns/startOfWeek'; import { subDays } from 'date-fns/subDays'; import { subMonths } from 'date-fns/subMonths'; import { subQuarters } from 'date-fns/subQuarters'; import { subWeeks } from 'date-fns/subWeeks'; import { BentoDateRangePickerFirstDayOfWeek } from './date-range-picker.types'; import { storybookDocsParameter } from '@/utils/ts/storybook'; import BentoDateRangePicker from './date-range-picker.vue'; import BentoDateRangePickerDefaultExample from './__tests__/date-range-picker-default-example.vue?raw'; import { startOfDay, subHours, subMinutes } from 'date-fns'; const meta: Meta = { title: 'Date picker/Range dates', component: BentoDateRangePicker, argTypes: { // Properties maxRange: { name: 'maxRange', defaultValue: undefined, description: 'Set a maximum number of dates to be selectable by the range.', table: { type: { summary: 'number' } }, control: { type: 'number', }, }, min: { control: { type: 'date' } }, max: { control: { type: 'date' } }, // Funcs isDateDisabled: { name: 'isDateDisabled', defaultValue: '', description: 'Disables the dates that meet the criteria defined in the function.', table: { type: { summary: '(currentDate: Date) => boolean' } }, control: { disable: true }, }, // Enum props firstDayOfWeek: { options: Object.keys(BentoDateRangePickerFirstDayOfWeek) .map(key => BentoDateRangePickerFirstDayOfWeek[key]) .filter(value => typeof value !== 'string') as Array<number>, control: { type: 'select', }, }, // Events input: { name: 'input', defaultValue: '', description: 'Triggered when a date is selected', table: { type: { summary: '(selectedDate: Date) => void' } }, control: { disable: true }, }, value: { control: { type: 'text' }, table: { category: 'Deprecated' }, description: 'Deprecated for usage with properties since version 2.0. Use `v-model` or `model-value` property instead', }, }, }; export default meta; type Story = StoryObj<typeof BentoDateRangePicker>; // To be used by as the current time across all date filters const CURRENT_DATE_AND_TIME = new Date(); export const Default: Story = { render: (_args, { argTypes }) => ({ components: { BentoDateRangePicker }, props: Object.keys(argTypes), template: ` <div style="max-width: 380px"> <bento-date-range-picker v-bind="args" :model-value="selectedDate" @update:model-value="onDateChanged" /> </div> `, setup(props) { const selectedDate = ref((isVue2 ? props : _args).modelValue); const onDateChanged = (newSelectedDate: Date) => { selectedDate.value = newSelectedDate; action('input')(selectedDate); action('update:model-value')(selectedDate); }; return { // Values selectedDate, args: isVue2 ? props : _args, // Events onDateChanged, }; }, }), args: { allowTimeInput: true, description: 'Supporting text', label: 'Label', firstDayOfWeek: 1, numberOfMonths: 2, maxRange: 0, optional: true, required: true, isDateDisabled: (date: Date) => date.valueOf() > Date.now(), modelValue: { startDate: subDays(new Date(), 3), endDate: new Date(), range: 'lastFourDays' }, hasActions: false, }, parameters: storybookDocsParameter(BentoDateRangePickerDefaultExample), }; export const RangeSelector = { ...Default, args: { ...Default.args, modelValue: { startDate: subDays(startOfDay(CURRENT_DATE_AND_TIME), 1), endDate: CURRENT_DATE_AND_TIME, range: 'lastTwoDays', }, quickSelectRanges: [ { label: 'Last 15 min', value: 'lastFifteenMin', data: { startDate: subMinutes(CURRENT_DATE_AND_TIME, 15), isRelativeToNow: true, }, }, { label: 'Last 24 hours', value: 'lastTwentyFourHours', data: { startDate: subHours(CURRENT_DATE_AND_TIME, 24), isRelativeToNow: true, }, }, { label: 'Last 2 days', value: 'lastTwoDays', data: { startDate: subDays(startOfDay(CURRENT_DATE_AND_TIME), 1) }, }, { label: 'Last 3 days', value: 'lastThreeDays', data: { startDate: subDays(startOfDay(CURRENT_DATE_AND_TIME), 2) }, }, { label: 'Last 4 days', value: 'lastFourDays', data: { startDate: subDays(startOfDay(CURRENT_DATE_AND_TIME), 3) }, }, { label: 'Last 24 hours', value: 'lastTwentyFourHours', data: { startDate: subHours(CURRENT_DATE_AND_TIME, 24), isRelativeToNow: true, }, }, { label: 'Last 2 days', value: 'lastTwoDays', data: { startDate: subDays(startOfDay(CURRENT_DATE_AND_TIME), 1) }, }, { label: 'Last 3 days', value: 'lastThreeDays', data: { startDate: subDays(startOfDay(CURRENT_DATE_AND_TIME), 2) }, }, { label: 'Last 4 days', value: 'lastFourDays', data: { startDate: subDays(startOfDay(CURRENT_DATE_AND_TIME), 3) }, }, ], }, parameters: storybookDocsParameter(` <bento-date-range-picker v-model="selectedDate" label="Label" description="Supporting text" :first-day-of-week="BentoDateRangePickerFirstDayOfWeek.SUNDAY" isDateDisabled="(currentDate: Date) => boolean" maxRange required optional disabled hasActions :quick-select-ranges="[ { label: 'Last 2 days', value: 'lastTwoDays', data: { startDate: subDays(new Date(), 1) }, }, { label: 'Last 3 days', value: 'lastThreeDays', data: { startDate: subDays(new Date(), 2), endDate: new Date() }, }, { label: 'Last 4 days', value: 'lastFourDays', data: { startDate: subDays(new Date(), 3), endDate: new Date() }, }, ]" /> `), }; const GRANULARITIES_VALUE = { startDate: startOfWeek(subWeeks(new Date(), 1), { weekStartsOn: Default.args.firstDayOfWeek }), endDate: endOfWeek(subWeeks(new Date(), 1), { weekStartsOn: Default.args.firstDayOfWeek }), granularity: 'weekly', }; const granularitiesCode = ` <template> <bento-date-range-picker v-model="selectedDate" label="Label" description="Supporting text" :allow-time-input="false" :first-day-of-week="BentoDateRangePickerFirstDayOfWeek.MONDAY" :granularities="granularities" :max="new Date('${format(subDays(endOfMonth(new Date()), 2), 'yyyy-MM-dd')}')" hasActions /> </template> <script setup lang="ts"> import { BentoDateRangePicker, BentoDateRangePickerFirstDayOfWeek, type BentoDateRangePickerGranularityConfig, type BentoDateRangePickerValue, } from "@adyen/bento-vue2" const selectedDate = ref<BentoDateRangePickerValue>({ startDate: new Date('${format(GRANULARITIES_VALUE.startDate, 'yyyy-MM-dd')}'), endDate: new Date('${format(GRANULARITIES_VALUE.endDate, 'yyyy-MM-dd')}'), granularity: 'monthly', }); const granularities: Array<BentoDateRangePickerGranularityConfig> = [ { type: 'daily' }, { type: 'weekly' }, { type: 'monthly' }, }] </script> `; export const Granularities = { ...Default, args: { allowTimeInput: false, description: 'Supporting text', label: 'Label', numberOfMonths: 2, min: subMonths(endOfMonth(new Date()), 6), max: subDays(endOfMonth(new Date()), 2), granularities: [ { type: 'daily', }, { type: 'weekly', }, { type: 'monthly', }, ], modelValue: GRANULARITIES_VALUE, quickSelectRanges: [ { label: 'Last 2 days', value: 'lastTwoDays', data: { startDate: subDays(new Date(), 1), endDate: new Date(), granularity: 'daily' }, }, { label: 'Last quarter', value: 'lastQuarter', data: { startDate: startOfQuarter(subQuarters(new Date(), 1)), endDate: lastDayOfQuarter(subQuarters(new Date(), 1)), granularity: 'monthly', }, }, ], hasActions: true, }, parameters: storybookDocsParameter(granularitiesCode), }; export const MonthVariant = { ...Default, args: { ...Default.args, allowTimeInput: false, variant: 'month', modelValue: { startDate: subMonths(new Date(), 2), endDate: new Date(), granularity: 'monthly' }, isDateDisabled: null, max: addMonths(new Date(), 6), granularities: [{ type: 'monthly' }, { type: 'quarterly' }], quickSelectRanges: [ { label: 'Last 3 months', value: 'lastTwoMonths', data: { startDate: subMonths(new Date(), 3), endDate: subMonths(new Date(), 1), granularity: 'monthly', }, }, { label: 'Last quarter', value: 'lastQuarter', data: { startDate: startOfQuarter(subQuarters(new Date(), 1)), endDate: lastDayOfQuarter(subQuarters(new Date(), 1)), granularity: 'quarterly', }, }, ], }, parameters: storybookDocsParameter(` <bento-date-range-picker v-model="selectedDate" label="Label" description="Supporting text" isDateDisabled="(currentDate: Date) => boolean" maxRange required optional disabled hasActions variant="month" /> `), };
1
+ import { type Meta, type StoryObj } from '@storybook/vue'; import { action } from '@storybook/addon-actions'; import { ref } from 'vue'; import { isVue2 } from 'vue-demi'; import { format } from 'date-fns/format'; import { addMonths } from 'date-fns/addMonths'; import { endOfMonth } from 'date-fns/endOfMonth'; import { endOfWeek } from 'date-fns/endOfWeek'; import { lastDayOfQuarter } from 'date-fns/lastDayOfQuarter'; import { startOfQuarter } from 'date-fns/startOfQuarter'; import { startOfWeek } from 'date-fns/startOfWeek'; import { subDays } from 'date-fns/subDays'; import { subMonths } from 'date-fns/subMonths'; import { subQuarters } from 'date-fns/subQuarters'; import { subWeeks } from 'date-fns/subWeeks'; import { BentoDateRangePickerFirstDayOfWeek } from './date-range-picker.types'; import { storybookDocsParameter } from '@/utils/ts/storybook'; import BentoDateRangePicker from './date-range-picker.vue'; import BentoDateRangePickerDefaultExample from './__tests__/date-range-picker-default-example.vue?raw'; import { startOfDay, subHours, subMinutes } from 'date-fns'; const meta: Meta = { title: 'Date picker/Range dates', component: BentoDateRangePicker, argTypes: { // Properties maxRange: { name: 'maxRange', defaultValue: undefined, description: 'Set a maximum number of dates to be selectable by the range.', table: { type: { summary: 'number' } }, control: { type: 'number', }, }, min: { control: { type: 'date' } }, max: { control: { type: 'date' } }, // Funcs isDateDisabled: { name: 'isDateDisabled', defaultValue: '', description: 'Disables the dates that meet the criteria defined in the function.', table: { type: { summary: '(currentDate: Date) => boolean' } }, control: { disable: true }, }, // Enum props firstDayOfWeek: { options: Object.keys(BentoDateRangePickerFirstDayOfWeek) .map(key => BentoDateRangePickerFirstDayOfWeek[key]) .filter(value => typeof value !== 'string') as Array<number>, control: { type: 'select', }, }, // Events input: { name: 'input', defaultValue: '', description: 'Triggered when a date is selected', table: { type: { summary: '(selectedDate: Date) => void' } }, control: { disable: true }, }, value: { control: { type: 'text' }, table: { category: 'Deprecated' }, description: 'Deprecated for usage with properties since version 2.0. Use `v-model` or `model-value` property instead', }, }, }; export default meta; type Story = StoryObj<typeof BentoDateRangePicker>; // To be used by as the current time across all date filters const CURRENT_DATE_AND_TIME = new Date(); export const Default: Story = { render: (_args, { argTypes }) => ({ components: { BentoDateRangePicker }, props: Object.keys(argTypes), template: ` <div style="max-width: 380px"> <bento-date-range-picker v-bind="args" :model-value="selectedDate" @update:model-value="onDateChanged" /> </div> `, setup(props) { const selectedDate = ref((isVue2 ? props : _args).modelValue); const onDateChanged = (newSelectedDate: Date) => { selectedDate.value = newSelectedDate; action('input')(selectedDate); action('update:model-value')(selectedDate); }; return { // Values selectedDate, args: isVue2 ? props : _args, // Events onDateChanged, }; }, }), args: { yearRange: { minYear: undefined, maxYear: undefined }, allowTimeInput: true, description: 'Supporting text', label: 'Label', firstDayOfWeek: 1, numberOfMonths: 2, maxRange: 0, optional: true, required: true, isDateDisabled: (date: Date) => date.valueOf() > Date.now(), modelValue: { startDate: subDays(new Date(), 3), endDate: new Date(), range: 'lastFourDays' }, hasActions: true, }, parameters: storybookDocsParameter(BentoDateRangePickerDefaultExample), }; export const RangeSelector = { ...Default, args: { yearRange: { minYear: undefined, maxYear: undefined }, ...Default.args, modelValue: { startDate: subDays(startOfDay(CURRENT_DATE_AND_TIME), 1), endDate: CURRENT_DATE_AND_TIME, range: 'lastTwoDays', }, quickSelectRanges: [ { label: 'Last 15 min', value: 'lastFifteenMin', data: { startDate: subMinutes(CURRENT_DATE_AND_TIME, 15), isRelativeToNow: true, }, }, { label: 'Last 24 hours', value: 'lastTwentyFourHours', data: { startDate: subHours(CURRENT_DATE_AND_TIME, 24), isRelativeToNow: true, }, }, { label: 'Last 2 days', value: 'lastTwoDays', data: { startDate: subDays(startOfDay(CURRENT_DATE_AND_TIME), 1) }, }, { label: 'Last 3 days', value: 'lastThreeDays', data: { startDate: subDays(startOfDay(CURRENT_DATE_AND_TIME), 2) }, }, { label: 'Last 4 days', value: 'lastFourDays', data: { startDate: subDays(startOfDay(CURRENT_DATE_AND_TIME), 3) }, }, { label: 'Last 24 hours', value: 'lastTwentyFourHours', data: { startDate: subHours(CURRENT_DATE_AND_TIME, 24), isRelativeToNow: true, }, }, { label: 'Last 2 days', value: 'lastTwoDays', data: { startDate: subDays(startOfDay(CURRENT_DATE_AND_TIME), 1) }, }, { label: 'Last 3 days', value: 'lastThreeDays', data: { startDate: subDays(startOfDay(CURRENT_DATE_AND_TIME), 2) }, }, { label: 'Last 4 days', value: 'lastFourDays', data: { startDate: subDays(startOfDay(CURRENT_DATE_AND_TIME), 3) }, }, ], }, parameters: storybookDocsParameter(` <bento-date-range-picker v-model="selectedDate" label="Label" description="Supporting text" :first-day-of-week="BentoDateRangePickerFirstDayOfWeek.SUNDAY" isDateDisabled="(currentDate: Date) => boolean" maxRange required optional disabled hasActions :quick-select-ranges="[ { label: 'Last 2 days', value: 'lastTwoDays', data: { startDate: subDays(new Date(), 1) }, }, { label: 'Last 3 days', value: 'lastThreeDays', data: { startDate: subDays(new Date(), 2), endDate: new Date() }, }, { label: 'Last 4 days', value: 'lastFourDays', data: { startDate: subDays(new Date(), 3), endDate: new Date() }, }, ]" /> `), }; const GRANULARITIES_VALUE = { startDate: startOfWeek(subWeeks(new Date(), 1), { weekStartsOn: Default.args.firstDayOfWeek }), endDate: endOfWeek(subWeeks(new Date(), 1), { weekStartsOn: Default.args.firstDayOfWeek }), granularity: 'weekly', }; const granularitiesCode = ` <template> <bento-date-range-picker v-model="selectedDate" label="Label" description="Supporting text" :allow-time-input="false" :first-day-of-week="BentoDateRangePickerFirstDayOfWeek.MONDAY" :granularities="granularities" :max="new Date('${format(subDays(endOfMonth(new Date()), 2), 'yyyy-MM-dd')}')" hasActions /> </template> <script setup lang="ts"> import { BentoDateRangePicker, BentoDateRangePickerFirstDayOfWeek, type BentoDateRangePickerGranularityConfig, type BentoDateRangePickerValue, } from "@adyen/bento-vue2" const selectedDate = ref<BentoDateRangePickerValue>({ startDate: new Date('${format(GRANULARITIES_VALUE.startDate, 'yyyy-MM-dd')}'), endDate: new Date('${format(GRANULARITIES_VALUE.endDate, 'yyyy-MM-dd')}'), granularity: 'monthly', }); const granularities: Array<BentoDateRangePickerGranularityConfig> = [ { type: 'daily' }, { type: 'weekly' }, { type: 'monthly' }, }] </script> `; export const Granularities = { ...Default, args: { yearRange: { minYear: undefined, maxYear: undefined }, allowTimeInput: false, description: 'Supporting text', label: 'Label', numberOfMonths: 2, min: subMonths(endOfMonth(new Date()), 6), max: subDays(endOfMonth(new Date()), 2), granularities: [ { type: 'daily', }, { type: 'weekly', }, { type: 'monthly', }, ], modelValue: GRANULARITIES_VALUE, quickSelectRanges: [ { label: 'Last 2 days', value: 'lastTwoDays', data: { startDate: subDays(new Date(), 1), endDate: new Date(), granularity: 'daily' }, }, { label: 'Last quarter', value: 'lastQuarter', data: { startDate: startOfQuarter(subQuarters(new Date(), 1)), endDate: lastDayOfQuarter(subQuarters(new Date(), 1)), granularity: 'monthly', }, }, ], hasActions: true, }, parameters: storybookDocsParameter(granularitiesCode), }; export const MonthVariant = { ...Default, args: { yearRange: { minYear: undefined, maxYear: undefined }, ...Default.args, allowTimeInput: false, variant: 'month', modelValue: { startDate: subMonths(new Date(), 2), endDate: new Date(), granularity: 'monthly' }, isDateDisabled: null, max: addMonths(new Date(), 6), granularities: [{ type: 'monthly' }, { type: 'quarterly' }], quickSelectRanges: [ { label: 'Last 3 months', value: 'lastTwoMonths', data: { startDate: subMonths(new Date(), 3), endDate: subMonths(new Date(), 1), granularity: 'monthly', }, }, { label: 'Last quarter', value: 'lastQuarter', data: { startDate: startOfQuarter(subQuarters(new Date(), 1)), endDate: lastDayOfQuarter(subQuarters(new Date(), 1)), granularity: 'quarterly', }, }, ], }, parameters: storybookDocsParameter(` <bento-date-range-picker v-model="selectedDate" label="Label" description="Supporting text" isDateDisabled="(currentDate: Date) => boolean" maxRange required optional disabled hasActions variant="month" /> `), };
@@ -1 +1 @@
1
- import type { FormField, FormFieldEmits } from '../../types/form-field'; import { CalendarFirstDayOfWeek as BentoDateRangePickerFirstDayOfWeek, type CalendarGranularityConfig as BentoDateRangePickerGranularityConfig, type CalendarIsDateDisabled as BentoDateRangePickerIsDateDisabled, type CalendarRangeDateValue as BentoDateRangePickerValue, CalendarVariant as BentoDateRangePickerVariant, } from '@/internal/calendar/calendar.types'; import type { DateRangePickerCalendarRangeSelectorItems as BentoDateRangePickerQuickSelectRanges } from './components/date-range-picker-calendar/date-range-picker-calendar.types'; export { BentoDateRangePickerFirstDayOfWeek, type BentoDateRangePickerIsDateDisabled, type BentoDateRangePickerValue, type BentoDateRangePickerGranularityConfig, type BentoDateRangePickerQuickSelectRanges, BentoDateRangePickerVariant, }; /** * @deprecated Since v2.0.0. Use string literal instead. */ export enum BentoDateRangePickerEvent { INPUT = 'input', } export type BentoDateRangePickerEmits = FormFieldEmits<any>; export interface BentoDateRangePickerProps extends FormField { /** * Allows user to enter time values in the form * @default false */ allowTimeInput?: boolean; /** * Allows user to set the first day of the week. 0 is sunday, 1 is monday, 6 is saturday * @default undefined */ firstDayOfWeek?: BentoDateRangePickerFirstDayOfWeek | number; /** * A list of available granularities. * If present, the date picker will display a segmented control to change granularity. */ granularities?: Array<BentoDateRangePickerGranularityConfig>; /** * Indicate if a date should be disabled or not */ isDateDisabled?: BentoDateRangePickerIsDateDisabled; /** * Upon opening date range picker, end date's month will be pre-selected. * @default false */ showEndDateOnOpen?: boolean; /** * Enables the range selector. * If provided, must be an array that sets the custom range dropdown items. */ quickSelectRanges?: BentoDateRangePickerQuickSelectRanges; /** * Set a maximum number of dates to be selectable by the range. */ maxRange?: number; /** * Sets a minimum limit for selectable dates - dates before `min` will not be selectable */ min?: Date; /** * Sets a maximum limit for selectable dates - dates after `max` will not be selectable */ max?: Date; /** * Number of months rendered on pane */ numberOfMonths?: number; /** * Selected date */ modelValue?: BentoDateRangePickerValue | null; /** * Selected date * @deprecated Since v2.0.0. Use `v-model` or `model-value` instead. */ value?: BentoDateRangePickerValue | null; /** * The type of calendar to display. Defaults to showing days. */ variant?: `${BentoDateRangePickerVariant}`; /** * Enables the date picker selection to persist until it is explicitly applied. * Displays a set of buttons: Apply and Cancel. * * @deprecated Since v2.0.0. Temporary feature. Please set this to true as it will default to true on removal. * @default false */ hasActions?: boolean; }
1
+ import type { FormField, FormFieldEmits } from '../../types/form-field'; import { CalendarFirstDayOfWeek as BentoDateRangePickerFirstDayOfWeek, type CalendarGranularityConfig as BentoDateRangePickerGranularityConfig, type CalendarIsDateDisabled as BentoDateRangePickerIsDateDisabled, type CalendarRangeDateValue as BentoDateRangePickerValue, CalendarVariant as BentoDateRangePickerVariant, type CalendarYearRange, } from '@/internal/calendar/calendar.types'; import type { DateRangePickerCalendarRangeSelectorItems as BentoDateRangePickerQuickSelectRanges } from './components/date-range-picker-calendar/date-range-picker-calendar.types'; export { BentoDateRangePickerFirstDayOfWeek, type BentoDateRangePickerIsDateDisabled, type BentoDateRangePickerValue, type BentoDateRangePickerGranularityConfig, type BentoDateRangePickerQuickSelectRanges, BentoDateRangePickerVariant, }; /** * @deprecated Since v2.0.0. Use string literal instead. */ export enum BentoDateRangePickerEvent { INPUT = 'input', } export type BentoDateRangePickerEmits = FormFieldEmits<any>; export type BentoDateRangePickerYearRange = CalendarYearRange; export interface BentoDateRangePickerProps extends FormField { /** * Allows user to enter time values in the form * @default false */ allowTimeInput?: boolean; /** * Allows user to set the first day of the week. 0 is sunday, 1 is monday, 6 is saturday * @default undefined */ firstDayOfWeek?: BentoDateRangePickerFirstDayOfWeek | number; /** * A list of available granularities. * If present, the date picker will display a segmented control to change granularity. */ granularities?: Array<BentoDateRangePickerGranularityConfig>; /** * Indicate if a date should be disabled or not */ isDateDisabled?: BentoDateRangePickerIsDateDisabled; /** * Upon opening date range picker, end date's month will be pre-selected. * @default false */ showEndDateOnOpen?: boolean; /** * Enables the range selector. * If provided, must be an array that sets the custom range dropdown items. */ quickSelectRanges?: BentoDateRangePickerQuickSelectRanges; /** * Set a maximum number of dates to be selectable by the range. */ maxRange?: number; /** * Sets a minimum limit for selectable dates - dates before `min` will not be selectable */ min?: Date; /** * Sets a maximum limit for selectable dates - dates after `max` will not be selectable */ max?: Date; /** * Number of months rendered on pane */ numberOfMonths?: number; /** * Selected date */ modelValue?: BentoDateRangePickerValue | null; /** * Selected date * @deprecated Since v2.0.0. Use `v-model` or `model-value` instead. */ value?: BentoDateRangePickerValue | null; /** * The type of calendar to display. Defaults to showing days. */ variant?: `${BentoDateRangePickerVariant}`; /** * Range of years to be selectable from the year dropdown */ yearRange?: BentoDateRangePickerYearRange; /** * Enables the date picker selection to persist until it is explicitly applied. * Displays a set of buttons: Apply and Cancel. * * @deprecated Since v2.0.0. Temporary feature. Please set this to true as it will default to true on removal. * @default false */ hasActions?: boolean; }
@@ -1 +1 @@
1
- <!-- TODO: Remove aria props as these are part of JSX elements /vue/types/jsx.d.ts --> <!-- eslint-disable vue/attribute-hyphenation --> <template> <div v-bento-click-outside-directive="closeDateRangePicker" class="b-date-range-picker"> <!-- Label --> <field-label v-if="label" :id="labelId" :label="label" :tooltip-text="tooltipText" :optional="optional" :required="required" /> <!-- Input --> <div @keydown.enter="onEnterPressedOverInput"> <dropdown-input-default ref="inputContainerRef" always-combobox-is-input :ariaControls="datePickerContainerId" :ariaExpanded="isDateRangePickerOpen" :aria-labelledby="dropdownInputAriaLabelledBy" :aria-describedby="ariaDescribedBy" :aria-required="required" :disabled="disabled" :is-invalid="!!errorMessage || shouldDisplayError" :open="isDateRangePickerOpen" :display-value="displayedDateValue" :readonly="isReadOnly" @open="openDateRangePicker" @clear="clearDateRangePickerValue" @close="closeDateRangePicker" /> </div> <!-- Error Messages --> <error-message v-if="shouldDisplayError" :id="dateErrorId" :error-message="t('provideDateInAFormat')" class="b-date-range-picker__error-message" /> <error-message v-if="!!errorMessage" :id="errorId" :error-message="errorMessage" class="b-date-range-picker__error-message" /> <!-- Description --> <bento-typography v-if="description" :id="descriptionId" class="b-date-range-picker__description" :class="descriptionConditionalClasses" el="span" > {{ description }} </bento-typography> <!-- Calendar container --> <bento-popover v-if="inputContainerRef" :id="datePickerContainerId" class="b-date-range-picker__container" :open="isDateRangePickerOpen" :target-element="inputContainerRef" :aria-label="popoverAriaLabel" position="bottom-start" without-space fit-content trap-all overflow-visible @keydown.esc.native="closeDateRangePicker" > <date-range-picker-calendar :allow-time-input="withTimeInput" :quick-select-ranges="adjustedQuickSelectRanges" :value="internalModelValue" :first-day-of-week="resolvedFirstDayOfWeek" :granularities="granularities" :is-date-disabled="isDateDisabled" :max-range="maxRange" :min="min" :max="max" :number-of-months="computedNumberOfMonths" :show-end-date-on-open="showEndDateOnOpen" :date-form-data="displayedTextInputValue" :variant="variant" @custom-range="onRangeSelectorInput" @input="onDateRangePickerInput" @error="onDateRangePickerError" @form-date="onDateRangePickerFormDateInputUpdate" > <template v-if="hasActions" #actions> <bento-divider /> <div class="b-date-range-picker__footer"> <bento-button-actions :actions="rangePickerButtonActions" :layout="BentoButtonActionsLayout.SPACE_BETWEEN" /> </div> </template> </date-range-picker-calendar> </bento-popover> </div> </template> <script setup lang="ts"> import { computed, type HTMLAttributes, onMounted, ref, toRaw, toRef, useAttrs, watch } from 'vue'; import { useBreakpoints } from '@vueuse/core'; // Components import { BentoButtonActions, BentoButtonActionsLayout } from '../button'; import { BentoDivider } from '@/components/divider'; import { BentoPopover } from '@/components/popover'; import { BentoTypography } from '../typography'; import { DateRangePickerCalendar } from './components/date-range-picker-calendar'; import { DropdownInputDefault } from '@/components/dropdown/components'; import { ErrorMessage, FieldLabel } from '@/internal'; // Utils import { generateUid } from '@/core/utils/ts'; import { useI18n } from '@/utils/ts/i18n'; import { dateToTimeInputString } from '@/utils/ts/format-date'; import { useDateInputFormatter } from '@/composables/use-date-input-formatter/use-date-input-formatter'; // Composables import { useDateRangePickerCalendarText } from './composables/use-date-range-picker-calendar-text'; import { useFirstDayOfWeek, useFormLayoutFieldLoading } from '@/composables'; // Directives import { BentoClickOutsideDirective as vBentoClickOutsideDirective } from '@/directives'; // Types import { type BentoDateRangePickerEmits, type BentoDateRangePickerProps, type BentoDateRangePickerValue, } from './date-range-picker.types'; import { type DateRangePickerCalendarFormData, type DateRangePickerCalendarRangeSelectorItem, } from './components/date-range-picker-calendar/date-range-picker-calendar.types'; import { deprecate } from '@/utils/ts/deprecate'; import messages from './messages.json'; import { useFormFieldEmits } from '@/utils/ts/form-field/form-field'; type MessageSchema = (typeof messages)['en-US']; const emit = defineEmits<BentoDateRangePickerEmits>(); const props = withDefaults(defineProps<BentoDateRangePickerProps>(), { description: undefined, disabled: false, errorMessage: null, firstDayOfWeek: undefined, hasActions: false, isDateDisabled: undefined, label: undefined, max: null, maxRange: null, min: null, modelValue: null, numberOfMonths: undefined, optional: false, placeholder: null, quickSelectRanges: undefined, readonly: false, required: false, showEndDateOnOpen: false, tooltipText: null, value: null, variant: undefined, // Default will be set by @/internal/calendar }); const attrs: HTMLAttributes = useAttrs(); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const { dateToInputDateString } = useDateInputFormatter(); const datePickerContainerId = generateUid('bento-date-range-picker-container'); const labelId = generateUid('bento-date-range-picker-label'); const descriptionId = generateUid('bento-date-range-picker-description'); const errorId = generateUid('bento-date-range-picker-error'); const dateErrorId = generateUid('bento-date-range-picker-date-error'); const popoverAriaLabel = computed(() => props.label || t('dateRangePicker', { name: datePickerContainerId })); const { resolvedFirstDayOfWeek } = useFirstDayOfWeek(toRef(props, 'firstDayOfWeek')); const breakpoints = useBreakpoints({ none: 0, one: 710, // width of date range picker with one panes two: 1030, // width of date range picker with two panes three: 1290, // width of date range picker with three panes }); const computedNumberOfMonths = computed(() => { const activeBreakpoint = breakpoints.active().value; const breakpointToCalendarPanesMap = { none: 0, one: 1, two: 2, }; if (activeBreakpoint in breakpointToCalendarPanesMap) { const maxPanes = breakpointToCalendarPanesMap[activeBreakpoint as keyof typeof breakpointToCalendarPanesMap]; // If numberOfMonths is not provided, use the max panes for the breakpoint. // Otherwise, use the smaller of the two values. return props.numberOfMonths === undefined ? maxPanes : Math.min(props.numberOfMonths, maxPanes); } // Default case for 'three' and larger breakpoints, respect user-provided numberOfMonths. return props.numberOfMonths; }); const adjustedQuickSelectRanges = computed<Array<DateRangePickerCalendarRangeSelectorItem>>(() => { return ( props?.quickSelectRanges && props.quickSelectRanges.map((range: DateRangePickerCalendarRangeSelectorItem) => { const timeDiff = !range.data.endDate ? new Date(Date.now()).getTime() - range.data.startDate.getTime() : undefined; return { ...range, data: { ...range.data, // Adding timeDifference to calculate an up-to-date endDate on range selection timeDifference: timeDiff, }, }; }) ); }); const dropdownInputAriaLabelledBy = computed(() => (props.label ? labelId : (attrs?.['aria-labelledby'] ?? null))); const isDateIncorrect = ref(false); const inputContainerRef = ref(null); const isDateRangePickerOpen = ref(false); const { isReadOnly } = useFormLayoutFieldLoading(toRef(props, 'readonly')); const { emitValue } = useFormFieldEmits(emit); watch( () => props.disabled, () => { // Close date picker popover if it's opened when it's disabled if (props.disabled && isDateRangePickerOpen.value) { isDateRangePickerOpen.value = false; } } ); const internalModelValue = computed(() => props.modelValue || props.value); const shouldDisplayError = computed(() => { if (props.hasActions) { return !isApplyDisabled.value && isDateIncorrect.value; } return isDateIncorrect.value; }); const descriptionConditionalClasses = computed(() => ({ 'b-date-range-picker__description--error': !!props.errorMessage || shouldDisplayError.value, })); const isMonthVariant = computed(() => props.variant === 'month'); const withTimeInput = computed(() => props.allowTimeInput && props.variant !== 'month'); const { dateDisplayValue, textInputDisplayValue, formatTextInputDisplayValue } = useDateRangePickerCalendarText({ datePickerValue: internalModelValue, isDateIncorrect, quickSelectRanges: adjustedQuickSelectRanges, isMonthVariant, withTimeInput, placeholder: toRef(props, 'placeholder'), }); const ariaDescribedBy = computed( () => `${props.description ? descriptionId : ''} ${props.errorMessage ? errorId : ''} ${ isDateIncorrect.value ? dateErrorId : '' }`.trim() || null ); const closeDateRangePicker = () => { isDateRangePickerOpen.value = false; }; const openDateRangePicker = () => { isDateRangePickerOpen.value = true; formatTextInputDisplayValue(); }; /* * Used when `hasActions` is set to true */ const localDateDisplayValue = ref(dateDisplayValue.value); const localTextInputDisplayValue = ref<DateRangePickerCalendarFormData>( // textInputDisplayValue is a reactive object structuredClone(toRaw(textInputDisplayValue)) ); const selectedDate = ref<BentoDateRangePickerValue>(props.modelValue ?? props.value); const isApplyDisabled = ref(true); const displayedTextInputValue = computed(() => props.hasActions ? localTextInputDisplayValue.value : textInputDisplayValue ); const displayedDateValue = computed(() => props.hasActions ? localDateDisplayValue.value : dateDisplayValue.value ); onMounted(() => { if (!props.hasActions) { deprecate( 'BentoDateRangePicker "hasActions" property', `Set the BentoDateRangePicker "hasActions" property true which will display the "Apply" and "Cancel" buttons. This will be the default behavior in the v2 and "hasActions" will be removed.`, '2.0.0' ); } if (props.value) { deprecate( 'BentoDateRangePicker "value" property', 'The use of "value" prop in "BentoDateRangePicker" is no longer supported. Use the "v-model" or "model-value" property instead.', '2.0.0' ); } if ( props.granularities && props.variant !== 'month' && props.granularities.find(({ type }) => type === 'quarterly') ) { throw new Error('Quarterly granularity is only supported by BentoDateRangePicker with variant: "month"'); } }); // On update for the value we need to update all the locally stored data watch([() => props.value, () => props.modelValue], () => { localDateDisplayValue.value = dateDisplayValue.value; localTextInputDisplayValue.value = structuredClone(toRaw(textInputDisplayValue)); }); const rangePickerButtonActions = computed(() => [ { title: t('apply'), event: () => { emitValue(selectedDate.value); closeDateRangePicker(); isApplyDisabled.value = true; }, disabled: isApplyDisabled.value, }, { title: t('cancel'), event: closeDateRangePicker, }, ]); watch(selectedDate, date => { // skip emit until Apply button is clicked if (props.hasActions) { isApplyDisabled.value = isDateIncorrect.value; return; } emitValue(date); }); const onDateRangePickerInput = (selectedRange: BentoDateRangePickerValue) => { // Reset the errors when the date is correct isDateIncorrect.value = false; textInputDisplayValue.startDate = dateToInputDateString(selectedRange.startDate); textInputDisplayValue.endDate = dateToInputDateString(selectedRange.endDate); textInputDisplayValue.startTime = dateToTimeInputString(selectedRange.startDate); textInputDisplayValue.endTime = dateToTimeInputString(selectedRange.endDate); selectedDate.value = selectedRange; }; const clearDateRangePickerValue = async () => { if (isDateIncorrect.value) { isDateIncorrect.value = false; } selectedDate.value = null; }; const onEnterPressedOverInput = () => { // open the datepicker if (!isDateRangePickerOpen.value) { isDateRangePickerOpen.value = true; return; } // Close the date picker if the date is correct and it is open if (!isDateIncorrect.value) { isDateRangePickerOpen.value = false; } }; const onDateRangePickerFormDateInputUpdate = (dateFormDate: DateRangePickerCalendarFormData) => { isDateIncorrect.value = false; if (dateFormDate.startTime) { textInputDisplayValue.startTime = dateFormDate.startTime; } if (dateFormDate.endTime) { textInputDisplayValue.endTime = dateFormDate.endTime; } }; const onDateRangePickerError = dateTextInput => { isDateIncorrect.value = true; textInputDisplayValue.startDate = dateTextInput.startDate; textInputDisplayValue.endDate = dateTextInput.endDate; // Remove current date range selection selectedDate.value = { startDate: null, endDate: null }; }; const onRangeSelectorInput = (quickSelectRanges?: BentoDateRangePickerValue) => { if (quickSelectRanges) { textInputDisplayValue.startDate = dateToInputDateString(quickSelectRanges.startDate); textInputDisplayValue.endDate = dateToInputDateString(quickSelectRanges.endDate); textInputDisplayValue.startTime = dateToTimeInputString(quickSelectRanges.startDate); textInputDisplayValue.endTime = dateToTimeInputString(quickSelectRanges.endDate); selectedDate.value = quickSelectRanges; } }; </script> <script lang="ts"> /** * Date range picker selector. * * @example * import { BentoDateRangePicker } from '@adyen/bento-vue2'; * * export default { * components: { BentoDateRangePicker }, * template: ` * <bento-date-range-picker * label="Label" * description="Supporting text" * required * optional * disabled * @input="onDateChanged" * v-model="selectedDate" * /> * `, * setup() { * const selectedDate = ref(new Date()); // reactive({ startDate: new Date(), endDate: new Date() }) * return { * selectedDate, * } * } * } */ export default { i18n: { messages }, name: 'bento-date-range-picker', model: { prop: 'modelValue' }, }; </script> <style lang="scss" scoped src="./date-range-picker.scss" />
1
+ <!-- TODO: Remove aria props as these are part of JSX elements /vue/types/jsx.d.ts --> <!-- eslint-disable vue/attribute-hyphenation --> <template> <div v-bento-click-outside-directive="closeDateRangePicker" class="b-date-range-picker"> <!-- Label --> <field-label v-if="label" :id="labelId" :label="label" :tooltip-text="tooltipText" :optional="optional" :required="required" /> <!-- Input --> <div @keydown.enter="onEnterPressedOverInput"> <dropdown-input-default ref="inputContainerRef" always-combobox-is-input :ariaControls="datePickerContainerId" :ariaExpanded="isDateRangePickerOpen" :aria-labelledby="dropdownInputAriaLabelledBy" :aria-describedby="ariaDescribedBy" :aria-required="required" :disabled="disabled" :is-invalid="!!errorMessage || shouldDisplayError" :open="isDateRangePickerOpen" :display-value="displayedDateValue" :readonly="isReadOnly" @open="openDateRangePicker" @clear="clearDateRangePickerValue" @close="closeDateRangePicker" /> </div> <!-- Error Messages --> <error-message v-if="shouldDisplayError" :id="dateErrorId" :error-message="t('invalidDateFormat')" class="b-date-range-picker__error-message" /> <error-message v-if="!!errorMessage" :id="errorId" :error-message="errorMessage" class="b-date-range-picker__error-message" /> <!-- Description --> <bento-typography v-if="description" :id="descriptionId" class="b-date-range-picker__description" :class="descriptionConditionalClasses" el="span" > {{ description }} </bento-typography> <!-- Calendar container --> <bento-popover v-if="inputContainerRef" :id="datePickerContainerId" class="b-date-range-picker__container" :open="isDateRangePickerOpen" :target-element="inputContainerRef" :aria-label="popoverAriaLabel" position="bottom-start" without-space fit-content trap-all overflow-visible @keydown.esc.native="closeDateRangePicker" > <date-range-picker-calendar :allow-time-input="withTimeInput" :quick-select-ranges="adjustedQuickSelectRanges" :value="internalModelValue" :first-day-of-week="resolvedFirstDayOfWeek" :granularities="granularities" :is-date-disabled="isDateDisabled" :max-range="maxRange" :min="min" :max="max" :number-of-months="computedNumberOfMonths" :show-end-date-on-open="showEndDateOnOpen" :date-form-data="displayedTextInputValue" :variant="variant" :year-range="yearRange" @custom-range="onRangeSelectorInput" @input="onDateRangePickerInput" @error="onDateRangePickerError" @form-date="onDateRangePickerFormDateInputUpdate" > <template v-if="hasActions" #actions> <bento-divider /> <div class="b-date-range-picker__footer"> <bento-button-actions :actions="rangePickerButtonActions" :layout="BentoButtonActionsLayout.SPACE_BETWEEN" /> </div> </template> </date-range-picker-calendar> </bento-popover> </div> </template> <script setup lang="ts"> import { computed, type HTMLAttributes, onMounted, ref, toRaw, toRef, useAttrs, watch } from 'vue'; import { useBreakpoints } from '@vueuse/core'; // Components import { BentoButtonActions, BentoButtonActionsLayout } from '../button'; import { BentoDivider } from '@/components/divider'; import { BentoPopover } from '@/components/popover'; import { BentoTypography } from '../typography'; import { DateRangePickerCalendar } from './components/date-range-picker-calendar'; import { DropdownInputDefault } from '@/components/dropdown/components'; import { ErrorMessage, FieldLabel } from '@/internal'; // Utils import { generateUid } from '@/core/utils/ts'; import { useI18n } from '@/utils/ts/i18n'; import { dateToTimeInputString } from '@/utils/ts/format-date'; import { useDateInputFormatter } from '@/composables/use-date-input-formatter/use-date-input-formatter'; // Composables import { useDateRangePickerCalendarText } from './composables/use-date-range-picker-calendar-text'; import { useFirstDayOfWeek, useFormLayoutFieldLoading } from '@/composables'; // Directives import { BentoClickOutsideDirective as vBentoClickOutsideDirective } from '@/directives'; // Types import { type BentoDateRangePickerEmits, type BentoDateRangePickerProps, type BentoDateRangePickerValue, } from './date-range-picker.types'; import { type DateRangePickerCalendarFormData, type DateRangePickerCalendarRangeSelectorItem, } from './components/date-range-picker-calendar/date-range-picker-calendar.types'; import { deprecate } from '@/utils/ts/deprecate'; import messages from './messages.json'; import { useFormFieldEmits } from '@/utils/ts/form-field/form-field'; type MessageSchema = (typeof messages)['en-US']; const emit = defineEmits<BentoDateRangePickerEmits>(); const props = withDefaults(defineProps<BentoDateRangePickerProps>(), { description: undefined, disabled: false, errorMessage: null, firstDayOfWeek: undefined, hasActions: false, isDateDisabled: undefined, label: undefined, max: null, maxRange: null, min: null, modelValue: null, numberOfMonths: undefined, optional: false, placeholder: null, quickSelectRanges: undefined, readonly: false, required: false, showEndDateOnOpen: false, tooltipText: null, value: null, variant: undefined, // Default will be set by @/internal/calendar yearRange: undefined, }); const attrs: HTMLAttributes = useAttrs(); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const { dateToInputDateString } = useDateInputFormatter(); const datePickerContainerId = generateUid('bento-date-range-picker-container'); const labelId = generateUid('bento-date-range-picker-label'); const descriptionId = generateUid('bento-date-range-picker-description'); const errorId = generateUid('bento-date-range-picker-error'); const dateErrorId = generateUid('bento-date-range-picker-date-error'); const popoverAriaLabel = computed(() => props.label || t('dateRangePicker', { name: datePickerContainerId })); const { resolvedFirstDayOfWeek } = useFirstDayOfWeek(toRef(props, 'firstDayOfWeek')); const breakpoints = useBreakpoints({ none: 0, one: 710, // width of date range picker with one panes two: 1030, // width of date range picker with two panes three: 1290, // width of date range picker with three panes }); const computedNumberOfMonths = computed(() => { const activeBreakpoint = breakpoints.active().value; const breakpointToCalendarPanesMap = { none: 0, one: 1, two: 2, }; if (activeBreakpoint in breakpointToCalendarPanesMap) { const maxPanes = breakpointToCalendarPanesMap[activeBreakpoint as keyof typeof breakpointToCalendarPanesMap]; // If numberOfMonths is not provided, use the max panes for the breakpoint. // Otherwise, use the smaller of the two values. return props.numberOfMonths === undefined ? maxPanes : Math.min(props.numberOfMonths, maxPanes); } // Default case for 'three' and larger breakpoints, respect user-provided numberOfMonths. return props.numberOfMonths; }); const adjustedQuickSelectRanges = computed<Array<DateRangePickerCalendarRangeSelectorItem>>(() => { return ( props?.quickSelectRanges && props.quickSelectRanges.map((range: DateRangePickerCalendarRangeSelectorItem) => { const timeDiff = !range.data.endDate ? new Date(Date.now()).getTime() - range.data.startDate.getTime() : undefined; return { ...range, data: { ...range.data, // Adding timeDifference to calculate an up-to-date endDate on range selection timeDifference: timeDiff, }, }; }) ); }); const dropdownInputAriaLabelledBy = computed(() => (props.label ? labelId : (attrs?.['aria-labelledby'] ?? null))); const isDateIncorrect = ref(false); const inputContainerRef = ref(null); const isDateRangePickerOpen = ref(false); const { isReadOnly } = useFormLayoutFieldLoading(toRef(props, 'readonly')); const { emitValue } = useFormFieldEmits(emit); watch( () => props.disabled, () => { // Close date picker popover if it's opened when it's disabled if (props.disabled && isDateRangePickerOpen.value) { isDateRangePickerOpen.value = false; } } ); const internalModelValue = computed(() => props.modelValue || props.value); const shouldDisplayError = computed(() => { if (props.hasActions) { return !isApplyDisabled.value && isDateIncorrect.value; } return isDateIncorrect.value; }); const descriptionConditionalClasses = computed(() => ({ 'b-date-range-picker__description--error': !!props.errorMessage || shouldDisplayError.value, })); const isMonthVariant = computed(() => props.variant === 'month'); const withTimeInput = computed(() => props.allowTimeInput && props.variant !== 'month'); const { dateDisplayValue, textInputDisplayValue, formatTextInputDisplayValue } = useDateRangePickerCalendarText({ datePickerValue: internalModelValue, isDateIncorrect, quickSelectRanges: adjustedQuickSelectRanges, isMonthVariant, withTimeInput, placeholder: toRef(props, 'placeholder'), }); const ariaDescribedBy = computed( () => `${props.description ? descriptionId : ''} ${props.errorMessage ? errorId : ''} ${ isDateIncorrect.value ? dateErrorId : '' }`.trim() || null ); const closeDateRangePicker = () => { isDateRangePickerOpen.value = false; }; const openDateRangePicker = () => { isDateRangePickerOpen.value = true; formatTextInputDisplayValue(); }; /* * Used when `hasActions` is set to true */ const localDateDisplayValue = ref(dateDisplayValue.value); const localTextInputDisplayValue = ref<DateRangePickerCalendarFormData>( // textInputDisplayValue is a reactive object structuredClone(toRaw(textInputDisplayValue)) ); const selectedDate = ref<BentoDateRangePickerValue>(props.modelValue ?? props.value); const isApplyDisabled = ref(true); const displayedTextInputValue = computed(() => props.hasActions ? localTextInputDisplayValue.value : textInputDisplayValue ); const displayedDateValue = computed(() => props.hasActions ? localDateDisplayValue.value : dateDisplayValue.value ); onMounted(() => { if (!props.hasActions) { deprecate( 'BentoDateRangePicker "hasActions" property', `Set the BentoDateRangePicker "hasActions" property true which will display the "Apply" and "Cancel" buttons. This will be the default behavior in the v2 and "hasActions" will be removed.`, '2.0.0' ); } if (props.value) { deprecate( 'BentoDateRangePicker "value" property', 'The use of "value" prop in "BentoDateRangePicker" is no longer supported. Use the "v-model" or "model-value" property instead.', '2.0.0' ); } if ( props.granularities && props.variant !== 'month' && props.granularities.find(({ type }) => type === 'quarterly') ) { throw new Error('Quarterly granularity is only supported by BentoDateRangePicker with variant: "month"'); } }); // On update for the value we need to update all the locally stored data watch([() => props.value, () => props.modelValue], () => { localDateDisplayValue.value = dateDisplayValue.value; localTextInputDisplayValue.value = structuredClone(toRaw(textInputDisplayValue)); }); const rangePickerButtonActions = computed(() => [ { title: t('apply'), event: () => { emitValue(selectedDate.value); closeDateRangePicker(); isApplyDisabled.value = true; }, disabled: isApplyDisabled.value, }, { title: t('cancel'), event: closeDateRangePicker, }, ]); watch(selectedDate, date => { // skip emit until Apply button is clicked if (props.hasActions) { isApplyDisabled.value = isDateIncorrect.value; return; } emitValue(date); }); const onDateRangePickerInput = (selectedRange: BentoDateRangePickerValue) => { // Reset the errors when the date is correct isDateIncorrect.value = false; textInputDisplayValue.startDate = dateToInputDateString(selectedRange.startDate); textInputDisplayValue.endDate = dateToInputDateString(selectedRange.endDate); textInputDisplayValue.startTime = dateToTimeInputString(selectedRange.startDate); textInputDisplayValue.endTime = dateToTimeInputString(selectedRange.endDate); selectedDate.value = selectedRange; }; const clearDateRangePickerValue = async () => { if (isDateIncorrect.value) { isDateIncorrect.value = false; } selectedDate.value = null; }; const onEnterPressedOverInput = () => { // open the datepicker if (!isDateRangePickerOpen.value) { isDateRangePickerOpen.value = true; return; } // Close the date picker if the date is correct and it is open if (!isDateIncorrect.value) { isDateRangePickerOpen.value = false; } }; const onDateRangePickerFormDateInputUpdate = (dateFormDate: DateRangePickerCalendarFormData) => { isDateIncorrect.value = false; if (dateFormDate.startTime) { textInputDisplayValue.startTime = dateFormDate.startTime; } if (dateFormDate.endTime) { textInputDisplayValue.endTime = dateFormDate.endTime; } }; const onDateRangePickerError = dateTextInput => { isDateIncorrect.value = true; textInputDisplayValue.startDate = dateTextInput.startDate; textInputDisplayValue.endDate = dateTextInput.endDate; // Remove current date range selection selectedDate.value = { startDate: null, endDate: null }; }; const onRangeSelectorInput = (quickSelectRanges?: BentoDateRangePickerValue) => { if (quickSelectRanges) { textInputDisplayValue.startDate = dateToInputDateString(quickSelectRanges.startDate); textInputDisplayValue.endDate = dateToInputDateString(quickSelectRanges.endDate); textInputDisplayValue.startTime = dateToTimeInputString(quickSelectRanges.startDate); textInputDisplayValue.endTime = dateToTimeInputString(quickSelectRanges.endDate); selectedDate.value = quickSelectRanges; } }; </script> <script lang="ts"> /** * Date range picker selector. * * @example * import { BentoDateRangePicker } from '@adyen/bento-vue2'; * * export default { * components: { BentoDateRangePicker }, * template: ` * <bento-date-range-picker * label="Label" * description="Supporting text" * required * optional * disabled * @input="onDateChanged" * v-model="selectedDate" * /> * `, * setup() { * const selectedDate = ref(new Date()); // reactive({ startDate: new Date(), endDate: new Date() }) * return { * selectedDate, * } * } * } */ export default { i18n: { messages }, name: 'bento-date-range-picker', model: { prop: 'modelValue' }, }; </script> <style lang="scss" scoped src="./date-range-picker.scss" />
@@ -0,0 +1,129 @@
1
+ import { Canvas, Meta, Source } from '@storybook/blocks';
2
+ import * as DateTimePickerStories from './date-time-picker.stories';
3
+
4
+ <Meta of={DateTimePickerStories} />
5
+
6
+ # Date time picker
7
+
8
+ The date time picker combines a `bento-date-picker` (single date mode) with a `bento-input-time` to let users select
9
+ both a date and a time in one compound field. Its `v-model` value is a single `Date | null`; when set, the `Date`
10
+ contains both the selected day and the time from the time input.
11
+
12
+ <Canvas of={DateTimePickerStories.Default} />
13
+
14
+ ## Use Cases
15
+
16
+ Use a `bento-date-time-picker` if:
17
+
18
+ - You need users to provide both a date and a time, such as scheduling an event or setting a deadline with an exact
19
+ time.
20
+
21
+ ### Do not use
22
+
23
+ - When only a date is needed — use `bento-date-picker` instead.
24
+ - When only a time is needed — use `bento-input-time` instead.
25
+
26
+ ## Behaviour
27
+
28
+ ### Date input
29
+
30
+ The date portion uses `bento-date-picker` in its default (day) mode. Users can type a date in `YYYY-MM-DD` format or
31
+ open the calendar popover to select one. If `dateProps.description` is provided, it is appended before the date format
32
+ hint, e.g. `Supporting text YYYY-MM-DD`.
33
+
34
+ ### Time input
35
+
36
+ The time portion uses `bento-input-time`. As the user types, colons are inserted automatically to produce a well-formed
37
+ `HH:MM:SS` string. Partial or invalid time strings do not update `v-model`; the component emits `input:error` instead.
38
+
39
+ ### Model value
40
+
41
+ <Source
42
+ dark
43
+ language="html"
44
+ code={`
45
+ <bento-date-time-picker :model-value="scheduledAt" @update:model-value="newValue => scheduledAt = newValue" />
46
+ `}
47
+ />
48
+
49
+ <Source
50
+ dark
51
+ language="typescript"
52
+ code={`
53
+ const scheduledAt = ref<Date | null>(new Date(2026, 4, 1, 10, 30, 0));
54
+ `}
55
+ />
56
+
57
+ ## Modifiers
58
+
59
+ ### Labels
60
+
61
+ Use `dateProps.label` and `timeProps.label` to set the accessible labels for each sub-field independently.
62
+
63
+ <Source
64
+ dark
65
+ language="html"
66
+ code={`
67
+ <bento-date-time-picker :date-props="{ label: 'Start date' }" :time-props="{ label: 'Start time' }" />
68
+ `}
69
+ />
70
+
71
+ ### Error message
72
+
73
+ Use `errorMessage` to display a shared error below both inputs and apply error styling to the compound field.
74
+
75
+ <Source
76
+ dark
77
+ language="html"
78
+ code={`
79
+ <bento-date-time-picker error-message="The selected date and time must be in the future" />
80
+ `}
81
+ />
82
+
83
+ Per-side errors can still be passed with `dateProps.errorMessage` or `timeProps.errorMessage`.
84
+
85
+ ### Disabled
86
+
87
+ Use `disabled` to disable both inputs. `dateProps.disabled` or `timeProps.disabled` override the shared value for that
88
+ side.
89
+
90
+ ### Readonly
91
+
92
+ Use `readonly` to make both inputs readonly. `dateProps.readonly` or `timeProps.readonly` override the shared value for
93
+ that side.
94
+
95
+ ### Required
96
+
97
+ Use `required` to mark both inputs required. `dateProps.required` or `timeProps.required` override the shared value for
98
+ that side.
99
+
100
+ ### Min and max
101
+
102
+ Use `min` and `max` to validate the combined `Date`. The date portion is also forwarded to the date picker for calendar
103
+ bounds. Use `isDateDisabled` to disable dates with custom logic instead of, or in addition to, `min` and `max`.
104
+
105
+ ### Pass-through props
106
+
107
+ Use `dateProps` and `timeProps` for props that are specific to only one field, such as `label`, `description`,
108
+ `placeholder`, `optional`, or `tooltipText`. If a shared wrapper prop and a per-side prop are both set, the per-side
109
+ prop wins for that field. `dateProps.description` is appended to the automatic date format hint instead of replacing it.
110
+
111
+ ## Accessibility
112
+
113
+ ### Keyboard interaction
114
+
115
+ Both sub-components follow the keyboard interaction patterns of their respective components:
116
+
117
+ - The date picker is opened with `Space` or `Enter` and navigated with arrow keys.
118
+ - The time input filters non-numeric keystrokes automatically.
119
+
120
+ ### Roles, states and properties
121
+
122
+ | ARIA attribute | is automatic? | notes |
123
+ | ------------------ | ---------------------------------------------- | ---------------------------------------------------------------------- |
124
+ | `role="group"` | Yes | Applied to the root wrapper |
125
+ | `label` | Yes, via `dateProps.label` / `timeProps.label` | Each input has its own accessible label |
126
+ | `required` | Yes, via `required` or per-side props | Forwarded to both inputs |
127
+ | `disabled` | Yes, via `disabled` or per-side props | Forwarded to both inputs |
128
+ | `aria-invalid` | Yes | Applied to the group and invalid inputs |
129
+ | `aria-describedby` | Yes | Preserves external ids and adds the shared group error id when present |
@@ -0,0 +1 @@
1
+ import { action } from '@storybook/addon-actions'; import { isVue2 } from 'vue-demi'; import { ref } from 'vue'; import { storybookDocsParameter } from '@/utils/ts/storybook'; import BentoDateTimePicker from './date-time-picker.vue'; import type { Meta, StoryObj } from '@storybook/vue'; import type { BentoDateTimePickerValue } from './date-time-picker.types'; import defaultCode from './__tests__/date-time-picker-default-example.vue?raw'; const meta: Meta = { title: 'Date time picker', component: BentoDateTimePicker, }; export default meta; type Story = StoryObj<typeof BentoDateTimePicker>; export const Default: Story = { render: (_args, { argTypes }) => ({ components: { BentoDateTimePicker }, props: Object.keys(argTypes), template: ` <div style="max-width: 600px"> <bento-date-time-picker v-bind="args" :model-value="modelValue" @update:model-value="onValueChanged" @input:error="onInputError" @input:valid="onInputValid" /> </div> `, setup(props) { const modelValue = ref<BentoDateTimePickerValue>(props.modelValue ?? null); const onValueChanged = (newValue: BentoDateTimePickerValue) => { modelValue.value = newValue; action('update:model-value')(newValue); }; return { args: isVue2 ? props : _args, modelValue, onInputError: action('input:error'), onInputValid: action('input:valid'), onValueChanged, }; }, }), args: { dateProps: { label: 'Date', description: 'Supporting text', required: true, }, timeProps: { label: 'Time', }, modelValue: new Date(2026, 4, 1, 10, 30, 0), }, parameters: storybookDocsParameter(defaultCode), }; export const WithSharedError: Story = { ...Default, args: { ...Default.args, errorMessage: 'The selected date and time must be in the future', }, }; export const Disabled: Story = { ...Default, args: { ...Default.args, disabled: true, }, };
@@ -0,0 +1 @@
1
+ import type { BentoDatePickerIsDateDisabled, BentoDatePickerProps } from '@/components/date-picker'; import type { BentoInputTimeProps } from '@/components/input-time'; export type BentoDateTimePickerValue = Date | null; export interface BentoDateTimePickerEmits { /** * Emitted when the component's model changes */ (e: 'update:model-value', value: BentoDateTimePickerValue): void; /** * Emitted when the combined date and time value passes validation. */ (e: 'input:valid'): void; /** * Emitted when the combined date and time value fails validation. */ (e: 'input:error'): void; } export interface BentoDateTimePickerProps { /** * The combined date and time value. * @default null */ modelValue?: BentoDateTimePickerValue; /** * If true, disables both fields unless overridden by `dateProps` or `timeProps`. * @default false */ disabled?: boolean; /** * If set, displays a shared error message and applies error styling to both fields. * @default null */ errorMessage?: string | null; /** * Indicate if a date should be disabled or not. */ isDateDisabled?: BentoDatePickerIsDateDisabled; /** * Sets a maximum date-time value. * @default null */ max?: Date | null; /** * Sets a minimum date-time value. * @default null */ min?: Date | null; /** * If true, makes both fields readonly unless overridden by `dateProps` or `timeProps`. * @default false */ readonly?: boolean; /** * If true, marks both fields required unless overridden by `dateProps` or `timeProps`. * @default false */ required?: boolean; /** * Props passed directly to the inner `bento-date-picker`. * Accepts all `BentoDatePickerProps` except `modelValue` and `value`. * @default {} */ dateProps?: Omit<BentoDatePickerProps, 'modelValue' | 'value'>; /** * Props passed directly to the inner `bento-input-time`. * Accepts all `BentoInputTimeProps` except `modelValue`. * @default {} */ timeProps?: Omit<BentoInputTimeProps, 'modelValue'>; }
@@ -0,0 +1 @@
1
+ <template> <div class="b-date-time-picker"> <bento-form-layout-group class="b-date-time-picker__inputs" layout="50-50" :error-message="props.errorMessage"> <bento-date-picker class="b-date-time-picker__date-input" v-bind="resolvedDateProps" :model-value="dateValue" @update:model-value="onDateChanged" /> <bento-input-time class="b-date-time-picker__time-input" v-bind="resolvedTimeProps" :model-value="timeValue" @update:model-value="onTimeChanged" /> </bento-form-layout-group> </div> </template> <script setup lang="ts"> import { computed, nextTick, ref, watch } from 'vue'; import { setMilliseconds } from 'date-fns/setMilliseconds'; import { startOfDay } from 'date-fns/startOfDay'; // Components import { BentoDatePicker } from '@/components/date-picker'; import { BentoFormLayoutGroup } from '@/components/form-layout'; import { BentoInputTime } from '@/components/input-time'; // Utils import { useDateInputFormatter, useTouchDevice } from '@/composables'; import { useI18n } from '@/utils/ts/i18n'; import { dateToTimeInputString, setTimeInDateObject } from '@/utils/ts/format-date'; import { isValidTimeString } from '@/utils/ts/time-input'; // Types import type { BentoDateTimePickerEmits, BentoDateTimePickerProps, BentoDateTimePickerValue, } from './date-time-picker.types'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const props = withDefaults(defineProps<BentoDateTimePickerProps>(), { dateProps: () => ({}), disabled: false, errorMessage: null, isDateDisabled: undefined, max: null, min: null, modelValue: null, readonly: false, required: false, timeProps: () => ({}), }); const emit = defineEmits<BentoDateTimePickerEmits>(); const currentDate = ref<Date | null>(null); const currentTimeText = ref(''); const dateError = ref<keyof MessageSchema | null>(null); const timeError = ref<keyof MessageSchema | null>(null); const { dateFormat } = useDateInputFormatter(); const { isTouchDevice } = useTouchDevice(); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const dateValue = computed(() => currentDate.value); const timeValue = computed(() => currentTimeText.value); const resolvedIsDateDisabled = computed(() => props.dateProps?.isDateDisabled ?? props.isDateDisabled); const dateDescription = computed(() => { if (isTouchDevice.value) { return props.dateProps?.description; } else { return props.dateProps?.description ? `${props.dateProps.description} ${t(dateFormat.value)}` : t(dateFormat.value); } }); const isMergedDateInBounds = (date: Date) => { if (resolvedIsDateDisabled.value?.(startOfDay(date))) { return false; } if (props.min && date < setMilliseconds(props.min, 0)) { return false; } if (props.max && date > setMilliseconds(props.max, 0)) { return false; } return true; }; const resolvedDateProps = computed(() => ({ disabled: props.disabled, isDateDisabled: props.isDateDisabled, max: props.max, min: props.min, readonly: props.readonly, required: props.required, errorMessage: dateErrorMessage.value, ...props.dateProps, description: dateDescription.value, })); const resolvedTimeProps = computed(() => ({ disabled: props.disabled, errorMessage: timeErrorMessage.value, readonly: props.readonly, required: props.required, ...props.timeProps, })); const dateErrorMessage = computed(() => { if (props.errorMessage) { return props.errorMessage; } return dateError.value ? t(dateError.value) : null; }); const timeErrorMessage = computed(() => { if (props.errorMessage) { return props.errorMessage; } return timeError.value ? t(timeError.value) : null; }); const clearErrors = () => { dateError.value = null; timeError.value = null; }; /** * Guards the modelValue watcher from clearing user input when the null was emitted * by our own validation logic (e.g. partial/invalid input). Without this flag, * the parent's v-model round-trip would trigger the watcher and wipe the fields. * Reset on nextTick so that genuine external resets (e.g. a form clear button) * still clear the UI. */ const isSelfEmittedUpdate = ref(false); watch( () => props.modelValue, newValue => { if (isSelfEmittedUpdate.value) { return; } if (newValue) { // Sync internal state when a valid Date is set (e.g. programmatic update from parent) currentDate.value = newValue; currentTimeText.value = dateToTimeInputString(newValue); } else { // External reset (e.g. parent sets modelValue to null via a form clear button): // clear all internal state so the UI resets fully. currentDate.value = null; currentTimeText.value = ''; clearErrors(); } }, { immediate: true } ); const validateBounds = (value: BentoDateTimePickerValue) => !value || isMergedDateInBounds(value); const emitValidationState = (isValid: boolean) => { if (isValid) { emit('input:valid'); } else { emit('input:error'); } }; const emitModelValue = (value: BentoDateTimePickerValue) => { isSelfEmittedUpdate.value = true; emit('update:model-value', value); nextTick(() => { isSelfEmittedUpdate.value = false; }); }; const emitValidatedValue = (value: BentoDateTimePickerValue) => { emitModelValue(value); emitValidationState(validateBounds(value)); }; const emitInvalidModelValue = (value: BentoDateTimePickerValue) => { emitModelValue(value); emitValidationState(false); }; const onDateChanged = (date: Date | null) => { currentDate.value = date; // Date invalid/cleared: error if time is populated, valid if both empty if (!date) { if (currentTimeText.value) { dateError.value = 'invalidDateFormat'; emitInvalidModelValue(null); } else { clearErrors(); emitValidatedValue(null); } return; } clearErrors(); // Prefill time with midnight when empty so the user sees a value if (!currentTimeText.value) { currentTimeText.value = '00:00:00'; } const time = currentTimeText.value; // Date valid but time invalid: preserve date, signal error if (!isValidTimeString(time)) { emitInvalidModelValue(null); return; } // Both valid: merge and emit emitValidatedValue(setTimeInDateObject(date, time)); }; const onTimeChanged = (time: string) => { currentTimeText.value = time; // Time cleared: error if date is set (time is required once prefilled), valid-null if both empty if (!time) { if (currentDate.value) { timeError.value = 'enterTimeForDate'; emitInvalidModelValue(null); } else { clearErrors(); emitValidatedValue(null); } return; } timeError.value = null; // Time invalid: preserve date, signal error if (!isValidTimeString(time)) { emitInvalidModelValue(null); return; } // Time valid but no date: signal error if (!currentDate.value) { dateError.value = 'invalidDateFormat'; emitInvalidModelValue(null); return; } // Both valid: merge and emit emitValidatedValue(setTimeInDateObject(currentDate.value, time)); }; </script> <script lang="ts"> /** * Date time picker that combines a date picker (single date) with a time input. * * @example * import { BentoDateTimePicker } from '@adyen/bento-vue2'; * * export default { * components: { BentoDateTimePicker }, * template: ` * <bento-date-time-picker * :date-props="{ label: 'Date' }" * :time-props="{ label: 'Time' }" * required * :model-value="dateTimeValue" * @update:model-value="newValue => dateTimeValue = newValue" * /> * ` * } */ export default { i18n: { messages }, name: 'bento-date-time-picker', inheritAttrs: false, model: { prop: 'modelValue', event: 'update:model-value' }, }; </script> <style lang="scss" scoped src="./date-time-picker.scss" />