@adyen/bento-mcp 0.5.3 → 0.6.0

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 (64) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/assets/components/alert/alert.docs.mdx +17 -17
  3. package/dist/assets/components/alert/alert.vue +1 -1
  4. package/dist/assets/components/country/country.vue +1 -1
  5. package/dist/assets/components/data-grid/data-grid.docs.mdx +3 -0
  6. package/dist/assets/components/data-grid/data-grid.stories.ts +1 -1
  7. package/dist/assets/components/data-grid/data-grid.vue +1 -1
  8. package/dist/assets/components/date/date.vue +1 -1
  9. package/dist/assets/components/date-picker/date-picker.docs.mdx +6 -0
  10. package/dist/assets/components/date-picker/date-picker.stories.ts +1 -1
  11. package/dist/assets/components/date-picker/date-picker.types.ts +1 -1
  12. package/dist/assets/components/date-picker/date-picker.vue +1 -1
  13. package/dist/assets/components/date-range-picker/components/date-range-picker-calendar/date-range-picker-calendar.vue +1 -1
  14. package/dist/assets/components/date-range-picker/components/date-time-input/date-time-input.types.ts +1 -0
  15. package/dist/assets/components/date-range-picker/components/date-time-input/date-time-input.vue +1 -0
  16. package/dist/assets/components/date-range-picker/date-range-picker.docs.mdx +6 -0
  17. package/dist/assets/components/date-range-picker/date-range-picker.stories.ts +1 -1
  18. package/dist/assets/components/date-range-picker/date-range-picker.types.ts +1 -1
  19. package/dist/assets/components/date-range-picker/date-range-picker.vue +1 -1
  20. package/dist/assets/components/date-time-picker/date-time-picker.vue +1 -1
  21. package/dist/assets/components/draggable/draggable.vue +1 -1
  22. package/dist/assets/components/dropdown/components/dropdown-base-textbox/dropdown-base-textbox.vue +1 -1
  23. package/dist/assets/components/dropdown/components/dropdown-options-container/dropdown-options-container.vue +1 -1
  24. package/dist/assets/components/dropdown/components/variants/dropdown-country/dropdown-country.types.ts +1 -1
  25. package/dist/assets/components/dropdown/dropdown.types.ts +1 -1
  26. package/dist/assets/components/dropdown/dropdown.vue +1 -1
  27. package/dist/assets/components/filter-bar/components/checkbox-group-filter/checkbox-group-filter.vue +1 -1
  28. package/dist/assets/components/filter-bar/components/date-filter/date-filter.vue +1 -1
  29. package/dist/assets/components/filter-bar/components/date-range-filter/date-range-filter.vue +1 -1
  30. package/dist/assets/components/filter-bar/components/filter-bar-button/filter-bar-button.vue +1 -1
  31. package/dist/assets/components/filter-bar/components/input-with-dropdown-filter/input-with-dropdown-filter.vue +1 -1
  32. package/dist/assets/components/filter-bar/filter-bar.docs.mdx +8 -2
  33. package/dist/assets/components/filter-bar/filter-bar.stories.ts +1 -1
  34. package/dist/assets/components/filter-bar/filter-bar.types.ts +1 -1
  35. package/dist/assets/components/filter-bar/filter-bar.vue +1 -1
  36. package/dist/assets/components/input-field-password/input-field-password.vue +1 -1
  37. package/dist/assets/components/internal/calendar/calendar.stories.ts +1 -1
  38. package/dist/assets/components/internal/calendar/calendar.types.ts +1 -1
  39. package/dist/assets/components/internal/calendar/calendar.vue +1 -1
  40. package/dist/assets/components/internal/calendar/components/calendar-month/calendar-month.types.ts +1 -1
  41. package/dist/assets/components/internal/calendar/components/calendar-month/calendar-month.vue +1 -1
  42. package/dist/assets/components/internal/calendar/components/calendar-month/components/calendar-month-day/calendar-month-day.vue +1 -1
  43. package/dist/assets/components/internal/calendar/components/calendar-year/calendar-year.types.ts +1 -1
  44. package/dist/assets/components/internal/calendar/components/calendar-year/calendar-year.vue +1 -1
  45. package/dist/assets/components/internal/calendar/components/calendar-year-selector/calendar-year-selector.types.ts +1 -0
  46. package/dist/assets/components/internal/calendar/components/calendar-year-selector/calendar-year-selector.vue +1 -0
  47. package/dist/assets/components/internal/header-meta/header-meta.vue +1 -1
  48. package/dist/assets/components/internal/listbox/components/listbox-multi-select/listbox-multi-select.vue +1 -1
  49. package/dist/assets/components/internal/listbox/components/listbox-single-select/listbox-single-select.vue +1 -1
  50. package/dist/assets/components/list/components/list-item/list-item.types.ts +1 -1
  51. package/dist/assets/components/list/components/list-item/list-item.vue +1 -1
  52. package/dist/assets/components/list/list.docs.mdx +62 -0
  53. package/dist/assets/components/list/list.stories.ts +1 -1
  54. package/dist/assets/components/list/list.types.ts +1 -1
  55. package/dist/assets/components/list/list.vue +1 -1
  56. package/dist/assets/components/popover/popover.docs.mdx +5 -1
  57. package/dist/assets/components/summary-grid/summary-grid.vue +1 -1
  58. package/dist/assets/components/tabs/tabs.docs.mdx +6 -1
  59. package/dist/assets/components/toast/toast.docs.mdx +7 -0
  60. package/dist/assets/composables/use-bento-delayed-hover/use-bento-delayed-hover.docs.mdx +5 -1
  61. package/dist/assets/deprecations.md +1605 -155
  62. package/dist/assets/usage.json +74 -74
  63. package/dist/main.js +135 -48
  64. package/package.json +1 -1
@@ -1 +1 @@
1
- <template> <div v-bento-keyboard-navigation-directive class="b-calendar-month" v-on="listeners"> <div class="b-calendar-month__month-selector"> <!-- go to previous month --> <div class="b-calendar-month__previous"> <bento-button v-if="shouldShowPreviousMonthButton" variant="tertiary" @click="moveToPreviousMonth($event, true)" > <template #iconLeft> <chevron-left-icon :svg-title="t('previousMonth')" /> </template> </bento-button> </div> <!-- month's name --> <bento-typography :id="monthLabelId" el="h2" variant="title" aria-live="polite"> {{ formattedMonthDate }} </bento-typography> <!-- go to next month --> <div class="b-calendar-month__next"> <bento-button v-if="shouldShowNextMonthButton" variant="tertiary" @click="moveToNextMonth($event, true)" > <template #iconLeft> <chevron-right-icon :svg-title="t('nextMonth')" /> </template> </bento-button> </div> </div> <table class="b-calendar-month__month-container" role="grid" :aria-labelledby="monthLabelId"> <thead> <tr class="b-calendar-month__week-header"> <th v-for="(dayOfWeekFormatted, index) in daysOfWeek" :key="index" class="b-calendar-month__day-of-week" scope="col" > <bento-typography class="b-calendar-month__day-of-week-font" el="span" variant="caption"> {{ dayOfWeekFormatted }} </bento-typography> </th> </tr> </thead> <tbody> <tr v-for="(week, index) in weeksOfMonth" :key="index"> <td v-if="index === 0 && week.length < 7" :colspan="7 - week.length"> </td> <calendar-month-day v-for="(day, dayIndex) in week" :ref="addDayRef(day)" :key="`calendar-day-${dayIndex}`" :day="day" :disabled="computedIsDateDisabled(day)" :selected="isSelectedDate(day)" :variant="dayVariant(day)" @click="onDayClicked" @mouseover="$emit('mouseover', day)" @keyup.up.native="$emit('changeDay', day)" @keyup.down.native="$emit('changeDay', day)" @keyup.left.native="$emit('changeDay', day)" @keyup.right.native="$emit('changeDay', day)" /> </tr> <template v-if="weeksOfMonth?.length < 6"> <tr v-for="n in 6 - weeksOfMonth?.length" :key="`calendar month filler row ${n}`"> <td aria-hidden="true" class="b-calendar-month__empty-cell-filler" :colspan="7"></td> </tr> </template> </tbody> </table> </div> </template> <script setup lang="ts"> import { computed, toRef } from 'vue'; import BentoButton from '@/components/button/button.vue'; import { CalendarMonthDay, CalendarMonthDayVariant } from './components/calendar-month-day'; import { BentoTypography } from '@/components/typography'; import { useI18n } from '@/utils/ts/i18n'; import type { CalendarMonthProps } from './calendar-month.types'; import { generateUid } from '@/core/utils/ts'; import { BentoKeyboardNavigationDirective as vBentoKeyboardNavigationDirective } from '@/directives'; import { type CalendarEmit, CalendarFirstDayOfWeek } from '../../calendar.types'; import { useMoveCalendarDate } from './composables/move-calendar-date'; import { setDay } from 'date-fns/setDay'; import { startOfMonth } from 'date-fns/startOfMonth'; import { startOfDay } from 'date-fns/startOfDay'; import { isSameDay } from 'date-fns/isSameDay'; import { isWithinInterval } from 'date-fns/isWithinInterval'; import { getDateTimeFormatter } from '@/utils/ts/format-date'; import ChevronLeftIcon from '@adyen/ui-assets-icons-16/vue/chevron-left'; import ChevronRightIcon from '@adyen/ui-assets-icons-16/vue/chevron-right'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const emit = defineEmits<CalendarEmit>(); const props = withDefaults(defineProps<CalendarMonthProps>(), { endDate: null, firstDayOfWeek: CalendarFirstDayOfWeek.MONDAY, isDateDisabled: undefined, isRange: false, max: null, min: null, position: 'unique', startDate: null, // can be undefined, eslint complains without default }); // Date formatter locale options const { locale, t } = useI18n<{ message: MessageSchema }>({ messages }); const monthLabelId = generateUid('bento-date-picker-month-label'); const computedIsDateDisabled = computed( () => (date: Date) => (props.min && date < props.min) || (props.max && date > props.max) || props.isDateDisabled?.(date) ); const { internalDate, baseDate, // Calendar refs addDayRef, // Day navigation moveToNextDay, moveToPreviousDay, moveToPreviousWeek, moveToNextWeek, moveToFirstDayOfWeek, moveToLastDayOfWeek, moveToPreviousMonth, moveToPreviousYear, moveToNextMonth, moveToNextYear, focusOnDate, } = useMoveCalendarDate( { currentSelectedDate: toRef(props, 'startDate'), month: toRef(props, 'month'), isDateDisabled: computedIsDateDisabled.value, firstDayOfWeek: props.firstDayOfWeek, }, emit ); /** * Default to Monday if the value passed in is not part of the defined Enum */ const fallbackFirstDayOfWeek = computed(() => Object.values(CalendarFirstDayOfWeek).includes(props.firstDayOfWeek) ? props.firstDayOfWeek : CalendarFirstDayOfWeek.MONDAY ); /** * Renders the previous month button if it's Unique or First calendar */ const shouldShowPreviousMonthButton = computed(() => ['first', 'unique'].includes(props.position)); /** * Renders the next month button if it's Unique or Last calendar */ const shouldShowNextMonthButton = computed(() => ['last', 'unique'].includes(props.position)); /** * Formats the month date to the name of the month and the year */ const formattedMonthDate = computed(() => getDateTimeFormatter(locale.value, { month: 'long', year: 'numeric' }).format(baseDate.value) ); /** * Formats all the days of the week to their day name */ const daysOfWeek = computed(() => new Array(7).fill(new Date()).map((date, index) => { const dayOfWeekDate = setDay(date, fallbackFirstDayOfWeek.value + index); return getDateTimeFormatter(locale.value, { weekday: 'short' }).format(dayOfWeekDate); }) ); /** * Calculates all the weeks of the current "props.month" * and adds the respective day number to each column and week */ const weeksOfMonth = computed(() => { const weeks = []; const date = startOfMonth(props.month); let week = weeks[weeks.push([]) - 1]; while (date.getMonth() === props.month.getMonth()) { if (date.getDay() - fallbackFirstDayOfWeek.value === 0 && week.length > 0) { week = weeks[weeks.push([]) - 1]; } week.push(startOfDay(date)); date.setDate(date.getDate() + 1); } return weeks; }); const onDayClicked = (selectedDate: Date) => { emit('input', selectedDate); }; /** * Calculates the variation of the days selected. * For single select it's always SINGLE * For ranges: * - Start & End is the same: SINGLE * - Start: BEGINNING * - End: END * - Within the range: MIDDLE * @param day Current day */ const dayVariant = (day: Date) => { if (!props.isRange || isSameDay(props.startDate, props.endDate)) { return CalendarMonthDayVariant.SINGLE; } if (props.startDate && isSameDay(props.startDate, day)) { return CalendarMonthDayVariant.BEGINNING; } if (props.endDate && isSameDay(props.endDate, day)) { return CalendarMonthDayVariant.END; } if (props.startDate && props.endDate && isWithinInterval(day, { start: props.startDate, end: props.endDate })) { return CalendarMonthDayVariant.MIDDLE; } return undefined; }; /** * Checks every date in the calendar to see if it's selected. * @param day Current day in the calendar */ const isSelectedDate = (day: Date) => { // Single selection if (internalDate.value && isSameDay(internalDate.value, day) && !props.endDate) { return true; } // Range return ( props.startDate && props.endDate && isWithinInterval(day, { start: props.startDate, end: props.endDate }) ); }; const listeners = { 'keydown:arrow-right': moveToNextDay, 'keydown:arrow-left': moveToPreviousDay, 'keydown:arrow-up': moveToPreviousWeek, 'keydown:arrow-down': moveToNextWeek, 'keydown:home': moveToFirstDayOfWeek, 'keydown:end': moveToLastDayOfWeek, 'keydown:page-up': moveToPreviousMonth, 'keydown:shift-page-up': moveToPreviousYear, 'keydown:page-down': moveToNextMonth, 'keydown:shift-page-down': moveToNextYear, }; defineExpose({ focusOnDate, }); </script> <script lang="ts"> /** * CalendarMonth that displays all the days of a given month and allows * to move back and forth the visible month with the Next and Previous buttons * * @example * import { CalendarMonthMonth, CalendarMonthFirstDayOfWeek, CalendarPosition } from '@/internal/calendar'; * * export default { * components: { CalendarMonthMonth }, * template: ` * <calendar * :first-day-of-week="CalendarMonthFirstDayOfWeek.SUNDAY" * :is-date-disabled="(date: Date) => boolean" * :month="new Date()" * position="last" * is-range * :start-date="new Date()" * :end-date="new Date()" * @input="(selectedDate: Date) => void" * @mouseover="(event: Event) => void" * @nextMonth="() => void" * @previousMonth="() => void" * /> * ` * } */ export default { i18n: { messages }, name: 'calendar-month', }; </script> <style lang="scss" scoped src="./calendar-month.scss" />
1
+ <template> <div v-bento-keyboard-navigation-directive class="b-calendar-month" v-on="listeners"> <div class="b-calendar-month__month-selector"> <!-- go to previous month --> <div class="b-calendar-month__previous"> <bento-button v-if="shouldShowPreviousMonthButton" variant="tertiary" @click="moveToPreviousMonth($event, true)" > <template #iconLeft> <chevron-left-icon :svg-title="t('previousMonth')" /> </template> </bento-button> </div> <div class="b-calendar-month__month-year-container"> <!-- Month name --> <bento-typography :id="monthLabelId" el="h2" variant="title" aria-live="polite"> {{ formattedMonthName }} <span class="b-calendar-month__visually-hidden">{{ baseDate.getFullYear() }}</span> </bento-typography> <!-- Year selector --> <calendar-year-selector :current-year="baseDate.getFullYear()" :year-range="yearRange" :min="min" :max="max" @update:year="onYearSelected" /> </div> <!-- go to next month --> <div class="b-calendar-month__next"> <bento-button v-if="shouldShowNextMonthButton" variant="tertiary" @click="moveToNextMonth($event, true)" > <template #iconLeft> <chevron-right-icon :svg-title="t('nextMonth')" /> </template> </bento-button> </div> </div> <table class="b-calendar-month__month-container" role="grid" :aria-labelledby="monthLabelId"> <thead> <tr class="b-calendar-month__week-header"> <th v-for="(dayOfWeekFormatted, index) in daysOfWeek" :key="index" class="b-calendar-month__day-of-week" scope="col" > <bento-typography class="b-calendar-month__day-of-week-font" el="span" variant="caption"> {{ dayOfWeekFormatted }} </bento-typography> </th> </tr> </thead> <tbody> <tr v-for="(week, index) in weeksOfMonth" :key="index"> <td v-if="index === 0 && week.length < 7" :colspan="7 - week.length"> </td> <calendar-month-day v-for="(day, dayIndex) in week" :ref="addDayRef(day)" :key="`calendar-day-${dayIndex}`" :day="day" :disabled="computedIsDateDisabled(day)" :selected="isSelectedDate(day)" :variant="dayVariant(day)" @click="onDayClicked" @mouseover="$emit('mouseover', day)" @keyup.up.native="$emit('changeDay', day)" @keyup.down.native="$emit('changeDay', day)" @keyup.left.native="$emit('changeDay', day)" @keyup.right.native="$emit('changeDay', day)" /> </tr> <template v-if="weeksOfMonth?.length < 6"> <tr v-for="n in 6 - weeksOfMonth?.length" :key="`calendar month filler row ${n}`"> <td aria-hidden="true" class="b-calendar-month__empty-cell-filler" :colspan="7"></td> </tr> </template> </tbody> </table> </div> </template> <script setup lang="ts"> import { computed, toRef } from 'vue'; import BentoButton from '@/components/button/button.vue'; import { CalendarYearSelector } from '../calendar-year-selector'; import { CalendarMonthDay, CalendarMonthDayVariant } from './components/calendar-month-day'; import { BentoTypography } from '@/components/typography'; import { useI18n } from '@/utils/ts/i18n'; import type { CalendarMonthProps } from './calendar-month.types'; import { generateUid } from '@/core/utils/ts'; import { BentoKeyboardNavigationDirective as vBentoKeyboardNavigationDirective } from '@/directives'; import { type CalendarEmit, CalendarFirstDayOfWeek } from '../../calendar.types'; import { useMoveCalendarDate } from './composables/move-calendar-date'; import { setDay } from 'date-fns/setDay'; import { startOfMonth } from 'date-fns/startOfMonth'; import { startOfDay } from 'date-fns/startOfDay'; import { isSameDay } from 'date-fns/isSameDay'; import { isWithinInterval } from 'date-fns/isWithinInterval'; import { getDateTimeFormatter } from '@/utils/ts/format-date'; import ChevronLeftIcon from '@adyen/ui-assets-icons-16/vue/chevron-left'; import ChevronRightIcon from '@adyen/ui-assets-icons-16/vue/chevron-right'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const emit = defineEmits<CalendarEmit>(); const props = withDefaults(defineProps<CalendarMonthProps>(), { endDate: null, firstDayOfWeek: CalendarFirstDayOfWeek.MONDAY, isDateDisabled: undefined, isRange: false, max: null, min: null, position: 'unique', startDate: undefined, yearRange: undefined, }); // Date formatter locale options const { locale, t } = useI18n<{ message: MessageSchema }>({ messages }); const monthLabelId = generateUid('bento-date-picker-month-label'); const computedIsDateDisabled = computed( () => (date: Date) => (props.min && date < props.min) || (props.max && date > props.max) || props.isDateDisabled?.(date) ); const { internalDate, baseDate, // Calendar refs addDayRef, // Day navigation moveToNextDay, moveToPreviousDay, moveToPreviousWeek, moveToNextWeek, moveToFirstDayOfWeek, moveToLastDayOfWeek, moveToPreviousMonth, moveToPreviousYear, moveToNextMonth, moveToNextYear, focusOnDate, showNextMonth, showPreviousMonth, } = useMoveCalendarDate( { currentSelectedDate: toRef(props, 'startDate'), month: toRef(props, 'month'), isDateDisabled: computedIsDateDisabled.value, firstDayOfWeek: props.firstDayOfWeek, }, emit ); /** * Default to Monday if the value passed in is not part of the defined Enum */ const fallbackFirstDayOfWeek = computed(() => Object.values(CalendarFirstDayOfWeek).includes(props.firstDayOfWeek) ? props.firstDayOfWeek : CalendarFirstDayOfWeek.MONDAY ); /** * Renders the previous month button if it's Unique or First calendar */ const shouldShowPreviousMonthButton = computed(() => ['first', 'unique'].includes(props.position)); /** * Renders the next month button if it's Unique or Last calendar */ const shouldShowNextMonthButton = computed(() => ['last', 'unique'].includes(props.position)); /** * Formats the month date to the name of the month */ const formattedMonthName = computed(() => getDateTimeFormatter(locale.value, { month: 'long' }).format(baseDate.value) ); /** * Navigates the calendar to the newly selected year, preserving the currently displayed month. * @param newYear The year selected from the dropdown */ const onYearSelected = (newYear: number) => { const currentYear = baseDate.value.getFullYear(); const diffYears = newYear - currentYear; const diffMonths = diffYears * 12; if (diffMonths > 0) { showNextMonth(diffMonths); } else if (diffMonths < 0) { showPreviousMonth(Math.abs(diffMonths)); } }; /** * Formats all the days of the week to their day name */ const daysOfWeek = computed(() => new Array(7).fill(new Date()).map((date, index) => { const dayOfWeekDate = setDay(date, fallbackFirstDayOfWeek.value + index); return getDateTimeFormatter(locale.value, { weekday: 'short' }).format(dayOfWeekDate); }) ); /** * Calculates all the weeks of the current "props.month" * and adds the respective day number to each column and week */ const weeksOfMonth = computed(() => { const weeks = []; const date = startOfMonth(props.month); let week = weeks[weeks.push([]) - 1]; while (date.getMonth() === props.month.getMonth()) { if (date.getDay() - fallbackFirstDayOfWeek.value === 0 && week.length > 0) { week = weeks[weeks.push([]) - 1]; } week.push(startOfDay(date)); date.setDate(date.getDate() + 1); } return weeks; }); const onDayClicked = (selectedDate: Date) => { emit('input', selectedDate); }; /** * Calculates the variation of the days selected. * For single select it's always SINGLE * For ranges: * - Start & End is the same: SINGLE * - Start: BEGINNING * - End: END * - Within the range: MIDDLE * @param day Current day */ const dayVariant = (day: Date) => { if (!props.isRange || isSameDay(props.startDate, props.endDate)) { return CalendarMonthDayVariant.SINGLE; } if (props.startDate && isSameDay(props.startDate, day)) { return CalendarMonthDayVariant.BEGINNING; } if (props.endDate && isSameDay(props.endDate, day)) { return CalendarMonthDayVariant.END; } if (props.startDate && props.endDate && isWithinInterval(day, { start: props.startDate, end: props.endDate })) { return CalendarMonthDayVariant.MIDDLE; } return undefined; }; /** * Checks every date in the calendar to see if it's selected. * @param day Current day in the calendar */ const isSelectedDate = (day: Date) => { // Single selection if (internalDate.value && isSameDay(internalDate.value, day) && !props.endDate) { return true; } // Range return ( props.startDate && props.endDate && isWithinInterval(day, { start: props.startDate, end: props.endDate }) ); }; const listeners = { 'keydown:arrow-right': moveToNextDay, 'keydown:arrow-left': moveToPreviousDay, 'keydown:arrow-up': moveToPreviousWeek, 'keydown:arrow-down': moveToNextWeek, 'keydown:home': moveToFirstDayOfWeek, 'keydown:end': moveToLastDayOfWeek, 'keydown:page-up': moveToPreviousMonth, 'keydown:shift-page-up': moveToPreviousYear, 'keydown:page-down': moveToNextMonth, 'keydown:shift-page-down': moveToNextYear, }; defineExpose({ focusOnDate, }); </script> <script lang="ts"> /** * CalendarMonth that displays all the days of a given month and allows * to move back and forth the visible month with the Next and Previous buttons * * @example * import { CalendarMonthMonth, CalendarMonthFirstDayOfWeek, CalendarPosition } from '@/internal/calendar'; * * export default { * components: { CalendarMonthMonth }, * template: ` * <calendar * :first-day-of-week="CalendarMonthFirstDayOfWeek.SUNDAY" * :is-date-disabled="(date: Date) => boolean" * :month="new Date()" * position="last" * is-range * :start-date="new Date()" * :end-date="new Date()" * @input="(selectedDate: Date) => void" * @mouseover="(event: Event) => void" * @nextMonth="() => void" * @previousMonth="() => void" * /> * ` * } */ export default { i18n: { messages }, name: 'calendar-month', }; </script> <style lang="scss" scoped src="./calendar-month.scss" />
@@ -1 +1 @@
1
- <template> <td class="b-calendar-month-day" :class="conditionalClasses" :tabindex="selected ? '0' : '-1'" :aria-selected="selected ? selected : undefined" :aria-disabled="disabled" :aria-current="isCurrentDay ? 'date' : undefined" :data-date="day.toDateString()" @click="e => onClick(e, day)" @mouseover="$emit(CalendarMonthDayEvent.MOUSE_OVER)" @keydown.space.enter="e => onClick(e, day)" > <div class="b-calendar-month-day__wrapper"> <bento-typography class="b-calendar-month-day__label" el="span" variant="body" strong> {{ formattedDate }} </bento-typography> </div> </td> </template> <script lang="ts"> import { computed, defineComponent } from 'vue'; import { BentoTypography } from '../../../../../../typography'; import { CalendarMonthDayClass, CalendarMonthDayEvent, CalendarMonthDayVariant } from './calendar-month-day.types'; import { CalendarMonthDateProps } from './calendar-month-day.props'; import { getDateTimeFormatter } from '@/utils/ts/format-date'; import { useI18n } from '@/utils/ts/i18n'; /** * Handles the styles and states of the individual dates of the calendar. * * @example * import { CalendarMonthDay, CalendarMonthDayVariant } from './calendar-day'; * * export default { * components: { CalendarMonthDay }, * template: ` * <table> * <tbody> * <tr> * <calendar-day * :day="new Date()" * disabled * selected * :variant="CalendarMonthDayVariant.MIDDLE" * /> * </tr> * </tbody> * </table> * ` * } */ export default defineComponent({ name: 'calendar-month-day', components: { BentoTypography }, inheritAttrs: true, props: CalendarMonthDateProps, emits: [CalendarMonthDayEvent.CLICK, CalendarMonthDayEvent.MOUSE_OVER], setup(props, { emit }) { const { locale } = useI18n(undefined, true); const isCurrentDay = computed(() => { const today = new Date(); return ( props.day.getDate() === today.getDate() && props.day.getMonth() === today.getMonth() && props.day.getFullYear() === today.getFullYear() ); }); /** * Only get translated day part of the date */ const formattedDate = computed(() => { const formatter = getDateTimeFormatter(locale.value, { day: 'numeric' }); const parts = formatter.formatToParts(props.day); const dayPart = parts.find(part => part.type === 'day'); return dayPart?.value ?? ''; }); const conditionalClasses = computed(() => ({ // Variants [`b-calendar-month-day--${CalendarMonthDayClass.BEGINNING}`]: props.variant === CalendarMonthDayVariant.BEGINNING, [`b-calendar-month-day--${CalendarMonthDayClass.MIDDLE}`]: props.variant === CalendarMonthDayVariant.MIDDLE, [`b-calendar-month-day--${CalendarMonthDayClass.END}`]: props.variant === CalendarMonthDayVariant.END, [`b-calendar-month-day--${CalendarMonthDayClass.SINGLE}`]: props.variant === CalendarMonthDayVariant.SINGLE, // Selected [`b-calendar-month-day--${CalendarMonthDayClass.UNSELECTED}`]: !props.selected, [`b-calendar-month-day--${CalendarMonthDayClass.SELECTED}`]: props.selected, // Current Day [`b-calendar-month-day--${CalendarMonthDayClass.CURRENT_DAY}`]: isCurrentDay.value, // Disabled [`b-calendar-month-day--${CalendarMonthDayClass.DISABLED}`]: props.disabled, })); const onClick = (e: MouseEvent | KeyboardEvent, selectedDay: Date) => { if (!props.disabled) { emit(CalendarMonthDayEvent.CLICK, selectedDay, props.day); } else { e.preventDefault(); e.stopPropagation(); } }; return { // Values conditionalClasses, isCurrentDay, formattedDate, // Enums CalendarMonthDayEvent, // Events onClick, }; }, }); </script> <style lang="scss" scoped src="./calendar-month-day.scss" />
1
+ <template> <td class="b-calendar-month-day" :class="conditionalClasses" :tabindex="selected ? '0' : '-1'" :aria-selected="selected ? selected : undefined" :aria-disabled="disabled" :aria-current="isCurrentDay ? 'date' : undefined" :data-date="day.toDateString()" @click="e => onClick(e, day)" @mouseover="$emit(CalendarMonthDayEvent.MOUSE_OVER)" @keydown.space.enter="e => onClick(e, day)" > <div class="b-calendar-month-day__wrapper"> <bento-typography class="b-calendar-month-day__label" el="span" variant="body" strong> <time :datetime="dateTime">{{ formattedDate }}</time> </bento-typography> </div> </td> </template> <script lang="ts"> import { computed, defineComponent } from 'vue'; import { BentoTypography } from '../../../../../../typography'; import { CalendarMonthDayClass, CalendarMonthDayEvent, CalendarMonthDayVariant } from './calendar-month-day.types'; import { CalendarMonthDateProps } from './calendar-month-day.props'; import { getDateTimeFormatter } from '@/utils/ts/format-date'; import { useI18n } from '@/utils/ts/i18n'; /** * Handles the styles and states of the individual dates of the calendar. * * @example * import { CalendarMonthDay, CalendarMonthDayVariant } from './calendar-day'; * * export default { * components: { CalendarMonthDay }, * template: ` * <table> * <tbody> * <tr> * <calendar-day * :day="new Date()" * disabled * selected * :variant="CalendarMonthDayVariant.MIDDLE" * /> * </tr> * </tbody> * </table> * ` * } */ export default defineComponent({ name: 'calendar-month-day', components: { BentoTypography }, inheritAttrs: true, props: CalendarMonthDateProps, emits: [CalendarMonthDayEvent.CLICK, CalendarMonthDayEvent.MOUSE_OVER], setup(props, { emit }) { const { locale } = useI18n(undefined, true); const isCurrentDay = computed(() => { const today = new Date(); return ( props.day.getDate() === today.getDate() && props.day.getMonth() === today.getMonth() && props.day.getFullYear() === today.getFullYear() ); }); /** * Only get translated day part of the date */ const formattedDate = computed(() => { const formatter = getDateTimeFormatter(locale.value, { day: 'numeric' }); const parts = formatter.formatToParts(props.day); const dayPart = parts.find(part => part.type === 'day'); return dayPart?.value ?? ''; }); const dateTime = computed(() => (Number.isNaN(props.day.getTime()) ? undefined : props.day.toISOString())); const conditionalClasses = computed(() => ({ // Variants [`b-calendar-month-day--${CalendarMonthDayClass.BEGINNING}`]: props.variant === CalendarMonthDayVariant.BEGINNING, [`b-calendar-month-day--${CalendarMonthDayClass.MIDDLE}`]: props.variant === CalendarMonthDayVariant.MIDDLE, [`b-calendar-month-day--${CalendarMonthDayClass.END}`]: props.variant === CalendarMonthDayVariant.END, [`b-calendar-month-day--${CalendarMonthDayClass.SINGLE}`]: props.variant === CalendarMonthDayVariant.SINGLE, // Selected [`b-calendar-month-day--${CalendarMonthDayClass.UNSELECTED}`]: !props.selected, [`b-calendar-month-day--${CalendarMonthDayClass.SELECTED}`]: props.selected, // Current Day [`b-calendar-month-day--${CalendarMonthDayClass.CURRENT_DAY}`]: isCurrentDay.value, // Disabled [`b-calendar-month-day--${CalendarMonthDayClass.DISABLED}`]: props.disabled, })); const onClick = (e: MouseEvent | KeyboardEvent, selectedDay: Date) => { if (!props.disabled) { emit(CalendarMonthDayEvent.CLICK, selectedDay, props.day); } else { e.preventDefault(); e.stopPropagation(); } }; return { // Values conditionalClasses, isCurrentDay, formattedDate, dateTime, // Enums CalendarMonthDayEvent, // Events onClick, }; }, }); </script> <style lang="scss" scoped src="./calendar-month-day.scss" />
@@ -1 +1 @@
1
- import { type CalendarIsDateDisabled, type CalendarPosition } from '../../calendar.types'; export interface CalendarYearProps { /** * end of the selected range */ endDate?: Date; /** * Indicate if a date should be disabled or not */ isDateDisabled?: CalendarIsDateDisabled; /** * Indicates if the calendar is part of a range date picker or a single date picker */ isRange?: boolean; /** * Sets a minimum limit for selectable dates - dates before `min` will not be selectable */ min?: Date; /** * Sets a maximum limit for selectable dates - dates after `max` will not be selectable */ max?: Date; /** * When rendering multiple calendars, this property allows to render the * Next and Previous calendar buttons to the right, left or not at all * depending on the position: Unique (only 1), First, Middle or Last */ position?: CalendarPosition; /** * start of the selected range */ startDate?: Date; /** * Year to be displayed in the calendar */ year: Date; }
1
+ import { type CalendarIsDateDisabled, type CalendarPosition, type CalendarYearRange } from '../../calendar.types'; export interface CalendarYearProps { /** * end of the selected range */ endDate?: Date; /** * Indicate if a date should be disabled or not */ isDateDisabled?: CalendarIsDateDisabled; /** * Indicates if the calendar is part of a range date picker or a single date picker */ isRange?: boolean; /** * Sets a minimum limit for selectable dates - dates before `min` will not be selectable */ min?: Date; /** * Sets a maximum limit for selectable dates - dates after `max` will not be selectable */ max?: Date; /** * Range of years to be selectable from the year dropdown */ yearRange?: CalendarYearRange; /** * When rendering multiple calendars, this property allows to render the * Next and Previous calendar buttons to the right, left or not at all * depending on the position: Unique (only 1), First, Middle or Last */ position?: CalendarPosition; /** * start of the selected range */ startDate?: Date; /** * Year to be displayed in the calendar */ year: Date; }
@@ -1 +1 @@
1
- <template> <div v-bento-keyboard-navigation-directive class="b-calendar-year" v-on="listeners"> <div class="b-calendar-year__year-selector"> <!-- go to previous year --> <div class="b-calendar-year__previous"> <bento-button v-if="shouldShowPreviousYearButton" variant="tertiary" @click="moveToPreviousYear($event, true)" > <template #iconLeft> <chevron-left-icon :svg-title="t('previousYear')" /> </template> </bento-button> </div> <!-- year --> <bento-typography :id="yearLabelId" el="h2" variant="title" aria-live="polite"> {{ year.getFullYear() }} </bento-typography> <!-- go to next year --> <div class="b-calendar-year__next"> <bento-button v-if="shouldShowNextYearButton" variant="tertiary" @click="moveToNextYear($event, true)"> <template #iconLeft> <chevron-right-icon :svg-title="t('nextYear')" /> </template> </bento-button> </div> </div> <table class="b-calendar-year__year-container" role="grid" :aria-labelledby="yearLabelId"> <tbody> <tr v-for="(row, index) in monthRows" :key="index"> <calendar-year-month v-for="(month, monthIndex) in row" :ref="addDayRef(month)" :key="`calendar-month-${monthIndex}`" :month="month" :disabled="isDateDisabled(month)" :selected="isSelectedDate(month)" :variant="monthVariant(month)" @click="onMonthClicked" @mouseover="emit('mouseover', month)" /> </tr> </tbody> </table> </div> </template> <script setup lang="ts"> import { computed, toRef } from 'vue'; import { isSameMonth } from 'date-fns/isSameMonth'; import { isWithinInterval } from 'date-fns/isWithinInterval'; import BentoButton from '@/components/button/button.vue'; import { BentoTypography } from '@/components/typography'; import CalendarYearMonth from './components/calendar-year-month/calendar-year-month.vue'; import ChevronLeftIcon from '@adyen/ui-assets-icons-16/vue/chevron-left'; import ChevronRightIcon from '@adyen/ui-assets-icons-16/vue/chevron-right'; import { BentoKeyboardNavigationDirective as vBentoKeyboardNavigationDirective } from '@/directives'; import { generateUid } from '@/core/utils/ts'; import { useI18n } from '@/utils/ts/i18n'; import { useMoveCalendarDate } from '@/internal/calendar/components/calendar-month/composables/move-calendar-date'; import { type CalendarYearProps } from './calendar-year.types'; import { CalendarYearMonthVariant } from './components/calendar-year-month/calendar-year-month.types'; import { type CalendarEmit, CalendarPosition } from '../../calendar.types'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const yearLabelId = generateUid('bento-date-picker-year-label'); const props = withDefaults(defineProps<CalendarYearProps>(), { endDate: null, isDateDisabled: undefined, isRange: false, min: null, max: null, position: CalendarPosition.UNIQUE, startDate: null, }); const emit = defineEmits<CalendarEmit>(); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const isDateDisabled = (date: Date) => (props.min && date < props.min) || (props.max && date > props.max) || props.isDateDisabled?.(date); /** * Renders the previous year button if it's Unique or First calendar */ const shouldShowPreviousYearButton = computed(() => (['first', 'unique'] as Array<CalendarPosition>).includes(props.position) ); /** * Renders the next year button if it's Unique or Last calendar */ const shouldShowNextYearButton = computed(() => (['last', 'unique'] as Array<CalendarPosition>).includes(props.position) ); const { internalDate, // Calendar refs addDayRef, // Day navigation moveToPreviousMonth, moveToPreviousYear, moveToNextMonth, moveToNextYear, moveToFirstMonthOfYear, moveToLastMonthOfYear, focusOnDate, } = useMoveCalendarDate( { currentSelectedDate: toRef(props, 'startDate'), month: toRef(props, 'year'), isDateDisabled, }, emit ); defineExpose({ focusOnDate }); /** * Calculates all the months of the current "props.month" * and adds the respective day number to each column and week */ const monthRows = computed(() => { const rows = []; let count = 0; for (let i = 0; i < 3; i++) { const innerArray = []; for (let j = 0; j < 4; j++) { innerArray.push(new Date(props.year.getFullYear(), count++, 1)); } rows.push(innerArray); } return rows; }); /** * Calculates the variation of the days selected. * For single select it's always SINGLE * For ranges: * - Start & End is the same: SINGLE * - Start: BEGINNING * - End: END * - Within the range: MIDDLE * @param {Date} day Current day */ const monthVariant = (day: Date) => { if (!props.isRange || isSameMonth(props.startDate, props.endDate)) { return CalendarYearMonthVariant.SINGLE; } if (props.startDate && isSameMonth(props.startDate, day)) { return CalendarYearMonthVariant.BEGINNING; } if (props.endDate && isSameMonth(props.endDate, day)) { return CalendarYearMonthVariant.END; } if (props.startDate && props.endDate && isWithinInterval(day, { start: props.startDate, end: props.endDate })) { return CalendarYearMonthVariant.MIDDLE; } return null; }; /** * Checks every date in the calendar to see if it's selected. * @param date Current date in the calendar */ const isSelectedDate = (date: Date) => { // Single selection if (internalDate.value && isSameMonth(internalDate.value, date)) { return true; } // Range return ( props.startDate && props.endDate && isWithinInterval(date, { start: props.startDate, end: props.endDate }) ); }; const onMonthClicked = (selectedDate: Date) => { emit('input', selectedDate); }; const listeners = !props.isRange ? { 'keydown:arrow-right': moveToNextMonth, 'keydown:arrow-left': moveToPreviousMonth, 'keydown:arrow-up': (ev: Event) => moveToPreviousMonth(ev, false, 4), 'keydown:arrow-down': (ev: Event) => moveToNextMonth(ev, false, 4), 'keydown:home': moveToFirstMonthOfYear, 'keydown:end': moveToLastMonthOfYear, 'keydown:page-up': moveToPreviousYear, 'keydown:page-down': moveToNextYear, } : {}; </script> <script lang="ts"> /** * CalendarYear that displays all the months of a given year and allows * to move back and forth the visible year with the Next and Previous buttons * * @example * import { CalendarYear } from '@/internal/calendar'; * * export default { * components: { CalendarYear }, * template: ` * <calendar-year * :is-date-disabled="(date: Date) => boolean" * :year="new Date()" * is-range * :start-date="new Date()" * :end-date="new Date()" * @input="(selectedDate: Date) => void" * @mouseover="(event: Event) => void" * @nextMonth="() => void" * @previousMonth="() => void" * /> * ` * } */ export default { i18n: { messages }, }; </script> <style lang="scss" scoped src="./calendar-year.scss" />
1
+ <template> <div v-bento-keyboard-navigation-directive class="b-calendar-year" v-on="listeners"> <div class="b-calendar-year__year-selector"> <!-- go to previous year --> <div class="b-calendar-year__previous"> <bento-button v-if="shouldShowPreviousYearButton" variant="tertiary" @click="moveToPreviousYear($event, true, 1)" > <template #iconLeft> <chevron-left-icon :svg-title="t('previousYear')" /> </template> </bento-button> </div> <!-- year --> <div class="b-calendar-year__year-label-container"> <!-- Year selector --> <calendar-year-selector :current-year="year.getFullYear()" :year-range="yearRange" :min="min" :max="max" @update:year="onYearSelected" /> </div> <!-- go to next year --> <div class="b-calendar-year__next"> <bento-button v-if="shouldShowNextYearButton" variant="tertiary" @click="moveToNextYear($event, true)"> <template #iconLeft> <chevron-right-icon :svg-title="t('nextYear')" /> </template> </bento-button> </div> </div> <table class="b-calendar-year__year-container" role="grid" :aria-labelledby="yearLabelId"> <tbody> <tr v-for="(row, index) in monthRows" :key="index"> <calendar-year-month v-for="(month, monthIndex) in row" :ref="addDayRef(month)" :key="`calendar-month-${monthIndex}`" :month="month" :disabled="isDateDisabled(month)" :selected="isSelectedDate(month)" :variant="monthVariant(month)" @click="onMonthClicked" @mouseover="emit('mouseover', month)" /> </tr> </tbody> </table> </div> </template> <script setup lang="ts"> import { computed, toRef } from 'vue'; import { isSameMonth } from 'date-fns/isSameMonth'; import { isWithinInterval } from 'date-fns/isWithinInterval'; import BentoButton from '@/components/button/button.vue'; import CalendarYearMonth from './components/calendar-year-month/calendar-year-month.vue'; import { CalendarYearSelector } from '../calendar-year-selector'; import ChevronLeftIcon from '@adyen/ui-assets-icons-16/vue/chevron-left'; import ChevronRightIcon from '@adyen/ui-assets-icons-16/vue/chevron-right'; import { BentoKeyboardNavigationDirective as vBentoKeyboardNavigationDirective } from '@/directives'; import { generateUid } from '@/core/utils/ts'; import { useI18n } from '@/utils/ts/i18n'; import { useMoveCalendarDate } from '@/internal/calendar/components/calendar-month/composables/move-calendar-date'; import { type CalendarYearProps } from './calendar-year.types'; import { CalendarYearMonthVariant } from './components/calendar-year-month/calendar-year-month.types'; import { type CalendarEmit, CalendarPosition } from '../../calendar.types'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const yearLabelId = generateUid('bento-date-picker-year-label'); const props = withDefaults(defineProps<CalendarYearProps>(), { endDate: null, isDateDisabled: undefined, isRange: false, min: null, max: null, yearRange: undefined, position: CalendarPosition.UNIQUE, startDate: null, }); const emit = defineEmits<CalendarEmit>(); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const isDateDisabled = (date: Date) => (props.min && date < props.min) || (props.max && date > props.max) || props.isDateDisabled?.(date); /** * Renders the previous year button if it's Unique or First calendar */ const shouldShowPreviousYearButton = computed(() => (['first', 'unique'] as Array<CalendarPosition>).includes(props.position) ); /** * Renders the next year button if it's Unique or Last calendar */ const shouldShowNextYearButton = computed(() => (['last', 'unique'] as Array<CalendarPosition>).includes(props.position) ); const { internalDate, // Calendar refs addDayRef, // Day navigation moveToPreviousMonth, moveToPreviousYear, moveToNextMonth, moveToNextYear, moveToFirstMonthOfYear, moveToLastMonthOfYear, focusOnDate, } = useMoveCalendarDate( { currentSelectedDate: toRef(props, 'startDate'), month: toRef(props, 'year'), isDateDisabled, }, emit ); const onYearSelected = (newYear: number) => { const currentYear = props.year.getFullYear(); const diffYears = newYear - currentYear; if (diffYears > 0) { moveToNextYear(null, true, diffYears); } else if (diffYears < 0) { moveToPreviousYear(null, true, Math.abs(diffYears)); } }; /** * Calculates all the months of the current "props.month" * and adds the respective day number to each column and week */ const monthRows = computed(() => { const rows = []; let count = 0; for (let i = 0; i < 3; i++) { const innerArray = []; for (let j = 0; j < 4; j++) { innerArray.push(new Date(props.year.getFullYear(), count++, 1)); } rows.push(innerArray); } return rows; }); /** * Calculates the variation of the days selected. * For single select it's always SINGLE * For ranges: * - Start & End is the same: SINGLE * - Start: BEGINNING * - End: END * - Within the range: MIDDLE * @param {Date} day Current day */ const monthVariant = (day: Date) => { if (!props.isRange || isSameMonth(props.startDate, props.endDate)) { return CalendarYearMonthVariant.SINGLE; } if (props.startDate && isSameMonth(props.startDate, day)) { return CalendarYearMonthVariant.BEGINNING; } if (props.endDate && isSameMonth(props.endDate, day)) { return CalendarYearMonthVariant.END; } if (props.startDate && props.endDate && isWithinInterval(day, { start: props.startDate, end: props.endDate })) { return CalendarYearMonthVariant.MIDDLE; } return null; }; /** * Checks every date in the calendar to see if it's selected. * @param date Current date in the calendar */ const isSelectedDate = (date: Date) => { // Single selection if (internalDate.value && isSameMonth(internalDate.value, date)) { return true; } // Range return ( props.startDate && props.endDate && isWithinInterval(date, { start: props.startDate, end: props.endDate }) ); }; const onMonthClicked = (selectedDate: Date) => { emit('input', selectedDate); }; const listeners = !props.isRange ? { 'keydown:arrow-right': moveToNextMonth, 'keydown:arrow-left': moveToPreviousMonth, 'keydown:arrow-up': (ev: Event) => moveToPreviousMonth(ev, false, 4), 'keydown:arrow-down': (ev: Event) => moveToNextMonth(ev, false, 4), 'keydown:home': moveToFirstMonthOfYear, 'keydown:end': moveToLastMonthOfYear, 'keydown:page-up': moveToPreviousYear, 'keydown:page-down': moveToNextYear, } : {}; defineExpose({ focusOnDate }); </script> <script lang="ts"> /** * CalendarYear that displays all the months of a given year and allows * to move back and forth the visible year with the Next and Previous buttons * * @example * import { CalendarYear } from '@/internal/calendar'; * * export default { * components: { CalendarYear }, * template: ` * <calendar-year * :is-date-disabled="(date: Date) => boolean" * :year="new Date()" * is-range * :start-date="new Date()" * :end-date="new Date()" * @input="(selectedDate: Date) => void" * @mouseover="(event: Event) => void" * @nextMonth="() => void" * @previousMonth="() => void" * /> * ` * } */ export default { i18n: { messages }, }; </script> <style lang="scss" scoped src="./calendar-year.scss" />
@@ -0,0 +1 @@
1
+ import type { CalendarYearRange } from '../../calendar.types'; export interface CalendarYearSelectorProps { /** * The currently selected/displayed year */ currentYear: number; /** * Range of years to be selectable from the year dropdown * @default undefined */ yearRange?: CalendarYearRange; /** * Sets a maximum limit for selectable dates - dates after `max` will not be selectable * @default null */ max?: Date; /** * Sets a minimum limit for selectable dates - dates before `min` will not be selectable * @default null */ min?: Date; }
@@ -0,0 +1 @@
1
+ <template> <bento-dropdown :items="yearOptions" :aria-label="t('selectYear')" :model-value="currentYear" @update:model-value="onYearSelected" /> </template> <script setup lang="ts"> import { computed, provide } from 'vue'; import BentoDropdown from '@/components/dropdown/dropdown.vue'; import { useI18n } from '@/utils/ts/i18n'; import type { CalendarYearSelectorProps } from './calendar-year-selector.types'; import { DROPDOWN_SMALL_SIZE_INJECTION_KEY, DROPDOWN_UNDERLINE_STYLE_INJECTION_KEY, } from '@/components/dropdown/dropdown.keys'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const DEFAULT_YEAR_RANGE_OFFSET = 100; const props = withDefaults(defineProps<CalendarYearSelectorProps>(), { yearRange: undefined, max: undefined, min: undefined, }); const emit = defineEmits<{ (e: 'update:year', year: number): void; }>(); const { t } = useI18n<{ message: MessageSchema }>({ messages }); // Provide dropdown styles provide(DROPDOWN_SMALL_SIZE_INJECTION_KEY, true); provide(DROPDOWN_UNDERLINE_STYLE_INJECTION_KEY, true); /** * Computes the available year options for the calendar's year selector dropdown. * The selectable range is determined by the `yearRange` prop, which dictates how many * years in the past and future should be available, while respecting `min` and `max` date constraints. */ const yearOptions = computed(() => { // Use the system's current year as the anchor so the range doesn't shift during month navigation const systemCurrentYear = new Date().getFullYear(); let startYear = props.yearRange?.minYear ?? systemCurrentYear - DEFAULT_YEAR_RANGE_OFFSET; let endYear = props.yearRange?.maxYear ?? systemCurrentYear + DEFAULT_YEAR_RANGE_OFFSET; // Prevent the user from scrolling to years earlier than the minimum allowed date if (props.min && props.min.getFullYear() > startYear) { startYear = props.min.getFullYear(); } // Prevent the user from scrolling to years earlier than the maximum allowed date if (props.max && props.max.getFullYear() < endYear) { endYear = props.max.getFullYear(); } // Populate options array const optionsLength = Math.max(0, endYear - startYear + 1); return new Array(optionsLength).fill(0).map((_, index) => { const year = startYear + index; return { label: String(year), value: year }; }); }); const onYearSelected = (newYear: number) => { emit('update:year', newYear); }; </script> <script lang="ts"> /** * A standalone year selector dropdown for the calendar component. */ export default { i18n: { messages }, name: 'calendar-year-selector', }; </script>
@@ -1 +1 @@
1
- <template> <div class="b-header-meta__container"> <bento-typography v-if="description || hasSlot('description')" class="b-header-meta__description" el="div"> <slot name="description">{{ description }}</slot> </bento-typography> <div v-if="lastUpdated" class="b-header-meta__timestamp-section"> <bento-typography class="b-header-meta__timestamp" el="div"> {{ timestamp }} </bento-typography> <bento-loading-button v-if="reloadButton" variant="tertiary" :state="reloadButton.state" @click="reloadButton.event" ><template #iconLeft><reload-icon aria-hidden="true" /></template >{{ reloadButton.title }}</bento-loading-button > </div> </div> </template> <script setup lang="ts"> import { computed, useSlots } from 'vue'; import { useDynamicDateFnsLocale, useHasSlot } from '@/composables'; import { formatDistanceToNow } from 'date-fns/formatDistanceToNow'; import { BentoTypography } from '@/components/typography'; import { BentoLoadingButton } from '@/components/button'; import ReloadIcon from '@adyen/ui-assets-icons-16/vue/refresh'; import { useI18n } from '@/utils/ts/i18n'; import type { HeaderMetaProps } from './header-meta.types'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const { t, locale } = useI18n<{ message: MessageSchema }>({ messages }); const props = withDefaults(defineProps<HeaderMetaProps>(), { description: null, lastUpdated: null, reloadButton: null, }); const activeLocale = computed(() => (props.lastUpdated ? locale.value : '')); const dateLocale = useDynamicDateFnsLocale(activeLocale); const timestamp = computed(() => props.lastUpdated ? t('lastUpdated', { date: formatDistanceToNow(props.lastUpdated, { locale: dateLocale.value, addSuffix: true, }), }) : null ); const slots = useSlots(); const hasSlot = useHasSlot(slots); </script> <script lang="ts"> /** * An internal component used by the BentoHeader and BentoHeaderWithViews components. * * @example * import { HeaderMeta } from '@/internal'; * * export default { * components: { HeaderMeta }, * template: ` * <header-meta description="header description" :last-updated="new Date()" /> * ` * } * @slot description - The description content. Overrides the `description` prop. * @property description - Text showed under the title. * @property lastUpdated - JavaScript "Date" object for when the content was last updated. * @property reloadButton - Reload button which shows next to the header's timestamp. */ export default { i18n: { messages }, }; </script> <style lang="scss" scoped src="./header-meta.scss" />
1
+ <template> <div class="b-header-meta__container"> <bento-typography v-if="description || hasSlot('description')" class="b-header-meta__description" el="div"> <slot name="description">{{ description }}</slot> </bento-typography> <div v-if="lastUpdated" class="b-header-meta__timestamp-section"> <bento-typography class="b-header-meta__timestamp" el="div"> <time :datetime="lastUpdatedDateTime">{{ timestamp }}</time> </bento-typography> <bento-loading-button v-if="reloadButton" variant="tertiary" :state="reloadButton.state" @click="reloadButton.event" ><template #iconLeft><reload-icon aria-hidden="true" /></template >{{ reloadButton.title }}</bento-loading-button > </div> </div> </template> <script setup lang="ts"> import { computed, useSlots } from 'vue'; import { useDynamicDateFnsLocale, useHasSlot } from '@/composables'; import { formatDistanceToNow } from 'date-fns/formatDistanceToNow'; import { BentoTypography } from '@/components/typography'; import { BentoLoadingButton } from '@/components/button'; import ReloadIcon from '@adyen/ui-assets-icons-16/vue/refresh'; import { useI18n } from '@/utils/ts/i18n'; import type { HeaderMetaProps } from './header-meta.types'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const { t, locale } = useI18n<{ message: MessageSchema }>({ messages }); const props = withDefaults(defineProps<HeaderMetaProps>(), { description: null, lastUpdated: null, reloadButton: null, }); const activeLocale = computed(() => (props.lastUpdated ? locale.value : '')); const dateLocale = useDynamicDateFnsLocale(activeLocale); const lastUpdatedDateTime = computed(() => { if (!props.lastUpdated) { return undefined; } const date = props.lastUpdated instanceof Date ? props.lastUpdated : new Date(props.lastUpdated); return Number.isNaN(date.getTime()) ? undefined : date.toISOString(); }); const timestamp = computed(() => props.lastUpdated ? t('lastUpdated', { date: formatDistanceToNow(props.lastUpdated, { locale: dateLocale.value, addSuffix: true, }), }) : null ); const slots = useSlots(); const hasSlot = useHasSlot(slots); </script> <script lang="ts"> /** * An internal component used by the BentoHeader and BentoHeaderWithViews components. * * @example * import { HeaderMeta } from '@/internal'; * * export default { * components: { HeaderMeta }, * template: ` * <header-meta description="header description" :last-updated="new Date()" /> * ` * } * @slot description - The description content. Overrides the `description` prop. * @property description - Text showed under the title. * @property lastUpdated - JavaScript "Date" object for when the content was last updated. * @property reloadButton - Reload button which shows next to the header's timestamp. */ export default { i18n: { messages }, }; </script> <style lang="scss" scoped src="./header-meta.scss" />
@@ -1 +1 @@
1
- <template> <div ref="listboxRef" class="b-listbox-multi-select"> <!-- Selected options --> <div v-if="showSelectedItems"> <ul v-bento-keyboard-navigation-directive class="b-listbox-multi-select__chips" v-on="chipsSectionListeners" > <li v-for="item in selectedOptions" :key="item.value"> <bento-chip ref="selectedListboxItemsRef" :disabled="isOptionDisabled(item)" :label="item.label" condensed @click="item.items ? unselectAllCategory(item) : toggleCheckboxSelection('select', item)" /> </li> </ul> <hr class="b-listbox-multi-select__divider" aria-hidden="true" /> </div> <div v-bento-keyboard-navigation-directive v-bind="ariaAttributes" :role="computedRole" v-on="listboxListeners"> <!-- Select all --> <bento-listbox-option v-if="showSelectAllCheckbox" ref="selectAllListboxItemsRef" class="b-listbox-multi-select__option" :tabindex="focusedIndex === 0 ? 0 : -1" :aria-selected="ariaCheckedComputed !== 'false'" :aria-checked="ariaCheckedComputed" :role="computedOptionRole" @click="handleSelectAll" @enter-pressed="handleSelectAll" @space-pressed.prevent="handleSelectAll" > <decorative-checkbox :checked="isAllSelected" :indeterminate="isIndeterminate" /> <bento-typography el="span"> {{ selectAllCheckboxText }} </bento-typography> </bento-listbox-option> <hr v-if="showSelectAllCheckbox" class="b-listbox-multi-select__divider" aria-hidden="true" /> <!-- Available options --> <listbox-multi-select-options ref="listboxItemsRef" :items="items" :is-option-disabled="isOptionDisabled" :is-multi-level="isMultiLevelSelect" :static-categories="staticCategories" :searching="searching" :selected-items-list="selectedItemsList" :focused-index="focusedIndex" :items-index="computedItemsIndex" :role="computedOptionRole" @toggle-checkbox-selection="toggleCheckboxSelection('select', $event)" @select-category="selectAllCategory" @unselect-category="unselectAllCategory" @toggle-category-visibility="toggleCategoryVisibility" @space-pressed="toggleCheckboxSelection(BentoListboxEvent.SPACE, $event)" @tab-pressed="toggleCheckboxSelection(BentoListboxEvent.TAB, $event)" @enter-pressed="toggleCheckboxSelection(BentoListboxEvent.ENTER, $event)" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </listbox-multi-select-options> </div> </div> </template> <script setup lang="ts"> import { computed, type HTMLAttributes, nextTick, ref, toRef, useAttrs, useSlots, watch } from 'vue'; import { isVue2 } from 'vue-demi'; import { DecorativeCheckbox } from '@/internal/decorative-checkbox'; import { BentoTypography } from '@/components/typography'; import { BentoChip } from '@/components/chip'; import { BentoListboxOption } from '../listbox-option'; import { ListboxMultiSelectOptions } from './components/listbox-multi-select-options'; import { useCheckboxSelectAll } from '@/composables'; import { useListboxKeyboardNavigation } from '../../composables/useListboxKeyboardNavigation'; import { useMultiLevelItems } from '../../composables/use-multi-level-items/use-multi-level-items'; import { useVisibleDomOptions } from '@/internal/listbox/composables/use-visible-dom-options/use-visible-dom-options'; import { useI18n } from '@/utils/ts/i18n'; import { BentoKeyboardNavigationDirective as vBentoKeyboardNavigationDirective } from '@/directives'; import { BentoListboxEvent, type BentoListboxMultiSelectValue, type BentoListboxOptionItem, type BentoListboxOptions, type BentoListboxValue, } from '@/types/listbox'; import { type Booleanish } from '@/types/prop-types'; import { BentoKeyboardNavigationKeyDownEvent } from '@/types/keyboard-navigation'; import { type ListboxMultiSelectProps } from './listbox-multi-select.types'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const props = withDefaults(defineProps<ListboxMultiSelectProps>(), { hasMoreItems: false, isOptionDisabled: isVue2 ? () => () => false : () => false, lazyLoadType: 'none', searching: false, staticCategories: false, }); const emit = defineEmits<{ /** * Emits when the selection changes */ (e: 'select', value: Array<BentoListboxOptionItem>): void; }>(); const { t, n } = useI18n<{ message: MessageSchema }>({ messages }); const slots = useSlots(); const attrs = useAttrs(); const ariaAttributes = computed( () => ({ 'aria-label': attrs['aria-label']?.toString() as string, 'aria-busy': attrs['aria-busy']?.toString() as Booleanish, 'aria-atomic': attrs['aria-atomic']?.toString() as Booleanish, 'aria-live': attrs['aria-live']?.toString() as string, 'aria-multiselectable': true, }) as HTMLAttributes ); const listboxRef = ref(null); const selectAllListboxItemsRef = ref(null); const selectedListboxItemsRef = ref(null); const listboxItemsRef = ref(null); const selectedItemsList = computed<Array<BentoListboxValue>>(() => props.selectedValues.map(({ value }) => value)); const { isMultiLevelSelect, flattenedItemsList } = useMultiLevelItems(toRef(props, 'items')); const isTreePattern = computed(() => isMultiLevelSelect.value && !props.staticCategories); const computedRole = computed(() => (isTreePattern.value ? 'tree' : 'listbox')); const computedOptionRole = computed(() => (isTreePattern.value ? 'treeitem' : 'option')); const optionStates = (option: BentoListboxOptionItem) => { const isDisabled = props.isOptionDisabled(option); const isPreSelected = (selectedItemsList.value as BentoListboxMultiSelectValue)?.includes(option.value) || false; return { isDisabled, isPreSelected }; }; // Items that are not disabled and pre-selected disabled items const enabledItems = computed(() => { if (props.searching && isMultiLevelSelect) { return props.items; } const fullItemsList = flattenedItemsList.value.filter(option => { const { isDisabled, isPreSelected } = optionStates(option); return (isDisabled && isPreSelected) || !isDisabled; }); props.selectedValues.forEach(selectedValue => { if (!fullItemsList.find(item => item.value === selectedValue.value)) { fullItemsList.push(selectedValue); } }); return fullItemsList; }); const visibleSelectedList = computed( () => enabledItems.value?.filter(option => { if (option.items && isMultiLevelSelect.value && props.searching) { const amountOfSelectedCategoryOptions = option.items.filter(categoryOption => (props.selectedValues ?? []).includes(categoryOption.value) ).length; return amountOfSelectedCategoryOptions === option.items.length; } return selectedItemsList.value?.includes(option.value); // Check for category }) as BentoListboxMultiSelectValue ); const calculateTotalItems = () => { if (isMultiLevelSelect.value && !props.searching) { return flattenedItemsList.value.length; } return props.items.length; }; const selectAllCheckboxText = computed(() => { const possibleSelections = enabledItems.value.length; const total = calculateTotalItems(); const amountSelectedValues = visibleSelectedList.value?.length || 0; if (amountSelectedValues === 0) { return t('selectAll', { total: n(possibleSelections) }); } if (amountSelectedValues === total) { return t('allSelected', { total: n(total) }); } return amountSelectedValues <= total ? t('numberOfTotalSelected', { number: n(amountSelectedValues), total: n(total) }) : t('numbersSelected', { number: n(amountSelectedValues) }); }); const selectAll = (selectableItems: BentoListboxOptions) => { const filterOutDisabledAndNotPreselectedOptions = option => { const { isDisabled, isPreSelected } = optionStates(option); return (isDisabled && isPreSelected) || !isDisabled; }; // Adding or removing this list of values const addOrRemoveList = selectableItems .filter(filterOutDisabledAndNotPreselectedOptions) .filter(item => !selectedItemsList.value.includes(item.value)); // MAKE SURE NOT TO ADD DUPLICATES emit('select', [...props.selectedValues, ...addOrRemoveList]); }; const unselectAll = (selectableItems: BentoListboxOptions) => { const findEnabledUnselectedItems = option => { const { isDisabled, isPreSelected } = optionStates(option); return !(isDisabled && isPreSelected); }; const unselectedItemsList = selectableItems.filter(findEnabledUnselectedItems).map(({ value }) => value); const newSelectedList = props.selectedValues.filter(item => !unselectedItemsList?.includes(item.value)); emit('select', newSelectedList); }; const selectAllItems = () => { selectAll(flattenedItemsList.value); }; const unselectAllItems = () => { unselectAll(flattenedItemsList.value); }; const { isAllSelected, isIndeterminate, toggleSelectAll } = useCheckboxSelectAll( visibleSelectedList, enabledItems, selectAllItems, unselectAllItems ); const ariaCheckedComputed = computed(() => { if (isIndeterminate.value) { return 'mixed'; } return `${isAllSelected.value}`; }); const isSelectAllOn = ref(isAllSelected.value); const handleSelectAll = () => { isSelectAllOn.value = !isSelectAllOn.value; toggleSelectAll(isSelectAllOn.value); }; watch(isAllSelected, () => { isSelectAllOn.value = isAllSelected.value; }); const selectAllCategory = (category: BentoListboxOptionItem) => { selectAll(category.items); }; const unselectAllCategory = (category: BentoListboxOptionItem) => { unselectAll(category.items); }; const toggleCheckboxSelection = (eventName, selectedOption: BentoListboxOptionItem) => { let exists = false; const newSelectedList = props.selectedValues.filter(({ value }) => { if (selectedOption.value === value) { exists = true; return false; } return true; }); if (!exists) { newSelectedList.push(selectedOption); } emit(eventName, newSelectedList); }; const selectedOptions = computed(() => { if (!isMultiLevelSelect.value) { return props.selectedValues; } const isOptionSelected = (option: BentoListboxOptionItem): boolean => (selectedItemsList.value as unknown as Array<string | number>).includes(option.value); const categoryOptionsSelected = (categoryOptions: Array<BentoListboxOptionItem>): boolean => !props.staticCategories && categoryOptions.every(option => isOptionSelected(option)); const optionsFlat = props.items.flatMap(option => option.items && !categoryOptionsSelected(option.items) ? option.items : option ); const options = optionsFlat.filter(option => { if (option.items) { return true; } return isOptionSelected(option); }); return options; }); const showSelectedItems = computed(() => { const isLazyLoad = ['automatic', 'button'].includes(props.lazyLoadType); const hasAllItemsSelected = props.selectedValues?.length > 0 && visibleSelectedList.value?.length !== flattenedItemsList.value?.length; if (isLazyLoad && props.hasMoreItems) { return !!props.selectedValues?.length; } return hasAllItemsSelected; }); const showSelectAllCheckbox = computed(() => { const isLazyLoad = ['automatic', 'button'].includes(props.lazyLoadType); if (!isLazyLoad) { return true; } return !props.hasMoreItems; }); // Keyboard navigation const expandedCategories = ref([]); const { visibleDomOptions, updateVisibleDomOptions } = useVisibleDomOptions( listboxRef, toRef(props, 'items'), computedOptionRole ); const toggleCategoryVisibility = category => { if (expandedCategories.value.includes(category)) { expandedCategories.value = expandedCategories.value.filter(item => item !== category); } else { expandedCategories.value.push(category); } nextTick(() => { updateVisibleDomOptions(); }); }; const focusItem = (nextFocusedItemIndex: number) => { visibleDomOptions.value[nextFocusedItemIndex]?.focus(); }; const { moveToNextElement, moveToPreviousElement, moveToFirstElement, moveToLastElement, preventDefault, focusedIndex, setListboxFocusedIndex, } = useListboxKeyboardNavigation( { items: visibleDomOptions, }, focusItem ); const listboxListeners = { [BentoKeyboardNavigationKeyDownEvent.ARROW_UP]: (event: CustomEvent<KeyboardEvent>) => moveToPreviousElement(event), [BentoKeyboardNavigationKeyDownEvent.ARROW_DOWN]: (event: CustomEvent<KeyboardEvent>) => moveToNextElement(event), [BentoKeyboardNavigationKeyDownEvent.HOME]: moveToFirstElement, [BentoKeyboardNavigationKeyDownEvent.END]: moveToLastElement, }; defineExpose({ /** * Method to set the index of the item to be focused on * Used for focus management to be kept in-sync with parent e.g. dropdowns */ setListboxFocusedIndex, }); const chipsSectionListeners = { [BentoKeyboardNavigationKeyDownEvent.ARROW_UP]: preventDefault, [BentoKeyboardNavigationKeyDownEvent.ARROW_DOWN]: preventDefault, }; const computedItemsIndex = computed(() => { let itemIndex = showSelectAllCheckbox.value ? 1 : 0; const indexMap = {}; props.items.forEach(item => { let subItemsVisible = expandedCategories.value.includes(item.value); if (item.items) { if (props.staticCategories) { subItemsVisible = true; } else { indexMap[item.value] = itemIndex++; } if (subItemsVisible) { item.items.forEach(subItem => (indexMap[subItem.value] = itemIndex++)); } } else { indexMap[item.value] = itemIndex++; } }); return indexMap; }); </script> <script lang="ts"> /** * Listbox multi-select options list * * @example * import { BentoListboxMultiSelect } from '@adyen/bento-vue2'; * * export default { * components: { BentoListboxMultiSelect }, * template: ` * <bento-listbox-multi-select * :items="[{ label: 'Option 1', value: 'option-1' }]" * :selected-values="['option-1']" * /> * ` * } */ export default { i18n: { messages } }; </script> <style lang="scss" scoped src="./listbox-multi-select.scss" />
1
+ <template> <div ref="listboxRef" class="b-listbox-multi-select"> <!-- Selected options --> <div v-if="showSelectedItems"> <ul v-bento-keyboard-navigation-directive class="b-listbox-multi-select__chips" v-on="chipsSectionListeners" > <li v-for="item in selectedOptions" :key="item.value"> <bento-chip ref="selectedListboxItemsRef" :disabled="isOptionDisabled(item)" :label="item.label" condensed @click="item.items ? unselectAllCategory(item) : toggleCheckboxSelection('select', item)" /> </li> </ul> <hr class="b-listbox-multi-select__divider" aria-hidden="true" /> </div> <div v-bento-keyboard-navigation-directive v-bind="ariaAttributes" :role="computedRole" v-on="listboxListeners"> <!-- Select all --> <bento-listbox-option v-if="showSelectAllCheckbox" ref="selectAllListboxItemsRef" class="b-listbox-multi-select__option" :tabindex="focusedIndex === 0 ? 0 : -1" :aria-selected="ariaCheckedComputed !== 'false'" :aria-checked="ariaCheckedComputed" :role="computedOptionRole" @click="handleSelectAll" @enter-pressed="handleSelectAll" @space-pressed.prevent="handleSelectAll" > <decorative-checkbox :checked="isAllSelected" :indeterminate="isIndeterminate" /> <bento-typography el="span"> {{ selectAllCheckboxText }} </bento-typography> </bento-listbox-option> <hr v-if="showSelectAllCheckbox" class="b-listbox-multi-select__divider" aria-hidden="true" /> <!-- Available options --> <listbox-multi-select-options ref="listboxItemsRef" :items="items" :is-option-disabled="isOptionDisabled" :is-multi-level="isMultiLevelSelect" :static-categories="staticCategories" :searching="searching" :selected-items-list="selectedItemsList" :focused-index="focusedIndex" :items-index="computedItemsIndex" :role="computedOptionRole" @toggle-checkbox-selection="toggleCheckboxSelection('select', $event)" @select-category="selectAllCategory" @unselect-category="unselectAllCategory" @toggle-category-visibility="toggleCategoryVisibility" @space-pressed="toggleCheckboxSelection(BentoListboxEvent.SPACE, $event)" @tab-pressed="toggleCheckboxSelection(BentoListboxEvent.TAB, $event)" @enter-pressed="toggleCheckboxSelection(BentoListboxEvent.ENTER, $event)" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </listbox-multi-select-options> </div> </div> </template> <script setup lang="ts"> import { computed, type HTMLAttributes, nextTick, ref, toRef, useAttrs, useSlots, watch } from 'vue'; import { isVue2 } from 'vue-demi'; import { DecorativeCheckbox } from '@/internal/decorative-checkbox'; import { BentoTypography } from '@/components/typography'; import { BentoChip } from '@/components/chip'; import { BentoListboxOption } from '../listbox-option'; import { ListboxMultiSelectOptions } from './components/listbox-multi-select-options'; import { useCheckboxSelectAll } from '@/composables'; import { useListboxKeyboardNavigation } from '../../composables/useListboxKeyboardNavigation'; import { useMultiLevelItems } from '../../composables/use-multi-level-items/use-multi-level-items'; import { useVisibleDomOptions } from '@/internal/listbox/composables/use-visible-dom-options/use-visible-dom-options'; import { useI18n } from '@/utils/ts/i18n'; import { BentoKeyboardNavigationDirective as vBentoKeyboardNavigationDirective } from '@/directives'; import { BentoListboxEvent, type BentoListboxMultiSelectValue, type BentoListboxOptionItem, type BentoListboxOptions, type BentoListboxValue, } from '@/types/listbox'; import { type Booleanish } from '@/types/prop-types'; import { BentoKeyboardNavigationKeyDownEvent } from '@/types/keyboard-navigation'; import { type ListboxMultiSelectProps } from './listbox-multi-select.types'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const props = withDefaults(defineProps<ListboxMultiSelectProps>(), { hasMoreItems: false, isOptionDisabled: isVue2 ? () => () => false : () => false, lazyLoadType: 'none', searching: false, staticCategories: false, }); const emit = defineEmits<{ /** * Emits when the selection changes */ (e: 'select', value: Array<BentoListboxOptionItem>): void; }>(); const { t, n } = useI18n<{ message: MessageSchema }>({ messages }); const slots = useSlots(); const attrs = useAttrs(); const ariaAttributes = computed( () => ({ 'aria-label': attrs['aria-label']?.toString() as string, 'aria-busy': attrs['aria-busy']?.toString() as Booleanish, 'aria-atomic': attrs['aria-atomic']?.toString() as Booleanish, 'aria-live': attrs['aria-live']?.toString() as string, 'aria-multiselectable': true, }) as HTMLAttributes ); const listboxRef = ref(null); const selectAllListboxItemsRef = ref(null); const selectedListboxItemsRef = ref(null); const listboxItemsRef = ref(null); const selectedItemsList = computed<Array<BentoListboxValue>>(() => props.selectedValues.map(({ value }) => value)); const { isMultiLevelSelect, flattenedItemsList } = useMultiLevelItems(toRef(props, 'items')); const isTreePattern = computed(() => isMultiLevelSelect.value && !props.staticCategories); const computedRole = computed(() => (isTreePattern.value ? 'tree' : 'listbox')); const computedOptionRole = computed(() => (isTreePattern.value ? 'treeitem' : 'option')); const optionStates = (option: BentoListboxOptionItem) => { const isDisabled = props.isOptionDisabled(option); const isPreSelected = (selectedItemsList.value as BentoListboxMultiSelectValue)?.includes(option.value) || false; return { isDisabled, isPreSelected }; }; // Items that are not disabled and pre-selected disabled items const enabledItems = computed(() => { if (props.searching && isMultiLevelSelect) { return props.items; } const fullItemsList = flattenedItemsList.value.filter(option => { const { isDisabled, isPreSelected } = optionStates(option); return (isDisabled && isPreSelected) || !isDisabled; }); props.selectedValues.forEach(selectedValue => { if (!fullItemsList.find(item => item.value === selectedValue.value)) { fullItemsList.push(selectedValue); } }); return fullItemsList; }); const visibleSelectedList = computed( () => enabledItems.value?.filter(option => { if (option.items && isMultiLevelSelect.value && props.searching) { const amountOfSelectedCategoryOptions = option.items.filter(categoryOption => (props.selectedValues ?? []).includes(categoryOption.value) ).length; return amountOfSelectedCategoryOptions === option.items.length; } return selectedItemsList.value?.includes(option.value); // Check for category }) as BentoListboxMultiSelectValue ); const calculateTotalItems = () => { if (isMultiLevelSelect.value && !props.searching) { return flattenedItemsList.value.length; } return props.items.length; }; const selectAllCheckboxText = computed(() => { const possibleSelections = enabledItems.value.length; const total = calculateTotalItems(); const amountSelectedValues = visibleSelectedList.value?.length || 0; if (amountSelectedValues === 0) { return t('selectAll', { total: n(possibleSelections) }); } if (amountSelectedValues === total) { return t('allSelected', { total: n(total) }); } return amountSelectedValues <= total ? t('numberOfTotalSelected', { number: n(amountSelectedValues), total: n(total) }) : t('numbersSelected', { number: n(amountSelectedValues) }); }); const selectAll = (selectableItems: BentoListboxOptions) => { const filterOutDisabledAndNotPreselectedOptions = option => { const { isDisabled, isPreSelected } = optionStates(option); return (isDisabled && isPreSelected) || !isDisabled; }; // Adding or removing this list of values const addOrRemoveList = selectableItems .filter(filterOutDisabledAndNotPreselectedOptions) .filter(item => !selectedItemsList.value.includes(item.value)); // MAKE SURE NOT TO ADD DUPLICATES emit('select', [...props.selectedValues, ...addOrRemoveList]); }; const unselectAll = (selectableItems: BentoListboxOptions) => { const findEnabledUnselectedItems = option => { const { isDisabled, isPreSelected } = optionStates(option); return !(isDisabled && isPreSelected); }; const unselectedItemsList = selectableItems.filter(findEnabledUnselectedItems).map(({ value }) => value); const newSelectedList = props.selectedValues.filter(item => !unselectedItemsList?.includes(item.value)); emit('select', newSelectedList); }; const selectAllItems = () => { selectAll(flattenedItemsList.value); }; const unselectAllItems = () => { unselectAll(flattenedItemsList.value); }; const { isAllSelected, isIndeterminate, toggleSelectAll } = useCheckboxSelectAll( visibleSelectedList, enabledItems, selectAllItems, unselectAllItems ); const ariaCheckedComputed = computed(() => { if (isIndeterminate.value) { return 'mixed'; } return `${isAllSelected.value}`; }); const isSelectAllOn = ref(isAllSelected.value); const handleSelectAll = () => { isSelectAllOn.value = !isSelectAllOn.value; toggleSelectAll(isSelectAllOn.value); }; watch(isAllSelected, () => { isSelectAllOn.value = isAllSelected.value; }); const selectAllCategory = (category: BentoListboxOptionItem) => { selectAll(category.items); }; const unselectAllCategory = (category: BentoListboxOptionItem) => { unselectAll(category.items); }; const toggleCheckboxSelection = (eventName, selectedOption: BentoListboxOptionItem) => { let exists = false; const newSelectedList = props.selectedValues.filter(({ value }) => { if (selectedOption.value === value) { exists = true; return false; } return true; }); if (!exists) { newSelectedList.push(selectedOption); } emit(eventName, newSelectedList); }; const selectedOptions = computed(() => { if (!isMultiLevelSelect.value) { return props.selectedValues; } const isOptionSelected = (option: BentoListboxOptionItem): boolean => (selectedItemsList.value as unknown as Array<string | number>).includes(option.value); const categoryOptionsSelected = (categoryOptions: Array<BentoListboxOptionItem>): boolean => !props.staticCategories && categoryOptions.every(option => isOptionSelected(option)); const optionsFlat = props.items.flatMap(option => option.items && !categoryOptionsSelected(option.items) ? option.items : option ); const options = optionsFlat.filter(option => { if (option.items) { return true; } return isOptionSelected(option); }); // Append selected values not present in the current items list (e.g. not yet fetched) const knownValues = new Set<BentoListboxValue>(); optionsFlat.forEach(option => { knownValues.add(option.value); option.items?.forEach(sub => knownValues.add(sub.value)); }); const unfetchedSelected = props.selectedValues.filter(sv => !knownValues.has(sv.value)); return [...options, ...unfetchedSelected]; }); const showSelectedItems = computed(() => { const isLazyLoad = ['automatic', 'button'].includes(props.lazyLoadType); const hasAllItemsSelected = props.selectedValues?.length > 0 && visibleSelectedList.value?.length !== flattenedItemsList.value?.length; if (isLazyLoad && props.hasMoreItems) { return !!props.selectedValues?.length; } return hasAllItemsSelected; }); const showSelectAllCheckbox = computed(() => { const isLazyLoad = ['automatic', 'button'].includes(props.lazyLoadType); if (!isLazyLoad) { return true; } return !props.hasMoreItems; }); // Keyboard navigation const expandedCategories = ref([]); const { visibleDomOptions, updateVisibleDomOptions } = useVisibleDomOptions( listboxRef, toRef(props, 'items'), computedOptionRole ); const toggleCategoryVisibility = category => { if (expandedCategories.value.includes(category)) { expandedCategories.value = expandedCategories.value.filter(item => item !== category); } else { expandedCategories.value.push(category); } nextTick(() => { updateVisibleDomOptions(); }); }; const focusItem = (nextFocusedItemIndex: number) => { visibleDomOptions.value[nextFocusedItemIndex]?.focus(); }; const { moveToNextElement, moveToPreviousElement, moveToFirstElement, moveToLastElement, preventDefault, focusedIndex, setListboxFocusedIndex, } = useListboxKeyboardNavigation( { items: visibleDomOptions, }, focusItem ); const listboxListeners = { [BentoKeyboardNavigationKeyDownEvent.ARROW_UP]: (event: CustomEvent<KeyboardEvent>) => moveToPreviousElement(event), [BentoKeyboardNavigationKeyDownEvent.ARROW_DOWN]: (event: CustomEvent<KeyboardEvent>) => moveToNextElement(event), [BentoKeyboardNavigationKeyDownEvent.HOME]: moveToFirstElement, [BentoKeyboardNavigationKeyDownEvent.END]: moveToLastElement, }; defineExpose({ /** * Method to set the index of the item to be focused on * Used for focus management to be kept in-sync with parent e.g. dropdowns */ setListboxFocusedIndex, }); const chipsSectionListeners = { [BentoKeyboardNavigationKeyDownEvent.ARROW_UP]: preventDefault, [BentoKeyboardNavigationKeyDownEvent.ARROW_DOWN]: preventDefault, }; const computedItemsIndex = computed(() => { let itemIndex = showSelectAllCheckbox.value ? 1 : 0; const indexMap = {}; props.items.forEach(item => { let subItemsVisible = expandedCategories.value.includes(item.value); if (item.items) { if (props.staticCategories) { subItemsVisible = true; } else { indexMap[item.value] = itemIndex++; } if (subItemsVisible) { item.items.forEach(subItem => (indexMap[subItem.value] = itemIndex++)); } } else { indexMap[item.value] = itemIndex++; } }); return indexMap; }); </script> <script lang="ts"> /** * Listbox multi-select options list * * @example * import { BentoListboxMultiSelect } from '@adyen/bento-vue2'; * * export default { * components: { BentoListboxMultiSelect }, * template: ` * <bento-listbox-multi-select * :items="[{ label: 'Option 1', value: 'option-1' }]" * :selected-values="['option-1']" * /> * ` * } */ export default { i18n: { messages } }; </script> <style lang="scss" scoped src="./listbox-multi-select.scss" />
@@ -1 +1 @@
1
- <template> <div ref="listboxRef" v-bento-keyboard-navigation-directive v-bind="ariaAttributes" role="listbox" v-on="listboxListeners" @focusin="handleVirtualScrollingFocus" > <template v-if="hasCategories && staticCategories"> <template v-for="item in items"> <bento-listbox-single-select-category v-if="item.items && item.items.length > 0" :key="`category-${item.value}`" :category-label="item.label" > <bento-listbox-single-select-option v-for="option in item.items" :key="option.value" ref="listboxItemRef" role="option" :option="option" :disabled="isOptionDisabled ? isOptionDisabled(option) : null" :selected="option.value === selectedItem.value" v-on="listboxOptionListeners" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </bento-listbox-single-select-option> </bento-listbox-single-select-category> <bento-listbox-single-select-option v-else :key="`option-${item.value}`" ref="listboxItemRef" role="option" :option="item" :disabled="isOptionDisabled ? isOptionDisabled(item) : null" :selected="item.value === selectedItem.value" v-on="listboxOptionListeners" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </bento-listbox-single-select-option> </template> </template> <template v-else> <bento-listbox-single-select-option v-for="option in items" :key="option.value" ref="listboxItemRef" role="option" :option="option" :disabled="isOptionDisabled ? isOptionDisabled(option) : null" :selected="option.value === selectedItem.value" v-on="listboxOptionListeners" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </bento-listbox-single-select-option> </template> </div> </template> <script lang="ts"> import { computed, defineComponent, type HTMLAttributes, nextTick, type PropType, ref, toRef } from 'vue'; import { BentoListboxSingleSelectCategory } from './components/listbox-single-select-category'; import { BentoListboxSingleSelectOption } from '../listbox-single-select-option'; import { useListboxKeyboardNavigation } from '../../composables/useListboxKeyboardNavigation'; import { useVisibleDomOptions } from '@/internal/listbox/composables/use-visible-dom-options/use-visible-dom-options'; import { BentoListboxEvent, type BentoListboxIsOptionDisabled, type BentoListboxOptions } from '@/types/listbox'; import { BentoKeyboardNavigationKeyDownEvent } from '@/types/keyboard-navigation'; import { type Booleanish } from '@/types/prop-types'; import { BentoKeyboardNavigationDirective } from '@/directives'; /** * Listbox single-select options list * * @example * import { BentoListboxMultiSelect } from '@adyen/bento-vue2'; * * export default { * components: { BentoListboxMultiSelect }, * template: ` * <bento-listbox-multi-select * :items="[{ label: 'Option 1', value: 'option-1' }]" * :selected-values="['option-1']" * /> * ` * } */ export default defineComponent({ name: 'bento-listbox-single-select', components: { BentoListboxSingleSelectCategory, BentoListboxSingleSelectOption, }, directives: { BentoKeyboardNavigationDirective }, inheritAttrs: false, props: { /** * Function that allows the options to be disabled * * @type {BentoListboxIsOptionDisabled} * @param {BentoListboxOptionItem} option - listbox option object * @param {string} option.label - Option's label text * @param {string|number} option.value - Option's value * @param {unknown} option.data - Option's extra data to be passed to the default slot */ isOptionDisabled: { type: Function as PropType<BentoListboxIsOptionDisabled>, default: () => false, }, /** * The option elements to populate the listbox with. * It must be an array of {@see BentoListboxOptionItem }. * * @property {string} value.label - Text to be displayed in the option * @property {string,number} value.value - Value of the option */ items: { type: Array as PropType<BentoListboxOptions>, required: true, }, /** * The `input` value. * Providing an empty string will select no options. * Set to an empty string `''` if you don't want any of the available options to be selected */ selectedValue: { type: Array as PropType<BentoListboxOptions>, default: undefined, }, /** * If enabled, dropdown will display non-selectable, static categories */ staticCategories: { type: Boolean, default: false }, /** * Total number of items in the full list (used for virtual scroll keyboard navigation). * When greater than 0, virtual scroll keyboard navigation is enabled. */ totalItemCount: { type: Number, default: 0 }, /** * Function to scroll the virtual list container to a given item index. */ scrollToIndex: { type: Function as PropType<(index: number) => void>, default: undefined }, /** * The start index of the currently rendered virtual scroll slice. */ virtualScrollStartIndex: { type: Number, default: 0 }, }, emits: [BentoListboxEvent.SELECT, BentoListboxEvent.ENTER, BentoListboxEvent.SPACE, BentoListboxEvent.TAB], setup(props, { emit, expose, slots, attrs }) { const listboxRef = ref(null); const listboxItemRef = ref([]); const selectedItem = computed(() => !!props.selectedValue?.length && props.selectedValue.at(0)); const ariaAttributes = computed( () => ({ 'aria-label': attrs['aria-label']?.toString() as string, 'aria-busy': attrs['aria-busy']?.toString() as Booleanish, 'aria-atomic': attrs['aria-atomic']?.toString() as Booleanish, 'aria-live': attrs['aria-live']?.toString() as string, }) as HTMLAttributes ); /** * Finds out if items have sub items */ const hasCategories = computed(() => !!props.items.some(({ items }) => !!items)); const onOptionSelected = eventName => (selectedOption: BentoListboxOptions) => { emit(eventName, [selectedOption]); }; // Keyboard navigation const { visibleDomOptions, updateVisibleDomOptions } = useVisibleDomOptions( listboxRef, toRef(props, 'items'), ref('option'), false ); const isVirtualScrollEnabled = computed(() => props.totalItemCount > 0); // When virtual scroll re-renders items (e.g. on mouse wheel scroll), the // browser may auto-focus an option. Sync the keyboard navigation index to // match the focused element so subsequent arrow key presses navigate from // the correct position. const handleVirtualScrollingFocus = (event: FocusEvent) => { if (!isVirtualScrollEnabled.value) { return; } const target = event.target as HTMLElement; if (target === listboxRef.value || target?.getAttribute('role') !== 'option') { return; } const localIndex = visibleDomOptions.value.indexOf(target as HTMLDivElement); if (localIndex !== -1) { const globalIndex = localIndex + props.virtualScrollStartIndex; setListboxFocusedIndex(globalIndex); } }; const focusItem = async (globalIndex: number) => { if (isVirtualScrollEnabled.value && props.scrollToIndex) { props.scrollToIndex(globalIndex); await nextTick(); await updateVisibleDomOptions(); const newDomIndex = globalIndex - props.virtualScrollStartIndex; visibleDomOptions.value[newDomIndex]?.focus(); } else { visibleDomOptions.value[globalIndex]?.focus(); } }; const navigateToItem = async (globalIndex: number) => { setListboxFocusedIndex(globalIndex); await focusItem(globalIndex); }; const { moveToNextElement, moveToPreviousElement, moveToFirstElement, moveToLastElement, setListboxFocusedIndex, } = useListboxKeyboardNavigation( { items: visibleDomOptions, totalItemCount: toRef(props, 'totalItemCount'), }, focusItem ); const listboxListeners = { [BentoKeyboardNavigationKeyDownEvent.ARROW_UP]: moveToPreviousElement, [BentoKeyboardNavigationKeyDownEvent.ARROW_DOWN]: moveToNextElement, [BentoKeyboardNavigationKeyDownEvent.HOME]: moveToFirstElement, [BentoKeyboardNavigationKeyDownEvent.END]: moveToLastElement, }; const listboxOptionListeners = { [BentoListboxEvent.SELECT]: onOptionSelected(BentoListboxEvent.SELECT), [BentoListboxEvent.SPACE]: onOptionSelected(BentoListboxEvent.SPACE), [BentoListboxEvent.TAB]: onOptionSelected(BentoListboxEvent.TAB), [BentoListboxEvent.ENTER]: onOptionSelected(BentoListboxEvent.ENTER), }; expose({ /** * Reference for a single listbox item. * Required for keyboard navigation in dropdown as $children is removed in vue@3 */ listboxItemRef, /** * Method to set the index of the item to be focused on * Used for focus management to be kept in-sync with parent e.g. dropdowns */ setListboxFocusedIndex, /** * Navigates to an item by global index, handling virtual scroll if enabled. * Scrolls the virtual list and focuses the item. */ navigateToItem, }); return { // Enums BentoListboxEvent, // Values listboxRef, listboxItemRef, selectedItem, slots, ariaAttributes, // Computed hasCategories, // Methods onOptionSelected, // Keyboard navigation listboxListeners, listboxOptionListeners, // Virtual scroll focus management handleVirtualScrollingFocus, }; }, }); </script>
1
+ <template> <div ref="listboxRef" v-bento-keyboard-navigation-directive v-bind="ariaAttributes" role="listbox" v-on="listboxListeners" @focusin="handleVirtualScrollingFocus" > <template v-if="hasCategories && staticCategories"> <template v-for="item in items"> <bento-listbox-single-select-category v-if="item.items && item.items.length > 0" :key="`category-${item.value}`" :category-label="item.label" > <bento-listbox-single-select-option v-for="option in item.items" :key="option.value" ref="listboxItemRef" role="option" :option="option" :disabled="isOptionDisabled ? isOptionDisabled(option) : null" :selected="option.value === selectedItem.value" v-on="listboxOptionListeners" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </bento-listbox-single-select-option> </bento-listbox-single-select-category> <bento-listbox-single-select-option v-else :key="`option-${item.value}`" ref="listboxItemRef" role="option" :option="item" :disabled="isOptionDisabled ? isOptionDisabled(item) : null" :selected="item.value === selectedItem.value" v-on="listboxOptionListeners" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </bento-listbox-single-select-option> </template> </template> <template v-else> <bento-listbox-single-select-option v-for="option in items" :key="option.value" ref="listboxItemRef" role="option" :option="option" :disabled="isOptionDisabled ? isOptionDisabled(option) : null" :selected="option.value === selectedItem.value" v-on="listboxOptionListeners" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </bento-listbox-single-select-option> </template> </div> </template> <script lang="ts"> import { computed, defineComponent, type HTMLAttributes, nextTick, type PropType, ref, toRef } from 'vue'; import { BentoListboxSingleSelectCategory } from './components/listbox-single-select-category'; import { BentoListboxSingleSelectOption } from '../listbox-single-select-option'; import { useListboxKeyboardNavigation } from '../../composables/useListboxKeyboardNavigation'; import { useVisibleDomOptions } from '@/internal/listbox/composables/use-visible-dom-options/use-visible-dom-options'; import { BentoListboxEvent, type BentoListboxIsOptionDisabled, type BentoListboxOptions } from '@/types/listbox'; import { BentoKeyboardNavigationKeyDownEvent } from '@/types/keyboard-navigation'; import { type Booleanish } from '@/types/prop-types'; import { BentoKeyboardNavigationDirective } from '@/directives'; /** * Listbox single-select options list * * @example * import { BentoListboxMultiSelect } from '@adyen/bento-vue2'; * * export default { * components: { BentoListboxMultiSelect }, * template: ` * <bento-listbox-multi-select * :items="[{ label: 'Option 1', value: 'option-1' }]" * :selected-values="['option-1']" * /> * ` * } */ export default defineComponent({ name: 'bento-listbox-single-select', components: { BentoListboxSingleSelectCategory, BentoListboxSingleSelectOption, }, directives: { BentoKeyboardNavigationDirective }, inheritAttrs: false, props: { /** * Function that allows the options to be disabled * * @type {BentoListboxIsOptionDisabled} * @param {BentoListboxOptionItem} option - listbox option object * @param {string} option.label - Option's label text * @param {string|number} option.value - Option's value * @param {unknown} option.data - Option's extra data to be passed to the default slot */ isOptionDisabled: { type: Function as PropType<BentoListboxIsOptionDisabled>, default: () => false, }, /** * The option elements to populate the listbox with. * It must be an array of {@see BentoListboxOptionItem }. * * @property {string} value.label - Text to be displayed in the option * @property {string,number} value.value - Value of the option */ items: { type: Array as PropType<BentoListboxOptions>, required: true, }, /** * The `input` value. * Providing an empty string will select no options. * Set to an empty string `''` if you don't want any of the available options to be selected */ selectedValue: { type: Array as PropType<BentoListboxOptions>, default: undefined, }, /** * If enabled, dropdown will display non-selectable, static categories */ staticCategories: { type: Boolean, default: false }, /** * Total number of items in the full list (used for virtual scroll keyboard navigation). * When greater than 0, virtual scroll keyboard navigation is enabled. */ totalItemCount: { type: Number, default: 0 }, /** * Function to scroll the virtual list container to a given item index. */ scrollToIndex: { type: Function as PropType<(index: number) => void>, default: undefined }, /** * The start index of the currently rendered virtual scroll slice. */ virtualScrollStartIndex: { type: Number, default: 0 }, }, emits: [BentoListboxEvent.SELECT, BentoListboxEvent.ENTER, BentoListboxEvent.SPACE, BentoListboxEvent.TAB], setup(props, { emit, expose, slots, attrs }) { const listboxRef = ref(null); const listboxItemRef = ref([]); const selectedItem = computed(() => !!props.selectedValue?.length && props.selectedValue.at(0)); const ariaAttributes = computed( () => ({ 'aria-label': attrs['aria-label']?.toString() as string, 'aria-busy': attrs['aria-busy']?.toString() as Booleanish, 'aria-atomic': attrs['aria-atomic']?.toString() as Booleanish, 'aria-live': attrs['aria-live']?.toString() as string, }) as HTMLAttributes ); /** * Finds out if items have sub items */ const hasCategories = computed(() => !!props.items.some(({ items }) => !!items)); const onOptionSelected = eventName => (selectedOption: BentoListboxOptions) => { emit(eventName, [selectedOption]); }; // Keyboard navigation const { visibleDomOptions, updateVisibleDomOptions } = useVisibleDomOptions( listboxRef, toRef(props, 'items'), ref('option'), false ); const isVirtualScrollEnabled = computed(() => props.totalItemCount > 0); // When virtual scroll re-renders items (e.g. on mouse wheel scroll), the // browser may auto-focus an option. Sync the keyboard navigation index to // match the focused element so subsequent arrow key presses navigate from // the correct position. const handleVirtualScrollingFocus = (event: FocusEvent) => { if (!isVirtualScrollEnabled.value) { return; } const target = event.target as HTMLElement; if (target === listboxRef.value || target?.getAttribute('role') !== 'option') { return; } const localIndex = visibleDomOptions.value.indexOf(target as HTMLDivElement); if (localIndex !== -1) { const globalIndex = localIndex + props.virtualScrollStartIndex; setListboxFocusedIndex(globalIndex); } }; const focusItem = async (globalIndex: number) => { if (isVirtualScrollEnabled.value && props.scrollToIndex) { props.scrollToIndex(globalIndex); await nextTick(); await updateVisibleDomOptions(); const newDomIndex = globalIndex - props.virtualScrollStartIndex; visibleDomOptions.value[newDomIndex]?.focus(); } else { visibleDomOptions.value[globalIndex]?.focus(); } }; const navigateToItem = async (globalIndex: number) => { setListboxFocusedIndex(globalIndex); await focusItem(globalIndex); }; const scrollToItem = async (globalIndex: number) => { await updateVisibleDomOptions(); if (isVirtualScrollEnabled.value && props.scrollToIndex) { props.scrollToIndex(globalIndex); } else { visibleDomOptions.value[globalIndex]?.scrollIntoView?.({ block: 'nearest' }); } }; const { moveToNextElement, moveToPreviousElement, moveToFirstElement, moveToLastElement, setListboxFocusedIndex, } = useListboxKeyboardNavigation( { items: visibleDomOptions, totalItemCount: toRef(props, 'totalItemCount'), }, focusItem ); const listboxListeners = { [BentoKeyboardNavigationKeyDownEvent.ARROW_UP]: moveToPreviousElement, [BentoKeyboardNavigationKeyDownEvent.ARROW_DOWN]: moveToNextElement, [BentoKeyboardNavigationKeyDownEvent.HOME]: moveToFirstElement, [BentoKeyboardNavigationKeyDownEvent.END]: moveToLastElement, }; const listboxOptionListeners = { [BentoListboxEvent.SELECT]: onOptionSelected(BentoListboxEvent.SELECT), [BentoListboxEvent.SPACE]: onOptionSelected(BentoListboxEvent.SPACE), [BentoListboxEvent.TAB]: onOptionSelected(BentoListboxEvent.TAB), [BentoListboxEvent.ENTER]: onOptionSelected(BentoListboxEvent.ENTER), }; expose({ /** * Reference for a single listbox item. * Required for keyboard navigation in dropdown as $children is removed in vue@3 */ listboxItemRef, /** * Method to set the index of the item to be focused on * Used for focus management to be kept in-sync with parent e.g. dropdowns */ setListboxFocusedIndex, /** * Navigates to an item by global index, handling virtual scroll if enabled. * Scrolls the virtual list and focuses the item. */ navigateToItem, /** * Scrolls to an item by global index without changing focus. * Used when the dropdown opens via click to bring the selected option into view. */ scrollToItem, }); return { // Enums BentoListboxEvent, // Values listboxRef, listboxItemRef, selectedItem, slots, ariaAttributes, // Computed hasCategories, // Methods onOptionSelected, // Keyboard navigation listboxListeners, listboxOptionListeners, // Virtual scroll focus management handleVirtualScrollingFocus, }; }, }); </script>
@@ -1 +1 @@
1
- import { type BentoStatusVariant } from '@/components/status'; import { type BentoTagProps } from '@/components/tag'; export interface BentoListItemProps { /** * Label text shown in the content area when the `content` slot is not used. */ label?: string; /** * Description text shown below label when the `content` slot is not used. */ description?: string; /** * Toggles the status indicator. */ showStatus?: boolean; /** * Color for the status indicator. * @values blue, green, grey, orange, red, yellow */ statusColor?: BentoStatusVariant | `${BentoStatusVariant}`; /** * Tag props passed to the internal BentoTag component. */ tag?: BentoTagProps; /** * Shows a chevron icon in the item. */ withChevron?: boolean; /** * Shows the top divider line. */ showTopDivider?: boolean; /** * Shows the bottom divider line. */ showBottomDivider?: boolean; }
1
+ import type { BentoDraggableDisabledMenus } from '@/components/draggable/composables/use-bento-draggable/use-bento-draggable'; import { type BentoStatusVariant } from '@/components/status'; import { type BentoTagProps } from '@/components/tag'; export interface BentoListItemDraggableOptions { /** Override the disabled menu state for this item. */ disabledMenus?: BentoDraggableDisabledMenus; /** Disable the drag handle for this item. */ disabled?: boolean; /** * Custom accessible label for the built-in drag handle. */ label?: string; /** Override the default move-up handler. */ moveUp?: () => void; /** Override the default move-down handler. */ moveDown?: () => void; /** Override the default move-to-top handler. */ moveTop?: () => void; /** Override the default move-to-bottom handler. */ moveBottom?: () => void; } export interface BentoListItemProps { /** * Label text shown in the content area when the `content` slot is not used. */ label?: string; /** * Description text shown below label when the `content` slot is not used. */ description?: string; /** * Optional overrides for move actions and disabled menu state. * Falls back to the parent `BentoList` draggable context when not provided. */ draggableOptions?: BentoListItemDraggableOptions; /** * Toggles the status indicator. */ showStatus?: boolean; /** * Color for the status indicator. * @values blue, green, grey, orange, red, yellow */ statusColor?: BentoStatusVariant | `${BentoStatusVariant}`; /** * Tag props passed to the internal BentoTag component. */ tag?: BentoTagProps; /** * Shows a chevron icon in the item. */ withChevron?: boolean; /** * Shows the top divider line. */ showTopDivider?: boolean; /** * Shows the bottom divider line. */ showBottomDivider?: boolean; }
@@ -1 +1 @@
1
- <template> <li class="b-list-item" @click="onItemClick" @keydown="onItemKeydown"> <bento-typography class="b-list-item__inner" :class="conditionalClasses" el="div" :role="props.withChevron ? 'button' : undefined" :tabindex="props.withChevron ? 0 : undefined" > <div v-if="hasSlot('image')" class="b-list-item__image"> <slot name="image" /> </div> <div v-if="hasSlot('start')" class="b-list-item__start"> <slot name="start" /> </div> <div class="b-list-item__content"> <div v-if="hasSlot('content')" class="b-list-item__label"> <slot name="content" /> </div> <template v-else> <div class="b-list-item__header"> <bento-typography v-if="props.label" class="b-list-item__label" el="span" stronger> {{ props.label }} </bento-typography> <bento-status v-if="props.showStatus" class="b-list-item__status" :variant="props.statusColor" aria-hidden="true" /> <bento-tag v-if="props.tag" v-bind="props.tag" class="b-list-item__tag" /> </div> <span v-if="props.description" class="b-list-item__description"> {{ props.description }} </span> </template> </div> <div v-if="hasSlot('end')" class="b-list-item__end"> <slot name="end" /> </div> <chevron-right-icon v-if="props.withChevron" class="b-list-item__chevron" aria-hidden="true" /> </bento-typography> </li> </template> <script setup lang="ts"> import { computed, useSlots } from 'vue'; import ChevronRightIcon from '@adyen/ui-assets-icons-16/vue/chevron-right-small'; import { BentoStatus } from '@/components/status'; import { BentoTag } from '@/components/tag'; import { BentoTypography } from '@/components/typography'; import { useHasSlot } from '@/composables'; import type { BentoListItemProps } from './list-item.types'; const slots = useSlots(); const hasSlot = useHasSlot(slots); const props = withDefaults(defineProps<BentoListItemProps>(), { showStatus: false, statusColor: 'blue', tag: undefined, withChevron: false, showTopDivider: false, showBottomDivider: false, }); const emit = defineEmits<{ /** * Emitted when a clickable list item is triggered. */ (e: 'click'): void; }>(); const onItemClick = (): void => { if (!props.withChevron) { return; } emit('click'); }; const onItemKeydown = (event: KeyboardEvent): void => { if (!props.withChevron) { return; } if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); emit('click'); } }; const conditionalClasses = computed(() => ({ 'b-list-item__inner--clickable': props.withChevron, 'b-list-item__inner--top-divider': props.showTopDivider, 'b-list-item__inner--bottom-divider': props.showBottomDivider, })); </script> <script lang="ts"> /** * `BentoListItem` renders an item to be used inside `BentoList`. * * @example * import { BentoList, BentoListItem, BentoButton, BentoToggle } from '@adyen/bento-vue2'; * * export default { * components: { BentoList, BentoListItem, BentoButton, BentoToggle }, * template: ` * <bento-list> * <bento-list-item * label="Payment details" * description="Updated 2 hours ago" * :tag="{ label: 'Live', variant: 'green' }" * show-bottom-divider * > * <template #start> * <bento-toggle /> * </template> * <template #end> * <bento-button variant="secondary">Edit</bento-button> * </template> * </bento-list-item> * </bento-list> * ` * } */ export default { name: 'bento-list-item', }; </script> <style lang="scss" scoped src="./list-item.scss" />
1
+ <template> <li ref="itemRef" class="b-list-item" @click="onItemClick" @keydown="onItemKeydown"> <bento-typography class="b-list-item__inner" :class="conditionalClasses" el="div" :role="props.withChevron ? 'button' : undefined" :tabindex="props.withChevron ? 0 : undefined" > <div v-if="hasSlot('image')" class="b-list-item__image"> <slot name="image" /> </div> <bento-draggable-handle v-if="showDragHandle" condensed :label="dragHandleLabel" :disabled-menus="disabledMenus" :disabled="draggableOptions?.disabled" @move-up="moveItemUp" @move-down="moveItemDown" @move-top="moveItemTop" @move-bottom="moveItemBottom" /> <div v-if="hasSlot('start')" class="b-list-item__start"> <slot v-if="hasSlot('start')" name="start" /> </div> <div class="b-list-item__content"> <div v-if="hasSlot('content')" class="b-list-item__label" data-list-item-label> <slot name="content" /> </div> <template v-else> <div class="b-list-item__header"> <bento-typography v-if="props.label" class="b-list-item__label" data-list-item-label el="span" stronger > {{ props.label }} </bento-typography> <bento-status v-if="props.showStatus" class="b-list-item__status" :variant="props.statusColor" aria-hidden="true" /> <bento-tag v-if="props.tag" v-bind="props.tag" class="b-list-item__tag" /> </div> <span v-if="props.description" class="b-list-item__description"> {{ props.description }} </span> </template> </div> <div v-if="hasSlot('end')" class="b-list-item__end"> <slot name="end" /> </div> <chevron-right-icon v-if="props.withChevron" class="b-list-item__chevron" aria-hidden="true" /> </bento-typography> </li> </template> <script setup lang="ts"> import { computed, inject, ref, useSlots } from 'vue'; import ChevronRightIcon from '@adyen/ui-assets-icons-16/vue/chevron-right-small'; import { BentoDraggableHandle } from '@/components/draggable/components/draggable-handle'; import { BentoStatus } from '@/components/status'; import { BentoTag } from '@/components/tag'; import { BentoTypography } from '@/components/typography'; import { useHasSlot } from '@/composables'; import { useI18n } from '@/utils/ts/i18n'; import { BENTO_LIST_DRAGGABLE_INJECTION_KEY } from '../../list.keys'; import type { BentoListDraggableContext } from '../../list.keys'; import type { BentoListItemProps } from './list-item.types'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const slots = useSlots(); const hasSlot = useHasSlot(slots); const itemRef = ref<HTMLElement | null>(null); const draggableContext = inject<BentoListDraggableContext | null>(BENTO_LIST_DRAGGABLE_INJECTION_KEY, null); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const props = withDefaults(defineProps<BentoListItemProps>(), { draggableOptions: null, showStatus: false, statusColor: 'blue', tag: undefined, withChevron: false, showTopDivider: false, showBottomDivider: false, }); const emit = defineEmits<{ /** * Emitted when a clickable list item is triggered. */ (e: 'click'): void; /** * Emitted when the item is moved up via the drag handle menu. */ (e: 'move-up'): void; /** * Emitted when the item is moved down via the drag handle menu. */ (e: 'move-down'): void; /** * Emitted when the item is moved to the top via the drag handle menu. */ (e: 'move-top'): void; /** * Emitted when the item is moved to the bottom via the drag handle menu. */ (e: 'move-bottom'): void; }>(); const onItemClick = (): void => { if (!props.withChevron) { return; } emit('click'); }; const onItemKeydown = (event: KeyboardEvent): void => { if (!props.withChevron) { return; } if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); emit('click'); } }; const conditionalClasses = computed(() => ({ 'b-list-item__inner--clickable': props.withChevron, 'b-list-item__inner--top-divider': props.showTopDivider, 'b-list-item__inner--bottom-divider': props.showBottomDivider, })); const showDragHandle = computed(() => Boolean(draggableContext?.isDraggable.value)); const dragHandleLabel = computed(() => props.draggableOptions?.label || props.label || t('listItem')); const disabledMenus = computed(() => { if (props.draggableOptions?.disabledMenus) { return props.draggableOptions.disabledMenus; } return draggableContext?.getDisabledMenus(itemRef.value); }); const moveItemUp = () => { emit('move-up'); (props.draggableOptions?.moveUp ?? (() => draggableContext?.moveItemUp(itemRef.value)))(); }; const moveItemDown = () => { emit('move-down'); (props.draggableOptions?.moveDown ?? (() => draggableContext?.moveItemDown(itemRef.value)))(); }; const moveItemTop = () => { emit('move-top'); (props.draggableOptions?.moveTop ?? (() => draggableContext?.moveItemTop(itemRef.value)))(); }; const moveItemBottom = () => { emit('move-bottom'); (props.draggableOptions?.moveBottom ?? (() => draggableContext?.moveItemBottom(itemRef.value)))(); }; </script> <script lang="ts"> /** * `BentoListItem` renders an item to be used inside `BentoList`. * * @example * import { BentoList, BentoListItem, BentoButton, BentoToggle } from '@adyen/bento-vue2'; * * export default { * components: { BentoList, BentoListItem, BentoButton, BentoToggle }, * template: ` * <bento-list> * <bento-list-item * label="Payment details" * description="Updated 2 hours ago" * :tag="{ label: 'Live', variant: 'green' }" * show-bottom-divider * > * <template #start> * <bento-toggle /> * </template> * <template #end> * <bento-button variant="secondary">Edit</bento-button> * </template> * </bento-list-item> * </bento-list> * ` * } */ export default { name: 'bento-list-item', i18n: { messages }, }; </script> <style lang="scss" scoped src="./list-item.scss" />
@@ -33,6 +33,40 @@ features like grab handle, image, dividers, or chevron.
33
33
  - If a chevron is shown, avoid also showing buttons in the end slot (conflicts with navigation)
34
34
  - Don't place multiple competing controls (e.g. toggle + checkbox + two buttons)
35
35
 
36
+ ## Draggable
37
+
38
+ Enable drag-and-drop reordering by setting `draggable` on `bento-list`. Each item automatically receives a drag handle
39
+ with keyboard-accessible move actions (up, down, to top, to bottom).
40
+
41
+ <Canvas withSource="closed">
42
+ <Story of={ListStories.EverythingBagel} />
43
+ </Canvas>
44
+
45
+ ### List props
46
+
47
+ - `draggable`: enables drag-and-drop reordering for all items.
48
+ - `draggableOptions`: forwards SortableJS options (e.g. `{ animation: 150 }`) and accepts an optional `dragAnnouncement`
49
+ string (`'disabled'` to suppress screen reader announcements).
50
+
51
+ ### List item props
52
+
53
+ Each `bento-list-item` accepts a `draggableOptions` prop to override defaults on a per-item basis:
54
+
55
+ | Property | Type | Description |
56
+ | --------------- | ---------- | ------------------------------------------------------------------ |
57
+ | `disabled` | `boolean` | Disables the drag handle for this item. |
58
+ | `label` | `string` | Custom accessible label for the drag handle (defaults to `label`). |
59
+ | `disabledMenus` | `object` | Override which directional menu actions are disabled. |
60
+ | `moveUp` | `function` | Override the default move-up handler. |
61
+ | `moveDown` | `function` | Override the default move-down handler. |
62
+ | `moveTop` | `function` | Override the default move-to-top handler. |
63
+ | `moveBottom` | `function` | Override the default move-to-bottom handler. |
64
+
65
+ ### Events
66
+
67
+ Each `bento-list-item` emits `move-up`, `move-down`, `move-top`, and `move-bottom` events when the corresponding handle
68
+ action is triggered, before the move is executed.
69
+
36
70
  ## Variations
37
71
 
38
72
  ### Type
@@ -79,6 +113,24 @@ Each `bento-list-item` is composed of:
79
113
 
80
114
  The List component is rendered as an ordered or unordered list depending on the `type` prop.
81
115
 
116
+ ### Nested interactive elements
117
+
118
+ `withChevron` makes the entire row a `role="button"` element. Placing interactive children (toggles, buttons, drag
119
+ handles) inside it creates nested interactive content, which violates
120
+ [WCAG 4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value.html). Avoid these combinations:
121
+
122
+ - **`withChevron` + `draggable`**: the drag handle is a button nested inside the row button.
123
+ - **`withChevron` + interactive `start`/`end` slots**: toggles, checkboxes, or buttons nested inside the row button.
124
+
125
+ If an item needs to be both clickable and draggable, use a custom `content` slot with separate interactive regions
126
+ instead of `withChevron`.
127
+
128
+ ### Drag-and-drop announcements
129
+
130
+ When `draggable` is enabled, a live region (`role="status"`, `aria-live="polite"`) announces reorder actions to screen
131
+ readers. Each move produces a localized message such as _"You have moved Item from position 1 to position 2 of 3."_ Set
132
+ `draggableOptions.dragAnnouncement` to `'disabled'` to suppress announcements, or provide a custom template string.
133
+
82
134
  ### Keyboard Interaction
83
135
 
84
136
  By default, `bento-list` itself is non-interactive and keyboard behavior depends on interactive elements rendered inside
@@ -92,6 +144,16 @@ When `withChevron` is enabled on a `bento-list-item`, the full row becomes inter
92
144
  | `Space` | Activates the clickable list item, triggering the click event. |
93
145
  | `Enter` | Activates the clickable list item, triggering the click event. |
94
146
 
147
+ When `draggable` is enabled, each item's drag handle supports:
148
+
149
+ | Keyboard interaction | Action |
150
+ | -------------------- | ------------------------------------------------ |
151
+ | `Tab` | Moves focus to or from the drag handle button. |
152
+ | `Enter` / `Space` | Opens the reorder menu. |
153
+ | `Arrow keys` | Navigates menu items (Move up, Move down, etc.). |
154
+ | `Enter` | Executes the selected move action. |
155
+ | `Escape` | Closes the reorder menu. |
156
+
95
157
  ## Resources
96
158
 
97
159
  - [Figma link](https://www.figma.com/design/uLabwF3243jdMDsNSP7U9I/Bento---Components?node-id=46446-2486&p=f&t=mea9nPq0MXKb2lOX-0)
@@ -1 +1 @@
1
- import { BentoButton } from '@/components/button'; import { BentoToggle } from '@/components/toggle'; import BentoList from './list.vue'; import BentoListItem from './components/list-item/list-item.vue'; import { storybookDocsParameter } from '@/utils/ts/storybook'; import ListDefault from './__tests__/list-default.vue?raw'; import { isVue2 } from 'vue-demi'; import type { Meta, StoryObj } from '@storybook/vue'; const meta: Meta = { title: 'List', component: BentoList, argTypes: { type: { options: ['unordered', 'ordered'], control: { type: 'select', }, }, }, }; export default meta; type Story = StoryObj<typeof BentoList>; export const Default: Story = { render: (_args, { argTypes }) => ({ components: { BentoList, BentoListItem, BentoButton, BentoToggle }, props: Object.keys(argTypes), template: ` <bento-list v-bind="args"> <bento-list-item label="Payment details" description="Updated 2 hours ago" :tag="{ label: 'Live', variant: 'green' }" show-bottom-divider > <template #start> <bento-toggle aria-label="Payment details" /> </template> <template #end> <bento-button variant="secondary">Edit</bento-button> </template> </bento-list-item> <bento-list-item label="Payout settings" description="Updated yesterday" :tag="{ label: 'Review', variant: 'orange' }" show-bottom-divider > <template #start> <bento-toggle aria-label="Payout settings" /> </template> <template #end> <bento-button variant="secondary">Edit</bento-button> </template> </bento-list-item> <bento-list-item label="Risk configuration" description="Updated 3 days ago" :tag="{ label: 'Draft', variant: 'grey' }" > <template #start> <bento-toggle aria-label="Risk configuration" /> </template> <template #end> <bento-button variant="secondary">Edit</bento-button> </template> </bento-list-item> </bento-list> `, setup(props) { return { args: isVue2 ? props : _args, }; }, }), args: { type: 'unordered', }, parameters: storybookDocsParameter(ListDefault), };
1
+ import { ref } from 'vue'; import { action } from '@storybook/addon-actions'; import { BentoButton } from '@/components/button'; import { BentoToggle } from '@/components/toggle'; import BentoList from './list.vue'; import BentoListItem from './components/list-item/list-item.vue'; import { storybookDocsParameter } from '@/utils/ts/storybook'; import ListDefault from './__tests__/list-default.vue?raw'; import ListEverythingBagel from './__tests__/list-everything-bagel.vue?raw'; import { isVue2 } from 'vue-demi'; import type { Meta, StoryObj } from '@storybook/vue'; const meta: Meta = { title: 'List', component: BentoList, argTypes: { type: { options: ['unordered', 'ordered'], control: { type: 'select', }, }, }, }; export default meta; type Story = StoryObj<typeof BentoList>; export const Default: Story = { render: (_args, { argTypes }) => ({ components: { BentoList, BentoListItem, BentoButton, BentoToggle }, props: Object.keys(argTypes), template: ` <bento-list v-bind="args"> <bento-list-item label="Payment details" description="Updated 2 hours ago" :tag="{ label: 'Live', variant: 'green' }" show-bottom-divider > <template #start> <bento-toggle aria-label="Payment details" /> </template> <template #end> <bento-button variant="secondary">Edit</bento-button> </template> </bento-list-item> <bento-list-item label="Payout settings" description="Updated yesterday" :tag="{ label: 'Review', variant: 'orange' }" show-bottom-divider > <template #start> <bento-toggle aria-label="Payout settings" /> </template> <template #end> <bento-button variant="secondary">Edit</bento-button> </template> </bento-list-item> <bento-list-item label="Risk configuration" description="Updated 3 days ago" :tag="{ label: 'Draft', variant: 'grey' }" > <template #start> <bento-toggle aria-label="Risk configuration" /> </template> <template #end> <bento-button variant="secondary">Edit</bento-button> </template> </bento-list-item> </bento-list> `, setup(props) { return { args: isVue2 ? props : _args, }; }, }), args: { type: 'unordered', }, parameters: storybookDocsParameter(ListDefault), }; export const EverythingBagel: Story = { render: (_args, { argTypes }) => ({ components: { BentoList, BentoListItem, BentoButton, BentoToggle }, props: Object.keys(argTypes), template: ` <bento-list v-bind="args" @reorder="onReorder"> <bento-list-item v-for="item in items" :key="item.id" :label="item.label" :description="item.description" :tag="item.tag" :show-status="item.showStatus" :status-color="item.statusColor" :draggable-options="item.draggableOptions" show-bottom-divider > <template v-if="item.showToggle" #start> <bento-toggle :aria-label="item.label" /> </template> <template v-if="item.showEdit" #end> <bento-button variant="secondary">Edit</bento-button> </template> </bento-list-item> </bento-list> `, setup(props) { const items = ref([ { id: 'payment', label: 'Payment details', description: 'Updated 2 hours ago', tag: { label: 'Live', variant: 'green' }, showStatus: true, statusColor: 'green', showToggle: true, showEdit: true, }, { id: 'payout', label: 'Payout settings', description: 'Updated yesterday', tag: { label: 'Review', variant: 'orange' }, showStatus: true, statusColor: 'orange', showToggle: true, showEdit: true, }, { id: 'risk', label: 'Risk configuration', description: 'Updated 3 days ago', tag: { label: 'Draft', variant: 'grey' }, showStatus: true, }, { id: 'compliance', label: 'Compliance check', description: 'Locked by admin', draggableOptions: { disabled: true }, }, ]); const reorderAction = action('reorder'); const onReorder = ({ oldIndex, newIndex }: { oldIndex: number; newIndex: number }) => { const updated = [...items.value]; const [moved] = updated.splice(oldIndex, 1); updated.splice(newIndex, 0, moved); items.value = updated; reorderAction({ oldIndex, newIndex, order: updated.map(i => i.label) }); }; return { args: isVue2 ? props : _args, items, onReorder, }; }, }), args: { type: 'unordered', draggable: true, draggableOptions: { animation: 150 }, }, parameters: storybookDocsParameter(ListEverythingBagel), };
@@ -1 +1 @@
1
- export type BentoListType = 'unordered' | 'ordered'; export interface BentoListProps { /** * Defines if the list should render as unordered (`ul`) or ordered (`ol`). * @values unordered, ordered */ type?: BentoListType; }
1
+ import type { BentoDraggableOptions } from '@/components/draggable/draggable.types'; export type BentoListType = 'unordered' | 'ordered'; export interface BentoListProps { /** * Defines if the list should render as unordered (`ul`) or ordered (`ol`). * @values unordered, ordered */ type?: BentoListType; /** * Enables drag-and-drop reordering for list items. * Requires rendering `BentoDraggableHandle` inside each item. */ draggable?: boolean; /** * Drag options forwarded to draggable behavior. */ draggableOptions?: BentoDraggableOptions & { dragAnnouncement?: string | 'disabled' }; }
@@ -1 +1 @@
1
- <template> <component :is="listElement" class="b-list"> <slot /> </component> </template> <script setup lang="ts"> import { computed } from 'vue'; import type { BentoListProps } from './list.types'; const props = withDefaults(defineProps<BentoListProps>(), { type: 'unordered', }); const listElement = computed(() => (props.type === 'ordered' ? 'ol' : 'ul')); </script> <script lang="ts"> /** * `BentoList` renders an ordered or unordered list container for `BentoListItem` rows. * * @example * import { BentoList, BentoListItem } from '@adyen/bento-vue2'; * * export default { * components: { BentoList, BentoListItem }, * template: ` * <bento-list> * <bento-list-item * label="Payment details" * description="Updated 2 hours ago" * :tag="{ label: 'Live', variant: 'green' }" * show-bottom-divider * /> * <bento-list-item * label="Risk configuration" * description="Updated yesterday" * :tag="{ label: 'Review', variant: 'orange' }" * /> * </bento-list> * ` * } */ export default { name: 'bento-list', }; </script> <style lang="scss" scoped src="./list.scss" />
1
+ <template> <div class="b-list"> <component :is="listElement" ref="listRef" v-bind="$attrs" class="b-list__list"> <slot /> </component> <div v-if="isDraggable" class="b-list__announcement" role="status" aria-live="polite"> {{ announcementMessage }} </div> </div> </template> <script setup lang="ts"> import { useMutationObserver } from '@vueuse/core'; import { computed, provide, ref, watch } from 'vue'; import { useBentoDraggable } from '@/components/draggable/composables/use-bento-draggable/use-bento-draggable'; import { useDraggableAnnouncement } from '@/components/draggable/composables/use-draggable-announcement/use-draggable-announcement'; import type { BentoDraggableOptions, BentoDraggableSortableEvent } from '@/components/draggable/draggable.types'; import { BENTO_LIST_DRAGGABLE_INJECTION_KEY } from './list.keys'; import type { BentoListDraggableContext } from './list.keys'; import type { BentoListProps } from './list.types'; // used for announcement composable import draggableMessages from '@/components/draggable/composables/use-draggable-announcement/messages.json'; const ITEM_SELECTOR = '.b-list-item'; const props = withDefaults(defineProps<BentoListProps>(), { type: 'unordered', draggable: false, draggableOptions: () => ({}), }); const emit = defineEmits<{ /** * Emitted after items are reordered, with the old and new indices. */ (e: 'reorder', value: { oldIndex: number; newIndex: number }): void; }>(); const listElement = computed(() => (props.type === 'ordered' ? 'ol' : 'ul')); const listRef = ref<HTMLElement | null>(null); const listItems = ref<Array<HTMLElement>>([]); const isDraggable = computed(() => props.draggable); const getListItems = (): Array<HTMLElement> => listRef.value ? (Array.from(listRef.value.children).filter(child => child.matches(ITEM_SELECTOR)) as Array<HTMLElement>) : []; const syncListItems = () => { listItems.value = getListItems(); }; watch( listRef, newElement => { if (!newElement) { listItems.value = []; return; } syncListItems(); }, { immediate: true } ); useMutationObserver(listRef, syncListItems, { childList: true }); const getItemIndex = (el: HTMLElement | null) => (el ? listItems.value.indexOf(el) : -1); const getItemLabel = (el: HTMLElement | null, fallbackIndex: number) => el?.querySelector('[data-list-item-label]')?.textContent?.trim() || `List item ${fallbackIndex + 1}`; const announceMoveAt = (fromIndex: number, toIndex: number) => { if (!isDraggable.value) { return; } const items = getListItems(); announceMove({ itemLabel: getItemLabel(items[toIndex] ?? null, toIndex), fromIndex, toIndex, total: items.length, announcement: props.draggableOptions?.dragAnnouncement, }); }; const moveItemDOM = (fromIndex: number, toIndex: number) => { const items = getListItems(); const itemToMove = items[fromIndex]; const targetItem = items[toIndex]; if (fromIndex < toIndex) { listRef.value!.insertBefore(itemToMove, targetItem.nextSibling); } else { listRef.value!.insertBefore(itemToMove, targetItem); } syncListItems(); announceMoveAt(fromIndex, toIndex); emit('reorder', { oldIndex: fromIndex, newIndex: toIndex }); }; const computedDraggableOptions = computed<BentoDraggableOptions>(() => ({ ...props.draggableOptions, onSort: (event: BentoDraggableSortableEvent) => { syncListItems(); if (event.oldIndex !== undefined && event.newIndex !== undefined) { announceMoveAt(event.oldIndex, event.newIndex); emit('reorder', { oldIndex: event.oldIndex, newIndex: event.newIndex }); } props.draggableOptions?.onSort?.(event); }, })); const { announcementMessage, announceMove } = useDraggableAnnouncement(); const { moveItemUp, moveItemDown, moveItemTop, moveItemBottom, getDisabledMenus } = useBentoDraggable( listRef, computedDraggableOptions, { getItemCount: () => listItems.value.length, getItemIndex, onMoveItem: moveItemDOM, } ); provide<BentoListDraggableContext>(BENTO_LIST_DRAGGABLE_INJECTION_KEY, { isDraggable, moveItemUp, moveItemDown, moveItemTop, moveItemBottom, getDisabledMenus, }); </script> <script lang="ts"> /** * `BentoList` renders an ordered or unordered list container for `BentoListItem` rows. * * @example * import { BentoList, BentoListItem } from '@adyen/bento-vue2'; * * export default { * components: { BentoList, BentoListItem }, * template: ` * <bento-list> * <bento-list-item * label="Payment details" * description="Updated 2 hours ago" * :tag="{ label: 'Live', variant: 'green' }" * show-bottom-divider * /> * <bento-list-item * label="Risk configuration" * description="Updated yesterday" * :tag="{ label: 'Review', variant: 'orange' }" * /> * </bento-list> * ` * } */ export default { name: 'bento-list', inheritAttrs: false, i18n: { messages: draggableMessages }, }; </script> <style lang="scss" scoped src="./list.scss" />
@@ -71,13 +71,17 @@ Bind the handlers on the trigger via `v-on="hoverEvents"`:
71
71
  When the popover is **NOT** a descendant of the trigger element (for example, rendered as a sibling or teleported
72
72
  elsewhere in the DOM), also bind `hoverEvents` on the popover so that hovering or focusing its content keeps it open:
73
73
 
74
+ For hover-triggered popovers, pass `disable-focus-trap`. A focus trap can move focus back into the popover while the
75
+ delayed hover handlers are trying to close it, which can cause unexpected open/close behavior when multiple
76
+ hover-triggered popovers are present.
77
+
74
78
  <Source
75
79
  format={'html'}
76
80
  dark
77
81
  code={`
78
82
  <span ref="triggerRef" v-on="hoverEvents">Hover me</span>
79
83
 
80
- <bento-popover v-on="hoverEvents" :open="isOpen" :target-element="triggerRef">
84
+ <bento-popover v-on="hoverEvents" :open="isOpen" :target-element="triggerRef" disable-focus-trap>
81
85
  Popover content
82
86
  </bento-popover>
83
87
  `}
@@ -1 +1 @@
1
- <template> <div class="b-summary-grid"> <grid-layout v-for="index in numberOfRows" :key="`summary-grid-row-${index}`" :data-testid="`summary-grid-row-${index}`" :column-width="columnWidth?.[`row${index}`]" > <template v-if="hasSlot(`cell-${index}-1`)" #col-1> <slot :name="`cell-${index}-1`" /> </template> <template v-if="hasSlot(`cell-${index}-2`)" #col-2> <slot :name="`cell-${index}-2`" /> </template> <template v-if="hasSlot(`cell-${index}-3`)" #col-3> <slot :name="`cell-${index}-3`" /> </template> <template v-if="hasSlot(`cell-${index}-4`)" #col-4> <slot :name="`cell-${index}-4`" /> </template> <template v-if="hasSlot(`cell-${index}-5`)" #col-5> <slot :name="`cell-${index}-5`" /> </template> <template v-if="hasSlot(`cell-${index}-6`)" #col-6> <slot :name="`cell-${index}-6`" /> </template> </grid-layout> </div> </template> <script setup lang="ts"> import { onMounted, provide, toRef, useSlots } from 'vue'; import type { BentoSummaryGridLayout, BentoSummaryGridProps } from './summary-grid.types'; import { GridLayout } from '@/components/internal'; import { useHasSlot } from '@/composables'; import { SUMMARY_GRID_LAYOUT_INJECTION_KEY, SUMMARY_GRID_SLASHED_ZERO, } from '@/components/summary-grid/summary-grid.keys'; const props = withDefaults(defineProps<BentoSummaryGridProps>(), { columnWidth: null, slashedZero: false, }); const slots = useSlots(); const hasSlot = useHasSlot(slots); let numberOfRows = 1; if (slots) { const slotRows = Object.keys(slots) ?.filter(slotName => slotName.includes('cell-')) ?.map(slotName => parseInt(slotName.split('-')?.[1], 10)); numberOfRows = Math.max(...slotRows); } onMounted(() => { if (numberOfRows > 3) { throw new Error( 'BentoSummaryGrid must only have a maximum of 3 rows. Read the documentation for more information.' ); } }); const registerComponentsInSummaryGrid: BentoSummaryGridLayout = () => { const isSummaryGridLayout = true; return { isSummaryGridLayout }; }; provide(SUMMARY_GRID_LAYOUT_INJECTION_KEY, registerComponentsInSummaryGrid); provide(SUMMARY_GRID_SLASHED_ZERO, toRef(props, 'slashedZero')); </script> <script lang="ts"> /** * A summary grid component that displays a collection items. * * @example * import { BentoSummaryGrid, BentoSummaryGridItemAmount } from '@adyen/bento-vue2'; * * export default { * components: { BentoSummaryGrid, BentoSummaryGridItemAmount }, * template: ` * <bento-summary-grid> * <template #cell-2-2> * <bento-summary-grid-item-amount * title="Text Item" * label="14" * info-icon-tooltip-text="text for icon" * copy="copy this to clipboard" * /> * </template> * </bento-summary-grid> * ` * } */ export default {}; </script> <style lang="scss" scoped src="./summary-grid.scss" />
1
+ <template> <div class="b-summary-grid"> <grid-layout v-for="index in numberOfRows" :key="`summary-grid-row-${index}`" :data-testid="`summary-grid-row-${index}`" :column-width="columnWidth?.[`row${index}`]" > <template v-if="hasSlot(`cell-${index}-1`)" #col-1> <slot :name="`cell-${index}-1`" /> </template> <template v-if="hasSlot(`cell-${index}-2`)" #col-2> <slot :name="`cell-${index}-2`" /> </template> <template v-if="hasSlot(`cell-${index}-3`)" #col-3> <slot :name="`cell-${index}-3`" /> </template> <template v-if="hasSlot(`cell-${index}-4`)" #col-4> <slot :name="`cell-${index}-4`" /> </template> <template v-if="hasSlot(`cell-${index}-5`)" #col-5> <slot :name="`cell-${index}-5`" /> </template> <template v-if="hasSlot(`cell-${index}-6`)" #col-6> <slot :name="`cell-${index}-6`" /> </template> </grid-layout> </div> </template> <script setup lang="ts"> import { onMounted, provide, toRef, useSlots } from 'vue'; import type { BentoSummaryGridLayout, BentoSummaryGridProps } from './summary-grid.types'; import { GridLayout } from '@/components/internal/grid-layout'; import { useHasSlot } from '@/composables'; import { SUMMARY_GRID_LAYOUT_INJECTION_KEY, SUMMARY_GRID_SLASHED_ZERO, } from '@/components/summary-grid/summary-grid.keys'; const props = withDefaults(defineProps<BentoSummaryGridProps>(), { columnWidth: null, slashedZero: false, }); const slots = useSlots(); const hasSlot = useHasSlot(slots); let numberOfRows = 1; if (slots) { const slotRows = Object.keys(slots) ?.filter(slotName => slotName.includes('cell-')) ?.map(slotName => parseInt(slotName.split('-')?.[1], 10)); numberOfRows = Math.max(...slotRows); } onMounted(() => { if (numberOfRows > 3) { throw new Error( 'BentoSummaryGrid must only have a maximum of 3 rows. Read the documentation for more information.' ); } }); const registerComponentsInSummaryGrid: BentoSummaryGridLayout = () => { const isSummaryGridLayout = true; return { isSummaryGridLayout }; }; provide(SUMMARY_GRID_LAYOUT_INJECTION_KEY, registerComponentsInSummaryGrid); provide(SUMMARY_GRID_SLASHED_ZERO, toRef(props, 'slashedZero')); </script> <script lang="ts"> /** * A summary grid component that displays a collection items. * * @example * import { BentoSummaryGrid, BentoSummaryGridItemAmount } from '@adyen/bento-vue2'; * * export default { * components: { BentoSummaryGrid, BentoSummaryGridItemAmount }, * template: ` * <bento-summary-grid> * <template #cell-2-2> * <bento-summary-grid-item-amount * title="Text Item" * label="14" * info-icon-tooltip-text="text for icon" * copy="copy this to clipboard" * /> * </template> * </bento-summary-grid> * ` * } */ export default {}; </script> <style lang="scss" scoped src="./summary-grid.scss" />
@@ -23,12 +23,17 @@ page. Selecting another tab replaces content with the new tab’s content.
23
23
 
24
24
  ## Variations
25
25
 
26
+ <Canvas of={TabsStories.EverythingBagel} />
27
+
26
28
  ### Notification indicators
27
29
 
28
30
  If there’s something requiring user attention, the tab gets a blue notification dot. To set/unset this notification dot,
29
31
  set the `withNotificationIndicator` prop to `true` or `false` for the designated `bento-tab`.
30
32
 
31
- <Canvas of={TabsStories.WithNotificationIndicator} />
33
+ ### Error counter
34
+
35
+ To display an error count alongside a tab title, use the `error-counter` prop on `bento-tab`. This renders a red tag
36
+ with an error icon and the given count. It is useful for signalling validation errors or issues within a tab's content.
32
37
 
33
38
  ## Modifiers
34
39
 
@@ -24,6 +24,13 @@ another message is dismissed.
24
24
  If user hovers over the stack, if unfolds showing up to last 3 toasts and the rest of the toast stacked (if there are
25
25
  more than three).
26
26
 
27
+ ### Toasts triggered by the page when a modal/dialog is open
28
+
29
+ When a modal, dialog or some other page overlay component is open and a toast is triggered by the page in the background
30
+ (for example, if there’s some asynchronous process happening that fails), the toast won’t be displayed until the overlay
31
+ component is closed, following the principle that modal is a focused environment, so anything happening in the
32
+ background should wait until the user is done with their flow. Once the modal is closed, the toast(s) will appear.
33
+
27
34
  ## Use Cases
28
35
 
29
36
  Due to their temporary nature, use toasts for at-a-glance, non-critical feedback about the app’s processes, i.e. a
@@ -31,6 +31,10 @@ Bind `hoverEvents` on the trigger element:
31
31
  When the content is **not** a descendant of the trigger (e.g. rendered as a sibling or teleported elsewhere in the DOM),
32
32
  also bind `hoverEvents` on a wrapper around the content so that hovering or focusing it cancels the pending close:
33
33
 
34
+ When the content is a `bento-popover`, always pass `disable-focus-trap`. A focus trap can move focus back into the
35
+ popover while the delayed hover handlers are trying to close it, which can make multiple hover-triggered popovers appear
36
+ to reopen or close each other unexpectedly.
37
+
34
38
  <Source
35
39
  format={'html'}
36
40
  dark
@@ -38,7 +42,7 @@ also bind `hoverEvents` on a wrapper around the content so that hovering or focu
38
42
  <span ref="triggerRef" v-on="hoverEvents">Hover me</span>
39
43
 
40
44
  <span v-on="hoverEvents">
41
- <bento-popover :open="isOpen" :target-element="triggerRef">
45
+ <bento-popover :open="isOpen" :target-element="triggerRef" disable-focus-trap>
42
46
  Popover content
43
47
  </bento-popover>
44
48
  </span>