@adyen/bento-mcp 0.5.2 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/assets/components/alert/alert.docs.mdx +17 -17
  3. package/dist/assets/components/alert/alert.vue +1 -1
  4. package/dist/assets/components/country/country.vue +1 -1
  5. package/dist/assets/components/dashed-underline/dashed-underline.vue +1 -1
  6. package/dist/assets/components/data-grid/components/data-grid-row/data-grid-row.types.ts +1 -1
  7. package/dist/assets/components/data-grid/components/data-grid-row/data-grid-row.vue +1 -1
  8. package/dist/assets/components/data-grid/data-grid.docs.mdx +3 -0
  9. package/dist/assets/components/data-grid/data-grid.vue +1 -1
  10. package/dist/assets/components/date/date.vue +1 -1
  11. package/dist/assets/components/date-picker/composables/use-date-picker-single-calendar-text.types.ts +1 -1
  12. package/dist/assets/components/date-picker/date-picker.docs.mdx +6 -0
  13. package/dist/assets/components/date-picker/date-picker.stories.ts +1 -1
  14. package/dist/assets/components/date-picker/date-picker.types.ts +1 -1
  15. package/dist/assets/components/date-picker/date-picker.vue +1 -1
  16. package/dist/assets/components/date-range-picker/components/date-range-picker-calendar/date-range-picker-calendar.vue +1 -1
  17. package/dist/assets/components/date-range-picker/components/date-time-input/date-time-input.types.ts +1 -0
  18. package/dist/assets/components/date-range-picker/components/date-time-input/date-time-input.vue +1 -0
  19. package/dist/assets/components/date-range-picker/date-range-picker.docs.mdx +6 -0
  20. package/dist/assets/components/date-range-picker/date-range-picker.stories.ts +1 -1
  21. package/dist/assets/components/date-range-picker/date-range-picker.types.ts +1 -1
  22. package/dist/assets/components/date-range-picker/date-range-picker.vue +1 -1
  23. package/dist/assets/components/date-time-picker/date-time-picker.docs.mdx +129 -0
  24. package/dist/assets/components/date-time-picker/date-time-picker.stories.ts +1 -0
  25. package/dist/assets/components/date-time-picker/date-time-picker.types.ts +1 -0
  26. package/dist/assets/components/date-time-picker/date-time-picker.vue +1 -0
  27. package/dist/assets/components/draggable/draggable.vue +1 -1
  28. package/dist/assets/components/dropdown/components/dropdown-base-textbox/dropdown-base-textbox.vue +1 -1
  29. package/dist/assets/components/dropdown/components/dropdown-options-container/dropdown-options-container.vue +1 -1
  30. package/dist/assets/components/dropdown/components/dropdown-small-textbox/dropdown-small-textbox.vue +1 -1
  31. package/dist/assets/components/dropdown/components/variants/dropdown-country/dropdown-country.types.ts +1 -1
  32. package/dist/assets/components/dropdown/dropdown.types.ts +1 -1
  33. package/dist/assets/components/dropdown/dropdown.vue +1 -1
  34. package/dist/assets/components/file-uploader/file-uploader.vue +1 -1
  35. package/dist/assets/components/filter-bar/components/checkbox-group-filter/checkbox-group-filter.vue +1 -1
  36. package/dist/assets/components/filter-bar/components/date-filter/date-filter.vue +1 -1
  37. package/dist/assets/components/filter-bar/components/date-range-filter/date-range-filter.vue +1 -1
  38. package/dist/assets/components/filter-bar/filter-bar.docs.mdx +8 -2
  39. package/dist/assets/components/filter-bar/filter-bar.stories.ts +1 -1
  40. package/dist/assets/components/filter-bar/filter-bar.types.ts +1 -1
  41. package/dist/assets/components/filter-bar/filter-bar.vue +1 -1
  42. package/dist/assets/components/input-field/input-field.vue +1 -1
  43. package/dist/assets/components/input-field-password/input-field-password.vue +1 -1
  44. package/dist/assets/components/internal/calendar/calendar.stories.ts +1 -1
  45. package/dist/assets/components/internal/calendar/calendar.types.ts +1 -1
  46. package/dist/assets/components/internal/calendar/calendar.vue +1 -1
  47. package/dist/assets/components/internal/calendar/components/calendar-month/calendar-month.types.ts +1 -1
  48. package/dist/assets/components/internal/calendar/components/calendar-month/calendar-month.vue +1 -1
  49. package/dist/assets/components/internal/calendar/components/calendar-month/components/calendar-month-day/calendar-month-day.vue +1 -1
  50. package/dist/assets/components/internal/calendar/components/calendar-year/calendar-year.types.ts +1 -1
  51. package/dist/assets/components/internal/calendar/components/calendar-year/calendar-year.vue +1 -1
  52. package/dist/assets/components/internal/calendar/components/calendar-year-selector/calendar-year-selector.types.ts +1 -0
  53. package/dist/assets/components/internal/calendar/components/calendar-year-selector/calendar-year-selector.vue +1 -0
  54. package/dist/assets/components/internal/header-meta/header-meta.vue +1 -1
  55. package/dist/assets/components/internal/info-icon-with-popover/info-icon-with-popover.vue +1 -1
  56. package/dist/assets/components/internal/listbox/components/listbox-multi-select/listbox-multi-select.vue +1 -1
  57. package/dist/assets/components/internal/listbox/components/listbox-single-select/listbox-single-select.vue +1 -1
  58. package/dist/assets/components/link/link.vue +1 -1
  59. package/dist/assets/components/list/components/list-item/list-item.types.ts +1 -1
  60. package/dist/assets/components/list/components/list-item/list-item.vue +1 -1
  61. package/dist/assets/components/list/list.docs.mdx +62 -0
  62. package/dist/assets/components/list/list.stories.ts +1 -1
  63. package/dist/assets/components/list/list.types.ts +1 -1
  64. package/dist/assets/components/list/list.vue +1 -1
  65. package/dist/assets/components/navigation-menu/components/navigation-menu-group/navigation-menu-group.stories.ts +1 -1
  66. package/dist/assets/components/navigation-menu/components/navigation-menu-group/navigation-menu-group.vue +1 -1
  67. package/dist/assets/components/navigation-menu/components/navigation-menu-item/navigation-menu-item.stories.ts +1 -1
  68. package/dist/assets/components/navigation-menu/components/navigation-menu-item/navigation-menu-item.vue +1 -1
  69. package/dist/assets/components/navigation-menu/navigation-menu.docs.mdx +7 -43
  70. package/dist/assets/components/navigation-menu/navigation-menu.stories.ts +1 -1
  71. package/dist/assets/components/navigation-menu/navigation-menu.types.ts +1 -1
  72. package/dist/assets/components/navigation-menu/navigation-menu.vue +1 -1
  73. package/dist/assets/components/popover/popover.docs.mdx +78 -15
  74. package/dist/assets/components/popover/popover.stories.ts +1 -1
  75. package/dist/assets/components/selection-card/components/selection-card-group/selection-card-group.types.ts +1 -1
  76. package/dist/assets/components/summary-grid/summary-grid.vue +1 -1
  77. package/dist/assets/components/tabs/tabs.docs.mdx +6 -1
  78. package/dist/assets/components.json +1 -0
  79. package/dist/assets/composables/use-bento-delayed-hover/use-bento-delayed-hover.docs.mdx +77 -0
  80. package/dist/assets/composables/use-bento-delayed-hover/use-bento-delayed-hover.stories.ts +1 -0
  81. package/dist/assets/deprecations.md +156 -156
  82. package/dist/assets/index.ts +1 -1
  83. package/dist/assets/usage.json +75 -74
  84. package/dist/main.js +135 -48
  85. package/package.json +1 -1
@@ -1 +1 @@
1
- <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="infoIconRef" class="b-info-icon-with-popover" tabindex="0" data-testid="info-icon-with-popover" @mouseenter="openPopover" @mouseleave="closePopover" @focus="infoIconOnFocus" @focusout="infoIconOnFocusOut" > <info-icon ref="targetElement" class="b-info-icon-with-popover__svg" :aria-expanded="isOpen" :aria-describedby="popoverId" :svg-title="t('moreInformation')" /> <bento-popover v-if="targetElement" :open="isOpen" :target-element="targetElement" :position="popoverPosition" role="tooltip" fit-content disable-focus-trap > <bento-typography> <slot> <div v-if="popoverText"> {{ popoverText }} </div> </slot> </bento-typography> </bento-popover> </div> </template> <script setup lang="ts"> import { BentoPopover, BentoPopoverPositions } from '@/components/popover'; import { BentoTypography } from '@/components/typography'; import { generateUid } from '@/core/utils/ts'; import { useI18n } from '@/utils/ts/i18n'; import InfoIcon from '@adyen/ui-assets-icons-16/vue/info'; import { type PropType, ref } from 'vue'; import type { BentoPopoverProps } from '../../popover/popover.types'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const CLOSING_POPOVER_DELAY_MS = 200; defineProps({ /** * Text showed in the popover on hover */ popoverText: { type: String, default: null }, /** * Position the popover should take if space is available */ popoverPosition: { type: String as PropType<BentoPopoverProps['position']>, default: 'bottom-start', validator: (value: BentoPopoverPositions) => Object.values(BentoPopoverPositions).includes(value), }, }); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const infoIconRef = ref<HTMLDivElement>(); const targetElement = ref(null); const popoverId = generateUid('bento-info-icon-with-popover'); const isOpen = ref(); const closingTimeOutId = ref(); const openPopover = () => { isOpen.value = true; if (closingTimeOutId.value) { clearTimeout(closingTimeOutId.value); } }; const closePopover = () => { // We want to delay closing for a couple of seconds so // it doesn't flicker if a user hovers over the icon and then inside the popover. const timeOutId = setTimeout(() => { isOpen.value = false; }, CLOSING_POPOVER_DELAY_MS); closingTimeOutId.value = timeOutId; }; const infoIconOnFocus = () => { openPopover(); }; /** * Only close popover on blur if the newly focus element is not a child of this element * @param e - the event that contains the newly focused element */ const infoIconOnFocusOut = (e: FocusEvent) => { if ( !infoIconRef.value || !(e?.relatedTarget instanceof Element) || !infoIconRef.value.contains(e.relatedTarget) ) { closePopover(); } }; </script> <script lang="ts"> /** * Internal component to be used whenever an info icon needs to be rendered with a popover instead of a tooltip. * * @example * import { BentoInfoIconWithPopover } from '@adyen/bento-vue2'; * * export default { * components: { BentoInfoIconWithPopover }, * template: ` * <info-icon-with-popover popover-text="Content can be passed as a string like" :popover-position="position"> * Content can also be rendered as a slot * </info-icon-with-popover> * ` * } */ export default { i18n: { messages }, }; </script> <style lang="scss" scoped src="./info-icon-with-popover.scss" />
1
+ <template> <div class="b-info-icon-with-popover" tabindex="0" data-testid="info-icon-with-popover" v-on="hoverEvents"> <info-icon ref="targetElement" class="b-info-icon-with-popover__svg" :aria-expanded="isOpen" :aria-describedby="popoverId" :svg-title="t('moreInformation')" /> <bento-popover v-if="targetElement" :open="isOpen" :target-element="targetElement" :position="popoverPosition" role="tooltip" fit-content disable-focus-trap > <bento-typography> <slot> <div v-if="popoverText"> {{ popoverText }} </div> </slot> </bento-typography> </bento-popover> </div> </template> <script setup lang="ts"> import { BentoPopover, BentoPopoverPositions } from '@/components/popover'; import { useBentoDelayedHover } from '@/composables'; import { BentoTypography } from '@/components/typography'; import { generateUid } from '@/core/utils/ts'; import { useI18n } from '@/utils/ts/i18n'; import InfoIcon from '@adyen/ui-assets-icons-16/vue/info'; import { type PropType, ref } from 'vue'; import type { BentoPopoverProps } from '../../popover/popover.types'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; defineProps({ /** * Text showed in the popover on hover */ popoverText: { type: String, default: null }, /** * Position the popover should take if space is available */ popoverPosition: { type: String as PropType<BentoPopoverProps['position']>, default: 'bottom-start', validator: (value: BentoPopoverPositions) => Object.values(BentoPopoverPositions).includes(value), }, }); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const targetElement = ref(null); const popoverId = generateUid('bento-info-icon-with-popover'); const { isOpen, hoverEvents } = useBentoDelayedHover(); </script> <script lang="ts"> /** * Internal component to be used whenever an info icon needs to be rendered with a popover instead of a tooltip. * * @example * import { BentoInfoIconWithPopover } from '@adyen/bento-vue2'; * * export default { * components: { BentoInfoIconWithPopover }, * template: ` * <info-icon-with-popover popover-text="Content can be passed as a string like" :popover-position="position"> * Content can also be rendered as a slot * </info-icon-with-popover> * ` * } */ export default { i18n: { messages }, }; </script> <style lang="scss" scoped src="./info-icon-with-popover.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
- <template> <a v-if="external || isNotRouting" :class="conditionalClasses" v-bind="$attrs" :aria-describedby="externalLinkId" :href="$props.to.toString()" :title="getAttributeTitle($attrs.title)" :target="computedTarget" @click="onClick" > <bento-typography v-if="!disableTypography" class="b-link__text" el="span" variant="body"> <slot /> </bento-typography> <slot v-else /> <span v-if="showExternalLinkIcon" aria-hidden="true" class="b-link__external-link-icon"> <external-link-icon /> </span> <span v-if="external" :id="externalLinkId" class="b-link--visually-hidden">{{ t('opensInNewTab') }}</span> </a> <router-link v-else v-bind="{ ...$attrs, ...$props, ...nonForwardedProps }" :class="conditionalClasses" :target="target" :to="props.to" custom @click.native="onClick" > <bento-typography v-if="!disableTypography" class="b-link__text" el="span" variant="body"> <slot /> </bento-typography> <slot v-else /> </router-link> </template> <script setup lang="ts"> import { RouterLink } from 'vue-router'; import { computed } from 'vue'; import { generateUid } from '@/core/utils/ts'; import { BentoTypography } from '@/components/typography'; import { type BentoLinkProps, BentoLinkVariant } from './link.types'; import { useI18n } from '@/utils/ts/i18n'; import type { RouterLinkSlotArgument } from 'vue-router/types/router.d.ts'; import ExternalLinkIcon from '@adyen/ui-assets-icons-16/vue/external-link'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const { t } = useI18n<{ message: MessageSchema }>({ messages }); const props = withDefaults(defineProps<BentoLinkProps>(), { target: null, variant: BentoLinkVariant.DEFAULT, }); // Props that should not be forwarded to the underlying anchor element const nonForwardedProps = { variant: undefined, isButton: undefined, truncate: undefined, disableTypography: undefined, external: undefined, isNotRouting: undefined, }; const externalLinkId = generateUid('external-link'); const getAttributeTitle = (attributesTitle: unknown) => attributesTitle as string; const conditionalClasses = computed(() => ({ 'b-link': !props.isButton, [`b-link--${BentoLinkVariant.QUIET}`]: props.variant === BentoLinkVariant.QUIET, [`b-link--truncate`]: props.truncate, })); const computedTarget = computed(() => (props.external ? (props.target ?? '_blank') : props.target)); const showExternalLinkIcon = computed(() => !(props.isButton || props.isNotRouting)); const emit = defineEmits<{ (e: 'click', clickEvent: MouseEvent): void; }>(); const onClick = (e: MouseEvent, navigateCallback?: RouterLinkSlotArgument['navigate']) => { emit('click', e); if (navigateCallback) { navigateCallback(); } }; </script> <script lang="ts"> /** * Extends the `router-link` from the `vue-router` NPM package. * * @example * import { BentoLink } from '@adyen/bento-vue2'; * * export default { * components: { BentoLink }, * template: ` * <bento-link to="/"> * link * </bento-link> * ` * } */ export default { name: 'bento-link', inheritAttrs: false, i18n: { messages }, }; </script> <style lang="scss" scoped src="@/core/components/link/link.scss" />
1
+ <template> <a v-if="external || isNotRouting" :class="conditionalClasses" v-bind="$attrs" :aria-describedby="external ? externalLinkId : null" :href="$props.to.toString()" :title="getAttributeTitle($attrs.title)" :target="computedTarget" @click="onClick" > <bento-typography v-if="!disableTypography" class="b-link__text" el="span" variant="body"> <slot /> </bento-typography> <slot v-else /> <span v-if="showExternalLinkIcon" aria-hidden="true" class="b-link__external-link-icon"> <external-link-icon /> </span> <span v-if="external" :id="externalLinkId" class="b-link--visually-hidden">{{ t('opensInNewTab') }}</span> </a> <router-link v-else v-bind="{ ...$attrs, ...$props, ...nonForwardedProps }" :class="conditionalClasses" :target="target" :to="props.to" custom @click.native="onClick" > <bento-typography v-if="!disableTypography" class="b-link__text" el="span" variant="body"> <slot /> </bento-typography> <slot v-else /> </router-link> </template> <script setup lang="ts"> import { RouterLink } from 'vue-router'; import { computed } from 'vue'; import { generateUid } from '@/core/utils/ts'; import { BentoTypography } from '@/components/typography'; import { type BentoLinkProps, BentoLinkVariant } from './link.types'; import { useI18n } from '@/utils/ts/i18n'; import type { RouterLinkSlotArgument } from 'vue-router/types/router.d.ts'; import ExternalLinkIcon from '@adyen/ui-assets-icons-16/vue/external-link'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const { t } = useI18n<{ message: MessageSchema }>({ messages }); const props = withDefaults(defineProps<BentoLinkProps>(), { target: null, variant: BentoLinkVariant.DEFAULT, }); // Props that should not be forwarded to the underlying anchor element const nonForwardedProps = { variant: undefined, isButton: undefined, truncate: undefined, disableTypography: undefined, external: undefined, isNotRouting: undefined, }; const externalLinkId = generateUid('external-link'); const getAttributeTitle = (attributesTitle: unknown) => attributesTitle as string; const conditionalClasses = computed(() => ({ 'b-link': !props.isButton, [`b-link--${BentoLinkVariant.QUIET}`]: props.variant === BentoLinkVariant.QUIET, [`b-link--truncate`]: props.truncate, })); const computedTarget = computed(() => (props.external ? (props.target ?? '_blank') : props.target)); const showExternalLinkIcon = computed(() => !(props.isButton || props.isNotRouting)); const emit = defineEmits<{ (e: 'click', clickEvent: MouseEvent): void; }>(); const onClick = (e: MouseEvent, navigateCallback?: RouterLinkSlotArgument['navigate']) => { emit('click', e); if (navigateCallback) { navigateCallback(); } }; </script> <script lang="ts"> /** * Extends the `router-link` from the `vue-router` NPM package. * * @example * import { BentoLink } from '@adyen/bento-vue2'; * * export default { * components: { BentoLink }, * template: ` * <bento-link to="/"> * link * </bento-link> * ` * } */ export default { name: 'bento-link', inheritAttrs: false, i18n: { messages }, }; </script> <style lang="scss" scoped src="@/core/components/link/link.scss" />
@@ -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" />
@@ -1 +1 @@
1
- import BentoNavigationMenuGroup from './navigation-menu-group.vue'; import BentoNavigationMenu from '../../navigation-menu.vue'; import BentoNavigationMenuItem from '../navigation-menu-item/navigation-menu-item.vue'; import { BentoButton } from '@/components/button'; import type { Meta, StoryObj } from '@storybook/vue'; import NavPaymentsIcon from '@adyen/ui-assets-icons-16/vue/nav-payments'; import StarIcon from '@adyen/ui-assets-icons-16/vue/star'; import { isVue2 } from 'vue-demi'; const meta: Meta = { title: 'Navigation Menu/Navigation Menu Group', component: BentoNavigationMenuGroup, argTypes: { showIcon: { table: { category: 'Storybook controls', }, control: { type: 'boolean', }, }, icon: { table: { disable: true, }, }, default: { table: { disable: true, }, }, }, }; export default meta; type Story = StoryObj<typeof BentoNavigationMenuGroup>; export const Default: Story = { render: (_args, { argTypes }) => ({ components: { BentoButton, BentoNavigationMenu, BentoNavigationMenuGroup, BentoNavigationMenuItem, NavPaymentsIcon, StarIcon, }, props: Object.keys(argTypes), setup(props) { return { args: isVue2 ? props : _args, }; }, template: ` <div style="width: 248px;"> <bento-navigation-menu label="Pages"> <bento-navigation-menu-group :label="args.label" :is-expanded="args.isExpanded" > <template v-if="args.showIcon" #icon> <nav-payments-icon /> </template> <bento-navigation-menu-item nested value="payments" label="Payments" :link="{ to: '#payments', isNotRouting: true }"> <template v-if="args.showAction" #action> <bento-button variant="tertiary"> <template #iconLeft> <star-icon /> </template> </bento-button> </template> </bento-navigation-menu-item> <bento-navigation-menu-item nested value="preauth" label="Pre-authorized" :link="{ to: '#preauth', isNotRouting: true }"> <template v-if="args.showAction" #action> <bento-button variant="tertiary"> <template #iconLeft> <star-icon /> </template> </bento-button> </template> </bento-navigation-menu-item> </bento-navigation-menu-group> </bento-navigation-menu> </div> `, }), args: { label: 'Payments', isExpanded: false, showIcon: true, }, };
1
+ import BentoNavigationMenuGroup from './navigation-menu-group.vue'; import BentoNavigationMenu from '../../navigation-menu.vue'; import BentoNavigationMenuItem from '../navigation-menu-item/navigation-menu-item.vue'; import { BentoButton } from '@/components/button'; import type { Meta, StoryObj } from '@storybook/vue'; import NavPaymentsIcon from '@adyen/ui-assets-icons-16/vue/nav-payments'; import StarIcon from '@adyen/ui-assets-icons-16/vue/star'; import { isVue2 } from 'vue-demi'; const meta: Meta = { title: 'Navigation Menu/Navigation Menu Group', component: BentoNavigationMenuGroup, argTypes: { showIcon: { table: { category: 'Storybook controls', }, control: { type: 'boolean', }, }, icon: { table: { disable: true, }, }, default: { table: { disable: true, }, }, }, }; export default meta; type Story = StoryObj<typeof BentoNavigationMenuGroup>; export const Default: Story = { render: (_args, { argTypes }) => ({ components: { BentoButton, BentoNavigationMenu, BentoNavigationMenuGroup, BentoNavigationMenuItem, NavPaymentsIcon, StarIcon, }, props: Object.keys(argTypes), setup(props) { return { args: isVue2 ? props : _args, }; }, template: ` <div style="width: 248px;"> <bento-navigation-menu label="Pages"> <bento-navigation-menu-group :label="args.label" :is-expanded="args.isExpanded" > <template v-if="args.showIcon" #icon> <nav-payments-icon /> </template> <bento-navigation-menu-item nested value="payments" label="Payments" :link="{ to: '#payments', isNotRouting: true }"> <template v-if="args.showAction" #action> <bento-button variant="tertiary"> <template #iconLeft> <star-icon svg-title="Add to favorites" /> </template> </bento-button> </template> </bento-navigation-menu-item> <bento-navigation-menu-item nested value="preauth" label="Pre-authorized" :link="{ to: '#preauth', isNotRouting: true }"> <template v-if="args.showAction" #action> <bento-button variant="tertiary"> <template #iconLeft> <star-icon svg-title="Add to favorites" /> </template> </bento-button> </template> </bento-navigation-menu-item> </bento-navigation-menu-group> </bento-navigation-menu> </div> `, }), args: { label: 'Payments', isExpanded: false, showIcon: true, }, };
@@ -1 +1 @@
1
- <template> <li class="b-navigation-menu-group"> <button :id="buttonId" type="button" class="b-navigation-menu-group__header" :class="{ ...headerClasses, 'b-navigation-menu-group__header--no-icon': !hasSlot('icon'), }" :aria-expanded="`${isOpen && !isSemiCollapsed}`" :aria-controls="contentId" :aria-description="headerAriaDescription" @click="toggleGroup" > <div v-if="hasSlot('icon')" class="b-navigation-menu-group__icon"> <slot name="icon" /> </div> <bento-typography ref="labelRef" v-bento-tooltip-directive="isTruncated ? label : undefined" el="span" variant="body" stronger class="b-navigation-menu-group__label" > {{ label }} </bento-typography> <div class="b-navigation-menu-group__icon b-navigation-menu-group__icon--chevron"> <chevron-up-icon v-if="isOpen && !isSemiCollapsed" aria-hidden="true" /> <chevron-down-icon v-else aria-hidden="true" /> </div> </button> <Transition name="b-navigation-menu-group__animation--content"> <div v-show="isOpen" :id="contentId" class="b-navigation-menu-group__content-wrapper" role="region" :aria-labelledby="buttonId" :style="contentMaxHeight" > <ul ref="contentDiv" class="b-navigation-menu-group__content"> <slot /> </ul> </div> </Transition> </li> </template> <script setup lang="ts"> import { computed, inject, onMounted, onUnmounted, provide, ref, useSlots, watch } from 'vue'; import { useResizeObserver } from '@vueuse/core'; import { BentoTypography } from '@/components/typography'; import { useExpandableContentHeight } from '@/composables/use-expandable-content-height/use-expandable-content-height'; import { useHasSlot } from '@/composables'; import { BentoTooltipDirective as vBentoTooltipDirective } from '@/directives/tooltip'; import { generateUid } from '@/core/utils/ts'; import { useI18n } from '@/utils/ts/i18n'; import ChevronDownIcon from '@adyen/ui-assets-icons-16/vue/chevron-down'; import ChevronUpIcon from '@adyen/ui-assets-icons-16/vue/chevron-up'; import type { BentoNavigationMenuGroupProps, BentoNavigationMenuGroupState, BentoNavigationMenuItemRegistration, } from './navigation-menu-group.types'; import { NAVIGATION_MENU_STATE_INJECTION_KEY } from '../../navigation-menu.keys'; import { NAVIGATION_MENU_GROUP_STATE_INJECTION_KEY } from './navigation-menu-group.keys'; import { type BentoNavigationMenuState } from '../../navigation-menu.types'; import messages from '../../messages.json'; type MessageSchema = (typeof messages)['en-US']; const props = withDefaults(defineProps<BentoNavigationMenuGroupProps>(), { isExpanded: false, }); const slots = useSlots(); const hasSlot = useHasSlot(slots); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const headerClasses = computed(() => ({ 'b-navigation-menu-group__header--expanded': isOpen.value && !isSemiCollapsed.value, })); const headerAriaDescription = computed(() => (isSemiCollapsed.value ? t('showingCurrentPage') : undefined)); const labelRef = ref(null); const isTruncated = ref(false); useResizeObserver(labelRef, () => { const el = labelRef.value?.$el ?? labelRef.value; if (el) { isTruncated.value = el.scrollWidth > el.clientWidth; } }); const groupId = generateUid('navigation-menu-group'); const buttonId = generateUid('navigation-menu-group-button'); const contentId = generateUid('navigation-menu-group-content'); const navigationMenuState = inject<BentoNavigationMenuState | null>(NAVIGATION_MENU_STATE_INJECTION_KEY, null); const nestedItems = ref<Array<BentoNavigationMenuItemRegistration>>([]); const registerItem = (itemId: string, isSelected: () => boolean) => { nestedItems.value.push({ id: itemId, isSelected }); }; const unregisterItem = (itemId: string) => { nestedItems.value = nestedItems.value.filter(item => item.id !== itemId); }; const isSemiCollapsed = ref(false); provide<BentoNavigationMenuGroupState>(NAVIGATION_MENU_GROUP_STATE_INJECTION_KEY, { registerItem, unregisterItem, isSemiCollapsed, }); const hasActiveItem = computed(() => nestedItems.value.some(item => item.isSelected())); const emit = defineEmits<{ /** * Emits update event when group is toggled */ (e: 'update:is-expanded', value: boolean): void; }>(); const isOpen = ref(props.isExpanded); const contentDiv = ref<HTMLUListElement>(null); const { contentHeight } = useExpandableContentHeight(contentDiv, isOpen); const contentMaxHeight = computed(() => (contentHeight.value ? { 'max-height': contentHeight.value } : {})); const toggleGroup = () => { if (isOpen.value && !isSemiCollapsed.value) { if (hasActiveItem.value) { isSemiCollapsed.value = true; } else { isOpen.value = false; } } else if (isSemiCollapsed.value) { isSemiCollapsed.value = false; } else { isOpen.value = true; } emit('update:is-expanded', isOpen.value && !isSemiCollapsed.value); }; const setOpen = (open: boolean) => { if (open) { isOpen.value = true; isSemiCollapsed.value = false; } else if (hasActiveItem.value) { isSemiCollapsed.value = true; } else { isSemiCollapsed.value = false; isOpen.value = false; } emit('update:is-expanded', open); }; watch( () => props.isExpanded, value => { if (!isSemiCollapsed.value) { isOpen.value = value; } } ); onMounted(() => { if (hasActiveItem.value && !isOpen.value) { isOpen.value = true; emit('update:is-expanded', true); } if (navigationMenuState) { navigationMenuState.registerGroup(groupId, setOpen, () => isOpen.value && !isSemiCollapsed.value); } }); onUnmounted(() => navigationMenuState?.unregisterGroup(groupId)); </script> <script lang="ts"> /** * Navigation menu group component for creating collapsible sections. * * @usage * import { BentoNavigationMenuGroup, BentoNavigationMenuItem } from '@adyen/bento-vue2'; * * export default { * components: { BentoNavigationMenuGroup, BentoNavigationMenuItem }, * template: ` * <bento-navigation-menu-group label="Section" :is-expanded="true"> * <template #icon><folder-icon /></template> * <bento-navigation-menu-item nested value="item-1" label="Item 1" /> * <bento-navigation-menu-item nested value="item-2" label="Item 2" /> * </bento-navigation-menu-group> * ` * } */ export default { name: 'bento-navigation-menu-group', i18n: { messages }, }; </script> <style lang="scss" scoped src="./navigation-menu-group.scss" />
1
+ <template> <li class="b-navigation-menu-group"> <button :id="buttonId" type="button" class="b-navigation-menu-group__header" :class="{ ...headerClasses, 'b-navigation-menu-group__header--no-icon': !hasSlot('icon'), }" :aria-expanded="`${isOpen && !isSemiCollapsed}`" :aria-controls="contentId" :aria-description="headerAriaDescription" @click="toggleGroup" > <div v-if="hasSlot('icon')" class="b-navigation-menu-group__icon" aria-hidden="true"> <slot name="icon" /> </div> <bento-typography ref="labelRef" v-bento-tooltip-directive="isTruncated ? label : undefined" el="span" variant="body" stronger class="b-navigation-menu-group__label" > {{ label }} </bento-typography> <div class="b-navigation-menu-group__icon b-navigation-menu-group__icon--chevron" aria-hidden="true"> <chevron-up-icon v-if="isOpen && !isSemiCollapsed" /> <chevron-down-icon v-else /> </div> </button> <Transition name="b-navigation-menu-group__animation--content"> <div v-show="isOpen" :id="contentId" class="b-navigation-menu-group__content-wrapper" role="region" :aria-labelledby="buttonId" :style="contentMaxHeight" > <ul ref="contentDiv" class="b-navigation-menu-group__content"> <slot /> </ul> </div> </Transition> </li> </template> <script setup lang="ts"> import { computed, inject, onMounted, onUnmounted, provide, ref, useSlots, watch } from 'vue'; import { useResizeObserver } from '@vueuse/core'; import { BentoTypography } from '@/components/typography'; import { useExpandableContentHeight } from '@/composables/use-expandable-content-height/use-expandable-content-height'; import { useHasSlot } from '@/composables'; import { BentoTooltipDirective as vBentoTooltipDirective } from '@/directives/tooltip'; import { generateUid } from '@/core/utils/ts'; import { useI18n } from '@/utils/ts/i18n'; import ChevronDownIcon from '@adyen/ui-assets-icons-16/vue/chevron-down'; import ChevronUpIcon from '@adyen/ui-assets-icons-16/vue/chevron-up'; import type { BentoNavigationMenuGroupProps, BentoNavigationMenuGroupState, BentoNavigationMenuItemRegistration, } from './navigation-menu-group.types'; import { NAVIGATION_MENU_STATE_INJECTION_KEY } from '../../navigation-menu.keys'; import { NAVIGATION_MENU_GROUP_STATE_INJECTION_KEY } from './navigation-menu-group.keys'; import { type BentoNavigationMenuState } from '../../navigation-menu.types'; import messages from '../../messages.json'; type MessageSchema = (typeof messages)['en-US']; const props = withDefaults(defineProps<BentoNavigationMenuGroupProps>(), { isExpanded: false, }); const slots = useSlots(); const hasSlot = useHasSlot(slots); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const headerClasses = computed(() => ({ 'b-navigation-menu-group__header--expanded': isOpen.value && !isSemiCollapsed.value, })); const headerAriaDescription = computed(() => (isSemiCollapsed.value ? t('showingCurrentPage') : undefined)); const labelRef = ref(null); const isTruncated = ref(false); useResizeObserver(labelRef, () => { const el = labelRef.value?.$el ?? labelRef.value; if (el) { isTruncated.value = el.scrollWidth > el.clientWidth; } }); const groupId = generateUid('navigation-menu-group'); const buttonId = generateUid('navigation-menu-group-button'); const contentId = generateUid('navigation-menu-group-content'); const navigationMenuState = inject<BentoNavigationMenuState | null>(NAVIGATION_MENU_STATE_INJECTION_KEY, null); const nestedItems = ref<Array<BentoNavigationMenuItemRegistration>>([]); const registerItem = (itemId: string, isSelected: () => boolean) => { nestedItems.value.push({ id: itemId, isSelected }); }; const unregisterItem = (itemId: string) => { nestedItems.value = nestedItems.value.filter(item => item.id !== itemId); }; const isSemiCollapsed = ref(false); provide<BentoNavigationMenuGroupState>(NAVIGATION_MENU_GROUP_STATE_INJECTION_KEY, { registerItem, unregisterItem, isSemiCollapsed, }); const hasActiveItem = computed(() => nestedItems.value.some(item => item.isSelected())); const emit = defineEmits<{ /** * Emits update event when group is toggled */ (e: 'update:is-expanded', value: boolean): void; }>(); const isOpen = ref(props.isExpanded); const contentDiv = ref<HTMLUListElement>(null); const { contentHeight } = useExpandableContentHeight(contentDiv, isOpen); const contentMaxHeight = computed(() => (contentHeight.value ? { 'max-height': contentHeight.value } : {})); const toggleGroup = () => { if (isOpen.value && !isSemiCollapsed.value) { if (hasActiveItem.value) { isSemiCollapsed.value = true; } else { isOpen.value = false; } } else if (isSemiCollapsed.value) { isSemiCollapsed.value = false; } else { isOpen.value = true; } emit('update:is-expanded', isOpen.value && !isSemiCollapsed.value); }; const setOpen = (open: boolean) => { if (open) { isOpen.value = true; isSemiCollapsed.value = false; } else if (hasActiveItem.value) { isSemiCollapsed.value = true; } else { isSemiCollapsed.value = false; isOpen.value = false; } emit('update:is-expanded', open); }; watch( () => props.isExpanded, value => { if (!isSemiCollapsed.value) { isOpen.value = value; } } ); onMounted(() => { if (hasActiveItem.value && !isOpen.value) { isOpen.value = true; emit('update:is-expanded', true); } if (navigationMenuState) { navigationMenuState.registerGroup(groupId, setOpen, () => isOpen.value && !isSemiCollapsed.value); } }); onUnmounted(() => navigationMenuState?.unregisterGroup(groupId)); </script> <script lang="ts"> /** * Navigation menu group component for creating collapsible sections. * * @usage * import { BentoNavigationMenuGroup, BentoNavigationMenuItem } from '@adyen/bento-vue2'; * * export default { * components: { BentoNavigationMenuGroup, BentoNavigationMenuItem }, * template: ` * <bento-navigation-menu-group label="Section" :is-expanded="true"> * <template #icon><folder-icon /></template> * <bento-navigation-menu-item nested value="item-1" label="Item 1" :link="{ to: '/section/item-1' }" /> * <bento-navigation-menu-item nested value="item-2" label="Item 2" :link="{ to: '/section/item-2' }" /> * </bento-navigation-menu-group> * ` * } */ export default { name: 'bento-navigation-menu-group', i18n: { messages }, }; </script> <style lang="scss" scoped src="./navigation-menu-group.scss" />
@@ -1 +1 @@
1
- import BentoNavigationMenuItem from './navigation-menu-item.vue'; import BentoNavigationMenu from '../../navigation-menu.vue'; import { BentoButton } from '@/components/button'; import type { Meta, StoryObj } from '@storybook/vue'; import NavHomeIcon from '@adyen/ui-assets-icons-16/vue/nav-home'; import StarIcon from '@adyen/ui-assets-icons-16/vue/star'; import { isVue2 } from 'vue-demi'; const meta: Meta = { title: 'Navigation Menu/Navigation Menu Item', component: BentoNavigationMenuItem, argTypes: { showIcon: { table: { category: 'Storybook controls', }, control: { type: 'boolean', }, }, showAction: { table: { category: 'Storybook controls', }, control: { type: 'boolean', }, }, icon: { table: { disable: true, }, }, action: { table: { disable: true, }, }, default: { table: { disable: true, }, }, }, }; export default meta; type Story = StoryObj<typeof BentoNavigationMenuItem>; export const Default: Story = { render: (_args, { argTypes }) => ({ components: { BentoButton, BentoNavigationMenu, BentoNavigationMenuItem, NavHomeIcon, StarIcon }, props: Object.keys(argTypes), setup(props) { return { args: isVue2 ? props : _args, }; }, template: ` <div style="width: 248px;"> <bento-navigation-menu label="Pages"> <bento-navigation-menu-item :value="args.value" :label="args.label" :link="args.link" :nested="args.nested" :always-show-actions="args.alwaysShowActions" > <template v-if="args.showIcon" #icon> <nav-home-icon /> </template> <template v-if="args.showAction" #action> <bento-button variant="tertiary"> <template #iconLeft> <star-icon /> </template> </bento-button> </template> </bento-navigation-menu-item> </bento-navigation-menu> </div> `, }), args: { value: 'home', label: 'Home', link: { to: '#home', isNotRouting: true }, nested: false, alwaysShowActions: false, showIcon: true, showAction: true, }, };
1
+ import BentoNavigationMenuItem from './navigation-menu-item.vue'; import BentoNavigationMenu from '../../navigation-menu.vue'; import { BentoButton } from '@/components/button'; import type { Meta, StoryObj } from '@storybook/vue'; import NavHomeIcon from '@adyen/ui-assets-icons-16/vue/nav-home'; import StarIcon from '@adyen/ui-assets-icons-16/vue/star'; import { isVue2 } from 'vue-demi'; const meta: Meta = { title: 'Navigation Menu/Navigation Menu Item', component: BentoNavigationMenuItem, argTypes: { showIcon: { table: { category: 'Storybook controls', }, control: { type: 'boolean', }, }, showAction: { table: { category: 'Storybook controls', }, control: { type: 'boolean', }, }, icon: { table: { disable: true, }, }, action: { table: { disable: true, }, }, default: { table: { disable: true, }, }, }, }; export default meta; type Story = StoryObj<typeof BentoNavigationMenuItem>; export const Default: Story = { render: (_args, { argTypes }) => ({ components: { BentoButton, BentoNavigationMenu, BentoNavigationMenuItem, NavHomeIcon, StarIcon }, props: Object.keys(argTypes), setup(props) { return { args: isVue2 ? props : _args, }; }, template: ` <div style="width: 248px;"> <bento-navigation-menu label="Pages"> <bento-navigation-menu-item :value="args.value" :label="args.label" :link="args.link" :nested="args.nested" :always-show-actions="args.alwaysShowActions" > <template v-if="args.showIcon" #icon> <nav-home-icon /> </template> <template v-if="args.showAction" #action> <bento-button variant="tertiary"> <template #iconLeft> <star-icon svg-title="Add to favorites" /> </template> </bento-button> </template> </bento-navigation-menu-item> </bento-navigation-menu> </div> `, }), args: { value: 'home', label: 'Home', link: { to: '#home', isNotRouting: true }, nested: false, alwaysShowActions: false, showIcon: true, showAction: true, }, };