@adyen/bento-mcp 0.1.2 → 0.1.3

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 (36) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/dist/assets/components/anchor-scroller/anchor-scroller.vue +1 -1
  3. package/dist/assets/components/checkbox/checkbox.vue +1 -1
  4. package/dist/assets/components/checkbox/components/checkbox-group/checkbox-group.vue +1 -1
  5. package/dist/assets/components/data-grid/components/data-grid-row/data-grid-row.vue +1 -1
  6. package/dist/assets/components/data-grid/components/data-grid-tag-with-text-cell/data-grid-tag-with-text-cell.vue +1 -1
  7. package/dist/assets/components/data-grid/data-grid.vue +1 -1
  8. package/dist/assets/components/date-picker/date-picker.vue +1 -1
  9. package/dist/assets/components/date-range-picker/components/date-range-picker-calendar/date-range-picker-calendar.vue +1 -1
  10. package/dist/assets/components/date-range-picker/date-range-picker.vue +1 -1
  11. package/dist/assets/components/dropdown/components/dropdown-base-textbox/dropdown-base-textbox.vue +1 -1
  12. package/dist/assets/components/dropdown/dropdown.vue +1 -1
  13. package/dist/assets/components/file-uploader/components/file-uploader-restrictions/file-uploader-restrictions.vue +1 -1
  14. package/dist/assets/components/file-uploader/file-uploader.docs.mdx +9 -0
  15. package/dist/assets/components/file-uploader/file-uploader.stories.ts +1 -1
  16. package/dist/assets/components/file-uploader/file-uploader.vue +1 -1
  17. package/dist/assets/components/filter-bar/components/all-filters-modal/all-filters-modal.vue +1 -1
  18. package/dist/assets/components/filter-bar/components/date-range-filter/date-range-filter.vue +1 -1
  19. package/dist/assets/components/filter-bar/filter-bar.vue +1 -1
  20. package/dist/assets/components/form-layout/components/form-layout-title/form-layout-title.vue +1 -1
  21. package/dist/assets/components/form-layout/form-layout.docs.mdx +4 -0
  22. package/dist/assets/components/input-field/input-field.vue +1 -1
  23. package/dist/assets/components/input-field-phone-number/input-field-phone-number.vue +1 -1
  24. package/dist/assets/components/internal/controls-group/controls-group.vue +1 -1
  25. package/dist/assets/components/internal/field-label/field-label.vue +1 -1
  26. package/dist/assets/components/modal-fullscreen/components/modal-fullscreen-page.vue +1 -1
  27. package/dist/assets/components/modal-fullscreen/modal-fullscreen.vue +1 -1
  28. package/dist/assets/components/pagination/components/pagination-context/pagination-context.vue +1 -1
  29. package/dist/assets/components/pagination/components/pagination-results-per-page/pagination-results-per-page.vue +1 -1
  30. package/dist/assets/components/pagination/pagination.vue +1 -1
  31. package/dist/assets/components/radio-group/radio-group.vue +1 -1
  32. package/dist/assets/components/rich-text-editor/rich-text-editor.vue +1 -1
  33. package/dist/assets/components/selection-card/components/selection-card-group/selection-card-group.vue +1 -1
  34. package/dist/assets/components/textarea/textarea.vue +1 -1
  35. package/dist/main.js +145 -136
  36. package/package.json +1 -1
@@ -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> <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="dateTextInput.startDate" :error="startDateError" class="b-date-range-picker-calendar__form-input" @update:model-value="onStartDateInput" @keydown="onDateInputKeyDown" > <template #description>YYYY-MM-DD</template> </bento-input-field> <bento-input-field v-if="allowTimeInput" :aria-label="t('timeFrom')" :model-value="dateTextInput.startTime" :error="startTimeError" class="b-date-range-picker-calendar__form-input" @update:model-value="onStartTimeInput" @keydown="onTimeInputKeyDown" > <template #description>HH:MM:SS</template> </bento-input-field> </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="dateTextInput.endDate" :error="endDateError" @update:model-value="onEndDateInput" @keydown="onDateInputKeyDown" > <template #description>YYYY-MM-DD</template> </bento-input-field> <bento-input-field v-if="allowTimeInput" :aria-label="t('timeTo')" :model-value="dateTextInput.endTime" :error="endTimeError" class="b-date-range-picker-calendar__form-input" @update:model-value="onEndTimeInput" @keydown="onTimeInputKeyDown" > <template #description>HH:MM:SS</template> </bento-input-field> </div> </div> </div> <div v-if="hasSlot('actions')"> <slot name="actions" /> </div> </div> <!-- Calendar --> <div v-if="!hasNoCalendars" 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 { BentoTypography } from '@/components/typography'; import { BentoInputField } from '@/components/input-field'; import BentoDropdown from '@/components/dropdown/dropdown.vue'; import { BentoSegmentedControl, type BentoSegmentedControlItem } from '@/components/segmented-control'; import { useI18n } from '@/utils/ts/i18n'; import { parseDateTextToStartOfDay } from '../../utils/date-text-to-start-of-day'; import { debounce } from '@/utils/ts/debounce'; import { useHasSlot } 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 { formatDateInputValue, validateDateInput } from '@/utils/ts/date-input'; 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 { dateToInputDateString, dateToTimeInputString } from '@/utils/ts/format-date'; import { formatTimeInputValue, validateTimeInput } 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 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 } = useI18n<{ message: MessageSchema }>({ messages }); 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, } : {}), }); /** * Deserializes a date to transform it to a UTC standard so it can be compared regardless of timezone. * src: https://stackoverflow.com/a/38050824 * @param date Date to deserialize/normalize for comparisson */ function deserializeDate(date: Date) { if (!date) { return null; } return new Date( date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), date.getUTCHours(), date.getUTCMinutes(), date.getUTCSeconds() ); } /** * 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. */ 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( deserializeDate(newlyCalculatedQuickSelectDates.startDate), deserializeDate(internalRangeDate.startDate) ) && isEqual( deserializeDate(newlyCalculatedQuickSelectDates.endDate), deserializeDate(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 return ( isEqual(deserializeDate(data.startDate), deserializeDate(internalRangeDate.startDate)) && isEqual( deserializeDate(internalRangeDate.endDate), deserializeDate(endOfDay(internalRangeDate.endDate)) ) && isToday(deserializeDate(internalRangeDate.endDate)) && (props.granularities ? data.granularity === internalRangeDate.granularity : true) ); } if (props.allowTimeInput) { const res = isSameSecond(deserializeDate(data.startDate), deserializeDate(internalRangeDate.startDate)) && isSameSecond(deserializeDate(data.endDate), deserializeDate(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 format yyyy-MM-dd * @param {Date} dateToFormat Date to be formatted */ const formatDate = (dateToFormat?: Date) => { if (!dateToFormat) { return ''; } return dateToInputDateString(dateToFormat); }; // Form error flags const startDateError = ref(false); const endDateError = ref(false); const startTimeError = ref(false); const endTimeError = 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; }; const isValidTimeString = (timeText: string) => /^(?:[01]\d|2[01234]):(?:[012345]\d):(?:[012345]\d)$/.test(timeText); /** * 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(new Date(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(); }; /** * 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 YYYY-MM-DD 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; }; // YYYY-MM-DD regex const dateRegex = /^\d{4}-(0?[1-9]|1[012])-(0?[1-9]|[12][0-9]|3[01])$/; // Date matches text regex and is not disabled return dateRegex.test(dateText) && isDateEnabled(new Date(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 if (props.allowTimeInput) { startTimeError.value = !!( props.dateFormData.startTime && !isValidTime(props.dateFormData.startTime, props.dateFormData.startDate) ); } else { startTimeError.value = false; } // End date validation endDateError.value = !!(props.dateFormData.endDate && !isValidDate(props.dateFormData.endDate)); // End time validation if (props.allowTimeInput) { endTimeError.value = !!( props.dateFormData.endTime && !isValidTime(props.dateFormData.endTime, props.dateFormData.endDate) ); } else { endTimeError.value = false; } }); const onStartDateSelected = (newStartDate: Date) => { dateTextInput.startDate = dateToInputDateString(newStartDate); if (!dateTextInput.startTime) { // Set the time to the beginning of the day dateTextInput.startTime = dateToTimeInputString(newStartDate); } else if (startTimeError.value) { // Set the form time if it was previously set dateTextInput.startTime = props.dateFormData?.startTime; } // Reset errors startDateError.value = false; startTimeError.value = false; emit(DateRangePickerCalendarEvent.START_DATE_SELECTED, newStartDate); }; const onEndDateSelected = (newEndDate: Date) => { dateTextInput.endDate = dateToInputDateString(newEndDate); if (!dateTextInput.endTime) { // Set the time to the end of the day dateTextInput.endTime = dateToTimeInputString(endOfDay(newEndDate)); } else if (endTimeError.value) { // Set the form time if it was previously set dateTextInput.endTime = props.dateFormData?.endTime; } // Reset errors endDateError.value = false; endTimeError.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 new Date(props.dateFormData[keyName]); } return undefined; }; const validateStartDate = debounce((startDateText: string) => { // Checks for YYYY-MM-DD format 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 = parseDateTextToStartOfDay(dateText); internalRangeDate.startDate = setTimeInDateRef(startDate, dateTextInput.startTime); internalRangeDate.endDate = setTimeInDateRef( parseDateTextToStartOfDay(dateTextInput.endDate), dateTextInput.endTime ); if (isValidDate(dateTextInput.endDate)) { internalRangeDate.endDate = setTimeInDateRef( parseDateTextToStartOfDay(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 }); } }, FORM_INPUT_DEBOUNCE_TIME); const onStartDateInput = (startDateText: string) => { // Set the date as string when input through the form dateTextInput.startDate = formatDateInputValue(startDateText); validateStartDate(dateTextInput.startDate); }; const validateEndDate = debounce((endDateText: string) => { // Checks for YYYY-MM-DD format 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 = parseDateTextToStartOfDay(dateText); internalRangeDate.endDate = setTimeInDateRef(endDate, dateTextInput.endTime); if (isValidDate(dateTextInput.startDate)) { internalRangeDate.startDate = setTimeInDateRef( parseDateTextToStartOfDay(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 }); } }, FORM_INPUT_DEBOUNCE_TIME); const onEndDateInput = (endDateText: string) => { // Set the date as string when input through the form dateTextInput.endDate = formatDateInputValue(endDateText); validateEndDate(dateTextInput.endDate); }; const onDateInputKeyDown = (event: KeyboardEvent) => { validateDateInput(event); }; const validateStartTime = debounce((startTimeText: string) => { if (isValidTime(startTimeText, props.dateFormData.startDate)) { startTimeError.value = false; 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, }); } else { // Indicate form error startTimeError.value = true; // Emit Date picker calendar error emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); } }); const validateEndTime = debounce((endTimeText: string) => { if (isValidTime(endTimeText, props.dateFormData.endDate)) { endTimeError.value = false; 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, }); } else { // Indicate form error endTimeError.value = true; // Emit Date picker calendar error emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); } }); const onStartTimeInput = (startTimeText: string) => { dateTextInput.startTime = formatTimeInputValue(startTimeText); validateStartTime(dateTextInput.startTime); }; const onEndTimeInput = (endDateText: string) => { dateTextInput.endTime = formatTimeInputValue(endDateText); validateEndTime(dateTextInput.endTime); }; const onTimeInputKeyDown = (event: KeyboardEvent) => { validateTimeInput(event); }; 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 = dateToInputDateString(newEndDate); dateTextInput.startDate = dateToInputDateString(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 = dateToInputDateString(internalRangeDate.startDate); dateTextInput.endDate = dateToInputDateString(internalRangeDate.endDate); emit(DateRangePickerCalendarEvent.INPUT, { ...internalRangeDate, range: selectedRangeSelectorValue.value, }); } }; const computedFormContainerClasses = computed(() => ({ 'b-date-range-picker-calendar__form-container--no-calendars': hasNoCalendars.value, })); </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> <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="dateTextInput.startDate" :error="startDateError" class="b-date-range-picker-calendar__form-input" @update:model-value="onStartDateInput" @keydown="onDateInputKeyDown" > <template #description>{{ t(dateFormat) }}</template> </bento-input-field> <bento-input-field v-if="allowTimeInput" :aria-label="t('timeFrom')" :model-value="dateTextInput.startTime" :error="startTimeError" class="b-date-range-picker-calendar__form-input" @update:model-value="onStartTimeInput" @keydown="onTimeInputKeyDown" > <template #description>HH:MM:SS</template> </bento-input-field> </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="dateTextInput.endDate" :error="endDateError" @update:model-value="onEndDateInput" @keydown="onDateInputKeyDown" > <template #description>{{ t(dateFormat) }}</template> </bento-input-field> <bento-input-field v-if="allowTimeInput" :aria-label="t('timeTo')" :model-value="dateTextInput.endTime" :error="endTimeError" class="b-date-range-picker-calendar__form-input" @update:model-value="onEndTimeInput" @keydown="onTimeInputKeyDown" > <template #description>HH:MM:SS</template> </bento-input-field> </div> </div> </div> <div v-if="hasSlot('actions')"> <slot name="actions" /> </div> </div> <!-- Calendar --> <div v-if="!hasNoCalendars" 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 { BentoTypography } from '@/components/typography'; import { BentoInputField } from '@/components/input-field'; 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 } 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 { dateToTimeInputString } from '@/utils/ts/format-date/format-date'; import { formatTimeInputValue, validateTimeInput } 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 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 } = useI18n<{ message: MessageSchema }>({ messages }); 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, } : {}), }); /** * Deserializes a date to transform it to a UTC standard so it can be compared regardless of timezone. * src: https://stackoverflow.com/a/38050824 * @param date Date to deserialize/normalize for comparisson */ function deserializeDate(date: Date) { if (!date) { return null; } return new Date( date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), date.getUTCHours(), date.getUTCMinutes(), date.getUTCSeconds() ); } /** * 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. */ 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( deserializeDate(newlyCalculatedQuickSelectDates.startDate), deserializeDate(internalRangeDate.startDate) ) && isEqual( deserializeDate(newlyCalculatedQuickSelectDates.endDate), deserializeDate(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 return ( isEqual(deserializeDate(data.startDate), deserializeDate(internalRangeDate.startDate)) && isEqual( deserializeDate(internalRangeDate.endDate), deserializeDate(endOfDay(internalRangeDate.endDate)) ) && isToday(deserializeDate(internalRangeDate.endDate)) && (props.granularities ? data.granularity === internalRangeDate.granularity : true) ); } if (props.allowTimeInput) { const res = isSameSecond(deserializeDate(data.startDate), deserializeDate(internalRangeDate.startDate)) && isSameSecond(deserializeDate(data.endDate), deserializeDate(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); const startTimeError = ref(false); const endTimeError = 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; }; const isValidTimeString = (timeText: string) => /^(?:[01]\d|2[01234]):(?:[012345]\d):(?:[012345]\d)$/.test(timeText); /** * 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(); }; /** * 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 if (props.allowTimeInput) { startTimeError.value = !!( props.dateFormData.startTime && !isValidTime(props.dateFormData.startTime, props.dateFormData.startDate) ); } else { startTimeError.value = false; } // End date validation endDateError.value = !!(props.dateFormData.endDate && !isValidDate(props.dateFormData.endDate)); // End time validation if (props.allowTimeInput) { endTimeError.value = !!( props.dateFormData.endTime && !isValidTime(props.dateFormData.endTime, props.dateFormData.endDate) ); } else { endTimeError.value = false; } }); const onStartDateSelected = (newStartDate: Date) => { dateTextInput.startDate = formatDate(newStartDate); if (!dateTextInput.startTime) { // Set the time to the beginning of the day dateTextInput.startTime = dateToTimeInputString(newStartDate); } else if (startTimeError.value) { // Set the form time if it was previously set dateTextInput.startTime = props.dateFormData?.startTime; } // Reset errors startDateError.value = false; startTimeError.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)); } else if (endTimeError.value) { // Set the form time if it was previously set dateTextInput.endTime = props.dateFormData?.endTime; } // Reset errors endDateError.value = false; endTimeError.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 = debounce((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 }); } }, FORM_INPUT_DEBOUNCE_TIME); const onStartDateInput = (startDateText: string) => { // Set the date as string when input through the form dateTextInput.startDate = autoFormat(startDateText); validateStartDate(dateTextInput.startDate); }; const validateEndDate = debounce((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 }); } }, FORM_INPUT_DEBOUNCE_TIME); const onEndDateInput = (endDateText: string) => { // Set the date as string when input through the form dateTextInput.endDate = autoFormat(endDateText); validateEndDate(dateTextInput.endDate); }; const onDateInputKeyDown = (event: KeyboardEvent) => { onKeyDown(event); }; const validateStartTime = debounce((startTimeText: string) => { if (isValidTime(startTimeText, props.dateFormData.startDate)) { startTimeError.value = false; 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, }); } else { // Indicate form error startTimeError.value = true; // Emit Date picker calendar error emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); } }); const validateEndTime = debounce((endTimeText: string) => { if (isValidTime(endTimeText, props.dateFormData.endDate)) { endTimeError.value = false; 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, }); } else { // Indicate form error endTimeError.value = true; // Emit Date picker calendar error emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); } }); const onStartTimeInput = (startTimeText: string) => { dateTextInput.startTime = formatTimeInputValue(startTimeText); validateStartTime(dateTextInput.startTime); }; const onEndTimeInput = (endDateText: string) => { dateTextInput.endTime = formatTimeInputValue(endDateText); validateEndTime(dateTextInput.endTime); }; const onTimeInputKeyDown = (event: KeyboardEvent) => { validateTimeInput(event); }; 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, })); </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 +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="label ? labelId : null" :aria-describedby="ariaDescribedBy" :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, onMounted, ref, toRaw, toRef, 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 { dateToInputDateString, dateToTimeInputString } from '@/utils/ts/format-date'; // 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 { t } = useI18n<{ message: MessageSchema }>({ messages }); 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 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('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 +1 @@
1
- <template> <div :class="$attrs.class" data-testid="textbox-wrapper" @click="onToggleDropdown" @keydown.escape="onCloseDropdown" > <bento-typography class="b-dropdown-base-textbox" :class="conditionalClasses" el="div" data-testid="base-textbox" > <input v-if="shouldShowInput" v-bind="ariaAttributes" :id="textboxId" ref="textbox" class="b-dropdown-base-textbox__input" role="combobox" type="text" aria-autocomplete="list" size="1" :value="open ? value : displayValue" :disabled="disabled ? true : null" :readonly="readonly" :required="isRequired" @keydown="$emit(DropdownBaseTextboxEvent.KEYDOWN, $event)" @keydown.enter="onToggleDropdown" @input="onInputEvent" /> <div v-else :id="textboxId" v-bind="ariaAttributes" ref="textbox" class="b-dropdown-base-textbox__input" :class="comboboxConditionalClasses" :aria-readonly="readonly ? 'true' : null" role="combobox" :tabindex="disabled ? -1 : 0" > <template v-if="shouldShowSingleSelectSlot"> <slot v-if="hasSlot('display-value')" v-bind="selectedValueItem" name="display-value"></slot> <slot v-else v-bind="selectedValueItem"> {{ displayValue }} </slot> </template> <template v-else-if="shouldShowMultipleSlot"> <div class="b-dropdown-base-textbox__input-multiple-selected"> <div v-for="singleItem of selectedValueItems" :key="singleItem.value"> <div class="b-dropdown-base-textbox__input-selected-element"> <slot v-bind="singleItem"> {{ displayValue }} </slot> </div> </div> </div> </template> <template v-else>{{ displayValue }}</template> </div> <span v-if="additionalItemsSelected" class="b-dropdown-base-textbox__additional-items"> {{ additionalItemsSelected }} </span> <span v-if="hasSlot('icon') && !isFiltering" class="b-dropdown-base-textbox__icon" aria-hidden="true"> <slot name="icon"></slot> </span> <span v-if="isFiltering" class="b-dropdown-base-textbox__clear-search" :tabindex="disabled ? -1 : 0" data-testid="clear-search-button" @click.stop="onClearSearch" @keydown.enter.stop="onClearSearch" @keyup.space="onClearSearch" > <cross-circle-fill-small-icon v-if="size === BentoDropdownSize.SMALL" :svg-title="t('clearSearch')" /> <cross-circle-fill-icon v-else :svg-title="t('clearSearch')" /> </span> </bento-typography> </div> </template> <script setup lang="ts"> import { computed, defineComponent, nextTick, type PropType, ref, useAttrs, useSlots, watch } from 'vue'; import { generateUid } from '@/core/utils/ts'; import { useI18n } from '@/utils/ts/i18n'; import { useHasSlot } from '@/composables'; import { BentoTypography } from '@/components/typography'; import { type Booleanish } from '@/types/prop-types'; import { type BentoListboxOptionItem } from '@/types/listbox'; import type { HTMLAttributes } from 'vue/types/jsx.d.ts'; import { DropdownBaseTextboxEvent, DropdownBaseTextboxState } from './dropdown-base-textbox.types'; import CrossCircleFillIcon from '@adyen/ui-assets-icons-16/vue/cross-circle-fill'; import CrossCircleFillSmallIcon from '@adyen/ui-assets-icons-16/vue/cross-circle-fill-small'; import { BentoDropdownSize } from '@/components/dropdown/dropdown.types'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const { t } = useI18n<{ message: MessageSchema }>({ messages }); const attrs = useAttrs(); const slots = useSlots(); const hasSlot = useHasSlot(slots); const props = defineProps({ /** * The number of additional items that have been selected in the dropdown. */ additionalItemsSelected: { type: [Number, String] as PropType<number | string>, default: 0 }, /** * If the combobox DOM element should be an `input` element. */ alwaysComboboxIsInput: { type: Boolean, default: false, }, /** * Sets the aria-controls attribute on the textbox. This should correspond to the popover's id that the textbox triggers. */ ariaControls: { type: String, required: true }, /** * Indicates whether the content the textbox triggers is visible or not. Needs to be set accordingly when the textbox is used and changed every time the popover is shown/hidden. */ ariaExpanded: { type: [String, Boolean] as PropType<Booleanish>, required: true, }, /** * If no label element is used then the aria-label is be set should be set. */ ariaLabel: { type: String, default: null }, /** * Corresponds to the label's id that labels the textbox element. */ ariaLabelledby: { type: String, default: null }, /** * Disables any textbox functionality */ disabled: { type: Boolean, default: false }, /** * Selected option's label */ displayValue: { type: String, default: null }, /** * Sets the possibility to type into the textbox. */ dynamicFiltering: { type: Boolean, default: false }, /** * Marks the textbox as invalid, rendering an error state */ isInvalid: { type: Boolean, default: false }, /* * If enabled, the user will be able to select multiple items. */ multiple: { type: Boolean, default: false }, /** * Indicates if the dropdown is open so that * the respective event can be emitted */ open: { type: Boolean, default: false }, /** * Sets the dropdown to readonly state */ readonly: { type: Boolean, default: false }, /** * The input value */ value: { type: String, default: '' }, /** * The selected value's list box item. */ selectedValueItem: { type: [Object, Array] as PropType<BentoListboxOptionItem | Array<BentoListboxOptionItem>>, default: undefined, }, /** * Toggles whether to use the slot content select items shown in the textbox in multiple mode. */ showSlotContentInMultiple: { type: Boolean, default: false }, /** * Dropdown size */ size: { type: String as PropType<BentoDropdownSize | `${BentoDropdownSize}`>, default: null, }, }); const emit = defineEmits<{ /** * Emitted when the "clear" button is clicked */ (e: DropdownBaseTextboxEvent.CLEAR); /** * Emitted on click, space, enter and esc user events. It will close the dropdown. */ (e: DropdownBaseTextboxEvent.CLOSE); /** * Emitted when `dynamicFiltering` is enabled and a search input is entered */ (e: DropdownBaseTextboxEvent.INPUT, searchValue: string); /** * Emitted when any key is pressed and the focus is on the input field */ (e: DropdownBaseTextboxEvent.KEYDOWN, event: KeyboardEvent); /** * Emitted on click, space and enter user events. It will open the dropdown. */ (e: DropdownBaseTextboxEvent.OPEN); }>(); const textbox = ref(null); const textboxId = generateUid('textbox'); const conditionalClasses = computed(() => ({ [`b-dropdown-base-textbox--${DropdownBaseTextboxState.DISABLED}`]: props.disabled, [`b-dropdown-base-textbox--${DropdownBaseTextboxState.READONLY}`]: props.readonly, [`b-dropdown-base-textbox--${DropdownBaseTextboxState.HAS_ITEMS}`]: !!props.additionalItemsSelected, })); const isRequired = computed(() => attrs['required'] as Booleanish); const comboboxConditionalClasses = computed(() => ({ [`b-dropdown-base-textbox__input--dynamic-filtering`]: props.dynamicFiltering, })); const isFiltering = computed(() => props.dynamicFiltering && !!props.value && props.open); const ariaAttributes = computed( () => ({ 'aria-haspopup': 'listbox', 'aria-controls': props.ariaControls, 'aria-describedby': attrs['aria-describedby']?.toString() as string, 'aria-expanded': props.ariaExpanded ? 'true' : 'false', 'aria-labelledby': props.ariaLabelledby, 'aria-label': props.ariaLabelledby ? null : props.ariaLabel, 'aria-invalid': props.isInvalid, 'aria-disabled': props.disabled ? true : null, 'aria-required': shouldShowInput.value ? null : isRequired.value, }) as HTMLAttributes ); // Cast selectedValueItem if it is an array of selected items const selectedValueItems = computed(() => props.multiple ? (props.selectedValueItem as Array<BentoListboxOptionItem>) : [] ); const shouldShowInput = computed(() => props.dynamicFiltering && (props.open || props.alwaysComboboxIsInput)); const shouldShowSingleSelectSlot = computed(() => props.selectedValueItem && !props.multiple); const shouldShowMultipleSlot = computed( () => props.multiple && props.selectedValueItem && selectedValueItems.value.length > 0 && props.showSlotContentInMultiple ); watch( () => shouldShowInput.value, () => { if (shouldShowInput.value && !props.alwaysComboboxIsInput) { nextTick(() => { textbox.value.focus(); }); } } ); const onOpenDropdown = async () => { if (!props.disabled && !props.readonly) { emit(DropdownBaseTextboxEvent.OPEN); // wait for popover to open before setting focus await nextTick(); textbox.value.focus(); } }; const onCloseDropdown = () => { if (props.open) { textbox.value.focus(); emit(DropdownBaseTextboxEvent.CLOSE); } }; const onToggleDropdown = (event: Event) => { const isDynamicFilteringInputClickEvent = props.open && (event.target as HTMLDivElement).tagName === 'INPUT'; if (isDynamicFilteringInputClickEvent) { /** * When there's a click even on the input during filtering * and the popover is open, do not toggle the popover. * Allow for click events inside the input field */ return null; } return props.open ? onCloseDropdown() : onOpenDropdown(); }; const onClearSearch = () => { emit(DropdownBaseTextboxEvent.CLEAR); emit(DropdownBaseTextboxEvent.INPUT, ''); textbox.value.focus(); }; const onInputEvent = (event: Event) => { if (!props.readonly && !props.disabled) { emit(DropdownBaseTextboxEvent.INPUT, (event.target as HTMLInputElement).value); } else { event.preventDefault(); event.stopPropagation(); } }; const focus = () => { textbox.value.focus(); }; defineExpose({ focus, }); </script> <script lang="ts"> /** * Only for internal use, implements the logic of the textbox element to be implemented in the combobox. */ export default defineComponent({ i18n: { messages }, name: 'dropdown-base-textbox', inheritAttrs: false, }); </script> <style lang="scss" scoped src="./dropdown-base-textbox.scss" />
1
+ <template> <div :class="$attrs.class" data-testid="textbox-wrapper" @click="onToggleDropdown" @keydown.escape="onCloseDropdown" > <bento-typography class="b-dropdown-base-textbox" :class="conditionalClasses" el="div" data-testid="base-textbox" > <input v-if="shouldShowInput" v-bind="ariaAttributes" :id="textboxId" ref="textbox" class="b-dropdown-base-textbox__input" role="combobox" type="text" aria-autocomplete="list" size="1" :value="open ? value : displayValue" :disabled="disabled ? true : null" :readonly="readonly" :required="showRequiredAttribute" @keydown="$emit(DropdownBaseTextboxEvent.KEYDOWN, $event)" @keydown.enter="onToggleDropdown" @input="onInputEvent" /> <div v-else :id="textboxId" v-bind="ariaAttributes" ref="textbox" class="b-dropdown-base-textbox__input" :class="comboboxConditionalClasses" :aria-readonly="readonly ? 'true' : null" role="combobox" :tabindex="disabled ? -1 : 0" > <template v-if="shouldShowSingleSelectSlot"> <slot v-if="hasSlot('display-value')" v-bind="selectedValueItem" name="display-value"></slot> <slot v-else v-bind="selectedValueItem"> {{ displayValue }} </slot> </template> <template v-else-if="shouldShowMultipleSlot"> <div class="b-dropdown-base-textbox__input-multiple-selected"> <div v-for="singleItem of selectedValueItems" :key="singleItem.value"> <div class="b-dropdown-base-textbox__input-selected-element"> <slot v-bind="singleItem"> {{ displayValue }} </slot> </div> </div> </div> </template> <template v-else>{{ displayValue }}</template> </div> <span v-if="additionalItemsSelected" class="b-dropdown-base-textbox__additional-items"> {{ additionalItemsSelected }} </span> <span v-if="hasSlot('icon') && !isFiltering" class="b-dropdown-base-textbox__icon" aria-hidden="true"> <slot name="icon"></slot> </span> <span v-if="isFiltering" class="b-dropdown-base-textbox__clear-search" :tabindex="disabled ? -1 : 0" data-testid="clear-search-button" @click.stop="onClearSearch" @keydown.enter.stop="onClearSearch" @keyup.space="onClearSearch" > <cross-circle-fill-small-icon v-if="size === BentoDropdownSize.SMALL" :svg-title="t('clearSearch')" /> <cross-circle-fill-icon v-else :svg-title="t('clearSearch')" /> </span> </bento-typography> </div> </template> <script setup lang="ts"> import { computed, defineComponent, nextTick, type PropType, ref, useAttrs, useSlots, watch } from 'vue'; import { generateUid } from '@/core/utils/ts'; import { useI18n } from '@/utils/ts/i18n'; import { useHasSlot } from '@/composables'; import { BentoTypography } from '@/components/typography'; import { type Booleanish } from '@/types/prop-types'; import { type BentoListboxOptionItem } from '@/types/listbox'; import type { HTMLAttributes } from 'vue/types/jsx.d.ts'; import { DropdownBaseTextboxEvent, DropdownBaseTextboxState } from './dropdown-base-textbox.types'; import CrossCircleFillIcon from '@adyen/ui-assets-icons-16/vue/cross-circle-fill'; import CrossCircleFillSmallIcon from '@adyen/ui-assets-icons-16/vue/cross-circle-fill-small'; import { BentoDropdownSize } from '@/components/dropdown/dropdown.types'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const { t } = useI18n<{ message: MessageSchema }>({ messages }); const attrs = useAttrs(); const slots = useSlots(); const hasSlot = useHasSlot(slots); const props = defineProps({ /** * The number of additional items that have been selected in the dropdown. */ additionalItemsSelected: { type: [Number, String] as PropType<number | string>, default: 0 }, /** * If the combobox DOM element should be an `input` element. */ alwaysComboboxIsInput: { type: Boolean, default: false, }, /** * Sets the aria-controls attribute on the textbox. This should correspond to the popover's id that the textbox triggers. */ ariaControls: { type: String, required: true }, /** * Indicates whether the content the textbox triggers is visible or not. Needs to be set accordingly when the textbox is used and changed every time the popover is shown/hidden. */ ariaExpanded: { type: [String, Boolean] as PropType<Booleanish>, required: true, }, /** * If no label element is used then the aria-label is be set should be set. */ ariaLabel: { type: String, default: null }, /** * Corresponds to the label's id that labels the textbox element. */ ariaLabelledby: { type: String, default: null }, /** * Disables any textbox functionality */ disabled: { type: Boolean, default: false }, /** * Selected option's label */ displayValue: { type: String, default: null }, /** * Sets the possibility to type into the textbox. */ dynamicFiltering: { type: Boolean, default: false }, /** * Marks the textbox as invalid, rendering an error state */ isInvalid: { type: Boolean, default: false }, /* * If enabled, the user will be able to select multiple items. */ multiple: { type: Boolean, default: false }, /** * Indicates if the dropdown is open so that * the respective event can be emitted */ open: { type: Boolean, default: false }, /** * Sets the dropdown to readonly state */ readonly: { type: Boolean, default: false }, /** * The input value */ value: { type: String, default: '' }, /** * The selected value's list box item. */ selectedValueItem: { type: [Object, Array] as PropType<BentoListboxOptionItem | Array<BentoListboxOptionItem>>, default: undefined, }, /** * Toggles whether to use the slot content select items shown in the textbox in multiple mode. */ showSlotContentInMultiple: { type: Boolean, default: false }, /** * Dropdown size */ size: { type: String as PropType<BentoDropdownSize | `${BentoDropdownSize}`>, default: null, }, }); const emit = defineEmits<{ /** * Emitted when the "clear" button is clicked */ (e: DropdownBaseTextboxEvent.CLEAR); /** * Emitted on click, space, enter and esc user events. It will close the dropdown. */ (e: DropdownBaseTextboxEvent.CLOSE); /** * Emitted when `dynamicFiltering` is enabled and a search input is entered */ (e: DropdownBaseTextboxEvent.INPUT, searchValue: string); /** * Emitted when any key is pressed and the focus is on the input field */ (e: DropdownBaseTextboxEvent.KEYDOWN, event: KeyboardEvent); /** * Emitted on click, space and enter user events. It will open the dropdown. */ (e: DropdownBaseTextboxEvent.OPEN); }>(); const textbox = ref(null); const textboxId = generateUid('textbox'); const conditionalClasses = computed(() => ({ [`b-dropdown-base-textbox--${DropdownBaseTextboxState.DISABLED}`]: props.disabled, [`b-dropdown-base-textbox--${DropdownBaseTextboxState.READONLY}`]: props.readonly, [`b-dropdown-base-textbox--${DropdownBaseTextboxState.HAS_ITEMS}`]: !!props.additionalItemsSelected, })); const showRequiredAttribute = computed(() => shouldShowInput.value && attrs['aria-required'] ? ('true' as Booleanish) : null ); const comboboxConditionalClasses = computed(() => ({ [`b-dropdown-base-textbox__input--dynamic-filtering`]: props.dynamicFiltering, })); const isFiltering = computed(() => props.dynamicFiltering && !!props.value && props.open); const ariaAttributes = computed( () => ({ 'aria-haspopup': 'listbox', 'aria-controls': props.ariaControls, 'aria-describedby': attrs['aria-describedby']?.toString() as string, 'aria-expanded': props.ariaExpanded ? 'true' : 'false', 'aria-labelledby': props.ariaLabelledby, 'aria-label': props.ariaLabelledby ? null : props.ariaLabel, 'aria-invalid': props.isInvalid, 'aria-disabled': props.disabled ? true : null, 'aria-required': attrs['aria-required'] as Booleanish, }) as HTMLAttributes ); // Cast selectedValueItem if it is an array of selected items const selectedValueItems = computed(() => props.multiple ? (props.selectedValueItem as Array<BentoListboxOptionItem>) : [] ); const shouldShowInput = computed(() => props.dynamicFiltering && (props.open || props.alwaysComboboxIsInput)); const shouldShowSingleSelectSlot = computed(() => props.selectedValueItem && !props.multiple); const shouldShowMultipleSlot = computed( () => props.multiple && props.selectedValueItem && selectedValueItems.value.length > 0 && props.showSlotContentInMultiple ); watch( () => shouldShowInput.value, () => { if (shouldShowInput.value && !props.alwaysComboboxIsInput) { nextTick(() => { textbox.value.focus(); }); } } ); const onOpenDropdown = async () => { if (!props.disabled && !props.readonly) { emit(DropdownBaseTextboxEvent.OPEN); // wait for popover to open before setting focus await nextTick(); textbox.value.focus(); } }; const onCloseDropdown = () => { if (props.open) { textbox.value.focus(); emit(DropdownBaseTextboxEvent.CLOSE); } }; const onToggleDropdown = (event: Event) => { const isDynamicFilteringInputClickEvent = props.open && (event.target as HTMLDivElement).tagName === 'INPUT'; if (isDynamicFilteringInputClickEvent) { /** * When there's a click even on the input during filtering * and the popover is open, do not toggle the popover. * Allow for click events inside the input field */ return null; } return props.open ? onCloseDropdown() : onOpenDropdown(); }; const onClearSearch = () => { emit(DropdownBaseTextboxEvent.CLEAR); emit(DropdownBaseTextboxEvent.INPUT, ''); textbox.value.focus(); }; const onInputEvent = (event: Event) => { if (!props.readonly && !props.disabled) { emit(DropdownBaseTextboxEvent.INPUT, (event.target as HTMLInputElement).value); } else { event.preventDefault(); event.stopPropagation(); } }; const focus = () => { textbox.value.focus(); }; defineExpose({ focus, }); </script> <script lang="ts"> /** * Only for internal use, implements the logic of the textbox element to be implemented in the combobox. */ export default defineComponent({ i18n: { messages }, name: 'dropdown-base-textbox', inheritAttrs: false, }); </script> <style lang="scss" scoped src="./dropdown-base-textbox.scss" />
@@ -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="clickOutsideDropdown" class="b-dropdown" :class="conditionalClasses"> <field-label v-if="label" :id="labelId" :condensed="condensed" :label="label" :tooltip-text="tooltipText" :optional="optional" :required="required" /> <div ref="inputContainerRefWrapper" v-bento-keyboard-navigation-directive v-on="textboxKeyboardNavigationListeners" > <component :is="computedSize" ref="inputContainerRef" :condensed="condensed" :value="searchTerm" :additional-items-selected="additionalItemsSelected" :aria-controls="dropdownOptionsContainerId" :ariaExpanded="isDropdownOpen" :aria-labelledby="label ? labelId : null" :aria-describedby="ariaDescribedBy" :aria-label="computedAriaLabel" :aria-readonly="isReadOnly" :readonly="isReadOnly" :required="required" :disabled="disabled" :display-value="displayValue" :dynamic-filtering="dynamicFiltering" :is-invalid="!!errorMessage || error" :multiple="multiple" :open="isDropdownOpen" :selected-value-item="!isInputComponent ? selectedValueItem : undefined" :show-slot-content-in-multiple="showSlotContentInMultiple" @input="searchTerm = $event" @open="toggleDropdown" @close="toggleDropdown" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </component> </div> <bento-dropdown-options-container v-if="inputContainerRef" :id="dropdownOptionsContainerId" ref="optionsContainerRef" :aria-label="computedAriaLabel" :component-loading="componentLoading" :empty-state="emptyState" :disabled="disabled" :is-option-disabled="isOptionDisabled" :items="filteredItems" :multiple="multiple" :static-categories="staticCategories" :open="isDropdownOpen" :selected-value="cachedSelectedListboxOptions" :searching="isSearching" :target-element="inputContainerRefWrapper" :loading="loading" :has-more-items="hasMoreItems" :lazy-load-type="lazyLoadType" :virtual-scroll="virtualScroll" v-on="listboxListeners" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </bento-dropdown-options-container> <error-message v-if="!!errorMessage" :id="errorId" :error-message="errorMessage" class="b-dropdown__error-message" /> <bento-typography v-if="description || hasSlot('description')" :id="descriptionId" class="b-dropdown__description" :class="descriptionConditionalClasses" el="span" > <slot name="description"> {{ description }} </slot> </bento-typography> </div> </template> <script setup lang="ts"> import { computed, defineComponent, inject, nextTick, type Ref, ref, toRef, toRefs, useAttrs, useSlots, watch, } from 'vue'; // Components import { BentoDropdownOptionsContainer } from './components/dropdown-options-container'; import { BentoTypography } from '@/components/typography'; import { DropdownInputDefault, DropdownInputSmall } from './components'; import { BentoListbox, ErrorMessage, FieldLabel, useCachedSelectedValues, useMultiLevelItems } from '@/internal'; // Utils import { deprecate } from '@/utils/ts/deprecate'; import { generateUid } from '@/core/utils/ts'; import { useAriaLabel } from '@/utils/ts/aria-label'; import { useI18n } from '@/utils/ts/i18n'; import { useFormFieldEmits } from '@/utils/ts/form-field/form-field'; // Composables import { useDisplayValue, useListboxKeyboardNavigation, useTextboxKeyboardNavigation } from './composables'; import { useVisibleDomOptions } from '@/internal/listbox/composables/use-visible-dom-options/use-visible-dom-options'; import { useFormLayoutFieldLoading } from '@/composables/use-form-layout-loading/use-form-layout-loading'; import { useSearchBarFilter } from '@/components/search-bar/useSearchBarDataFilter'; // Directives import { BentoClickOutsideDirective as vBentoClickOutsideDirective } from '@/directives/click-outside'; import { BentoKeyboardNavigationDirective as vBentoKeyboardNavigationDirective } from '@/directives'; // Types import { BentoDropdownEvent, type BentoDropdownProps, BentoDropdownSize } from './dropdown.types'; import { BentoListboxEvent, type BentoListboxItemRole, type BentoListboxMultiSelectValue, type BentoListboxOptions, type BentoListboxSelectedValue, type BentoListboxSelectedValueLabelPair, } from '@/types/listbox'; import messages from './messages.json'; import { useHasSlot } from '@/composables'; import { INPUT_FIELD_COMPONENT_INJECTION_KEY } from '@/components/input-field/input-field.keys'; type MessageSchema = (typeof messages)['en-US']; const attrs = useAttrs(); const slots = useSlots(); const hasSlot = useHasSlot(slots); const props = withDefaults(defineProps<BentoDropdownProps>(), { ariaLabel: null, condensed: false, emptyState: undefined, disabled: false, dynamicFiltering: false, error: false, errorMessage: null, description: null, hasMoreItems: false, isOptionDisabled: undefined, items: () => [], label: null, loading: false, componentLoading: false, lazyLoadType: BentoListbox.props.lazyLoadType.default, multiple: false, optional: false, placeholder: null, readonly: false, required: false, search: undefined, showSlotContentInMultiple: false, size: null, staticCategories: false, tooltipText: null, value: null, modelValue: null, virtualScroll: false, enableValueLabelPair: false, }); const emit = defineEmits<{ /** * Emitted when an option is clicked. Updates the selected option from the "value" property linked to the v-model. * @deprecated since version 2.0. Use `v-model` or `update:model-value instead. */ (e: 'input', selectedValue: BentoListboxSelectedValue | any); // eslint-disable-line @typescript-eslint/no-explicit-any /** * Emitted when an option is clicked. Updates the selected option from the "modelValue" property linked to the v-model. */ (e: 'update:model-value', selectedValue: BentoListboxSelectedValue | any); // eslint-disable-line @typescript-eslint/no-explicit-any /** * Emitted when: * - `lazyLoad` is `automatic` and the scroll meets the end of the list * - `lazyload` is `button` and the button is clicked */ (e: 'show-more'); /** * Emitted when the dropdown is opened. */ (e: 'open'); }>(); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const { emitValue } = useFormFieldEmits<BentoListboxSelectedValue>(emit); const inputContainerRef = ref(null); const inputContainerRefWrapper = ref(null); const optionsContainerRef: Ref<InstanceType<typeof BentoDropdownOptionsContainer>> = ref(null); const isDropdownOpen = ref(false); const dropdownOptionsContainerId = generateUid('bento-dropdown-options-container'); const labelId = generateUid('dropdown-label'); const descriptionId = generateUid('dropdown-description'); const errorId = generateUid('dropdown-error'); const { isReadOnly } = useFormLayoutFieldLoading(toRef(props, 'readonly')); const dropdownValue = computed(() => props.modelValue ?? props.value); const { cachedSelectedListboxOptions, cachedListboxSelectedValues, setCachedValues } = useCachedSelectedValues( toRef(props, 'items'), dropdownValue, toRef(props, 'multiple'), toRef(props, 'enableValueLabelPair') ); const ariaLabelAttribute = ref(attrs['aria-label']); const ariaDescribedByAttribute = ref(attrs['aria-describedby']); const { ariaLabel, label } = toRefs(props); // vue-i18n issue with t() used in script - direct usage in template works // requires either createI18n({ legacy: false }) or to be wrapped in computed() const computedAriaLabelFallbackMessage = computed(() => t('ariaLabelFallback')); const computedAriaLabel = useAriaLabel({ // TODO: take ariaLabel ref away when aria-label prop is removed ariaLabel: ariaLabel.value || (ariaLabelAttribute as Ref<string>), label, defaultFallback: computedAriaLabelFallbackMessage, }); // Inject input field component key to change the dropdown size const isInputComponent = props.size === BentoDropdownSize.SMALL || inject(INPUT_FIELD_COMPONENT_INJECTION_KEY, false); const computedSize = computed(() => (isInputComponent ? BentoDropdownSize.SMALL : BentoDropdownSize.DEFAULT)); const conditionalClasses = computed(() => ({ 'b-dropdown--condensed': props.condensed, })); // Description styles const descriptionConditionalClasses = computed(() => ({ 'b-dropdown__description--error': !!props.errorMessage, })); // Filtering const searchTerm = ref(''); const items = toRef(props, 'items'); const staticCategories = toRef(props, 'staticCategories'); const filteredItems = useSearchBarFilter( searchTerm, items, props?.search?.searchEvent, staticCategories, props?.search?.debounceTime ); const isSearching = computed(() => props.dynamicFiltering && !!searchTerm.value); const displayValue = useDisplayValue({ items, placeholder: toRef(props, 'placeholder'), isMultiSelect: toRef(props, 'multiple'), selectedValues: cachedSelectedListboxOptions, }); const selectedValueItem = computed(() => { if (props?.multiple && Array.isArray(dropdownValue.value)) { return props.items.filter( ({ value }) => props?.value && (props?.value as BentoListboxMultiSelectValue).includes(value) ); } if (!props.multiple && props.staticCategories) { // Find the category that holds the selected value const category = props.items.find(categoryItem => categoryItem.items.some(item => item.value === dropdownValue.value) ); // Find the specific item within the category const selectedCategoryItem = category ? category.items.find(item => item.value === dropdownValue.value) : null; return selectedCategoryItem; } return props.items.find(({ value }) => value === dropdownValue.value); }); const selectedValueIndex = computed(() => { const index = filteredItems.value.findIndex(({ value }) => { return props.enableValueLabelPair ? (dropdownValue.value as BentoListboxSelectedValueLabelPair).value === value : dropdownValue.value === value; }); return index > -1 ? index : 0; }); const { isMultiLevelSelect, flattenedItemsList } = useMultiLevelItems(items); const isTreePattern = computed(() => isMultiLevelSelect.value && !props.staticCategories); const computedOptionRole = computed<BentoListboxItemRole>(() => (isTreePattern.value ? 'treeitem' : 'option')); const additionalItemsSelected = computed(() => { if (props.multiple) { const selectedItemsCount = Array.isArray(dropdownValue.value) ? dropdownValue.value.length : 0; const total = isMultiLevelSelect.value ? flattenedItemsList.value.length : items.value.length; if (selectedItemsCount === 0) { return null; } // Display count always - show "all" only when is not external filtering (hasMoreItems = true) if (!props.hasMoreItems && total > 0 && total === selectedItemsCount) { return t('all'); } return selectedItemsCount; } return null; }); // ARIA const ariaDescribedBy = computed( () => [ props.description ? descriptionId : null, ariaDescribedByAttribute.value, props.errorMessage ? errorId : null, ] .filter(Boolean) .join(' ') || null ); const listboxRef = computed(() => optionsContainerRef.value?.$el as HTMLDivElement); const { visibleDomOptions, updateVisibleDomOptions } = useVisibleDomOptions( listboxRef, filteredItems, computedOptionRole ); const openDropdown = () => { if (isReadOnly.value) { return; } isDropdownOpen.value = true; nextTick(() => { updateVisibleDomOptions(); }); }; const closeDropdown = () => { isDropdownOpen.value = false; }; const toggleDropdown = () => { if (isReadOnly.value) { return; } isDropdownOpen.value = !isDropdownOpen.value; nextTick(() => { updateVisibleDomOptions(); }); // Clear search if (isSearching.value) { searchTerm.value = ''; } }; const clickOutsideDropdown = () => { if (isSearching.value) { searchTerm.value = ''; } if (isDropdownOpen.value) { isDropdownOpen.value = false; // Reset the internal dropdown options state optionsContainerRef.value.onOutsideDropdownClick(); } }; const onOptionSelected = (selectedValue: BentoListboxOptions) => { // Clear and close the dropdown on single selection if (!props.multiple) { searchTerm.value = ''; isDropdownOpen.value = false; focus(); } setCachedValues(selectedValue); /** * Emit the value/label pair objects if enableValueLabelPair is enabled, * otherwise just emit the list of selected values (number or string) */ emitValue(cachedListboxSelectedValues.value); }; const emitShowMoreEvent = () => { emit(BentoDropdownEvent.SHOW_MORE); }; const textboxKeyboardNavigationListeners = useTextboxKeyboardNavigation( { inputContainerRef, optionsContainerRef, selectedValueIndex, isDropdownOpen, isDynamicFltering: toRef(props, 'dynamicFiltering'), items: visibleDomOptions, isMultiple: props.multiple, }, toggleDropdown, openDropdown, closeDropdown ); const listboxKeyboardNavigationListeners = useListboxKeyboardNavigation( { inputContainerRef, isDropdownOpen, }, closeDropdown ); const listboxListeners = { [BentoListboxEvent.CLOSE_DROPDOWN]: closeDropdown, [BentoListboxEvent.SELECT]: onOptionSelected, [BentoListboxEvent.SHOW_MORE]: emitShowMoreEvent, ...listboxKeyboardNavigationListeners, }; if (props.error) { deprecate( 'BentoDropdown "error" property', `Use the BentoDropdown "errorMessage" property instead to indicate that there is a modifier error. Having the field as "undefined" will hide/remove the error. <bento-dropdown errorMessage="Error message text" />`, '2.0.0' ); } if (props.size) { deprecate('BentoDropdown "size" property', `Do not use. Only 'default' size should be used.`, '2.0.0'); } if (props.value) { deprecate( 'BentoDropdown "value" property', `The use of "value" prop in "BentoDropdown" is no longer supported. Use the "v-model" or "model-value" property instead.`, '2.0.0' ); } const focus = () => { inputContainerRef.value.focus(); }; watch( () => isDropdownOpen.value, (isOpen: boolean) => { if (isOpen) { emit('open'); } } ); defineExpose({ focus, }); </script> <script lang="ts"> /** * A Dropdown shows a selected option's. * Use a dropdown when you want users to select options * from a list of pre-defined options. * * @example * import { BentoDropdown } from '@adyen/bento-vue2'; * import type { BentoDropdownOptions } from '@adyen/bento-vue2' * * export default { * components: { BentoDropdown }, * template: ` * <bento-dropdown * v-model="selectedValue" * :disabled="false" * :multiple="true" * :isOptionsDisabled="option => option.value === 2" * :items="options" * /> * `, * setup() { * const selectedValue = ref(2) // Default value * const options: BentoDropdownOptions = [ * { label: 'Option 1', value: 1 }, * { label: 'Option 2', value: 2 }, * ]; * return { options, selectedValue }; * } * } */ export default defineComponent({ i18n: { messages }, name: 'b-dropdown', components: { DropdownInputDefault, DropdownInputSmall, }, model: { prop: 'modelValue' }, }); </script> <style lang="scss" scoped src="./dropdown.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="clickOutsideDropdown" class="b-dropdown" :class="conditionalClasses"> <field-label v-if="label" :id="labelId" :condensed="condensed" :label="label" :tooltip-text="tooltipText" :optional="optional" :required="required" /> <div ref="inputContainerRefWrapper" v-bento-keyboard-navigation-directive v-on="textboxKeyboardNavigationListeners" > <component :is="computedSize" ref="inputContainerRef" :condensed="condensed" :value="searchTerm" :additional-items-selected="additionalItemsSelected" :aria-controls="dropdownOptionsContainerId" :ariaExpanded="isDropdownOpen" :aria-labelledby="label ? labelId : null" :aria-describedby="ariaDescribedBy" :aria-label="computedAriaLabel" :aria-readonly="isReadOnly" :aria-required="required" :readonly="isReadOnly" :disabled="disabled" :display-value="displayValue" :dynamic-filtering="dynamicFiltering" :is-invalid="!!errorMessage || error" :multiple="multiple" :open="isDropdownOpen" :selected-value-item="!isInputComponent ? selectedValueItem : undefined" :show-slot-content-in-multiple="showSlotContentInMultiple" @input="searchTerm = $event" @open="toggleDropdown" @close="toggleDropdown" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </component> </div> <bento-dropdown-options-container v-if="inputContainerRef" :id="dropdownOptionsContainerId" ref="optionsContainerRef" :aria-label="computedAriaLabel" :component-loading="componentLoading" :empty-state="emptyState" :disabled="disabled" :is-option-disabled="isOptionDisabled" :items="filteredItems" :multiple="multiple" :static-categories="staticCategories" :open="isDropdownOpen" :selected-value="cachedSelectedListboxOptions" :searching="isSearching" :target-element="inputContainerRefWrapper" :loading="loading" :has-more-items="hasMoreItems" :lazy-load-type="lazyLoadType" :virtual-scroll="virtualScroll" v-on="listboxListeners" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </bento-dropdown-options-container> <error-message v-if="!!errorMessage" :id="errorId" :error-message="errorMessage" class="b-dropdown__error-message" /> <bento-typography v-if="description || hasSlot('description')" :id="descriptionId" class="b-dropdown__description" :class="descriptionConditionalClasses" el="span" > <slot name="description"> {{ description }} </slot> </bento-typography> </div> </template> <script setup lang="ts"> import { computed, defineComponent, inject, nextTick, type Ref, ref, toRef, toRefs, useAttrs, useSlots, watch, } from 'vue'; // Components import { BentoDropdownOptionsContainer } from './components/dropdown-options-container'; import { BentoTypography } from '@/components/typography'; import { DropdownInputDefault, DropdownInputSmall } from './components'; import { BentoListbox, ErrorMessage, FieldLabel, useCachedSelectedValues, useMultiLevelItems } from '@/internal'; // Utils import { deprecate } from '@/utils/ts/deprecate'; import { generateUid } from '@/core/utils/ts'; import { useAriaLabel } from '@/utils/ts/aria-label'; import { useI18n } from '@/utils/ts/i18n'; import { useFormFieldEmits } from '@/utils/ts/form-field/form-field'; // Composables import { useDisplayValue, useListboxKeyboardNavigation, useTextboxKeyboardNavigation } from './composables'; import { useVisibleDomOptions } from '@/internal/listbox/composables/use-visible-dom-options/use-visible-dom-options'; import { useFormLayoutFieldLoading } from '@/composables/use-form-layout-loading/use-form-layout-loading'; import { useSearchBarFilter } from '@/components/search-bar/useSearchBarDataFilter'; // Directives import { BentoClickOutsideDirective as vBentoClickOutsideDirective } from '@/directives/click-outside'; import { BentoKeyboardNavigationDirective as vBentoKeyboardNavigationDirective } from '@/directives'; // Types import { BentoDropdownEvent, type BentoDropdownProps, BentoDropdownSize } from './dropdown.types'; import { BentoListboxEvent, type BentoListboxItemRole, type BentoListboxMultiSelectValue, type BentoListboxOptions, type BentoListboxSelectedValue, type BentoListboxSelectedValueLabelPair, } from '@/types/listbox'; import messages from './messages.json'; import { useHasSlot } from '@/composables'; import { INPUT_FIELD_COMPONENT_INJECTION_KEY } from '@/components/input-field/input-field.keys'; type MessageSchema = (typeof messages)['en-US']; const attrs = useAttrs(); const slots = useSlots(); const hasSlot = useHasSlot(slots); const props = withDefaults(defineProps<BentoDropdownProps>(), { ariaLabel: null, condensed: false, emptyState: undefined, disabled: false, dynamicFiltering: false, error: false, errorMessage: null, description: null, hasMoreItems: false, isOptionDisabled: undefined, items: () => [], label: null, loading: false, componentLoading: false, lazyLoadType: BentoListbox.props.lazyLoadType.default, multiple: false, optional: false, placeholder: null, readonly: false, required: false, search: undefined, showSlotContentInMultiple: false, size: null, staticCategories: false, tooltipText: null, value: null, modelValue: null, virtualScroll: false, enableValueLabelPair: false, }); const emit = defineEmits<{ /** * Emitted when an option is clicked. Updates the selected option from the "value" property linked to the v-model. * @deprecated since version 2.0. Use `v-model` or `update:model-value instead. */ (e: 'input', selectedValue: BentoListboxSelectedValue | any); // eslint-disable-line @typescript-eslint/no-explicit-any /** * Emitted when an option is clicked. Updates the selected option from the "modelValue" property linked to the v-model. */ (e: 'update:model-value', selectedValue: BentoListboxSelectedValue | any); // eslint-disable-line @typescript-eslint/no-explicit-any /** * Emitted when: * - `lazyLoad` is `automatic` and the scroll meets the end of the list * - `lazyload` is `button` and the button is clicked */ (e: 'show-more'); /** * Emitted when the dropdown is opened. */ (e: 'open'); }>(); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const { emitValue } = useFormFieldEmits<BentoListboxSelectedValue>(emit); const inputContainerRef = ref(null); const inputContainerRefWrapper = ref(null); const optionsContainerRef: Ref<InstanceType<typeof BentoDropdownOptionsContainer>> = ref(null); const isDropdownOpen = ref(false); const dropdownOptionsContainerId = generateUid('bento-dropdown-options-container'); const labelId = generateUid('dropdown-label'); const descriptionId = generateUid('dropdown-description'); const errorId = generateUid('dropdown-error'); const { isReadOnly } = useFormLayoutFieldLoading(toRef(props, 'readonly')); const dropdownValue = computed(() => props.modelValue ?? props.value); const { cachedSelectedListboxOptions, cachedListboxSelectedValues, setCachedValues } = useCachedSelectedValues( toRef(props, 'items'), dropdownValue, toRef(props, 'multiple'), toRef(props, 'enableValueLabelPair') ); const ariaLabelAttribute = ref(attrs['aria-label']); const ariaDescribedByAttribute = ref(attrs['aria-describedby']); const { ariaLabel, label } = toRefs(props); // vue-i18n issue with t() used in script - direct usage in template works // requires either createI18n({ legacy: false }) or to be wrapped in computed() const computedAriaLabelFallbackMessage = computed(() => t('ariaLabelFallback')); const computedAriaLabel = useAriaLabel({ // TODO: take ariaLabel ref away when aria-label prop is removed ariaLabel: ariaLabel.value || (ariaLabelAttribute as Ref<string>), label, defaultFallback: computedAriaLabelFallbackMessage, }); // Inject input field component key to change the dropdown size const isInputComponent = props.size === BentoDropdownSize.SMALL || inject(INPUT_FIELD_COMPONENT_INJECTION_KEY, false); const computedSize = computed(() => (isInputComponent ? BentoDropdownSize.SMALL : BentoDropdownSize.DEFAULT)); const conditionalClasses = computed(() => ({ 'b-dropdown--condensed': props.condensed, })); // Description styles const descriptionConditionalClasses = computed(() => ({ 'b-dropdown__description--error': !!props.errorMessage, })); // Filtering const searchTerm = ref(''); const items = toRef(props, 'items'); const staticCategories = toRef(props, 'staticCategories'); const filteredItems = useSearchBarFilter( searchTerm, items, props?.search?.searchEvent, staticCategories, props?.search?.debounceTime ); const isSearching = computed(() => props.dynamicFiltering && !!searchTerm.value); const displayValue = useDisplayValue({ items, placeholder: toRef(props, 'placeholder'), isMultiSelect: toRef(props, 'multiple'), selectedValues: cachedSelectedListboxOptions, }); const selectedValueItem = computed(() => { if (props?.multiple && Array.isArray(dropdownValue.value)) { return props.items.filter( ({ value }) => props?.value && (props?.value as BentoListboxMultiSelectValue).includes(value) ); } if (!props.multiple && props.staticCategories) { // Find the category that holds the selected value const category = props.items.find(categoryItem => categoryItem.items.some(item => item.value === dropdownValue.value) ); // Find the specific item within the category const selectedCategoryItem = category ? category.items.find(item => item.value === dropdownValue.value) : null; return selectedCategoryItem; } return props.items.find(({ value }) => value === dropdownValue.value); }); const selectedValueIndex = computed(() => { const index = filteredItems.value.findIndex(({ value }) => { return props.enableValueLabelPair ? (dropdownValue.value as BentoListboxSelectedValueLabelPair).value === value : dropdownValue.value === value; }); return index > -1 ? index : 0; }); const { isMultiLevelSelect, flattenedItemsList } = useMultiLevelItems(items); const isTreePattern = computed(() => isMultiLevelSelect.value && !props.staticCategories); const computedOptionRole = computed<BentoListboxItemRole>(() => (isTreePattern.value ? 'treeitem' : 'option')); const additionalItemsSelected = computed(() => { if (props.multiple) { const selectedItemsCount = Array.isArray(dropdownValue.value) ? dropdownValue.value.length : 0; const total = isMultiLevelSelect.value ? flattenedItemsList.value.length : items.value.length; if (selectedItemsCount === 0) { return null; } // Display count always - show "all" only when is not external filtering (hasMoreItems = true) if (!props.hasMoreItems && total > 0 && total === selectedItemsCount) { return t('all'); } return selectedItemsCount; } return null; }); // ARIA const ariaDescribedBy = computed( () => [ props.description ? descriptionId : null, ariaDescribedByAttribute.value, props.errorMessage ? errorId : null, ] .filter(Boolean) .join(' ') || null ); const listboxRef = computed(() => optionsContainerRef.value?.$el as HTMLDivElement); const { visibleDomOptions, updateVisibleDomOptions } = useVisibleDomOptions( listboxRef, filteredItems, computedOptionRole ); const openDropdown = () => { if (isReadOnly.value) { return; } isDropdownOpen.value = true; nextTick(() => { updateVisibleDomOptions(); }); }; const closeDropdown = () => { isDropdownOpen.value = false; }; const toggleDropdown = () => { if (isReadOnly.value) { return; } isDropdownOpen.value = !isDropdownOpen.value; nextTick(() => { updateVisibleDomOptions(); }); // Clear search if (isSearching.value) { searchTerm.value = ''; } }; const clickOutsideDropdown = () => { if (isSearching.value) { searchTerm.value = ''; } if (isDropdownOpen.value) { isDropdownOpen.value = false; // Reset the internal dropdown options state optionsContainerRef.value.onOutsideDropdownClick(); } }; const onOptionSelected = (selectedValue: BentoListboxOptions) => { // Clear and close the dropdown on single selection if (!props.multiple) { searchTerm.value = ''; isDropdownOpen.value = false; focus(); } setCachedValues(selectedValue); /** * Emit the value/label pair objects if enableValueLabelPair is enabled, * otherwise just emit the list of selected values (number or string) */ emitValue(cachedListboxSelectedValues.value); }; const emitShowMoreEvent = () => { emit(BentoDropdownEvent.SHOW_MORE); }; const textboxKeyboardNavigationListeners = useTextboxKeyboardNavigation( { inputContainerRef, optionsContainerRef, selectedValueIndex, isDropdownOpen, isDynamicFltering: toRef(props, 'dynamicFiltering'), items: visibleDomOptions, isMultiple: props.multiple, }, toggleDropdown, openDropdown, closeDropdown ); const listboxKeyboardNavigationListeners = useListboxKeyboardNavigation( { inputContainerRef, isDropdownOpen, }, closeDropdown ); const listboxListeners = { [BentoListboxEvent.CLOSE_DROPDOWN]: closeDropdown, [BentoListboxEvent.SELECT]: onOptionSelected, [BentoListboxEvent.SHOW_MORE]: emitShowMoreEvent, ...listboxKeyboardNavigationListeners, }; if (props.error) { deprecate( 'BentoDropdown "error" property', `Use the BentoDropdown "errorMessage" property instead to indicate that there is a modifier error. Having the field as "undefined" will hide/remove the error. <bento-dropdown errorMessage="Error message text" />`, '2.0.0' ); } if (props.size) { deprecate('BentoDropdown "size" property', `Do not use. Only 'default' size should be used.`, '2.0.0'); } if (props.value) { deprecate( 'BentoDropdown "value" property', `The use of "value" prop in "BentoDropdown" is no longer supported. Use the "v-model" or "model-value" property instead.`, '2.0.0' ); } const focus = () => { inputContainerRef.value.focus(); }; watch( () => isDropdownOpen.value, (isOpen: boolean) => { if (isOpen) { emit('open'); } } ); defineExpose({ focus, }); </script> <script lang="ts"> /** * A Dropdown shows a selected option's. * Use a dropdown when you want users to select options * from a list of pre-defined options. * * @example * import { BentoDropdown } from '@adyen/bento-vue2'; * import type { BentoDropdownOptions } from '@adyen/bento-vue2' * * export default { * components: { BentoDropdown }, * template: ` * <bento-dropdown * v-model="selectedValue" * :disabled="false" * :multiple="true" * :isOptionsDisabled="option => option.value === 2" * :items="options" * /> * `, * setup() { * const selectedValue = ref(2) // Default value * const options: BentoDropdownOptions = [ * { label: 'Option 1', value: 1 }, * { label: 'Option 2', value: 2 }, * ]; * return { options, selectedValue }; * } * } */ export default defineComponent({ i18n: { messages }, name: 'b-dropdown', components: { DropdownInputDefault, DropdownInputSmall, }, model: { prop: 'modelValue' }, }); </script> <style lang="scss" scoped src="./dropdown.scss" />
@@ -1 +1 @@
1
- <template> <div class="b-file-uploader-restrictions" :class="conditionalClasses"> <bento-typography v-if="supportedFileTypes" :class="setRestrictionErrorClass(supportedFileTypes.error)"> {{ t('supportedFileTypes', { list: supportedFileTypes.value }) }} </bento-typography> <bento-typography v-if="maxSize" :class="setRestrictionErrorClass(maxSize.error)"> {{ t('maxFileSize', { size: formatFileSize(locale, maxSize.value) }) }} </bento-typography> <bento-typography v-if="maxDimensions" :class="setRestrictionErrorClass(maxDimensions.error)"> {{ maxDimensionsLabel }} </bento-typography> <bento-typography v-if="maxCount" :class="setRestrictionErrorClass(maxCount.error)"> {{ tc('maxUpload', maxCount.value, { count: n(maxCount.value) }) }} </bento-typography> </div> </template> <script setup lang="ts"> import { computed } from 'vue'; import { BentoTypography } from '@/components/typography'; import { useI18n } from '@/utils/ts/i18n'; import { formatFileSize } from '../../utils'; import type { FileUploaderRestrictionsProps } from './file-uploader-restrictions.types'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const props = withDefaults(defineProps<FileUploaderRestrictionsProps>(), { condensed: null, disabled: null, maxCount: null, maxDimensions: null, maxSize: null, supportedFileTypes: null, }); const { t, tc, n, locale } = useI18n<{ message: MessageSchema }>({ messages }); const conditionalClasses = computed(() => ({ ['b-file-uploader-restrictions--condensed']: props.condensed, ['b-file-uploader-restrictions--disabled']: props.disabled, })); /** * Returns the error class to be set if a restriction has an error * @param error Indicates if the restriction has errored * @returns Conditional error class */ function setRestrictionErrorClass(error: boolean) { return { 'b-file-uploader-restrictions--error': error, }; } const maxDimensionsLabel = computed(() => { if (!props.maxDimensions) { return null; } const { value: { width, height }, } = props.maxDimensions; if (width && height) { return t('maxImageDimensions', { width: n(width), height: n(height) }); } if (width) { return t('maxImageWidth', { width: n(width) }); } if (height) { return t('maxImageHeight', { height: n(height) }); } return null; }); </script> <script lang="ts"> /** * List of restrictions defined for "bento-file-uploader". * * @example * import { FileUploaderRestrictions } from './'; * * export default { * components: { FileUploaderRestrictions }, * template: ` * <file-uploader-restrictions * :maxCount="1" * :maxSize="5000" * :maxDimensions="{ width: 100, height: 200 }" * supportedFileTypes="audio/*,.pdf,.jpg" * /> * ` * } */ export default { i18n: { messages }, }; </script> <style lang="scss" scoped src="./file-uploader-restrictions.scss" />
1
+ <template> <div class="b-file-uploader-restrictions" :class="conditionalClasses"> <bento-typography v-if="supportedFileTypes" :class="setRestrictionErrorClass(supportedFileTypes.error || (isMaxSizeArray && maxSize.error))" > {{ t('supportedFileTypes', { list: computedConditionalFiles }) }} </bento-typography> <bento-typography v-if="!isMaxSizeArray && maxSize" :class="setRestrictionErrorClass(maxSize.error)"> {{ maxFileSizeLabel }} </bento-typography> <bento-typography v-if="maxDimensions" :class="setRestrictionErrorClass(maxDimensions.error)"> {{ maxDimensionsLabel }} </bento-typography> <bento-typography v-if="maxCount" :class="setRestrictionErrorClass(maxCount.error)"> {{ tc('maxUpload', maxCount.value, { count: n(maxCount.value) }) }} </bento-typography> </div> </template> <script setup lang="ts"> import { computed } from 'vue'; import { BentoTypography } from '@/components/typography'; import { useI18n } from '@/utils/ts/i18n'; import { formatFileSize } from '../../utils'; import type { FileUploaderRestrictionsProps } from './file-uploader-restrictions.types'; import type { FileMaxSize } from '../../file-uploader.types'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const props = withDefaults(defineProps<FileUploaderRestrictionsProps>(), { condensed: null, disabled: null, maxCount: null, maxDimensions: null, maxSize: null, supportedFileTypes: null, }); const { t, tc, n, locale } = useI18n<{ message: MessageSchema }>({ messages }); const conditionalClasses = computed(() => ({ ['b-file-uploader-restrictions--condensed']: props.condensed, ['b-file-uploader-restrictions--disabled']: props.disabled, })); /** * Returns the error class to be set if a restriction has an error * @param error Indicates if the restriction has errored * @returns Conditional error class */ function setRestrictionErrorClass(error: boolean) { return { 'b-file-uploader-restrictions--error': error, }; } const isMaxSizeArray = computed(() => props.maxSize && Array.isArray(props.maxSize.value)); const maxFileSizeLabel = computed(() => { if (!props.maxSize || isMaxSizeArray.value) { return null; } return t('maxFileSize', { size: formatFileSize(locale.value, props.maxSize.value as number) }); }); const computedConditionalFiles = computed(() => { if (!props.supportedFileTypes) { return null; } if (!isMaxSizeArray.value) { return props.supportedFileTypes.value; } let list = props.supportedFileTypes.value; (props.maxSize.value as Array<FileMaxSize>).forEach(entry => { const displayName = entry.fileType.toUpperCase(); const sizeLabel = t('upToSize', { size: formatFileSize(locale.value, entry.maxSize) }); list = list.replace(displayName, `${displayName} ${sizeLabel}`); }); return list; }); const maxDimensionsLabel = computed(() => { if (!props.maxDimensions) { return null; } const { value: { width, height }, } = props.maxDimensions; if (width && height) { return t('maxImageDimensions', { width: n(width), height: n(height) }); } if (width) { return t('maxImageWidth', { width: n(width) }); } if (height) { return t('maxImageHeight', { height: n(height) }); } return null; }); </script> <script lang="ts"> /** * List of restrictions defined for "bento-file-uploader". * * @example * import { FileUploaderRestrictions } from './'; * * export default { * components: { FileUploaderRestrictions }, * template: ` * <file-uploader-restrictions * :maxCount="1" * :maxSize="5000" * :maxDimensions="{ width: 100, height: 200 }" * supportedFileTypes="audio/*,.pdf,.jpg" * /> * ` * } */ export default { i18n: { messages }, }; </script> <style lang="scss" scoped src="./file-uploader-restrictions.scss" />
@@ -99,6 +99,15 @@ the maximum number of files that can be uploaded.
99
99
  When this restriction is enabled if user tries to upload more than the allowed number, an error will be shown asking
100
100
  user to remove excess files.
101
101
 
102
+ ### Conditional file sizes
103
+
104
+ Specifying file sizes per file type is possible through the `maxSize` property with an array of objects - this
105
+ restriction sets a maximum file size limit for each file type that can be uploaded.
106
+
107
+ In this case, any file that exceeds the specified size limit will be rejected, and an error message will be displayed.
108
+
109
+ <Canvas of={FileUploaderStories.ConditionalFileSizes} />
110
+
102
111
  ## Accessibility
103
112
 
104
113
  ### Keyboard interaction
@@ -1 +1 @@
1
- import { action } from '@storybook/addon-actions'; import { isVue2 } from 'vue-demi'; import { storybookDocsParameter } from '@/utils/ts/storybook'; import BentoFileUploader from './file-uploader.vue'; import type { Meta, StoryObj } from '@storybook/vue'; const meta: Meta = { title: 'File uploader', component: BentoFileUploader, argTypes: { // Slots accept: { control: 'text', table: { type: { summary: "HTMLInputElement['accept']" }, }, }, // Events change: { table: { type: { summary: '(value?: FileList) => void' } }, control: { disable: true }, }, 'update:model-value': { table: { type: { summary: '(value?: FileList) => void' } }, control: { disable: true }, }, input: { description: 'Deprecated since version 2.0. Use `v-model` or `update:model-value` instead', table: { type: { summary: '(value?: FileList) => void' }, category: 'Deprecated', }, control: { disable: true }, }, 'error:upload': { table: { type: { summary: '(value: boolean) => void' } }, control: { disable: true }, }, placeholder: { table: { disable: true } }, }, }; const DEFAULT_PROPS = { label: 'Label', description: 'Custom supporting text', }; export default meta; type Story = StoryObj<typeof BentoFileUploader>; const defaultCode = ` <template> <bento-file-uploader label="${DEFAULT_PROPS.label}" description="${DEFAULT_PROPS.description}" accept="image/*,video/*,.pdf,.png" :condensed="false" :disabled="false" errorMessage="Custom error message" :maxCount="1" :maxDimensions="{ width: 200, height: 100 }" :maxSize="1000" v-model="files" :optional="false" required tooltipText="Useful information" /> </template> <script setup lang="ts"> import { BentoFileUploader, type BentoFileValue } from '@adyen/bento-vue2' import { ref } from 'vue' const files = ref<BentoFileValue>() </script> `; export const Default: Story = { render: (_args, { argTypes }) => ({ components: { BentoFileUploader }, props: Object.keys(argTypes), template: ` <div style="max-width: 500px"> <bento-file-uploader v-bind="args" @error:upload="errorAction" @change="changeAction" /> </div> `, setup(props) { const changeAction = value => action('change')(value); // As 'value' is a boolean, it needs to be a template literal otherwise // passing 'false' indicates there is no args const errorAction = value => action('error:upload')(`${value}`); return { // Values args: isVue2 ? props : _args, // Events changeAction, errorAction, }; }, }), args: { ...DEFAULT_PROPS, condensed: false, }, parameters: storybookDocsParameter(defaultCode), }; export const ModelValue: Story = { render: (_args, { argTypes }) => ({ components: { BentoFileUploader }, props: Object.keys(argTypes), template: ` <div style="max-width: 500px"> <bento-file-uploader v-bind="args" v-model="mockFileList" @error:upload="errorAction" @change="changeAction"/> </div> `, setup(props) { const changeAction = fileList => { action('change')(fileList); }; // As 'value' is a boolean, it needs to be a template literal otherwise // passing 'false' indicates there is no args const errorAction = value => action('error:upload')(`${value}`); const dataTransfer = new DataTransfer(); const dummyFile = new File(['foo'], 'foo.pdf', { type: 'text/pdf' }); dataTransfer.items.add(dummyFile); const mockFileList = dataTransfer.files; return { // Values args: isVue2 ? props : _args, mockFileList, // Events changeAction, errorAction, }; }, }), args: { ...DEFAULT_PROPS, condensed: false, }, parameters: storybookDocsParameter(defaultCode), };
1
+ import { action } from '@storybook/addon-actions'; import { isVue2 } from 'vue-demi'; import { storybookDocsParameter } from '@/utils/ts/storybook'; import BentoFileUploader from './file-uploader.vue'; import type { Meta, StoryObj } from '@storybook/vue'; import ConditionalFileSizesCode from './__tests__/file-uploader-conditional-file-sizes.vue?raw'; import EverythingBagelCode from './__tests__/file-uploader-everything-bagel.vue?raw'; const meta: Meta = { title: 'File uploader', component: BentoFileUploader, argTypes: { // Slots accept: { control: 'text', table: { type: { summary: "HTMLInputElement['accept']" }, }, }, // Events change: { table: { type: { summary: '(value?: FileList) => void' } }, control: { disable: true }, }, 'update:model-value': { table: { type: { summary: '(value?: FileList) => void' } }, control: { disable: true }, }, input: { description: 'Deprecated since version 2.0. Use `v-model` or `update:model-value` instead', table: { type: { summary: '(value?: FileList) => void' }, category: 'Deprecated', }, control: { disable: true }, }, 'error:upload': { table: { type: { summary: '(value: boolean) => void' } }, control: { disable: true }, }, placeholder: { table: { disable: true } }, }, }; const DEFAULT_PROPS = { label: 'Label', description: 'Custom supporting text', }; export default meta; type Story = StoryObj<typeof BentoFileUploader>; export const Default: Story = { render: (_args, { argTypes }) => ({ components: { BentoFileUploader }, props: Object.keys(argTypes), template: ` <div style="max-width: 500px"> <bento-file-uploader v-bind="args" @error:upload="errorAction" @change="changeAction" /> </div> `, setup(props) { const changeAction = value => action('change')(value); // As 'value' is a boolean, it needs to be a template literal otherwise // passing 'false' indicates there is no args const errorAction = value => action('error:upload')(`${value}`); return { // Values args: isVue2 ? props : _args, // Events changeAction, errorAction, }; }, }), args: { ...DEFAULT_PROPS, condensed: false, }, parameters: storybookDocsParameter(EverythingBagelCode), }; export const ModelValue: Story = { render: (_args, { argTypes }) => ({ components: { BentoFileUploader }, props: Object.keys(argTypes), template: ` <div style="max-width: 500px"> <bento-file-uploader v-bind="args" v-model="mockFileList" @error:upload="errorAction" @change="changeAction"/> </div> `, setup(props) { const changeAction = fileList => { action('change')(fileList); }; // As 'value' is a boolean, it needs to be a template literal otherwise // passing 'false' indicates there is no args const errorAction = value => action('error:upload')(`${value}`); const dataTransfer = new DataTransfer(); const dummyFile = new File(['foo'], 'foo.pdf', { type: 'text/pdf' }); dataTransfer.items.add(dummyFile); const mockFileList = dataTransfer.files; return { // Values args: isVue2 ? props : _args, mockFileList, // Events changeAction, errorAction, }; }, }), args: { ...DEFAULT_PROPS, condensed: false, }, parameters: storybookDocsParameter(EverythingBagelCode), }; export const ConditionalFileSizes: Story = { render: (_args, { argTypes }) => ({ components: { BentoFileUploader }, props: Object.keys(argTypes), template: ` <div style="max-width: 500px"> <bento-file-uploader v-bind="args" @error:upload="errorAction" @change="changeAction" /> </div> `, setup(props) { const changeAction = value => action('change')(value); // As 'value' is a boolean, it needs to be a template literal otherwise // passing 'false' indicates there is no args const errorAction = value => action('error:upload')(`${value}`); return { // Values args: isVue2 ? props : _args, // Events changeAction, errorAction, }; }, }), args: { ...DEFAULT_PROPS, condensed: false, accept: 'png,pdf', maxSize: [ { fileType: 'pdf', maxSize: 10485760 }, { fileType: 'png', maxSize: 5242880 }, ], }, parameters: storybookDocsParameter(ConditionalFileSizesCode), };
@@ -1 +1 @@
1
- <template> <div class="b-file-uploader" :class="conditionalClasses"> <field-label class="b-file-uploader__label" :for="inputFieldId" :label="label" :optional="optional" :required="required" :tooltip-text="tooltipText" /> <input :id="inputFieldId" ref="inputRef" aria-hidden="true" type="file" tabindex="-1" data-testid="input" class="b-file-uploader__input" :accept="accept" :disabled="disabled" :multiple="isMultiple" @change="onInputChange" @click="onInputClick" /> <!-- Used to separate the a11y text with breaks for the reader. Hidden to users --> <span :id="commaTextId" aria-hidden="true" class="b-file-uploader__invisible-comma"> , </span> <div v-if="!hideArea" ref="dropAreaRef" :aria-labelledby="fileUploadAriaLabelledBy" class="b-file-uploader__area" role="button" :tabindex="disabled ? -1 : 0" @click="inputRef.click()" @dragenter.stop.prevent="onDragEnter" @dragleave.stop.prevent="onDragLeave" @drop.stop.prevent="onDrop" @dragover="onDragOver" @keydown.enter.prevent.capture="inputRef.click()" @keydown.space.prevent.capture="inputRef.click()" > <div class="b-file-uploader__icon" aria-hidden="true"> <warning-filled-icon v-if="hasError" /> <upload-icon v-else /> </div> <template v-if="!condensed"> <bento-typography :id="dropFilesTextId" el="span" strongest class="b-file-uploader__title"> {{ tc('dropFiles', maxCount) }} </bento-typography> <bento-typography v-if="description" :id="customTextId" el="span" class="b-file-uploader__description"> {{ description }} </bento-typography> <file-uploader-restrictions v-bind="fileUploadRestrictionsProps" :id="fileUploaderRestrictionsId" /> </template> <bento-typography :id="browseFilesButtonId" stronger class="b-file-uploader__button"> {{ t('browseFiles') }} </bento-typography> </div> <error-message v-if="hasError && errorMessage" :id="errorId" :error-message="errorMessage" class="b-file-uploader__error-message" /> <bento-typography v-if="condensed && description" :id="customTextId" aria-hidden="true" el="span" class="b-file-uploader__description b-file-uploader__description--condensed" > {{ description }} </bento-typography> <file-uploader-restrictions v-if="condensed" v-bind="fileUploadRestrictionsProps" :id="fileUploaderRestrictionsId" /> <bento-alert v-if="maxFileCountError" type="critical"> {{ t('tooManyFiles') }} <template #description> {{ t('youHaveExceededTheMaximum', { count: n(props.maxCount) }) }} </template> </bento-alert> <div v-show="files?.length" class="b-file-uploader__files"> <file-uploader-file-card v-for="(file, index) in files" :ref="registerFileCardRef(index)" :key="file.id" :file="file" :supported-file-types="supportedFileTypeList" :readonly="readonly" @cancel="removeFile(file)" @remove="removeFile(file)" /> </div> <bento-typography v-if="!files?.length && readonly" class="b-file-uploader__no-files" wide>{{ t('noFilesUploaded') }}</bento-typography> </div> </template> <script setup lang="ts"> import { computed, nextTick, onMounted, reactive, ref, toRef, watch } from 'vue'; import UploadIcon from '@adyen/ui-assets-icons-16/vue/upload'; import WarningFilledIcon from '@adyen/ui-assets-icons-16/vue/warning-filled'; import { BentoAlert } from '@/components/alert'; import { BentoTypography } from '@/components/typography'; import { ErrorMessage, FieldLabel } from '@/internal'; import { FileUploaderFileCard } from './components/file-uploader-file-card'; import { FileUploaderRestrictions } from './components/file-uploader-restrictions'; import { useI18n } from '@/utils/ts/i18n'; import { generateUid } from '@/core/utils/ts'; import { getFileType, readFile } from './utils'; import { useFileTypeList } from './composables'; import { useDragDropState } from '@/components/file-uploader/composables/use-drag-drop-state/use-drag-drop-state'; import { deprecate } from '@/utils/ts/deprecate'; import { useFormFieldEmits } from '@/utils/ts/form-field/form-field'; import { type BentoFileUploaderProps, type BentoFileUploaderValue, FileErrorType, type FileObject, FileState, FileUploaderState, } from './file-uploader.types'; import type { FileUploaderRestrictionsProps } from '@/components/file-uploader/components/file-uploader-restrictions/file-uploader-restrictions.types'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const { t, tc, n } = useI18n<{ message: MessageSchema }>({ messages }); const emit = defineEmits<{ /** * Emitted when files are added or removed. * Provides the entire list of files available. * Can be undefined if the list is empty */ (e: 'change', value?: FileList): void; /** * Vue2: Emitted when files are added or removed. Updates the v-model. * Provides the entire list of files available. * Can be undefined if the list is empty * @deprecated from version 2.0.0. Use `v-model` or `update:model-value` instead. */ (e: 'input', value?: FileList): void; /** * Vue3: Emitted when files are added or removed. Updates the v-model. * Provides the entire list of files available. * Can be undefined if the list is empty */ (e: 'update:model-value', value?: FileList): void; /** * Emitted when any file uploaded has state === FileState.ERROR. * Provides parent component with boolean. * Emits false when no files have errors. */ (e: 'error:upload', value: boolean): void; }>(); const props = withDefaults(defineProps<BentoFileUploaderProps>(), { accept: null, condensed: false, description: null, disabled: false, errorMessage: null, label: null, maxCount: 1, // One file by default maxDimensions: null, maxSize: null, modelValue: null, optional: false, required: false, tooltipText: null, }); const { emitValue } = useFormFieldEmits<BentoFileUploaderValue>(emit); /** * Model value checks * Filter out type string, array and file as FileList is the only type un-deprecated for modelValue * */ /** * Type guard to check if the value is a FileList. * @param files The value to check. */ function isFileList(files: BentoFileUploaderValue): files is FileList { return ( !!files && typeof (files as FileList).length === 'number' && typeof (files as FileList).item === 'function' ); } /** * Used as vitest recognizes FileList as an Array of Files which then * causes any tests with pre-uploaded files to fail as isFileList will fail. * */ onMounted(() => { // Model is not a FileList if (!isFileList(props.modelValue)) { deprecate( 'BentoFileUploaderValue types: string | Array<string> | File | Array<File>', 'Use FileList instead.', '2.0.0' ); } }); const { supportedFileTypeList } = useFileTypeList(toRef(props, 'accept')); const browseFilesButtonId = generateUid('browse-button'); const customTextId = generateUid('custom-text'); const dropFilesTextId = generateUid('drop-files-text'); const commaTextId = generateUid('comma-text'); const fileUploaderRestrictionsId = generateUid('file-uploader-restrictions'); const files = ref<Array<FileObject>>(null); const fileCardRefs = ref<Array<InstanceType<typeof FileUploaderFileCard>>>([]); const inputRef = ref<HTMLInputElement>(null); const dropAreaRef = ref<HTMLDivElement>(null); const inputFieldId = generateUid('input'); const errorId = generateUid('input-error'); const fileUploadAriaLabelledBy = computed(() => { const formattedDropFileTextId = !props.condensed ? `${dropFilesTextId} ${commaTextId}` : ''; const formattedCustomTextId = props.description ? `${customTextId} ${commaTextId}` : ''; const formattedFileUploaderRestrictionsId = `${fileUploaderRestrictionsId} ${commaTextId}`; return `${formattedDropFileTextId} ${formattedCustomTextId} ${formattedFileUploaderRestrictionsId} ${browseFilesButtonId}`; }); const { dragState, dragErrors, resetDragState, onDragEnterValidations } = useDragDropState({ accept: toRef(props, 'accept'), maxCount: toRef(props, 'maxCount'), }); const fileUploadRestrictionsProps = computed<FileUploaderRestrictionsProps>(() => ({ condensed: props.condensed, disabled: props.disabled, maxCount: isMultiple.value ? { value: props.maxCount, error: dragErrors.maxCountOverflow || maxFileCountError.value, } : null, maxDimensions: props.maxDimensions ? { value: props.maxDimensions, error: files.value?.some(file => file.errors.includes(FileErrorType.ERROR_DIMENSIONS)), } : null, maxSize: props.maxSize ? { value: props.maxSize, error: files.value?.some(file => file.errors.includes(FileErrorType.ERROR_SIZE)), } : null, supportedFileTypes: supportedFileTypeList.value ? { value: supportedFileTypeList.value, error: dragErrors.invalidTypes || dragErrors.partiallyInvalidTypes || files.value?.some(file => file.errors.includes(FileErrorType.ERROR_TYPE)), } : null, })); // Prevents triggering "dragleave" events on children elements const enterTarget = ref<EventTarget>(); const conditionalClasses = computed(() => ({ [`b-file-uploader--${FileUploaderState.DISABLED}`]: props.disabled, [`b-file-uploader--${FileUploaderState.DRAG_OVER}`]: dragState.dragging, [`b-file-uploader--${FileUploaderState.ERROR}`]: hasError.value, [`b-file-uploader--condensed`]: props.condensed, })); const isMultiple = computed(() => props.maxCount > 1); const hideArea = computed(() => (props.maxCount && files.value?.length >= props.maxCount) || props.readonly); const loadingStateExists = computed(() => files.value?.some(file => file.state === 'loading')); const registerFileCardRef = (cardIndex: number) => fileCardRef => { fileCardRefs.value[cardIndex] = fileCardRef; }; /** * Verifies the new list of files and emits the `change` and `update:modelValue` events. * @param newList The new list of files to process and emit. */ function emitUpdate(newList: Array<FileObject>) { if (newList) { verifyFileCount(newList); verifyFileSize(newList); const value = convertToFileList(newList); emit('change', value); emitValue(value); } else { emit('change', undefined); emitValue(undefined); } } /** Errors */ const hasError = computed(() => !!props.errorMessage || dragState.error); const maxFileCountError = computed(() => files.value?.length > props.maxCount); const maxFileError = ref(false); const hasRestrictionError = computed(() => files.value?.some(file => file.state === 'error')); watch(hasRestrictionError, newValue => { // Without this, when files are initially uploaded the component // will immediately and unnecessarily emit 'false' and then // emit 'true' once processing has done, if errors exists. if (loadingStateExists.value) { return; } emit('error:upload', newValue); }); /** * Places the focus to the "drop area" after adding or removing a file. * If more files can be added, the focus is moved to the "drop area". * If no more files can be added, the focus is moved to the first file card. */ async function resetFocus() { await nextTick(); if (files.value.length === props.maxCount) { fileCardRefs.value[0]?.focus(); return; } dropAreaRef.value.focus(); } /** * Removes the file from the list of files when the * "remove" or "cancel" events are triggered from * the file card component * @param file File to be removed */ async function removeFile(file: FileObject) { files.value = files.value.filter(({ id }) => id !== file.id); await resetFocus(); emitUpdate(files.value); } /** Drag n Drop functions */ /** * Creates a FileObject from a native File and tracks its client-load progress. * @param file The native File object. * @returns A FileObject. */ function processFile(file: File) { const fileObject = reactive<FileObject>({ id: generateUid('file'), data: file, errors: [], progress: null, state: FileState.LOADING, type: getFileType(file), }); readFile(fileObject, props.accept, props.maxDimensions); return fileObject; } /** * Prevent opening the file browser when no * more files can be added (until one file is removed). * @param event Triggered event */ function onInputClick(event: MouseEvent) { if (files.value?.length === props.maxCount) { event.preventDefault(); } } /** * Sets the file/files to track their progress while being processed * @param fileList File or list of files to be processed. */ async function onFileInput(fileList: FileList) { // Prevent adding many files if maxCount is 1 if (props.maxCount === 1 && fileList.length > props.maxCount) { resetEnterTarget(); resetDragState(); return; } if (isMultiple.value) { // Add the new files to the file selection. // Removing files should be done by interacting with the file list below the area. files.value = [...(files.value || []), ...Array.from(fileList).map(file => processFile(file))]; } else { files.value = [processFile(fileList[0])]; await resetFocus(); } emitUpdate(files.value); } /** * Handles the "dragenter" event, triggered when the mouse enters the draggable area. * Enables the dragging state and sets the dragging error to true to * prevent dropping files from being added if there's a general error. * @param event Drag event */ function onDragEnter(event: DragEvent) { // Prevent changing state if disabled if (props.disabled) { return; } /** * Allows internal components as part of the dragable area. * Prevents trigerring "dragleave" events on children elements. * Set the "dragarea" as target */ enterTarget.value = event.target; const totalFilesCount = (event.dataTransfer.items?.length ?? 0) + (files.value?.length ?? 0); onDragEnterValidations(totalFilesCount, event); } /** * Resets the reference to the target. * This helps preventing loosing the state when dragging over children */ function resetEnterTarget() { enterTarget.value = null; } /** * Handles the "dragleave" event, triggered when the mouse leaves the draggable area. * Maintains the dragging state while dragging over children. * Resets the states when the mouse leaves the draggable area * @param event Drag event */ function onDragLeave(event: DragEvent) { // If target enter and leave are the same it means the drag has left the "dragarea" if (enterTarget.value === event.target) { resetEnterTarget(); resetDragState(); } } /** * Handles the "dragover" event, triggered when the mouse moves over the draggable area. * Disables the cursor when the component is disabled. * @param event Drag event */ function onDragOver(event: DragEvent) { event.preventDefault(); // Disable drag n drop and change the cursor if disabled if (props.disabled) { // eslint-disable-next-line no-param-reassign event.dataTransfer.dropEffect = 'none'; } } /** * Hadles the "drop" event, triggered when files are dropped inside the draggable area. * Processes the files that are dropped. * @param event Drag event */ async function onDrop(event: DragEvent) { // Prevent events when the input is disabled if (props.disabled) { return; } if (!dragErrors.invalidTypes && event.dataTransfer.files?.length) { onFileInput(event.dataTransfer.files); } resetEnterTarget(); resetDragState(); } /** * Handles the "change" event over the "input" element. * Processes the files that are selected through the browser's file explorer. */ async function onInputChange() { if (inputRef.value.files?.length) { onFileInput(inputRef.value.files); } // Reset input value inputRef.value.value = ''; await resetFocus(); } /** End of drag and drop functions */ /** * Watches the modelValue and if set * and the type is not deprecated, it processes the files * but doesn't emit them as the user already has those files. */ watch( () => props.modelValue, (newValue: BentoFileUploaderValue) => { // Sets the new value if it is a `FileList`. This ignores deprecated value types. if (newValue && isFileList(newValue)) { const fileList = newValue as FileList; const fileListArray: Array<File> = Array.from(fileList); files.value = fileListArray.map(file => processFile(file)); } else if (!newValue && files.value) { // Clear the file list when the value is programmatically unset. files.value = null; } }, { immediate: true } ); function verifyFileCount(fileList: Array<FileObject>) { const isValid = props.maxCount && fileList.length <= props.maxCount; maxFileError.value = !isValid; } function verifyFileSize(fileList: Array<FileObject>) { if (props.maxSize) { fileList.forEach(file => { const item = file; // Prevent eslint no-param-reassign if (item.data.size > props.maxSize) { item.state = FileState.ERROR; if (!item.errors.includes(FileErrorType.ERROR_SIZE)) { item.errors.push(FileErrorType.ERROR_SIZE); } } }); } } function convertToFileList(fileList: Array<FileObject>) { // Filter out files with errors const validFiles = fileList.filter( ({ errors }) => !errors.includes(FileErrorType.ERROR_LOAD) && !errors.includes(FileErrorType.ERROR_SIZE) ); if (validFiles.length) { const dataTransfer = new DataTransfer(); validFiles.forEach(({ data, id }) => { dataTransfer.items.add(data); }); return dataTransfer.files; } return undefined; } </script> <script lang="ts"> /** * The file uploader component enables users to upload one or multiple files from their device to our system. * * @example * import { BentoFileUploader, type BentoFileValue } from '@adyen/bento-vue2'; * * export default { * components: { BentoFileUploader }, * template: ` * <bento-file-uploader * accept="image/*,video/*,.pdf,.png" * condensed * description="Custom supporting text" * disabled * errorMessage="Custom error message" * label="Bento file uploader" * :maxCount="1" * :maxDimensions="{ width: 200, height: 100 }" * :maxSize="1000" * v-model="files" * :optional="false" * required * tooltipText="Useful information" * /> * `, * setup() { * const files = ref<BentoFileValue>() * } * } */ export default { model: { prop: 'modelValue' }, i18n: { messages }, }; </script> <style lang="scss" scoped src="./file-uploader.scss" />
1
+ <template> <div class="b-file-uploader" :class="conditionalClasses"> <field-label :id="fieldLabelId" class="b-file-uploader__label" :for="inputFieldId" :label="label" :optional="optional" :required="required" :tooltip-text="tooltipText" /> <input :id="inputFieldId" ref="inputRef" :aria-required="required" type="file" tabindex="-1" data-testid="input" class="b-file-uploader__input" :accept="accept" :disabled="disabled" :multiple="isMultiple" @change="onInputChange" @click="onInputClick" @invalid.prevent /> <!-- Used to separate the a11y text with breaks for the reader. Hidden to users --> <span :id="commaTextId" aria-hidden="true" class="b-file-uploader__invisible-comma"> , </span> <div v-if="!hideArea" ref="dropAreaRef" :aria-labelledby="fileUploadAriaLabelledBy" class="b-file-uploader__area" role="button" :tabindex="disabled ? -1 : 0" @click="inputRef.click()" @dragenter.stop.prevent="onDragEnter" @dragleave.stop.prevent="onDragLeave" @drop.stop.prevent="onDrop" @dragover="onDragOver" @keydown.enter.prevent.capture="inputRef.click()" @keydown.space.prevent.capture="inputRef.click()" > <div class="b-file-uploader__icon" aria-hidden="true"> <warning-filled-icon v-if="hasError" /> <upload-icon v-else /> </div> <template v-if="!condensed"> <bento-typography :id="dropFilesTextId" el="span" strongest class="b-file-uploader__title"> {{ tc('dropFiles', maxCount) }} </bento-typography> <bento-typography v-if="description" :id="customTextId" el="span" class="b-file-uploader__description"> {{ description }} </bento-typography> <file-uploader-restrictions v-bind="fileUploadRestrictionsProps" :id="fileUploaderRestrictionsId" /> </template> <bento-typography :id="browseFilesButtonId" stronger class="b-file-uploader__button"> {{ t('browseFiles') }} </bento-typography> </div> <error-message v-if="hasError && errorMessage" :id="errorId" :error-message="errorMessage" class="b-file-uploader__error-message" /> <bento-typography v-if="condensed && description" :id="customTextId" aria-hidden="true" el="span" class="b-file-uploader__description b-file-uploader__description--condensed" > {{ description }} </bento-typography> <file-uploader-restrictions v-if="condensed" v-bind="fileUploadRestrictionsProps" :id="fileUploaderRestrictionsId" /> <bento-alert v-if="maxFileCountError" type="critical"> {{ t('tooManyFiles') }} <template #description> {{ t('youHaveExceededTheMaximum', { count: n(props.maxCount) }) }} </template> </bento-alert> <div v-show="files?.length" class="b-file-uploader__files"> <file-uploader-file-card v-for="(file, index) in files" :ref="registerFileCardRef(index)" :key="file.id" :file="file" :supported-file-types="supportedFileTypeList" :readonly="readonly" @cancel="removeFile(file)" @remove="removeFile(file)" /> </div> <bento-typography v-if="!files?.length && readonly" class="b-file-uploader__no-files" wide>{{ t('noFilesUploaded') }}</bento-typography> </div> </template> <script setup lang="ts"> import { computed, nextTick, onMounted, reactive, ref, toRef, watch } from 'vue'; import UploadIcon from '@adyen/ui-assets-icons-16/vue/upload'; import WarningFilledIcon from '@adyen/ui-assets-icons-16/vue/warning-filled'; import { BentoAlert } from '@/components/alert'; import { BentoTypography } from '@/components/typography'; import { ErrorMessage, FieldLabel } from '@/internal'; import { FileUploaderFileCard } from './components/file-uploader-file-card'; import { FileUploaderRestrictions } from './components/file-uploader-restrictions'; import { useI18n } from '@/utils/ts/i18n'; import { generateUid } from '@/core/utils/ts'; import { getFileType, isFileTypeAccepted, readFile } from './utils'; import { useFileTypeList } from './composables'; import { useDragDropState } from '@/components/file-uploader/composables/use-drag-drop-state/use-drag-drop-state'; import { deprecate } from '@/utils/ts/deprecate'; import { useFormFieldEmits } from '@/utils/ts/form-field/form-field'; import { type BentoFileUploaderProps, type BentoFileUploaderValue, FileErrorType, type FileMaxSize, type FileObject, FileState, FileUploaderState, } from './file-uploader.types'; import type { FileUploaderRestrictionsProps } from '@/components/file-uploader/components/file-uploader-restrictions/file-uploader-restrictions.types'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const { t, tc, n } = useI18n<{ message: MessageSchema }>({ messages }); const emit = defineEmits<{ /** * Emitted when files are added or removed. * Provides the entire list of files available. * Can be undefined if the list is empty */ (e: 'change', value?: FileList): void; /** * Vue2: Emitted when files are added or removed. Updates the v-model. * Provides the entire list of files available. * Can be undefined if the list is empty * @deprecated from version 2.0.0. Use `v-model` or `update:model-value` instead. */ (e: 'input', value?: FileList): void; /** * Vue3: Emitted when files are added or removed. Updates the v-model. * Provides the entire list of files available. * Can be undefined if the list is empty */ (e: 'update:model-value', value?: FileList): void; /** * Emitted when any file uploaded has state === FileState.ERROR. * Provides parent component with boolean. * Emits false when no files have errors. */ (e: 'error:upload', value: boolean): void; }>(); const props = withDefaults(defineProps<BentoFileUploaderProps>(), { accept: null, condensed: false, description: null, disabled: false, errorMessage: null, label: null, maxCount: 1, // One file by default maxDimensions: null, maxSize: null, modelValue: null, optional: false, required: false, tooltipText: null, }); const { emitValue } = useFormFieldEmits<BentoFileUploaderValue>(emit); /** * Model value checks * Filter out type string, array and file as FileList is the only type un-deprecated for modelValue * */ /** * Type guard to check if the value is a FileList. * @param files The value to check. */ function isFileList(files: BentoFileUploaderValue): files is FileList { return ( !!files && typeof (files as FileList).length === 'number' && typeof (files as FileList).item === 'function' ); } /** * Used as vitest recognizes FileList as an Array of Files which then * causes any tests with pre-uploaded files to fail as isFileList will fail. * */ onMounted(() => { // Model is not a FileList if (!isFileList(props.modelValue)) { deprecate( 'BentoFileUploaderValue types: string | Array<string> | File | Array<File>', 'Use FileList instead.', '2.0.0' ); } }); const { supportedFileTypeList } = useFileTypeList(toRef(props, 'accept')); const browseFilesButtonId = generateUid('browse-button'); const customTextId = generateUid('custom-text'); const dropFilesTextId = generateUid('drop-files-text'); const commaTextId = generateUid('comma-text'); const fileUploaderRestrictionsId = generateUid('file-uploader-restrictions'); const fieldLabelId = generateUid('field-label'); const files = ref<Array<FileObject>>(null); const fileCardRefs = ref<Array<InstanceType<typeof FileUploaderFileCard>>>([]); const inputRef = ref<HTMLInputElement>(null); const dropAreaRef = ref<HTMLDivElement>(null); const inputFieldId = generateUid('input'); const errorId = generateUid('input-error'); const fileUploadAriaLabelledBy = computed(() => { const formattedFieldLabelId = props.label ? `${fieldLabelId} ${commaTextId}` : ''; const formattedDropFileTextId = !props.condensed ? `${dropFilesTextId} ${commaTextId}` : ''; const formattedCustomTextId = props.description ? `${customTextId} ${commaTextId}` : ''; const formattedFileUploaderRestrictionsId = `${fileUploaderRestrictionsId} ${commaTextId}`; return `${formattedFieldLabelId} ${formattedDropFileTextId} ${formattedCustomTextId} ${formattedFileUploaderRestrictionsId} ${browseFilesButtonId}`; }); const { dragState, dragErrors, resetDragState, onDragEnterValidations } = useDragDropState({ accept: toRef(props, 'accept'), maxCount: toRef(props, 'maxCount'), }); const fileUploadRestrictionsProps = computed<FileUploaderRestrictionsProps>(() => ({ condensed: props.condensed, disabled: props.disabled, maxCount: isMultiple.value ? { value: props.maxCount, error: dragErrors.maxCountOverflow || maxFileCountError.value, } : null, maxDimensions: props.maxDimensions ? { value: props.maxDimensions, error: files.value?.some(file => file.errors.includes(FileErrorType.ERROR_DIMENSIONS)), } : null, maxSize: props.maxSize ? { value: props.maxSize, error: files.value?.some(file => file.errors.includes(FileErrorType.ERROR_SIZE)), } : null, supportedFileTypes: supportedFileTypeList.value ? { value: supportedFileTypeList.value, error: dragErrors.invalidTypes || dragErrors.partiallyInvalidTypes || files.value?.some(file => file.errors.includes(FileErrorType.ERROR_TYPE)), } : null, })); // Prevents triggering "dragleave" events on children elements const enterTarget = ref<EventTarget>(); const conditionalClasses = computed(() => ({ [`b-file-uploader--${FileUploaderState.DISABLED}`]: props.disabled, [`b-file-uploader--${FileUploaderState.DRAG_OVER}`]: dragState.dragging, [`b-file-uploader--${FileUploaderState.ERROR}`]: hasError.value, [`b-file-uploader--condensed`]: props.condensed, })); const isMultiple = computed(() => props.maxCount > 1); const hideArea = computed(() => (props.maxCount && files.value?.length >= props.maxCount) || props.readonly); const loadingStateExists = computed(() => files.value?.some(file => file.state === 'loading')); const registerFileCardRef = (cardIndex: number) => fileCardRef => { fileCardRefs.value[cardIndex] = fileCardRef; }; /** * Verifies the new list of files and emits the `change` and `update:modelValue` events. * @param newList The new list of files to process and emit. */ function emitUpdate(newList: Array<FileObject>) { if (newList) { verifyFileCount(newList); verifyFileSize(newList); const value = convertToFileList(newList); emit('change', value); emitValue(value); } else { emit('change', undefined); emitValue(undefined); } } /** Errors */ const hasError = computed(() => !!props.errorMessage || dragState.error); const maxFileCountError = computed(() => files.value?.length > props.maxCount); const maxFileError = ref(false); const hasRestrictionError = computed(() => files.value?.some(file => file.state === 'error')); watch(hasRestrictionError, newValue => { // Without this, when files are initially uploaded the component // will immediately and unnecessarily emit 'false' and then // emit 'true' once processing has done, if errors exists. if (loadingStateExists.value) { return; } emit('error:upload', newValue); }); /** * Places the focus to the "drop area" after adding or removing a file. * If more files can be added, the focus is moved to the "drop area". * If no more files can be added, the focus is moved to the first file card. */ async function resetFocus() { await nextTick(); if (files.value.length === props.maxCount) { fileCardRefs.value[0]?.focus(); return; } dropAreaRef.value.focus(); } /** * Removes the file from the list of files when the * "remove" or "cancel" events are triggered from * the file card component * @param file File to be removed */ async function removeFile(file: FileObject) { files.value = files.value.filter(({ id }) => id !== file.id); await resetFocus(); emitUpdate(files.value); } /** Drag n Drop functions */ /** * Creates a FileObject from a native File and tracks its client-load progress. * @param file The native File object. * @returns A FileObject. */ function processFile(file: File) { const fileObject = reactive<FileObject>({ id: generateUid('file'), data: file, errors: [], progress: null, state: FileState.LOADING, type: getFileType(file), }); readFile(fileObject, props.accept, props.maxDimensions); return fileObject; } /** * Prevent opening the file browser when no * more files can be added (until one file is removed). * @param event Triggered event */ function onInputClick(event: MouseEvent) { if (files.value?.length === props.maxCount) { event.preventDefault(); } } /** * Sets the file/files to track their progress while being processed * @param fileList File or list of files to be processed. */ async function onFileInput(fileList: FileList) { // Prevent adding many files if maxCount is 1 if (props.maxCount === 1 && fileList.length > props.maxCount) { resetEnterTarget(); resetDragState(); return; } if (isMultiple.value) { // Add the new files to the file selection. // Removing files should be done by interacting with the file list below the area. files.value = [...(files.value || []), ...Array.from(fileList).map(file => processFile(file))]; } else { files.value = [processFile(fileList[0])]; await resetFocus(); } emitUpdate(files.value); } /** * Handles the "dragenter" event, triggered when the mouse enters the draggable area. * Enables the dragging state and sets the dragging error to true to * prevent dropping files from being added if there's a general error. * @param event Drag event */ function onDragEnter(event: DragEvent) { // Prevent changing state if disabled if (props.disabled) { return; } /** * Allows internal components as part of the dragable area. * Prevents trigerring "dragleave" events on children elements. * Set the "dragarea" as target */ enterTarget.value = event.target; const totalFilesCount = (event.dataTransfer.items?.length ?? 0) + (files.value?.length ?? 0); onDragEnterValidations(totalFilesCount, event); } /** * Resets the reference to the target. * This helps preventing loosing the state when dragging over children */ function resetEnterTarget() { enterTarget.value = null; } /** * Handles the "dragleave" event, triggered when the mouse leaves the draggable area. * Maintains the dragging state while dragging over children. * Resets the states when the mouse leaves the draggable area * @param event Drag event */ function onDragLeave(event: DragEvent) { // If target enter and leave are the same it means the drag has left the "dragarea" if (enterTarget.value === event.target) { resetEnterTarget(); resetDragState(); } } /** * Handles the "dragover" event, triggered when the mouse moves over the draggable area. * Disables the cursor when the component is disabled. * @param event Drag event */ function onDragOver(event: DragEvent) { event.preventDefault(); // Disable drag n drop and change the cursor if disabled if (props.disabled) { // eslint-disable-next-line no-param-reassign event.dataTransfer.dropEffect = 'none'; } } /** * Hadles the "drop" event, triggered when files are dropped inside the draggable area. * Processes the files that are dropped. * @param event Drag event */ async function onDrop(event: DragEvent) { // Prevent events when the input is disabled if (props.disabled) { return; } if (!dragErrors.invalidTypes && event.dataTransfer.files?.length) { onFileInput(event.dataTransfer.files); } resetEnterTarget(); resetDragState(); } /** * Handles the "change" event over the "input" element. * Processes the files that are selected through the browser's file explorer. */ async function onInputChange() { if (inputRef.value.files?.length) { onFileInput(inputRef.value.files); } // Reset input value inputRef.value.value = ''; await resetFocus(); } /** End of drag and drop functions */ /** * Watches the modelValue and if set * and the type is not deprecated, it processes the files * but doesn't emit them as the user already has those files. */ watch( () => props.modelValue, (newValue: BentoFileUploaderValue) => { // Sets the new value if it is a `FileList`. This ignores deprecated value types. if (newValue && isFileList(newValue)) { const fileList = newValue as FileList; const fileListArray: Array<File> = Array.from(fileList); files.value = fileListArray.map(file => processFile(file)); } else if (!newValue && files.value) { // Clear the file list when the value is programmatically unset. files.value = null; } }, { immediate: true } ); function verifyFileCount(fileList: Array<FileObject>) { const isValid = props.maxCount && fileList.length <= props.maxCount; maxFileError.value = !isValid; } function verifyFileSize(fileList: Array<FileObject>) { if (!props.maxSize) { return; } fileList.forEach(file => { const item = file; let maxSizeLimit: number | undefined; if (Array.isArray(props.maxSize)) { const matchingEntry = (props.maxSize as Array<FileMaxSize>).find(entry => isFileTypeAccepted(item.data, entry.fileType) ); maxSizeLimit = matchingEntry?.maxSize; } else { maxSizeLimit = props.maxSize as number; } if (maxSizeLimit && item.data.size > maxSizeLimit) { item.state = FileState.ERROR; if (!item.errors.includes(FileErrorType.ERROR_SIZE)) { item.errors.push(FileErrorType.ERROR_SIZE); } } }); } function convertToFileList(fileList: Array<FileObject>) { // Filter out files with errors const validFiles = fileList.filter( ({ errors }) => !errors.includes(FileErrorType.ERROR_LOAD) && !errors.includes(FileErrorType.ERROR_SIZE) ); if (validFiles.length) { const dataTransfer = new DataTransfer(); validFiles.forEach(({ data, id }) => { dataTransfer.items.add(data); }); return dataTransfer.files; } return undefined; } </script> <script lang="ts"> /** * The file uploader component enables users to upload one or multiple files from their device to our system. * * @example * import { BentoFileUploader, type BentoFileValue } from '@adyen/bento-vue2'; * * export default { * components: { BentoFileUploader }, * template: ` * <bento-file-uploader * accept="image/*,video/*,.pdf,.png" * condensed * description="Custom supporting text" * disabled * errorMessage="Custom error message" * label="Bento file uploader" * :maxCount="1" * :maxDimensions="{ width: 200, height: 100 }" * :maxSize="1000" * v-model="files" * :optional="false" * required * tooltipText="Useful information" * /> * `, * setup() { * const files = ref<BentoFileValue>() * } * } */ export default { model: { prop: 'modelValue' }, i18n: { messages }, }; </script> <style lang="scss" scoped src="./file-uploader.scss" />