@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> <component :is="el" ref="containerRef"> <slot v-for="(item, index) of items" :item="item" :index="index" :move-up="() => moveItemUp(index)" :move-right="() => moveItemRight(index)" :move-down="() => moveItemDown(index)" :move-left="() => moveItemLeft(index)" :move-top="() => moveItemTop(index)" :move-bottom="() => moveItemBottom(index)" :disabled-menus="disabledMenuItems(index)" name="default" ></slot> <div class="b-draggable__announcement" role="status" aria-live="polite"> {{ announcementMessage }} </div> </component> </template> <script setup lang="ts"> import { computed, nextTick, ref, watch } from 'vue'; import type { BentoDraggableEmits, BentoDraggableItems, BentoDraggableOptions, BentoDraggableProps, BentoDraggableSortableEvent, } from './draggable.types'; import { useBentoDraggable } from './composables/use-bento-draggable/use-bento-draggable'; import { useDraggableAnnouncement } from './composables/use-draggable-announcement'; import { useTrackKeyboardUser } from './composables/use-track-keyboard-user'; import { useI18n } from '@/utils/ts/i18n'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const props = withDefaults(defineProps<BentoDraggableProps>(), { el: 'div', items: () => [], options: () => ({}), announcement: '', disableAutoSort: false, }); const emit = defineEmits<BentoDraggableEmits>(); /** * Reference to the container of the draggable elements. */ const containerRef = ref<HTMLElement | null>(null); /** * Indicates if there is an element being dragged inside the container. */ const isDragging = ref<boolean>(false); /** * Internal copy of items used to track position changes for focus restoration. */ const internalItems = ref<BentoDraggableItems>([]); /** * Tracks the item being moved via keyboard so focus can be restored to the correct element. */ const lastMovedItem = ref<BentoDraggableItems[number] | null>(null); const computedOptions = computed<BentoDraggableOptions>(() => { if (!containerRef.value) { return {}; } return { ...props.options, onStart: (event: BentoDraggableSortableEvent) => { isDragging.value = true; if (props.options?.onStart) { props.options?.onStart(event); } }, onEnd: (event: BentoDraggableSortableEvent) => { // This is a hack to move the event to the end of the event queue. // cf this issue: https://github.com/SortableJS/Sortable/issues/1184 setTimeout(() => { isDragging.value = false; if (props.options?.onEnd) { props.options?.onEnd(event); } }); }, onSort: (event: BentoDraggableSortableEvent) => { // Always execute the user's custom onSort callback if provided if (props.options?.onSort) { props.options.onSort(event); return; } // Automatically sync the v-model unless the user explicitly disabled it if (!props.disableAutoSort) { const { oldIndex, newIndex, from, to } = event; if ( from === to && from === containerRef.value && oldIndex !== undefined && newIndex !== undefined ) { moveItem(oldIndex, newIndex); } } }, }; }); /** * Handle reordering announcements for screen readers. */ const { announcementMessage, announce } = useDraggableAnnouncement(); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const { draggableElement } = useBentoDraggable(containerRef, computedOptions); // When moving items between sibling `bento-draggable`, the item may be removed, causing the DOM to be destroyed and temporarily lose focus // This is an alternative to query element `:focus-within` as the `:focus-within` will be gone until the other `bento-draggable` adds it into the list const { isKeyboardUser } = useTrackKeyboardUser(); /** * Focuses the draggable handle button at the given index. * Waits for the next tick to ensure the DOM has been updated before focusing. * * @param index - The index of the item whose handle should receive focus. */ const focus = async (index: number) => { await nextTick(); if (index !== -1 && containerRef.value) { const itemElement = containerRef.value.children[index] as HTMLElement; const button = itemElement?.querySelector<HTMLButtonElement>('.b-draggable-handle button'); await nextTick(); button?.focus(); } }; /** * Moves an item from one position to another within the list, * emits the updated list, and announces the change for screen readers. * * @param indexOrigin - The current index of the item to move. * @param indexTarget - The desired index to move the item to. */ const moveItem = async (indexOrigin: number, indexTarget: number) => { if ( indexOrigin < 0 || indexTarget < 0 || indexOrigin >= props.items.length || indexTarget >= props.items.length || indexOrigin === indexTarget ) { return; } const newList = Array.from(props.items as BentoDraggableItems); const [removed] = newList.splice(indexOrigin, 1); newList.splice(indexTarget, 0, removed); lastMovedItem.value = removed; // Occasionally, there may be race conditions where the index is out-of-date due to the fast drag-and-drop or // other async operations like onEnd > setTimeout. // Filtering out null/undefined values ensures the list remains valid. emit('update:items', newList.filter(Boolean)); const itemLabel = removed.label; const message = props?.announcement || t('youHaveMovedFromPositionToPosition', { itemLabel, indexOrigin: indexOrigin + 1, indexTarget: indexTarget + 1, total: props.items.length, }); if (props.announcement !== 'disabled') { announce(message); } }; const moveItemUp = (index: number) => moveItem(index, index - 1); const moveItemDown = (index: number) => moveItem(index, index + 1); const moveItemLeft = (index: number) => moveItem(index, index - 1); const moveItemRight = (index: number) => moveItem(index, index + 1); const moveItemTop = (index: number) => moveItem(index, 0); const moveItemBottom = (index: number) => moveItem(index, props.items.length - 1); /** * Returns a pre-calculated disabled menus object for the current item. */ const disabledMenuItems = (index: number) => { return { up: index === 0, top: index === 0, down: index === props.items.length - 1, bottom: index === props.items.length - 1, left: true, right: true, }; }; /** * Watch for items changes and restore focus automatically */ watch( () => props.items, async newItems => { if (!newItems) { return; } if (isKeyboardUser.value && newItems.length > 0 && internalItems.value.length > 0) { if (lastMovedItem.value) { // Within-list reorder: focus the moved item at its new position const movedItemIndex = newItems.indexOf(lastMovedItem.value); if (movedItemIndex !== -1) { await focus(movedItemIndex); } lastMovedItem.value = null; } else if (newItems.length > internalItems.value.length) { // Cross-list addition: focus the newly added item const newItemIndex = newItems.findIndex(item => !internalItems.value.includes(item)); if (newItemIndex !== -1) { await focus(newItemIndex); } } } internalItems.value = [...newItems]; }, { deep: true, immediate: true } ); defineExpose({ /** * A ref to the container DOM element. */ containerRef, /** * The underlying SortableJS instance, which provides methods like `toArray()`, `sort()`, and `destroy()`. */ draggableElement, /** * A boolean ref that is `true` when an item is being dragged. */ isDragging, /** * Announces a message to screen reader users via the live region. */ announce, /** * Moves an item from one index to another and emits the updated list. */ moveItem, /** * Programmatically focuses the draggable handle button at the given index. */ focus, }); </script> <script lang="ts"> /** * Drag and drop container that transforms a list of items into draggable elements. * The library is based on SortableJS. * @see {@link https://github.com/SortableJS/Sortable#readme | SortableJS documentation} * * @example * import { BentoDraggable, BentoDraggableHandle, type BentoDraggableEvents } from '@adyen/bento-vue2'; * * export default { * components: { BentoDraggable, BentoDraggableHandle }, * template: ` * <bento-draggable * v-model="items" * condensed * :options="{ * group: 'nested', * animation: 150 * }" * > * <template #default="{ item, index, moveUp, moveDown }"> * <div :key="item.id"> * <bento-draggable-handle * :label="item.label" * @move-up="moveUp" * @move-down="moveDown" * /> * </div> * </template> * </bento-draggable> * `, * setup () { * const items = ref([ * { id: '1', label: 'Item 1' }, * { id: '2', label: 'Item 2' } * ]); * return { * items, * } * } * } */ export default { name: 'bento-draggable', model: { prop: 'items', event: 'update:items' }, i18n: { messages }, }; </script> <style lang="scss" scoped src="./draggable.scss" />
1
+ <template> <component :is="el" ref="containerRef"> <slot v-for="(item, index) of items" :item="item" :index="index" :move-up="() => moveItemUp(index)" :move-right="() => moveItemRight(index)" :move-down="() => moveItemDown(index)" :move-left="() => moveItemLeft(index)" :move-top="() => moveItemTop(index)" :move-bottom="() => moveItemBottom(index)" :disabled-menus="disabledMenuItems(index)" name="default" ></slot> <div class="b-draggable__announcement" role="status" aria-live="polite"> {{ announcementMessage }} </div> </component> </template> <script setup lang="ts"> import { computed, nextTick, ref, watch } from 'vue'; import type { BentoDraggableEmits, BentoDraggableItems, BentoDraggableOptions, BentoDraggableProps, BentoDraggableSortableEvent, } from './draggable.types'; import { useBentoDraggable } from './composables/use-bento-draggable/use-bento-draggable'; import { useDraggableAnnouncement } from './composables/use-draggable-announcement'; import { useTrackKeyboardUser } from './composables/use-track-keyboard-user'; import { useI18n } from '@/utils/ts/i18n'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const ALL_CHILDREN_EXCEPT_SCREEN_READER_ELEMENTS = '>:not(.b-draggable__announcement)'; const props = withDefaults(defineProps<BentoDraggableProps>(), { el: 'div', items: () => [], options: () => ({}), announcement: '', disableAutoSort: false, }); const emit = defineEmits<BentoDraggableEmits>(); /** * Reference to the container of the draggable elements. */ const containerRef = ref<HTMLElement | null>(null); /** * Indicates if there is an element being dragged inside the container. */ const isDragging = ref<boolean>(false); /** * Internal copy of items used to track position changes for focus restoration. */ const internalItems = ref<BentoDraggableItems>([]); /** * Tracks the item being moved via keyboard so focus can be restored to the correct element. */ const lastMovedItem = ref<BentoDraggableItems[number] | null>(null); const computedOptions = computed<BentoDraggableOptions>(() => { if (!containerRef.value) { return {}; } return { ...props.options, draggable: props.options?.draggable ?? ALL_CHILDREN_EXCEPT_SCREEN_READER_ELEMENTS, onStart: (event: BentoDraggableSortableEvent) => { isDragging.value = true; if (props.options?.onStart) { props.options?.onStart(event); } }, onEnd: (event: BentoDraggableSortableEvent) => { // This is a hack to move the event to the end of the event queue. // cf this issue: https://github.com/SortableJS/Sortable/issues/1184 setTimeout(() => { isDragging.value = false; if (props.options?.onEnd) { props.options?.onEnd(event); } }); }, onSort: (event: BentoDraggableSortableEvent) => { // Always execute the user's custom onSort callback if provided if (props.options?.onSort) { props.options.onSort(event); return; } // Automatically sync the v-model unless the user explicitly disabled it if (!props.disableAutoSort) { const { oldIndex, newIndex, from, to } = event; if ( from === to && from === containerRef.value && oldIndex !== undefined && newIndex !== undefined ) { moveItem(oldIndex, newIndex); } } }, }; }); /** * Handle reordering announcements for screen readers. */ const { announcementMessage, announce } = useDraggableAnnouncement(); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const { draggableElement } = useBentoDraggable(containerRef, computedOptions); // When moving items between sibling `bento-draggable`, the item may be removed, causing the DOM to be destroyed and temporarily lose focus // This is an alternative to query element `:focus-within` as the `:focus-within` will be gone until the other `bento-draggable` adds it into the list const { isKeyboardUser } = useTrackKeyboardUser(); /** * Focuses the draggable handle button at the given index. * Waits for the next tick to ensure the DOM has been updated before focusing. * * @param index - The index of the item whose handle should receive focus. */ const focus = async (index: number) => { await nextTick(); if (index !== -1 && containerRef.value) { const itemElement = containerRef.value.children[index] as HTMLElement; const button = itemElement?.querySelector<HTMLButtonElement>('.b-draggable-handle button'); await nextTick(); button?.focus(); } }; /** * Moves an item from one position to another within the list, * emits the updated list, and announces the change for screen readers. * * @param indexOrigin - The current index of the item to move. * @param indexTarget - The desired index to move the item to. */ const moveItem = async (indexOrigin: number, indexTarget: number) => { if ( indexOrigin < 0 || indexTarget < 0 || indexOrigin >= props.items.length || indexTarget >= props.items.length || indexOrigin === indexTarget ) { return; } const newList = Array.from(props.items as BentoDraggableItems); const [removed] = newList.splice(indexOrigin, 1); newList.splice(indexTarget, 0, removed); lastMovedItem.value = removed; // Occasionally, there may be race conditions where the index is out-of-date due to the fast drag-and-drop or // other async operations like onEnd > setTimeout. // Filtering out null/undefined values ensures the list remains valid. emit('update:items', newList.filter(Boolean)); const itemLabel = removed.label; const message = props?.announcement || t('youHaveMovedFromPositionToPosition', { itemLabel, indexOrigin: indexOrigin + 1, indexTarget: indexTarget + 1, total: props.items.length, }); if (props.announcement !== 'disabled') { announce(message); } }; const moveItemUp = (index: number) => moveItem(index, index - 1); const moveItemDown = (index: number) => moveItem(index, index + 1); const moveItemLeft = (index: number) => moveItem(index, index - 1); const moveItemRight = (index: number) => moveItem(index, index + 1); const moveItemTop = (index: number) => moveItem(index, 0); const moveItemBottom = (index: number) => moveItem(index, props.items.length - 1); /** * Returns a pre-calculated disabled menus object for the current item. */ const disabledMenuItems = (index: number) => { return { up: index === 0, top: index === 0, down: index === props.items.length - 1, bottom: index === props.items.length - 1, left: true, right: true, }; }; /** * Watch for items changes and restore focus automatically */ watch( () => props.items, async newItems => { if (!newItems) { return; } if (isKeyboardUser.value && newItems.length > 0 && internalItems.value.length > 0) { if (lastMovedItem.value) { // Within-list reorder: focus the moved item at its new position const movedItemIndex = newItems.indexOf(lastMovedItem.value); if (movedItemIndex !== -1) { await focus(movedItemIndex); } lastMovedItem.value = null; } else if (newItems.length > internalItems.value.length) { // Cross-list addition: focus the newly added item const newItemIndex = newItems.findIndex(item => !internalItems.value.includes(item)); if (newItemIndex !== -1) { await focus(newItemIndex); } } } internalItems.value = [...newItems]; }, { deep: true, immediate: true } ); defineExpose({ /** * A ref to the container DOM element. */ containerRef, /** * The underlying SortableJS instance, which provides methods like `toArray()`, `sort()`, and `destroy()`. */ draggableElement, /** * A boolean ref that is `true` when an item is being dragged. */ isDragging, /** * Announces a message to screen reader users via the live region. */ announce, /** * Moves an item from one index to another and emits the updated list. */ moveItem, /** * Programmatically focuses the draggable handle button at the given index. */ focus, }); </script> <script lang="ts"> /** * Drag and drop container that transforms a list of items into draggable elements. * The library is based on SortableJS. * @see {@link https://github.com/SortableJS/Sortable#readme | SortableJS documentation} * * @example * import { BentoDraggable, BentoDraggableHandle, type BentoDraggableEvents } from '@adyen/bento-vue2'; * * export default { * components: { BentoDraggable, BentoDraggableHandle }, * template: ` * <bento-draggable * v-model="items" * condensed * :options="{ * group: 'nested', * animation: 150 * }" * > * <template #default="{ item, index, moveUp, moveDown }"> * <div :key="item.id"> * <bento-draggable-handle * :label="item.label" * @move-up="moveUp" * @move-down="moveDown" * /> * </div> * </template> * </bento-draggable> * `, * setup () { * const items = ref([ * { id: '1', label: 'Item 1' }, * { id: '2', label: 'Item 2' } * ]); * return { * items, * } * } * } */ export default { name: 'bento-draggable', model: { prop: 'items', event: 'update:items' }, i18n: { messages }, }; </script> <style lang="scss" scoped src="./draggable.scss" />
@@ -1 +1 @@
1
- <template> <div :class="$attrs.class" data-testid="textbox-wrapper" @click="onToggleDropdown" @keydown.escape="onCloseDropdown" > <bento-typography class="b-dropdown-base-textbox" :class="conditionalClasses" el="div" data-testid="base-textbox" > <input v-if="shouldShowInput" v-bind="ariaAttributes" :id="textboxId" ref="textbox" class="b-dropdown-base-textbox__input" role="combobox" type="text" aria-autocomplete="list" size="1" :value="open ? value : displayValue" :disabled="disabled ? true : null" :readonly="readonly" :required="showRequiredAttribute" @keydown="$emit(DropdownBaseTextboxEvent.KEYDOWN, $event)" @keydown.enter="onToggleDropdown" @input="onInputEvent" /> <div v-else :id="textboxId" v-bind="ariaAttributes" ref="textbox" class="b-dropdown-base-textbox__input" :class="comboboxConditionalClasses" :aria-readonly="readonly ? 'true' : null" role="combobox" :tabindex="disabled ? -1 : 0" > <template v-if="shouldShowSingleSelectSlot"> <slot v-if="hasSlot('display-value')" v-bind="selectedValueItem" name="display-value"></slot> <slot v-else v-bind="selectedValueItem"> {{ displayValue }} </slot> </template> <template v-else-if="shouldShowMultipleSlot"> <div class="b-dropdown-base-textbox__input-multiple-selected"> <div v-for="singleItem of selectedValueItems" :key="singleItem.value"> <div class="b-dropdown-base-textbox__input-selected-element"> <slot v-bind="singleItem"> {{ displayValue }} </slot> </div> </div> </div> </template> <template v-else>{{ displayValue }}</template> </div> <span v-if="additionalItemsSelected" class="b-dropdown-base-textbox__additional-items"> {{ additionalItemsSelected }} </span> <span v-if="hasSlot('icon') && !isFiltering" class="b-dropdown-base-textbox__icon" aria-hidden="true"> <slot name="icon"></slot> </span> <span v-if="isFiltering" class="b-dropdown-base-textbox__clear-search" :tabindex="disabled ? -1 : 0" data-testid="clear-search-button" @click.stop="onClearSearch" @keydown.enter.stop="onClearSearch" @keyup.space="onClearSearch" > <cross-circle-fill-small-icon v-if="size === BentoDropdownSize.SMALL" :svg-title="t('clearSearch')" /> <cross-circle-fill-icon v-else :svg-title="t('clearSearch')" /> </span> </bento-typography> </div> </template> <script setup lang="ts"> import { computed, defineComponent, nextTick, type PropType, ref, useAttrs, useSlots, watch } from 'vue'; import { generateUid } from '@/core/utils/ts'; import { useI18n } from '@/utils/ts/i18n'; import { useHasSlot } from '@/composables'; import { BentoTypography } from '@/components/typography'; import { type Booleanish } from '@/types/prop-types'; import { type BentoListboxOptionItem } from '@/types/listbox'; import type { HTMLAttributes } from 'vue/types/jsx.d.ts'; import { DropdownBaseTextboxEvent, DropdownBaseTextboxState } from './dropdown-base-textbox.types'; import CrossCircleFillIcon from '@adyen/ui-assets-icons-16/vue/cross-circle-fill'; import CrossCircleFillSmallIcon from '@adyen/ui-assets-icons-16/vue/cross-circle-fill-small'; import { BentoDropdownSize } from '@/components/dropdown/dropdown.types'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const { t } = useI18n<{ message: MessageSchema }>({ messages }); const attrs = useAttrs(); const slots = useSlots(); const hasSlot = useHasSlot(slots); const props = defineProps({ /** * The number of additional items that have been selected in the dropdown. */ additionalItemsSelected: { type: [Number, String] as PropType<number | string>, default: 0 }, /** * If the combobox DOM element should be an `input` element. */ alwaysComboboxIsInput: { type: Boolean, default: false, }, /** * Sets the aria-controls attribute on the textbox. This should correspond to the popover's id that the textbox triggers. */ ariaControls: { type: String, required: true }, /** * Indicates whether the content the textbox triggers is visible or not. Needs to be set accordingly when the textbox is used and changed every time the popover is shown/hidden. */ ariaExpanded: { type: [String, Boolean] as PropType<Booleanish>, required: true, }, /** * If no label element is used then the aria-label is be set should be set. */ ariaLabel: { type: String, default: null }, /** * Corresponds to the label's id that labels the textbox element. */ ariaLabelledby: { type: String, default: null }, /** * Disables any textbox functionality */ disabled: { type: Boolean, default: false }, /** * Selected option's label */ displayValue: { type: String, default: null }, /** * Sets the possibility to type into the textbox. */ dynamicFiltering: { type: Boolean, default: false }, /** * Marks the textbox as invalid, rendering an error state */ isInvalid: { type: Boolean, default: false }, /* * If enabled, the user will be able to select multiple items. */ multiple: { type: Boolean, default: false }, /** * Indicates if the dropdown is open so that * the respective event can be emitted */ open: { type: Boolean, default: false }, /** * Sets the dropdown to readonly state */ readonly: { type: Boolean, default: false }, /** * The input value */ value: { type: String, default: '' }, /** * The selected value's list box item. */ selectedValueItem: { type: [Object, Array] as PropType<BentoListboxOptionItem | Array<BentoListboxOptionItem>>, default: undefined, }, /** * Toggles whether to use the slot content select items shown in the textbox in multiple mode. */ showSlotContentInMultiple: { type: Boolean, default: false }, /** * Dropdown size */ size: { type: String as PropType<BentoDropdownSize | `${BentoDropdownSize}`>, default: null, }, }); const emit = defineEmits<{ /** * Emitted when the "clear" button is clicked */ (e: DropdownBaseTextboxEvent.CLEAR); /** * Emitted on click, space, enter and esc user events. It will close the dropdown. */ (e: DropdownBaseTextboxEvent.CLOSE); /** * Emitted when `dynamicFiltering` is enabled and a search input is entered */ (e: DropdownBaseTextboxEvent.INPUT, searchValue: string); /** * Emitted when any key is pressed and the focus is on the input field */ (e: DropdownBaseTextboxEvent.KEYDOWN, event: KeyboardEvent); /** * Emitted on click, space and enter user events. It will open the dropdown. */ (e: DropdownBaseTextboxEvent.OPEN); }>(); const textbox = ref(null); const textboxId = generateUid('textbox'); const conditionalClasses = computed(() => ({ [`b-dropdown-base-textbox--${DropdownBaseTextboxState.DISABLED}`]: props.disabled, [`b-dropdown-base-textbox--${DropdownBaseTextboxState.READONLY}`]: props.readonly, [`b-dropdown-base-textbox--${DropdownBaseTextboxState.HAS_ITEMS}`]: !!props.additionalItemsSelected, })); const showRequiredAttribute = computed(() => shouldShowInput.value && attrs['aria-required'] ? ('true' as Booleanish) : null ); const comboboxConditionalClasses = computed(() => ({ [`b-dropdown-base-textbox__input--dynamic-filtering`]: props.dynamicFiltering, })); const isFiltering = computed(() => props.dynamicFiltering && !!props.value && props.open); const ariaAttributes = computed( () => ({ 'aria-haspopup': 'listbox', 'aria-controls': props.ariaControls, 'aria-describedby': attrs['aria-describedby']?.toString() as string, 'aria-expanded': props.ariaExpanded ? 'true' : 'false', 'aria-labelledby': props.ariaLabelledby, 'aria-label': props.ariaLabelledby ? null : props.ariaLabel, 'aria-invalid': props.isInvalid, 'aria-disabled': props.disabled ? true : null, 'aria-required': attrs['aria-required'] as Booleanish, }) as HTMLAttributes ); // Cast selectedValueItem if it is an array of selected items const selectedValueItems = computed(() => props.multiple ? (props.selectedValueItem as Array<BentoListboxOptionItem>) : [] ); const shouldShowInput = computed(() => props.dynamicFiltering && (props.open || props.alwaysComboboxIsInput)); const shouldShowSingleSelectSlot = computed(() => props.selectedValueItem && !props.multiple); const shouldShowMultipleSlot = computed( () => props.multiple && props.selectedValueItem && selectedValueItems.value.length > 0 && props.showSlotContentInMultiple ); watch( () => shouldShowInput.value, () => { if (shouldShowInput.value && !props.alwaysComboboxIsInput) { nextTick(() => { textbox.value.focus(); }); } } ); const onOpenDropdown = async () => { if (!props.disabled && !props.readonly) { emit(DropdownBaseTextboxEvent.OPEN); // wait for popover to open before setting focus await nextTick(); textbox.value.focus(); } }; const onCloseDropdown = () => { if (props.open) { textbox.value.focus(); emit(DropdownBaseTextboxEvent.CLOSE); } }; const onToggleDropdown = (event: Event) => { const isDynamicFilteringInputClickEvent = props.open && (event.target as HTMLDivElement).tagName === 'INPUT'; if (isDynamicFilteringInputClickEvent) { /** * When there's a click even on the input during filtering * and the popover is open, do not toggle the popover. * Allow for click events inside the input field */ return null; } return props.open ? onCloseDropdown() : onOpenDropdown(); }; const onClearSearch = () => { emit(DropdownBaseTextboxEvent.CLEAR); emit(DropdownBaseTextboxEvent.INPUT, ''); textbox.value.focus(); }; const onInputEvent = (event: Event) => { if (!props.readonly && !props.disabled) { emit(DropdownBaseTextboxEvent.INPUT, (event.target as HTMLInputElement).value); } else { event.preventDefault(); event.stopPropagation(); } }; const focus = () => { textbox.value.focus(); }; defineExpose({ focus, }); </script> <script lang="ts"> /** * Only for internal use, implements the logic of the textbox element to be implemented in the combobox. */ export default defineComponent({ i18n: { messages }, name: 'dropdown-base-textbox', inheritAttrs: false, }); </script> <style lang="scss" scoped src="./dropdown-base-textbox.scss" />
1
+ <template> <div :class="$attrs.class" data-testid="textbox-wrapper" @click="onToggleDropdown" @keydown.escape="onCloseDropdown" > <bento-typography class="b-dropdown-base-textbox" :class="conditionalClasses" :variant="isUnderlineStyle ? 'title' : 'body'" el="div" data-testid="base-textbox" > <input v-if="shouldShowInput" v-bind="ariaAttributes" :id="textboxId" ref="textbox" class="b-dropdown-base-textbox__input" role="combobox" type="text" aria-autocomplete="list" size="1" :value="open ? value : displayValue" :disabled="disabled ? true : null" :readonly="readonly" :required="showRequiredAttribute" @keydown="$emit(DropdownBaseTextboxEvent.KEYDOWN, $event)" @keydown.enter="onToggleDropdown" @input="onInputEvent" /> <div v-else :id="textboxId" v-bind="ariaAttributes" ref="textbox" class="b-dropdown-base-textbox__input" :class="comboboxConditionalClasses" :aria-readonly="readonly ? 'true' : null" role="combobox" :tabindex="disabled ? -1 : 0" > <template v-if="shouldShowSingleSelectSlot"> <slot v-if="hasSlot('display-value')" v-bind="selectedValueItem" name="display-value"></slot> <slot v-else v-bind="selectedValueItem"> {{ displayValue }} </slot> </template> <template v-else-if="shouldShowMultipleSlot"> <div class="b-dropdown-base-textbox__input-multiple-selected"> <div v-for="singleItem of selectedValueItems" :key="singleItem.value"> <div class="b-dropdown-base-textbox__input-selected-element"> <slot v-bind="singleItem"> {{ displayValue }} </slot> </div> </div> </div> </template> <template v-else>{{ displayValue }}</template> </div> <span v-if="additionalItemsSelected" class="b-dropdown-base-textbox__additional-items"> {{ additionalItemsSelected }} </span> <span v-if="hasSlot('icon') && !isFiltering" class="b-dropdown-base-textbox__icon" aria-hidden="true"> <slot name="icon"></slot> </span> <span v-if="isFiltering" class="b-dropdown-base-textbox__clear-search" :tabindex="disabled ? -1 : 0" data-testid="clear-search-button" @click.stop="onClearSearch" @keydown.enter.stop="onClearSearch" @keyup.space="onClearSearch" > <cross-circle-fill-small-icon v-if="size === BentoDropdownSize.SMALL" :svg-title="t('clearSearch')" /> <cross-circle-fill-icon v-else :svg-title="t('clearSearch')" /> </span> </bento-typography> </div> </template> <script setup lang="ts"> import { computed, defineComponent, inject, nextTick, type PropType, ref, useAttrs, useSlots, watch } from 'vue'; import { generateUid } from '@/core/utils/ts'; import { useI18n } from '@/utils/ts/i18n'; import { useHasSlot } from '@/composables'; import { BentoTypography } from '@/components/typography'; import { type Booleanish } from '@/types/prop-types'; import { type BentoListboxOptionItem } from '@/types/listbox'; import type { HTMLAttributes } from 'vue/types/jsx.d.ts'; import { DropdownBaseTextboxEvent, DropdownBaseTextboxState } from './dropdown-base-textbox.types'; import CrossCircleFillIcon from '@adyen/ui-assets-icons-16/vue/cross-circle-fill'; import CrossCircleFillSmallIcon from '@adyen/ui-assets-icons-16/vue/cross-circle-fill-small'; import { BentoDropdownSize } from '@/components/dropdown/dropdown.types'; import { DROPDOWN_UNDERLINE_STYLE_INJECTION_KEY } from '../../dropdown.keys'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const { t } = useI18n<{ message: MessageSchema }>({ messages }); const attrs = useAttrs(); const slots = useSlots(); const hasSlot = useHasSlot(slots); const props = defineProps({ /** * The number of additional items that have been selected in the dropdown. */ additionalItemsSelected: { type: [Number, String] as PropType<number | string>, default: 0 }, /** * If the combobox DOM element should be an `input` element. */ alwaysComboboxIsInput: { type: Boolean, default: false, }, /** * Sets the aria-controls attribute on the textbox. This should correspond to the popover's id that the textbox triggers. */ ariaControls: { type: String, required: true }, /** * Indicates whether the content the textbox triggers is visible or not. Needs to be set accordingly when the textbox is used and changed every time the popover is shown/hidden. */ ariaExpanded: { type: [String, Boolean] as PropType<Booleanish>, required: true, }, /** * If no label element is used then the aria-label is be set should be set. */ ariaLabel: { type: String, default: null }, /** * Corresponds to the label's id that labels the textbox element. */ ariaLabelledby: { type: String, default: null }, /** * Disables any textbox functionality */ disabled: { type: Boolean, default: false }, /** * Selected option's label */ displayValue: { type: String, default: null }, /** * Sets the possibility to type into the textbox. */ dynamicFiltering: { type: Boolean, default: false }, /** * Marks the textbox as invalid, rendering an error state */ isInvalid: { type: Boolean, default: false }, /* * If enabled, the user will be able to select multiple items. */ multiple: { type: Boolean, default: false }, /** * Indicates if the dropdown is open so that * the respective event can be emitted */ open: { type: Boolean, default: false }, /** * Sets the dropdown to readonly state */ readonly: { type: Boolean, default: false }, /** * The input value */ value: { type: String, default: '' }, /** * The selected value's list box item. */ selectedValueItem: { type: [Object, Array] as PropType<BentoListboxOptionItem | Array<BentoListboxOptionItem>>, default: undefined, }, /** * Toggles whether to use the slot content select items shown in the textbox in multiple mode. */ showSlotContentInMultiple: { type: Boolean, default: false }, /** * Dropdown size */ size: { type: String as PropType<BentoDropdownSize | `${BentoDropdownSize}`>, default: null, }, }); const emit = defineEmits<{ /** * Emitted when the "clear" button is clicked */ (e: DropdownBaseTextboxEvent.CLEAR); /** * Emitted on click, space, enter and esc user events. It will close the dropdown. */ (e: DropdownBaseTextboxEvent.CLOSE); /** * Emitted when `dynamicFiltering` is enabled and a search input is entered */ (e: DropdownBaseTextboxEvent.INPUT, searchValue: string); /** * Emitted when any key is pressed and the focus is on the input field */ (e: DropdownBaseTextboxEvent.KEYDOWN, event: KeyboardEvent); /** * Emitted on click, space and enter user events. It will open the dropdown. */ (e: DropdownBaseTextboxEvent.OPEN); }>(); const textbox = ref(null); const textboxId = generateUid('textbox'); // Inject underline style key to change the dropdown layout const isUnderlineStyle = inject(DROPDOWN_UNDERLINE_STYLE_INJECTION_KEY, false); const conditionalClasses = computed(() => ({ [`b-dropdown-base-textbox--${DropdownBaseTextboxState.DISABLED}`]: props.disabled, [`b-dropdown-base-textbox--${DropdownBaseTextboxState.READONLY}`]: props.readonly, [`b-dropdown-base-textbox--${DropdownBaseTextboxState.HAS_ITEMS}`]: !!props.additionalItemsSelected, 'b-dropdown-base-textbox--underline': isUnderlineStyle, })); const showRequiredAttribute = computed(() => shouldShowInput.value && attrs['aria-required'] ? ('true' as Booleanish) : null ); const comboboxConditionalClasses = computed(() => ({ [`b-dropdown-base-textbox__input--dynamic-filtering`]: props.dynamicFiltering, })); const isFiltering = computed(() => props.dynamicFiltering && !!props.value && props.open); const ariaAttributes = computed( () => ({ 'aria-haspopup': 'listbox', 'aria-controls': props.ariaControls, 'aria-describedby': attrs['aria-describedby']?.toString() as string, 'aria-expanded': props.ariaExpanded ? 'true' : 'false', 'aria-labelledby': props.ariaLabelledby, 'aria-label': props.ariaLabelledby ? null : props.ariaLabel, 'aria-invalid': props.isInvalid, 'aria-disabled': props.disabled ? true : null, 'aria-required': attrs['aria-required'] as Booleanish, }) as HTMLAttributes ); // Cast selectedValueItem if it is an array of selected items const selectedValueItems = computed(() => props.multiple ? (props.selectedValueItem as Array<BentoListboxOptionItem>) : [] ); const shouldShowInput = computed(() => props.dynamicFiltering && (props.open || props.alwaysComboboxIsInput)); const shouldShowSingleSelectSlot = computed(() => props.selectedValueItem && !props.multiple); const shouldShowMultipleSlot = computed( () => props.multiple && props.selectedValueItem && selectedValueItems.value.length > 0 && props.showSlotContentInMultiple ); watch( () => shouldShowInput.value, () => { if (shouldShowInput.value && !props.alwaysComboboxIsInput) { nextTick(() => { textbox.value.focus(); }); } } ); const onOpenDropdown = async () => { if (!props.disabled && !props.readonly) { emit(DropdownBaseTextboxEvent.OPEN); // wait for popover to open before setting focus await nextTick(); textbox.value.focus(); } }; const onCloseDropdown = () => { if (props.open) { textbox.value.focus(); emit(DropdownBaseTextboxEvent.CLOSE); } }; const onToggleDropdown = (event: Event) => { const isDynamicFilteringInputClickEvent = props.open && (event.target as HTMLDivElement).tagName === 'INPUT'; if (isDynamicFilteringInputClickEvent) { /** * When there's a click even on the input during filtering * and the popover is open, do not toggle the popover. * Allow for click events inside the input field */ return null; } return props.open ? onCloseDropdown() : onOpenDropdown(); }; const onClearSearch = () => { emit(DropdownBaseTextboxEvent.CLEAR); emit(DropdownBaseTextboxEvent.INPUT, ''); textbox.value.focus(); }; const onInputEvent = (event: Event) => { if (!props.readonly && !props.disabled) { emit(DropdownBaseTextboxEvent.INPUT, (event.target as HTMLInputElement).value); } else { event.preventDefault(); event.stopPropagation(); } }; const focus = () => { textbox.value.focus(); }; defineExpose({ focus, }); </script> <script lang="ts"> /** * Only for internal use, implements the logic of the textbox element to be implemented in the combobox. */ export default defineComponent({ i18n: { messages }, name: 'dropdown-base-textbox', inheritAttrs: false, }); </script> <style lang="scss" scoped src="./dropdown-base-textbox.scss" />
@@ -1 +1 @@
1
- <template> <component :is="popperContainerOrPopoverComponent" v-if="isDropdownOpen" class="b-dropdown-options-container" :class="conditionalClasses" v-bind="popperContainerOrPopoverProps" > <div @keydown.esc="onEscapeKey"> <bento-listbox :id="id" ref="dropdownOptionsListboxRef" :aria-label="ariaLabel" class="b-dropdown-options-container__listbox" :class="conditionalListboxClasses" :component-loading="componentLoading" :empty-state="emptyState" :is-option-disabled="isOptionDisabled" :items="items" :selected-value="internalSelectedValue" :multiple="multiple" :static-categories="staticCategories" :no-results-message="noResultsMessage" :searching="searching" :loading="loading" :lazy-load-type="lazyLoadType" :has-more-items="hasMoreItems" :virtual-scroll="virtualScroll" v-on="listeners" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </bento-listbox> </div> </component> </template> <script setup lang="ts"> import { computed, defineComponent, type PropType, ref, toRefs, useSlots } from 'vue'; import { BentoListbox } from '@/internal'; import { type BentoListboxEvent, type BentoListboxIsOptionDisabled, type BentoListboxOptions, } from '@/types/listbox'; import { PopperContainer } from '@/components/popper-container'; import { type BentoDropdownEmptyStateProps, type BentoDropdownEvent, BentoDropdownLazyLoadType, type BentoDropdownVirtulisationOptions, } from '../../dropdown.types'; import { useI18n } from '@/utils/ts/i18n'; import { BentoPopover } from '@/components/popover'; import { useDropdownOptionsListbox } from './composables/use-dropdown-options-listbox'; import { generateUid } from '@/core/utils/ts'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const slots = useSlots(); const emit = defineEmits<{ /** * Emitted when the dropdown is closing. */ (e: BentoDropdownEvent.CLOSE_DROPDOWN); /** * Emitted when the `ENTER` key is pressed */ (e: BentoListboxEvent.ENTER, newSelectedValue: BentoListboxOptions); /** * Emitted when the `ESCAPE` key is pressed */ (e: BentoListboxEvent.ESCAPE); /** * Emitted when an item is selected */ (e: BentoListboxEvent.SELECT, newSelectedValue: BentoListboxOptions); /** * Emitted when: * - `lazyLoad` is `automatic` and the scroll meets the end of the list * - `lazyload` is `button` and the button is clicked */ (e: BentoDropdownEvent.SHOW_MORE); /** * Emitted when the `SPACE` key is pressed */ (e: BentoListboxEvent.SPACE, newSelectedValue: BentoListboxOptions); /** * Emitted when the `TAB` key is pressed */ (e: BentoListboxEvent.TAB, newSelectedValue: BentoListboxOptions); }>(); const props = defineProps({ /** * Defines a string value that labels an interactive element. */ ariaLabel: { type: String, default: null }, /** * Indicates that dropdown items are loading */ componentLoading: { type: Boolean, default: false }, /** * Empty state props used with the inner empty state component which is displayed when no search results are found. */ emptyState: { type: Object as PropType<BentoDropdownEmptyStateProps>, default: undefined, }, /** * Disables dropdown functionality */ disabled: { type: Boolean, default: false }, /** * Indicates whether there are more items to load */ hasMoreItems: { type: Boolean, default: false }, /** * Identifies the listbox whose contents are controlled by the the combobox on which the aria-controls attribute is set. */ id: { type: String, required: true }, /** * Function that allows the options to be disabled * * @type {BentoDropdownIsOptionDisabled} * @param {BentoDropdownOptionItem} option - Dropdown 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: undefined, }, /** * The option elements to populate the dropdown with. * It must be an array of {@see BentoDropdownOptionItem } * * @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 type of `Lazy Load`. * Type "automatic" will enable infinite scrolling * Type "button" will enable lazy loading with "Show more" button */ lazyLoadType: { type: String as PropType<BentoDropdownLazyLoadType | `${BentoDropdownLazyLoadType}`>, default: BentoDropdownLazyLoadType.AUTOMATIC, validator: (value: BentoDropdownLazyLoadType) => Object.values(BentoDropdownLazyLoadType).includes(value), }, /** * Indicates if new options are lazy loading. */ loading: { type: Boolean, default: false }, /** * If enabled, the user will be able to select multiple items. */ multiple: { type: Boolean, default: false }, /** * Indicates if the dropdown is open so that * the container can be displayed. */ open: { type: Boolean, required: true }, /** * The `input` value. * Providing an empty string or empty array will select no options. * Set to an empty string `''` if you don't want any of the available options to be selected for single select */ selectedValue: { type: Array as PropType<BentoListboxOptions>, default: null, }, /** * Flag that indicates that an external search it's being made * to filter out the items inside the the multi-select listbox * * If enabled, hides the "Select all" checkbox in "multiple" mode. */ searching: BentoListbox.props.searching, /** * If enabled, dropdown will display non-selectable, static categories */ staticCategories: { type: Boolean, default: false }, /** * Reference to the DOM element or Vue component for positioning * the Dropdown container. */ targetElement: { type: PopperContainer.props.targetElement.type, required: true }, /** * Enables virtual scrolling if set to true or by providing an object with itemHeight function. * The itemHeight function is used to calculate the height of rendered item given it's index. */ virtualScroll: { type: [Boolean, Object] as PropType<BentoDropdownVirtulisationOptions>, default: false, }, }); const { selectedValue, multiple } = toRefs(props); const dropdownOptionsListboxRef = ref(null); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const isDropdownOpen = computed(() => props.open && !props.disabled); const noResultsMessage = computed(() => t('noOptionsMatchThisSearch') as string); const conditionalClasses = computed(() => ({ 'b-dropdown-options-container--single': !props?.multiple, })); const conditionalListboxClasses = computed(() => ({ 'b-dropdown-options-container__listbox--single': !props?.multiple, })); const { actions, internalSelectedValue, listeners, onEscapeKey, onOutsideDropdownClick } = useDropdownOptionsListbox(selectedValue, multiple, isDropdownOpen, emit); // Use different components depending on if the dropdown is a single or multi-select const popperContainerOrPopoverComponent = computed(() => (props?.multiple ? BentoPopover : PopperContainer)); const popperContainerOrPopoverProps = computed(() => { const containerMinAndMaxWidth = { 'min-width': `${(props?.targetElement as HTMLElement)?.clientWidth}px`, 'max-width': 'min(500px, 95%)', }; return props?.multiple ? ({ actionsLayout: 'space-between', ariaLabel: props.ariaLabel, actions: actions.value, divider: true, fallbackPosition: ['top-start'], id: generateUid(`dropdown-options-${props.id}`), open: props.open, position: 'bottom-start', style: { width: 'auto', ...containerMinAndMaxWidth, }, targetElement: props.targetElement, } as InstanceType<typeof BentoPopover>['$props']) : ({ fallbackPosition: ['top-start'], offset: [0, 8], position: 'bottom-start', style: { display: isDropdownOpen.value ? 'block' : 'none', ...containerMinAndMaxWidth, }, targetElement: props.targetElement, } as InstanceType<typeof PopperContainer>['$props']); }); defineExpose({ onOutsideDropdownClick, }); </script> <script lang="ts"> /** * Dropdown options container. * It lists all the available options. * * @example * <bento-dropdown-options-container * v-if="inputContainerRef" <!-- Ref to the input to match the width --> * :id="dropdownOptionsContainerId" * :target-element="inputContainerRef" * :aria-label="ariaLabel" * :open="isDropdownOpen" * :disabled="disabled" * :multiple="multiple" * :selected="value" * :isOptionDisabled="option => option.value === 2" * :items="[{ * { label: 'Option 1', value: 1 }, * { label: 'Option 2', value: 2 }, * }]" * @select="onOptionSelected" * /> */ export default defineComponent({ i18n: { messages }, name: 'bento-dropdown-options-container', }); </script> <style lang="scss" scoped src="./dropdown-options-container.scss" />
1
+ <template> <component :is="popperContainerOrPopoverComponent" v-if="isDropdownOpen" class="b-dropdown-options-container" :class="conditionalClasses" v-bind="popperContainerOrPopoverProps" > <div @keydown.esc="onEscapeKey"> <bento-listbox :id="id" ref="dropdownOptionsListboxRef" :aria-label="ariaLabel" class="b-dropdown-options-container__listbox" :class="conditionalListboxClasses" :component-loading="componentLoading" :empty-state="emptyState" :is-option-disabled="isOptionDisabled" :items="items" :selected-value="internalSelectedValue" :multiple="multiple" :static-categories="staticCategories" :no-results-message="noResultsMessage" :searching="searching" :loading="loading" :lazy-load-type="lazyLoadType" :has-more-items="hasMoreItems" :virtual-scroll="virtualScroll" v-on="listeners" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </bento-listbox> </div> </component> </template> <script setup lang="ts"> import { computed, defineComponent, type PropType, ref, toRefs, useSlots } from 'vue'; import { BentoListbox } from '@/internal/listbox'; import { type BentoListboxEvent, type BentoListboxIsOptionDisabled, type BentoListboxOptions, } from '@/types/listbox'; import { PopperContainer } from '@/components/popper-container'; import { type BentoDropdownEmptyStateProps, type BentoDropdownEvent, BentoDropdownLazyLoadType, type BentoDropdownVirtulisationOptions, } from '../../dropdown.types'; import { useI18n } from '@/utils/ts/i18n'; import { BentoPopover } from '@/components/popover'; import { useDropdownOptionsListbox } from './composables/use-dropdown-options-listbox'; import { generateUid } from '@/core/utils/ts'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const slots = useSlots(); const emit = defineEmits<{ /** * Emitted when the dropdown is closing. */ (e: BentoDropdownEvent.CLOSE_DROPDOWN); /** * Emitted when the `ENTER` key is pressed */ (e: BentoListboxEvent.ENTER, newSelectedValue: BentoListboxOptions); /** * Emitted when the `ESCAPE` key is pressed */ (e: BentoListboxEvent.ESCAPE); /** * Emitted when an item is selected */ (e: BentoListboxEvent.SELECT, newSelectedValue: BentoListboxOptions); /** * Emitted when: * - `lazyLoad` is `automatic` and the scroll meets the end of the list * - `lazyload` is `button` and the button is clicked */ (e: BentoDropdownEvent.SHOW_MORE); /** * Emitted when the `SPACE` key is pressed */ (e: BentoListboxEvent.SPACE, newSelectedValue: BentoListboxOptions); /** * Emitted when the `TAB` key is pressed */ (e: BentoListboxEvent.TAB, newSelectedValue: BentoListboxOptions); }>(); const props = defineProps({ /** * Defines a string value that labels an interactive element. */ ariaLabel: { type: String, default: null }, /** * Indicates that dropdown items are loading */ componentLoading: { type: Boolean, default: false }, /** * Empty state props used with the inner empty state component which is displayed when no search results are found. */ emptyState: { type: Object as PropType<BentoDropdownEmptyStateProps>, default: undefined, }, /** * Disables dropdown functionality */ disabled: { type: Boolean, default: false }, /** * Indicates whether there are more items to load */ hasMoreItems: { type: Boolean, default: false }, /** * Identifies the listbox whose contents are controlled by the the combobox on which the aria-controls attribute is set. */ id: { type: String, required: true }, /** * Function that allows the options to be disabled * * @type {BentoDropdownIsOptionDisabled} * @param {BentoDropdownOptionItem} option - Dropdown 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: undefined, }, /** * The option elements to populate the dropdown with. * It must be an array of {@see BentoDropdownOptionItem } * * @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 type of `Lazy Load`. * Type "automatic" will enable infinite scrolling * Type "button" will enable lazy loading with "Show more" button */ lazyLoadType: { type: String as PropType<BentoDropdownLazyLoadType | `${BentoDropdownLazyLoadType}`>, default: BentoDropdownLazyLoadType.AUTOMATIC, validator: (value: BentoDropdownLazyLoadType) => Object.values(BentoDropdownLazyLoadType).includes(value), }, /** * Indicates if new options are lazy loading. */ loading: { type: Boolean, default: false }, /** * If enabled, the user will be able to select multiple items. */ multiple: { type: Boolean, default: false }, /** * Indicates if the dropdown is open so that * the container can be displayed. */ open: { type: Boolean, required: true }, /** * The `input` value. * Providing an empty string or empty array will select no options. * Set to an empty string `''` if you don't want any of the available options to be selected for single select */ selectedValue: { type: Array as PropType<BentoListboxOptions>, default: null, }, /** * Flag that indicates that an external search it's being made * to filter out the items inside the the multi-select listbox * * If enabled, hides the "Select all" checkbox in "multiple" mode. */ searching: BentoListbox.props.searching, /** * If enabled, dropdown will display non-selectable, static categories */ staticCategories: { type: Boolean, default: false }, /** * Reference to the DOM element or Vue component for positioning * the Dropdown container. */ targetElement: { type: PopperContainer.props.targetElement.type, required: true }, /** * Enables virtual scrolling if set to true or by providing an object with itemHeight function. * The itemHeight function is used to calculate the height of rendered item given it's index. */ virtualScroll: { type: [Boolean, Object] as PropType<BentoDropdownVirtulisationOptions>, default: false, }, }); const { selectedValue, multiple } = toRefs(props); const dropdownOptionsListboxRef = ref(null); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const isDropdownOpen = computed(() => props.open && !props.disabled); const noResultsMessage = computed(() => t('noOptionsMatchThisSearch') as string); const conditionalClasses = computed(() => ({ 'b-dropdown-options-container--single': !props?.multiple, })); const conditionalListboxClasses = computed(() => ({ 'b-dropdown-options-container__listbox--single': !props?.multiple, })); const { actions, internalSelectedValue, listeners, onEscapeKey, onOutsideDropdownClick } = useDropdownOptionsListbox(selectedValue, multiple, isDropdownOpen, emit); // Use different components depending on if the dropdown is a single or multi-select const popperContainerOrPopoverComponent = computed(() => (props?.multiple ? BentoPopover : PopperContainer)); const popperContainerOrPopoverProps = computed(() => { const containerMinAndMaxWidth = { 'min-width': `${(props?.targetElement as HTMLElement)?.clientWidth}px`, 'max-width': 'min(500px, 95%)', }; return props?.multiple ? ({ actionsLayout: 'space-between', ariaLabel: props.ariaLabel, actions: actions.value, divider: true, fallbackPosition: ['top-start'], id: generateUid(`dropdown-options-${props.id}`), open: props.open, position: 'bottom-start', style: { width: 'auto', ...containerMinAndMaxWidth, }, targetElement: props.targetElement, } as InstanceType<typeof BentoPopover>['$props']) : ({ fallbackPosition: ['top-start'], offset: [0, 8], position: 'bottom-start', style: { display: isDropdownOpen.value ? 'block' : 'none', ...containerMinAndMaxWidth, }, targetElement: props.targetElement, } as InstanceType<typeof PopperContainer>['$props']); }); defineExpose({ onOutsideDropdownClick, }); </script> <script lang="ts"> /** * Dropdown options container. * It lists all the available options. * * @example * <bento-dropdown-options-container * v-if="inputContainerRef" <!-- Ref to the input to match the width --> * :id="dropdownOptionsContainerId" * :target-element="inputContainerRef" * :aria-label="ariaLabel" * :open="isDropdownOpen" * :disabled="disabled" * :multiple="multiple" * :selected="value" * :isOptionDisabled="option => option.value === 2" * :items="[{ * { label: 'Option 1', value: 1 }, * { label: 'Option 2', value: 2 }, * }]" * @select="onOptionSelected" * /> */ export default defineComponent({ i18n: { messages }, name: 'bento-dropdown-options-container', }); </script> <style lang="scss" scoped src="./dropdown-options-container.scss" />
@@ -1 +1 @@
1
- <template> <div class="b-dropdown-small-textbox" :class="conditionalClasses"> <dropdown-base-textbox ref="baseTextbox" v-bind="{ ...$attrs, ...$props }" class="b-dropdown-small-textbox__textbox" :size="BentoDropdownSize.SMALL" v-on="listeners" > <template #icon> <div class="b-dropdown-small-textbox__chevron"> <chevron-up-small-icon v-show="isDropdownOpen" svg-title="opened" /> <chevron-down-small-icon v-show="!isDropdownOpen" svg-title="closed" /> </div> </template> </dropdown-base-textbox> </div> </template> <script setup lang="ts"> import { computed, defineComponent, type PropType, ref } from 'vue'; import { DropdownBaseTextboxEvent, DropdownBaseTextboxState, } from '@/components/dropdown/components/dropdown-base-textbox/dropdown-base-textbox.types'; import { BentoDropdownSize } from '@/components/dropdown/dropdown.types'; import { type Booleanish } from '@/types/prop-types'; import DropdownBaseTextbox from '@/components/dropdown/components/dropdown-base-textbox/dropdown-base-textbox.vue'; import ChevronDownSmallIcon from '@adyen/ui-assets-icons-16/vue/chevron-down-small'; import ChevronUpSmallIcon from '@adyen/ui-assets-icons-16/vue/chevron-up-small'; import { stopEventPropagation } from '@/utils/ts/events'; const emit = defineEmits<{ /** * Emitted on clearing the input. */ (e: 'clear'); /** * Emitted on click, space and enter user events. It will open the dropdown. */ (e: 'close'); /** * Emitted on input event. */ (e: 'input', newInput: string); /** * Emitted on keydown event. */ (e: 'keydown', keydownEvent: KeyboardEvent); /** * Emitted on click, space, enter and esc user events. It will close the dropdown. */ (e: 'open'); }>(); const props = defineProps({ /** * The number of additional items that have been selected in the dropdown. */ additionalItemsSelected: { type: [Number, String] as PropType<number | string>, default: 0 }, /** * Sets the aria-controls attribute on the textbox. This should correspond to the popover's id that the textbox triggers. */ ariaControls: { type: String, required: true }, /** * Indicates whether the content the textbox triggers is visible or not. Needs to be set accordingly when the textbox is used and changed every time the popover is shown/hidden. */ ariaExpanded: { type: [String, Boolean] as PropType<Booleanish>, required: true, }, /** * If no label element is used then the aria-label is be set should be set. */ ariaLabel: { type: String, default: null }, /** * Corresponds to the label's id that labels the textbox element. */ ariaLabelledby: { type: String, default: null }, /** * Disables any textbox functionality */ disabled: { type: Boolean, default: false }, /** * Selected option's label */ displayValue: { type: String, default: null }, /** * Sets the possibility to type into the textbox */ dynamicFiltering: { type: Boolean, default: false }, /** * Marks the textbox as invalid, rendering an error state */ isInvalid: { type: Boolean, default: false }, /** * Indicates if the dropdown is open so that * the respective event can be emitted */ open: { type: Boolean, default: false }, /** * Sets the dropdown to readonly state */ readonly: { type: Boolean, default: false }, /** * The input value */ value: { type: String, default: '' }, }); const baseTextbox = ref(null); const conditionalClasses = computed(() => ({ [`b-dropdown-small-textbox--${DropdownBaseTextboxState.DISABLED}`]: props.disabled, [`b-dropdown-small-textbox--${DropdownBaseTextboxState.READONLY}`]: props.readonly, [`b-dropdown-small-textbox--${DropdownBaseTextboxState.OPENED}`]: props.open, })); const isDropdownOpen = computed(() => props.open && !props.disabled && !props.readonly); const onInput = (newInput: string) => { emit('input', newInput); }; const onKeyDown = (e: KeyboardEvent) => { emit('keydown', e); }; const openDropdown = (e: Event) => { stopEventPropagation(e); emit('open'); }; const closeDropdown = (e: Event) => { stopEventPropagation(e); emit('close'); }; const clearValue = (e: Event) => { stopEventPropagation(e); emit('clear'); }; const listeners = { [DropdownBaseTextboxEvent.INPUT]: onInput, [DropdownBaseTextboxEvent.OPEN]: openDropdown, [DropdownBaseTextboxEvent.CLOSE]: closeDropdown, [DropdownBaseTextboxEvent.CLEAR]: clearValue, [DropdownBaseTextboxEvent.KEYDOWN]: onKeyDown, }; const focus = () => { baseTextbox.value.focus(); }; defineExpose({ focus, }); </script> <script lang="ts"> /** * For internal use only, textbox component in its small variant used in the dropdown component. */ export default defineComponent({ name: 'dropdown-small-textbox', inheritAttrs: false, }); </script> <style lang="scss" scoped src="./dropdown-small-textbox.scss" />
1
+ <template> <div class="b-dropdown-small-textbox" :class="conditionalClasses"> <dropdown-base-textbox ref="baseTextbox" v-bind="{ ...$attrs, ...$props }" class="b-dropdown-small-textbox__textbox" :size="BentoDropdownSize.SMALL" v-on="listeners" > <template #icon> <div class="b-dropdown-small-textbox__chevron"> <chevron-up-small-icon v-show="isDropdownOpen" svg-title="opened" /> <chevron-down-small-icon v-show="!isDropdownOpen" svg-title="closed" /> </div> </template> </dropdown-base-textbox> </div> </template> <script setup lang="ts"> import { computed, defineComponent, inject, type PropType, ref } from 'vue'; import { DropdownBaseTextboxEvent, DropdownBaseTextboxState, } from '@/components/dropdown/components/dropdown-base-textbox/dropdown-base-textbox.types'; import { BentoDropdownSize } from '@/components/dropdown/dropdown.types'; import { type Booleanish } from '@/types/prop-types'; import DropdownBaseTextbox from '@/components/dropdown/components/dropdown-base-textbox/dropdown-base-textbox.vue'; import ChevronDownSmallIcon from '@adyen/ui-assets-icons-16/vue/chevron-down-small'; import ChevronUpSmallIcon from '@adyen/ui-assets-icons-16/vue/chevron-up-small'; import { stopEventPropagation } from '@/utils/ts/events'; import { DROPDOWN_UNDERLINE_STYLE_INJECTION_KEY } from '../../dropdown.keys'; const emit = defineEmits<{ /** * Emitted on clearing the input. */ (e: 'clear'); /** * Emitted on click, space and enter user events. It will open the dropdown. */ (e: 'close'); /** * Emitted on input event. */ (e: 'input', newInput: string); /** * Emitted on keydown event. */ (e: 'keydown', keydownEvent: KeyboardEvent); /** * Emitted on click, space, enter and esc user events. It will close the dropdown. */ (e: 'open'); }>(); const props = defineProps({ /** * The number of additional items that have been selected in the dropdown. */ additionalItemsSelected: { type: [Number, String] as PropType<number | string>, default: 0 }, /** * Sets the aria-controls attribute on the textbox. This should correspond to the popover's id that the textbox triggers. */ ariaControls: { type: String, required: true }, /** * Indicates whether the content the textbox triggers is visible or not. Needs to be set accordingly when the textbox is used and changed every time the popover is shown/hidden. */ ariaExpanded: { type: [String, Boolean] as PropType<Booleanish>, required: true, }, /** * If no label element is used then the aria-label is be set should be set. */ ariaLabel: { type: String, default: null }, /** * Corresponds to the label's id that labels the textbox element. */ ariaLabelledby: { type: String, default: null }, /** * Disables any textbox functionality */ disabled: { type: Boolean, default: false }, /** * Selected option's label */ displayValue: { type: String, default: null }, /** * Sets the possibility to type into the textbox */ dynamicFiltering: { type: Boolean, default: false }, /** * Marks the textbox as invalid, rendering an error state */ isInvalid: { type: Boolean, default: false }, /** * Indicates if the dropdown is open so that * the respective event can be emitted */ open: { type: Boolean, default: false }, /** * Sets the dropdown to readonly state */ readonly: { type: Boolean, default: false }, /** * The input value */ value: { type: String, default: '' }, }); const baseTextbox = ref(null); // Inject underline style key to change the dropdown layout const isUnderlineStyle = inject(DROPDOWN_UNDERLINE_STYLE_INJECTION_KEY, false); const conditionalClasses = computed(() => ({ [`b-dropdown-small-textbox--${DropdownBaseTextboxState.DISABLED}`]: props.disabled, [`b-dropdown-small-textbox--${DropdownBaseTextboxState.READONLY}`]: props.readonly, [`b-dropdown-small-textbox--${DropdownBaseTextboxState.OPENED}`]: props.open, 'b-dropdown-small-textbox--underline': isUnderlineStyle, })); const isDropdownOpen = computed(() => props.open && !props.disabled && !props.readonly); const onInput = (newInput: string) => { emit('input', newInput); }; const onKeyDown = (e: KeyboardEvent) => { emit('keydown', e); }; const openDropdown = (e: Event) => { stopEventPropagation(e); emit('open'); }; const closeDropdown = (e: Event) => { stopEventPropagation(e); emit('close'); }; const clearValue = (e: Event) => { stopEventPropagation(e); emit('clear'); }; const listeners = { [DropdownBaseTextboxEvent.INPUT]: onInput, [DropdownBaseTextboxEvent.OPEN]: openDropdown, [DropdownBaseTextboxEvent.CLOSE]: closeDropdown, [DropdownBaseTextboxEvent.CLEAR]: clearValue, [DropdownBaseTextboxEvent.KEYDOWN]: onKeyDown, }; const focus = () => { baseTextbox.value.focus(); }; defineExpose({ focus, }); </script> <script lang="ts"> /** * For internal use only, textbox component in its small variant used in the dropdown component. */ export default defineComponent({ name: 'dropdown-small-textbox', inheritAttrs: false, }); </script> <style lang="scss" scoped src="./dropdown-small-textbox.scss" />
@@ -1 +1 @@
1
- import { type BentoListboxOptionItem } from '@/types/listbox'; import { type OmitStrict } from '@/types/omit-strict'; import { type BentoCountryCode } from '@/components/country'; export type BentoCountryListboxOptionItem = OmitStrict<BentoListboxOptionItem, 'value' | 'label'> & { value: BentoCountryCode | `${BentoCountryCode}`; /** deprecated Use BentoDropdown component for custom labels */ label?: string; }; export type BentoCountryListboxOptions = Array<BentoCountryListboxOptionItem>;
1
+ import { type BentoListboxOptionItem } from '@/types/listbox'; import { type OmitStrict } from '@/types/omit-strict'; import { type BentoCountryCode } from '@/components/country/country.types'; export type BentoCountryListboxOptionItem = OmitStrict<BentoListboxOptionItem, 'value' | 'label'> & { value: BentoCountryCode | `${BentoCountryCode}`; /** deprecated Use BentoDropdown component for custom labels */ label?: string; }; export type BentoCountryListboxOptions = Array<BentoCountryListboxOptionItem>;
@@ -1 +1 @@
1
- import type { BentoListboxEmptyStateProps as BentoDropdownEmptyStateProps, BentoListboxSearchOptions as BentoDropdownSearchOptions, BentoListboxVirtulisationOptions as BentoDropdownVirtulisationOptions, BentoListboxIsOptionDisabled, BentoListboxLazyLoadType, BentoListboxOptions, BentoListboxSelectedValue, } from '../../types/listbox'; import { type FormField } from '../../types/form-field'; export type { BentoDropdownSearchOptions, BentoDropdownEmptyStateProps, BentoDropdownVirtulisationOptions }; export interface BentoDropdownProps extends FormField { /** * @deprecated Since v2.0.0. Use `aria-label` instead. * Defines a string value that labels an interactive element. */ ariaLabel?: string; /** * Renders the dropdown with a condensed style. */ condensed?: boolean; /** * Empty state props used with the inner empty state component which is displayed when no search results are found. */ emptyState?: BentoDropdownEmptyStateProps; /** * Sets the possibility to type into the textbox. */ dynamicFiltering?: boolean; /** * Highlights the dropdown in an erroneous state. * @deprecated Since v2.0.0. Use `errorMessage` instead. */ error?: boolean; /** * Indicates whether there are more items to load */ hasMoreItems?: boolean; /** * Function that dertermines which options are disabled from interaction. * @type {BentoListboxIsOptionDisabled} * @param {BentoListboxOptionItem} option - Dropdown 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?: BentoListboxIsOptionDisabled; /** * The option elements to populate the dropdown with. * It must be an array of {@see BentoListboxOptionItem } * @property value.label - Text to be displayed in the option * @property value.value - Value of the option */ items?: BentoListboxOptions; /** * Indicates if new options are lazy loading. */ loading?: boolean; /** * Indicates that dropdown items are loading */ componentLoading?: boolean; /** * The type of `Lazy Load`. * Type "automatic" will enable infinite scrolling * Type "button" will enable lazy loading with "Show more" button */ lazyLoadType?: BentoListboxLazyLoadType | `${BentoListboxLazyLoadType}`; /** * If enabled, the user will be able to select multiple items. */ multiple?: boolean; /** * Dropdown search options and events. * @see BentoDropdownSearchOptions */ search?: BentoDropdownSearchOptions; /** * Toggles whether to use the slot content select items shown in the textbox in multiple mode. */ showSlotContentInMultiple?: boolean; /** * @deprecated Since v2.0.0. Do not use. Only the `default` size is supported. */ size?: BentoDropdownSize | `${BentoDropdownSize}`; /** * If enabled, dropdown will display non-selectable, static categories */ staticCategories?: boolean; /** * 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. * @deprecated Since v2.0.0. Use `v-model` or `model-value` instead. */ value?: BentoListboxSelectedValue; /** * 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. */ modelValue?: BentoListboxSelectedValue; /** * Enables virtual scrolling if set to true or by providing an object with itemHeight function. * The itemHeight function is used to calculate the height of rendered item given it's index. */ virtualScroll?: BentoDropdownVirtulisationOptions; /** * Enables value/label value handling. When `true`, the component expects and emits value/label * pair objects instead of primitive values. * * This affects both the `value` prop and the `input` event payload. * * - **Input (`value` prop):** Expects a `{ value: string | number, label: string }` object (or an array of them for multi-select). * - **Output (`input` event):** Emits a `{ value: string | number, label: string }` object (or an array of them) upon selection. * * This is particularly useful when a selected item's label is not available in the `items` prop, * allowing the component to display the correct label for values loaded from external sources. * @default false */ enableValueLabelPair?: boolean; } /** * @deprecated Since v2.0.0. Do not use. Only the `default` size is supported. */ export enum BentoDropdownSize { DEFAULT = 'DropdownInputDefault', SMALL = 'DropdownInputSmall', } export { /** * @deprecated Since v2.0.0. Use string literal instead. */ BentoListboxLazyLoadType as BentoDropdownLazyLoadType, BentoListboxEvent as BentoDropdownEvent, } from '@/types/listbox';
1
+ import type { BentoListboxEmptyStateProps as BentoDropdownEmptyStateProps, BentoListboxSearchOptions as BentoDropdownSearchOptions, BentoListboxVirtulisationOptions as BentoDropdownVirtulisationOptions, BentoListboxIsOptionDisabled, BentoListboxLazyLoadType, BentoListboxOptions, BentoListboxSelectedValue, } from '../../types/listbox'; import { type FormField } from '../../types/form-field'; import type { BentoTeleportProps } from '../internal/teleport/teleport.types'; export type { BentoDropdownSearchOptions, BentoDropdownEmptyStateProps, BentoDropdownVirtulisationOptions }; export interface BentoDropdownProps extends FormField { /** * @deprecated Since v2.0.0. Use `aria-label` instead. * Defines a string value that labels an interactive element. */ ariaLabel?: string; /** * Renders the dropdown with a condensed style. */ condensed?: boolean; /** * Empty state props used with the inner empty state component which is displayed when no search results are found. */ emptyState?: BentoDropdownEmptyStateProps; /** * Sets the possibility to type into the textbox. */ dynamicFiltering?: boolean; /** * Highlights the dropdown in an erroneous state. * @deprecated Since v2.0.0. Use `errorMessage` instead. */ error?: boolean; /** * Indicates whether there are more items to load */ hasMoreItems?: boolean; /** * Function that dertermines which options are disabled from interaction. * @type {BentoListboxIsOptionDisabled} * @param {BentoListboxOptionItem} option - Dropdown 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?: BentoListboxIsOptionDisabled; /** * The option elements to populate the dropdown with. * It must be an array of {@see BentoListboxOptionItem } * @property value.label - Text to be displayed in the option * @property value.value - Value of the option */ items?: BentoListboxOptions; /** * Indicates if new options are lazy loading. */ loading?: boolean; /** * Indicates that dropdown items are loading */ componentLoading?: boolean; /** * The type of `Lazy Load`. * Type "automatic" will enable infinite scrolling * Type "button" will enable lazy loading with "Show more" button */ lazyLoadType?: BentoListboxLazyLoadType | `${BentoListboxLazyLoadType}`; /** * If enabled, the user will be able to select multiple items. */ multiple?: boolean; /** * Dropdown search options and events. * @see BentoDropdownSearchOptions */ search?: BentoDropdownSearchOptions; /** * Toggles whether to use the slot content select items shown in the textbox in multiple mode. */ showSlotContentInMultiple?: boolean; /** * @deprecated Since v2.0.0. Do not use. Only the `default` size is supported. */ size?: BentoDropdownSize | `${BentoDropdownSize}`; /** * If enabled, dropdown will display non-selectable, static categories */ staticCategories?: boolean; /** * Renders the dropdown options container in the root `body` DOM node * (or `dialog` if the dropdown is inside a dialog). */ teleport?: BentoTeleportProps; /** * 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. * @deprecated Since v2.0.0. Use `v-model` or `model-value` instead. */ value?: BentoListboxSelectedValue; /** * 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. */ modelValue?: BentoListboxSelectedValue; /** * Enables virtual scrolling if set to true or by providing an object with itemHeight function. * The itemHeight function is used to calculate the height of rendered item given it's index. */ virtualScroll?: BentoDropdownVirtulisationOptions; /** * Enables value/label value handling. When `true`, the component expects and emits value/label * pair objects instead of primitive values. * * This affects both the `value` prop and the `input` event payload. * * - **Input (`value` prop):** Expects a `{ value: string | number, label: string }` object (or an array of them for multi-select). * - **Output (`input` event):** Emits a `{ value: string | number, label: string }` object (or an array of them) upon selection. * * This is particularly useful when a selected item's label is not available in the `items` prop, * allowing the component to display the correct label for values loaded from external sources. * @default false */ enableValueLabelPair?: boolean; } /** * @deprecated Since v2.0.0. Do not use. Only the `default` size is supported. */ export enum BentoDropdownSize { DEFAULT = 'DropdownInputDefault', SMALL = 'DropdownInputSmall', } export { /** * @deprecated Since v2.0.0. Use string literal instead. */ BentoListboxLazyLoadType as BentoDropdownLazyLoadType, BentoListboxEvent as BentoDropdownEvent, } from '@/types/listbox';
@@ -1 +1 @@
1
- <!-- TODO: Remove aria props as these are part of JSX elements /vue/types/jsx.d.ts --> <!-- eslint-disable vue/attribute-hyphenation --> <template> <div v-bento-click-outside-directive="clickOutsideDropdown" class="b-dropdown" :class="conditionalClasses"> <field-label v-if="label" :id="labelId" :condensed="condensed" :label="label" :tooltip-text="tooltipText" :optional="optional" :required="required" /> <div ref="inputContainerRefWrapper" v-bento-keyboard-navigation-directive v-on="textboxKeyboardNavigationListeners" > <component :is="computedSize" ref="inputContainerRef" :condensed="condensed" :value="searchTerm" :additional-items-selected="additionalItemsSelected" :aria-controls="dropdownOptionsContainerId" :ariaExpanded="isDropdownOpen" :aria-labelledby="label ? labelId : null" :aria-describedby="ariaDescribedBy" :aria-label="computedAriaLabel" :aria-readonly="isReadOnly" :aria-required="required" :readonly="isReadOnly" :disabled="disabled" :display-value="displayValue" :dynamic-filtering="dynamicFiltering" :is-invalid="!!errorMessage || error" :multiple="multiple" :open="isDropdownOpen" :selected-value-item="!isInputComponent ? selectedValueItem : undefined" :show-slot-content-in-multiple="showSlotContentInMultiple" @input="searchTerm = $event" @open="toggleDropdown" @close="toggleDropdown" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </component> </div> <bento-dropdown-options-container v-if="inputContainerRef" :id="dropdownOptionsContainerId" ref="optionsContainerRef" :aria-label="computedAriaLabel" :component-loading="componentLoading" :empty-state="emptyState" :disabled="disabled" :is-option-disabled="isOptionDisabled" :items="filteredItems" :multiple="multiple" :static-categories="staticCategories" :open="isDropdownOpen" :selected-value="cachedSelectedListboxOptions" :searching="isSearching" :target-element="inputContainerRefWrapper" :loading="loading" :has-more-items="hasMoreItems" :lazy-load-type="lazyLoadType" :virtual-scroll="virtualScroll" v-on="listboxListeners" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </bento-dropdown-options-container> <error-message v-if="!!errorMessage" :id="errorId" :error-message="errorMessage" class="b-dropdown__error-message" /> <bento-typography v-if="description || hasSlot('description')" :id="descriptionId" class="b-dropdown__description" :class="descriptionConditionalClasses" el="span" > <slot name="description"> {{ description }} </slot> </bento-typography> </div> </template> <script setup lang="ts"> import { computed, defineComponent, inject, nextTick, type Ref, ref, toRef, toRefs, useAttrs, useSlots, watch, } from 'vue'; // Components import { BentoDropdownOptionsContainer } from './components/dropdown-options-container'; import { BentoTypography } from '@/components/typography'; import { DropdownInputDefault, DropdownInputSmall } from './components'; import { BentoListbox, ErrorMessage, FieldLabel, useCachedSelectedValues, useMultiLevelItems } from '@/internal'; // Utils import { deprecate } from '@/utils/ts/deprecate'; import { generateUid } from '@/core/utils/ts'; import { useAriaLabel } from '@/utils/ts/aria-label'; import { useI18n } from '@/utils/ts/i18n'; import { useFormFieldEmits } from '@/utils/ts/form-field/form-field'; // Composables import { useDisplayValue, useListboxKeyboardNavigation, useTextboxKeyboardNavigation } from './composables'; import { useVisibleDomOptions } from '@/internal/listbox/composables/use-visible-dom-options/use-visible-dom-options'; import { useFormLayoutFieldLoading } from '@/composables/use-form-layout-loading/use-form-layout-loading'; import { useSearchBarFilter } from '@/components/search-bar/useSearchBarDataFilter'; // Directives import { BentoClickOutsideDirective as vBentoClickOutsideDirective } from '@/directives/click-outside'; import { BentoKeyboardNavigationDirective as vBentoKeyboardNavigationDirective } from '@/directives'; // Types import { BentoDropdownEvent, type BentoDropdownProps, BentoDropdownSize } from './dropdown.types'; import { BentoListboxEvent, type BentoListboxItemRole, type BentoListboxMultiSelectValue, type BentoListboxOptions, type BentoListboxSelectedValue, type BentoListboxSelectedValueLabelPair, } from '@/types/listbox'; import messages from './messages.json'; import { useHasSlot } from '@/composables'; import { INPUT_FIELD_COMPONENT_INJECTION_KEY } from '@/components/input-field/input-field.keys'; type MessageSchema = (typeof messages)['en-US']; const attrs = useAttrs(); const slots = useSlots(); const hasSlot = useHasSlot(slots); const props = withDefaults(defineProps<BentoDropdownProps>(), { ariaLabel: null, condensed: false, emptyState: undefined, disabled: false, dynamicFiltering: false, error: false, errorMessage: null, description: null, hasMoreItems: false, isOptionDisabled: undefined, items: () => [], label: null, loading: false, componentLoading: false, lazyLoadType: BentoListbox.props.lazyLoadType.default, multiple: false, optional: false, placeholder: null, readonly: false, required: false, search: undefined, showSlotContentInMultiple: false, size: null, staticCategories: false, tooltipText: null, value: null, modelValue: null, virtualScroll: false, enableValueLabelPair: false, }); const emit = defineEmits<{ /** * Emitted when an option is clicked. Updates the selected option from the "value" property linked to the v-model. * @deprecated Since v2.0.0. Use `v-model` or `update:model-value` instead. */ (e: 'input', selectedValue: BentoListboxSelectedValue | any); // eslint-disable-line @typescript-eslint/no-explicit-any /** * Emitted when an option is clicked. Updates the selected option from the "modelValue" property linked to the v-model. */ (e: 'update:model-value', selectedValue: BentoListboxSelectedValue | any); // eslint-disable-line @typescript-eslint/no-explicit-any /** * Emitted when: * - `lazyLoad` is `automatic` and the scroll meets the end of the list * - `lazyload` is `button` and the button is clicked */ (e: 'show-more'); /** * Emitted when the dropdown is opened. */ (e: 'open'); }>(); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const { emitValue } = useFormFieldEmits<BentoListboxSelectedValue>(emit); const inputContainerRef = ref(null); const inputContainerRefWrapper = ref(null); const optionsContainerRef: Ref<InstanceType<typeof BentoDropdownOptionsContainer>> = ref(null); const isDropdownOpen = ref(false); const dropdownOptionsContainerId = generateUid('bento-dropdown-options-container'); const labelId = generateUid('dropdown-label'); const descriptionId = generateUid('dropdown-description'); const errorId = generateUid('dropdown-error'); const { isReadOnly } = useFormLayoutFieldLoading(toRef(props, 'readonly')); const dropdownValue = computed(() => props.modelValue ?? props.value); const { cachedSelectedListboxOptions, cachedListboxSelectedValues, setCachedValues } = useCachedSelectedValues( toRef(props, 'items'), dropdownValue, toRef(props, 'multiple'), toRef(props, 'enableValueLabelPair') ); const ariaLabelAttribute = ref(attrs['aria-label']); const ariaDescribedByAttribute = ref(attrs['aria-describedby']); const { ariaLabel, label } = toRefs(props); // vue-i18n issue with t() used in script - direct usage in template works // requires either createI18n({ legacy: false }) or to be wrapped in computed() const computedAriaLabelFallbackMessage = computed(() => t('ariaLabelFallback')); const computedAriaLabel = useAriaLabel({ // TODO: take ariaLabel ref away when aria-label prop is removed ariaLabel: ariaLabel.value || (ariaLabelAttribute as Ref<string>), label, defaultFallback: computedAriaLabelFallbackMessage, }); // Inject input field component key to change the dropdown size const isInputComponent = props.size === BentoDropdownSize.SMALL || inject(INPUT_FIELD_COMPONENT_INJECTION_KEY, false); const computedSize = computed(() => (isInputComponent ? BentoDropdownSize.SMALL : BentoDropdownSize.DEFAULT)); const conditionalClasses = computed(() => ({ 'b-dropdown--condensed': props.condensed, })); // Description styles const descriptionConditionalClasses = computed(() => ({ 'b-dropdown__description--error': !!props.errorMessage, })); // Filtering const searchTerm = ref(''); const items = toRef(props, 'items'); const staticCategories = toRef(props, 'staticCategories'); const filteredItems = useSearchBarFilter( searchTerm, items, props?.search?.searchEvent, staticCategories, props?.search?.debounceTime ); const isSearching = computed(() => props.dynamicFiltering && !!searchTerm.value); const displayValue = useDisplayValue({ items, placeholder: toRef(props, 'placeholder'), isMultiSelect: toRef(props, 'multiple'), selectedValues: cachedSelectedListboxOptions, }); const selectedValueItem = computed(() => { if (props?.multiple && Array.isArray(dropdownValue.value)) { return props.items.filter( ({ value }) => props?.value && (props?.value as BentoListboxMultiSelectValue).includes(value) ); } if (!props.multiple && props.staticCategories) { // Find the category that holds the selected value const category = props.items.find(categoryItem => categoryItem.items?.some(item => item.value === dropdownValue.value) ); // Find the specific item within the category const selectedCategoryItem = category ? category.items.find(item => item.value === dropdownValue.value) : props.items.find(({ value }) => value === dropdownValue.value); return selectedCategoryItem ?? null; } return props.items.find(({ value }) => value === dropdownValue.value); }); const selectedValueIndex = computed(() => { const index = filteredItems.value.findIndex(({ value }) => { return props.enableValueLabelPair ? (dropdownValue.value as BentoListboxSelectedValueLabelPair).value === value : dropdownValue.value === value; }); return index > -1 ? index : 0; }); const { isMultiLevelSelect, flattenedItemsList } = useMultiLevelItems(items); const isTreePattern = computed(() => isMultiLevelSelect.value && !props.staticCategories); const computedOptionRole = computed<BentoListboxItemRole>(() => (isTreePattern.value ? 'treeitem' : 'option')); const additionalItemsSelected = computed(() => { if (props.multiple) { const selectedItemsCount = Array.isArray(dropdownValue.value) ? dropdownValue.value.length : 0; const total = isMultiLevelSelect.value ? flattenedItemsList.value.length : items.value.length; if (selectedItemsCount === 0) { return null; } // Display count always - show "all" only when is not external filtering (hasMoreItems = true) if (!props.hasMoreItems && total > 0 && total === selectedItemsCount) { return t('all'); } return selectedItemsCount; } return null; }); // ARIA const ariaDescribedBy = computed( () => [ props.description ? descriptionId : null, ariaDescribedByAttribute.value, props.errorMessage ? errorId : null, ] .filter(Boolean) .join(' ') || null ); const listboxRef = computed(() => optionsContainerRef.value?.$el as HTMLDivElement); const { visibleDomOptions, updateVisibleDomOptions } = useVisibleDomOptions( listboxRef, filteredItems, computedOptionRole ); const openDropdown = () => { if (isReadOnly.value) { return; } isDropdownOpen.value = true; nextTick(() => { updateVisibleDomOptions(); }); }; const closeDropdown = () => { isDropdownOpen.value = false; }; const toggleDropdown = () => { if (isReadOnly.value) { return; } isDropdownOpen.value = !isDropdownOpen.value; nextTick(() => { updateVisibleDomOptions(); }); // Clear search if (isSearching.value) { searchTerm.value = ''; } }; const clickOutsideDropdown = () => { if (isSearching.value) { searchTerm.value = ''; } if (isDropdownOpen.value) { isDropdownOpen.value = false; // Reset the internal dropdown options state optionsContainerRef.value.onOutsideDropdownClick(); } }; const onOptionSelected = async (selectedValue: BentoListboxOptions) => { // Clear and close the dropdown on single selection if (!props.multiple) { searchTerm.value = ''; isDropdownOpen.value = false; } setCachedValues(selectedValue); /** * Emit the value/label pair objects if enableValueLabelPair is enabled, * otherwise just emit the list of selected values (number or string) */ emitValue(cachedListboxSelectedValues.value); // Focus the input after selection and dropdown has closed if (!props.multiple) { await nextTick(); focus(); } }; const emitShowMoreEvent = () => { emit(BentoDropdownEvent.SHOW_MORE); }; const textboxKeyboardNavigationListeners = useTextboxKeyboardNavigation( { inputContainerRef, optionsContainerRef, selectedValueIndex, isDropdownOpen, isDynamicFltering: toRef(props, 'dynamicFiltering'), items: visibleDomOptions, isMultiple: props.multiple, totalItemCount: computed(() => filteredItems.value.length), isVirtualScroll: !props?.multiple && props?.virtualScroll !== false && props?.lazyLoadType === 'none', }, toggleDropdown, openDropdown, closeDropdown ); const listboxKeyboardNavigationListeners = useListboxKeyboardNavigation( { inputContainerRef, isDropdownOpen, }, closeDropdown ); const listboxListeners = { [BentoListboxEvent.CLOSE_DROPDOWN]: closeDropdown, [BentoListboxEvent.SELECT]: onOptionSelected, [BentoListboxEvent.SHOW_MORE]: emitShowMoreEvent, ...listboxKeyboardNavigationListeners, }; if (props.error) { deprecate( 'BentoDropdown "error" property', `Use the BentoDropdown "errorMessage" property instead to indicate that there is a modifier error. Having the field as "undefined" will hide/remove the error. <bento-dropdown errorMessage="Error message text" />`, '2.0.0' ); } if (props.size) { deprecate('BentoDropdown "size" property', `Do not use. Only 'default' size should be used.`, '2.0.0'); } if (props.value) { deprecate( 'BentoDropdown "value" property', `The use of "value" prop in "BentoDropdown" is no longer supported. Use the "v-model" or "model-value" property instead.`, '2.0.0' ); } const focus = () => { inputContainerRef.value.focus(); }; watch( () => isDropdownOpen.value, (isOpen: boolean) => { if (isOpen) { emit('open'); } } ); defineExpose({ focus, }); </script> <script lang="ts"> /** * A Dropdown shows a selected option's. * Use a dropdown when you want users to select options * from a list of pre-defined options. * * @example * import { BentoDropdown } from '@adyen/bento-vue2'; * import type { BentoDropdownOptions } from '@adyen/bento-vue2' * * export default { * components: { BentoDropdown }, * template: ` * <bento-dropdown * v-model="selectedValue" * :disabled="false" * :multiple="true" * :isOptionsDisabled="option => option.value === 2" * :items="options" * /> * `, * setup() { * const selectedValue = ref(2) // Default value * const options: BentoDropdownOptions = [ * { label: 'Option 1', value: 1 }, * { label: 'Option 2', value: 2 }, * ]; * return { options, selectedValue }; * } * } */ export default defineComponent({ i18n: { messages }, name: 'b-dropdown', components: { DropdownInputDefault, DropdownInputSmall, }, model: { prop: 'modelValue' }, }); </script> <style lang="scss" scoped src="./dropdown.scss" />
1
+ <!-- TODO: Remove aria props as these are part of JSX elements /vue/types/jsx.d.ts --> <!-- eslint-disable vue/attribute-hyphenation --> <template> <div v-bento-click-outside-directive="clickOutsideDropdownOptions" class="b-dropdown" :class="conditionalClasses"> <field-label v-if="label" :id="labelId" :condensed="condensed" :label="label" :tooltip-text="tooltipText" :optional="optional" :required="required" /> <div ref="inputContainerRefWrapper" v-bento-keyboard-navigation-directive v-on="textboxKeyboardNavigationListeners" > <component :is="computedSize" ref="inputContainerRef" :condensed="condensed" :value="searchTerm" :additional-items-selected="additionalItemsSelected" :aria-controls="dropdownOptionsContainerId" :ariaExpanded="isDropdownOpen" :aria-labelledby="label ? labelId : null" :aria-describedby="ariaDescribedBy" :aria-label="computedAriaLabel" :aria-readonly="isReadOnly" :aria-required="required" :readonly="isReadOnly" :disabled="disabled" :display-value="displayValue" :dynamic-filtering="dynamicFiltering" :is-invalid="!!errorMessage || error" :multiple="multiple" :open="isDropdownOpen" :selected-value-item="!isInputComponent ? selectedValueItem : undefined" :show-slot-content-in-multiple="showSlotContentInMultiple" @input="searchTerm = $event" @open="toggleDropdown" @close="toggleDropdown" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </component> </div> <bento-teleport v-if="inputContainerRef" :disabled="teleport?.disabled" :to="teleport?.to"> <bento-dropdown-options-container :id="dropdownOptionsContainerId" ref="optionsContainerRef" :aria-label="computedAriaLabel" :component-loading="componentLoading" :empty-state="emptyState" :disabled="disabled" :is-option-disabled="isOptionDisabled" :items="filteredItems" :multiple="multiple" :static-categories="staticCategories" :open="isDropdownOpen" :selected-value="cachedSelectedListboxOptions" :searching="isSearching" :target-element="inputContainerRefWrapper" :loading="loading" :has-more-items="hasMoreItems" :lazy-load-type="lazyLoadType" :virtual-scroll="virtualScroll" v-on="listboxListeners" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </bento-dropdown-options-container> </bento-teleport> <error-message v-if="!!errorMessage" :id="errorId" :error-message="errorMessage" class="b-dropdown__error-message" /> <bento-typography v-if="description || hasSlot('description')" :id="descriptionId" class="b-dropdown__description" :class="descriptionConditionalClasses" el="span" > <slot name="description"> {{ description }} </slot> </bento-typography> </div> </template> <script setup lang="ts"> import { computed, defineComponent, inject, nextTick, type Ref, ref, toRef, toRefs, useAttrs, useSlots, watch, } from 'vue'; // Components import { BentoDropdownOptionsContainer } from './components/dropdown-options-container'; import { BentoTypography } from '@/components/typography'; import { DropdownInputDefault, DropdownInputSmall } from './components'; import { BentoListbox, useCachedSelectedValues, useMultiLevelItems } from '@/internal/listbox'; import { ErrorMessage } from '@/internal/error-message'; import { FieldLabel } from '@/internal/field-label'; import { BentoTeleport } from '@/internal/teleport'; // Utils import { deprecate } from '@/utils/ts/deprecate'; import { generateUid } from '@/core/utils/ts'; import { useAriaLabel } from '@/utils/ts/aria-label'; import { useI18n } from '@/utils/ts/i18n'; import { useFormFieldEmits } from '@/utils/ts/form-field/form-field'; // Composables import { useDisplayValue, useListboxKeyboardNavigation, useTextboxKeyboardNavigation } from './composables'; import { useVisibleDomOptions } from '@/internal/listbox/composables/use-visible-dom-options/use-visible-dom-options'; import { useFormLayoutFieldLoading } from '@/composables/use-form-layout-loading/use-form-layout-loading'; import { useSearchBarFilter } from '@/components/search-bar/useSearchBarDataFilter'; import { useHasSlot } from '@/composables'; // Directives import { BentoClickOutsideDirective as vBentoClickOutsideDirective } from '@/directives/click-outside'; import { BentoKeyboardNavigationDirective as vBentoKeyboardNavigationDirective } from '@/directives'; // Types import { BentoDropdownEvent, type BentoDropdownProps, BentoDropdownSize } from './dropdown.types'; import { BentoListboxEvent, type BentoListboxItemRole, type BentoListboxMultiSelectValue, type BentoListboxOptions, type BentoListboxSelectedValue, type BentoListboxSelectedValueLabelPair, } from '@/types/listbox'; import { DROPDOWN_SMALL_SIZE_INJECTION_KEY } from './dropdown.keys'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const attrs = useAttrs(); const slots = useSlots(); const hasSlot = useHasSlot(slots); const props = withDefaults(defineProps<BentoDropdownProps>(), { ariaLabel: null, condensed: false, emptyState: undefined, disabled: false, dynamicFiltering: false, error: false, errorMessage: null, description: null, hasMoreItems: false, isOptionDisabled: undefined, items: () => [], label: null, loading: false, componentLoading: false, lazyLoadType: BentoListbox.props.lazyLoadType.default, multiple: false, optional: false, placeholder: null, readonly: false, required: false, search: undefined, showSlotContentInMultiple: false, size: null, staticCategories: false, teleport: () => ({ disabled: true }), tooltipText: null, value: null, modelValue: null, virtualScroll: false, enableValueLabelPair: false, }); const emit = defineEmits<{ /** * Emitted when an option is clicked. Updates the selected option from the "value" property linked to the v-model. * @deprecated Since v2.0.0. Use `v-model` or `update:model-value` instead. */ (e: 'input', selectedValue: BentoListboxSelectedValue | any); // eslint-disable-line @typescript-eslint/no-explicit-any /** * Emitted when an option is clicked. Updates the selected option from the "modelValue" property linked to the v-model. */ (e: 'update:model-value', selectedValue: BentoListboxSelectedValue | any); // eslint-disable-line @typescript-eslint/no-explicit-any /** * Emitted when: * - `lazyLoad` is `automatic` and the scroll meets the end of the list * - `lazyload` is `button` and the button is clicked */ (e: 'show-more'); /** * Emitted when the dropdown is opened. */ (e: 'open'); }>(); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const { emitValue } = useFormFieldEmits<BentoListboxSelectedValue>(emit); const inputContainerRef = ref(null); const inputContainerRefWrapper = ref(null); const optionsContainerRef: Ref<InstanceType<typeof BentoDropdownOptionsContainer>> = ref(null); const isDropdownOpen = ref(false); const dropdownOptionsContainerId = generateUid('bento-dropdown-options-container'); const labelId = generateUid('dropdown-label'); const descriptionId = generateUid('dropdown-description'); const errorId = generateUid('dropdown-error'); const { isReadOnly } = useFormLayoutFieldLoading(toRef(props, 'readonly')); const dropdownValue = computed(() => props.modelValue ?? props.value); const { cachedSelectedListboxOptions, cachedListboxSelectedValues, setCachedValues } = useCachedSelectedValues( toRef(props, 'items'), dropdownValue, toRef(props, 'multiple'), toRef(props, 'enableValueLabelPair') ); const ariaLabelAttribute = ref(attrs['aria-label']); const ariaDescribedByAttribute = ref(attrs['aria-describedby']); const { ariaLabel, label } = toRefs(props); // vue-i18n issue with t() used in script - direct usage in template works // requires either createI18n({ legacy: false }) or to be wrapped in computed() const computedAriaLabelFallbackMessage = computed(() => t('ariaLabelFallback')); const computedAriaLabel = useAriaLabel({ // TODO: take ariaLabel ref away when aria-label prop is removed ariaLabel: ariaLabel.value || (ariaLabelAttribute as Ref<string>), label, defaultFallback: computedAriaLabelFallbackMessage, }); // Inject input field component key to change the dropdown size const isInputComponent = props.size === BentoDropdownSize.SMALL || inject(DROPDOWN_SMALL_SIZE_INJECTION_KEY, false); const computedSize = computed(() => (isInputComponent ? BentoDropdownSize.SMALL : BentoDropdownSize.DEFAULT)); const conditionalClasses = computed(() => ({ 'b-dropdown--condensed': props.condensed, })); // Description styles const descriptionConditionalClasses = computed(() => ({ 'b-dropdown__description--error': !!props.errorMessage, })); // Filtering const searchTerm = ref(''); const items = toRef(props, 'items'); const staticCategories = toRef(props, 'staticCategories'); const filteredItems = useSearchBarFilter( searchTerm, items, props?.search?.searchEvent, staticCategories, props?.search?.debounceTime ); const isSearching = computed(() => props.dynamicFiltering && !!searchTerm.value); const displayValue = useDisplayValue({ items, placeholder: toRef(props, 'placeholder'), isMultiSelect: toRef(props, 'multiple'), selectedValues: cachedSelectedListboxOptions, }); const selectedValueItem = computed(() => { if (props?.multiple && Array.isArray(dropdownValue.value)) { return props.items.filter( ({ value }) => props?.value && (props?.value as BentoListboxMultiSelectValue).includes(value) ); } if (!props.multiple && props.staticCategories) { // Find the category that holds the selected value const category = props.items.find(categoryItem => categoryItem.items?.some(item => item.value === dropdownValue.value) ); // Find the specific item within the category const selectedCategoryItem = category ? category.items.find(item => item.value === dropdownValue.value) : props.items.find(({ value }) => value === dropdownValue.value); return selectedCategoryItem ?? null; } return props.items.find(({ value }) => value === dropdownValue.value); }); const selectedValueIndex = computed(() => { const searchList = isMultiLevelSelect.value ? flattenedItemsList.value : filteredItems.value; const index = searchList.findIndex(({ value }) => { return props.enableValueLabelPair ? (dropdownValue.value as BentoListboxSelectedValueLabelPair)?.value === value : dropdownValue.value === value; }); return index > -1 ? index : 0; }); const { isMultiLevelSelect, flattenedItemsList } = useMultiLevelItems(items); const isTreePattern = computed(() => isMultiLevelSelect.value && !props.staticCategories); const computedOptionRole = computed<BentoListboxItemRole>(() => (isTreePattern.value ? 'treeitem' : 'option')); const additionalItemsSelected = computed(() => { if (props.multiple) { const selectedItemsCount = Array.isArray(dropdownValue.value) ? dropdownValue.value.length : 0; const total = isMultiLevelSelect.value ? flattenedItemsList.value.length : items.value.length; if (selectedItemsCount === 0) { return null; } // Display count always - show "all" only when is not external filtering (hasMoreItems = true) if (!props.hasMoreItems && total > 0 && total === selectedItemsCount) { return t('all'); } return selectedItemsCount; } return null; }); // ARIA const ariaDescribedBy = computed( () => [ props.description ? descriptionId : null, ariaDescribedByAttribute.value, props.errorMessage ? errorId : null, ] .filter(Boolean) .join(' ') || null ); const listboxRef = computed(() => optionsContainerRef.value?.$el as HTMLDivElement); const { visibleDomOptions, updateVisibleDomOptions } = useVisibleDomOptions( listboxRef, filteredItems, computedOptionRole ); const openDropdown = () => { if (isReadOnly.value) { return; } isDropdownOpen.value = true; nextTick(() => { updateVisibleDomOptions(); }); }; const closeDropdown = () => { isDropdownOpen.value = false; }; const toggleDropdown = () => { if (isReadOnly.value) { return; } isDropdownOpen.value = !isDropdownOpen.value; nextTick(() => { updateVisibleDomOptions(); }); // Clear search if (isSearching.value) { searchTerm.value = ''; } }; const clickOutsideDropdown = () => { if (isSearching.value) { searchTerm.value = ''; } if (isDropdownOpen.value) { isDropdownOpen.value = false; // Reset the internal dropdown options state optionsContainerRef.value.onOutsideDropdownClick(); } }; const clickOutsideDropdownOptions = computed(() => [ clickOutsideDropdown, { ignore: [listboxRef], }, ]); const onOptionSelected = async (selectedValue: BentoListboxOptions) => { // Clear and close the dropdown on single selection if (!props.multiple) { searchTerm.value = ''; isDropdownOpen.value = false; } setCachedValues(selectedValue); /** * Emit the value/label pair objects if enableValueLabelPair is enabled, * otherwise just emit the list of selected values (number or string) */ emitValue(cachedListboxSelectedValues.value); // Focus the input after selection and dropdown has closed if (!props.multiple) { await nextTick(); focus(); } }; const emitShowMoreEvent = () => { emit(BentoDropdownEvent.SHOW_MORE); }; const textboxKeyboardNavigationListeners = useTextboxKeyboardNavigation( { inputContainerRef, optionsContainerRef, selectedValueIndex, isDropdownOpen, isDynamicFltering: toRef(props, 'dynamicFiltering'), items: visibleDomOptions, isMultiple: props.multiple, totalItemCount: computed(() => filteredItems.value.length), isVirtualScroll: !props?.multiple && props?.virtualScroll !== false && props?.lazyLoadType === 'none', }, toggleDropdown, openDropdown, closeDropdown ); const listboxKeyboardNavigationListeners = useListboxKeyboardNavigation( { inputContainerRef, isDropdownOpen, }, closeDropdown ); const listboxListeners = { [BentoListboxEvent.CLOSE_DROPDOWN]: closeDropdown, [BentoListboxEvent.SELECT]: onOptionSelected, [BentoListboxEvent.SHOW_MORE]: emitShowMoreEvent, ...listboxKeyboardNavigationListeners, }; if (props.error) { deprecate( 'BentoDropdown "error" property', `Use the BentoDropdown "errorMessage" property instead to indicate that there is a modifier error. Having the field as "undefined" will hide/remove the error. <bento-dropdown errorMessage="Error message text" />`, '2.0.0' ); } if (props.size) { deprecate('BentoDropdown "size" property', `Do not use. Only 'default' size should be used.`, '2.0.0'); } if (props.value) { deprecate( 'BentoDropdown "value" property', `The use of "value" prop in "BentoDropdown" is no longer supported. Use the "v-model" or "model-value" property instead.`, '2.0.0' ); } const focus = () => { inputContainerRef.value.focus(); }; const scrollToSelectedOption = async () => { await nextTick(); if (!isDropdownOpen.value || props.multiple || !optionsContainerRef.value) { return; } const index = selectedValueIndex.value; if (index <= 0) { return; } // eslint-disable-next-line @typescript-eslint/no-explicit-any const listboxSingleSelect = (optionsContainerRef.value as any)?.$refs?.dropdownOptionsListboxRef?.$refs ?.listboxItemRef; listboxSingleSelect?.scrollToItem?.(index); }; watch( () => isDropdownOpen.value, (isOpen: boolean) => { if (isOpen) { emit('open'); scrollToSelectedOption(); } } ); defineExpose({ focus, }); </script> <script lang="ts"> /** * A Dropdown shows a selected option's. * Use a dropdown when you want users to select options * from a list of pre-defined options. * * @example * import { BentoDropdown } from '@adyen/bento-vue2'; * import type { BentoDropdownOptions } from '@adyen/bento-vue2' * * export default { * components: { BentoDropdown }, * template: ` * <bento-dropdown * v-model="selectedValue" * :disabled="false" * :multiple="true" * :isOptionsDisabled="option => option.value === 2" * :items="options" * /> * `, * setup() { * const selectedValue = ref(2) // Default value * const options: BentoDropdownOptions = [ * { label: 'Option 1', value: 1 }, * { label: 'Option 2', value: 2 }, * ]; * return { options, selectedValue }; * } * } */ export default defineComponent({ i18n: { messages }, name: 'b-dropdown', components: { DropdownInputDefault, DropdownInputSmall, }, model: { prop: 'modelValue' }, }); </script> <style lang="scss" scoped src="./dropdown.scss" />
@@ -1 +1 @@
1
- <template> <div class="b-file-uploader" :class="conditionalClasses"> <field-label :id="fieldLabelId" class="b-file-uploader__label" :for="inputFieldId" :label="label" :optional="optional" :required="required" :tooltip-text="tooltipText" /> <input :id="inputFieldId" ref="inputRef" :aria-required="required" type="file" tabindex="-1" data-testid="input" class="b-file-uploader__input" :accept="accept" :disabled="disabled" :multiple="isMultiple" @change="onInputChange" @click="onInputClick" @invalid.prevent /> <!-- Used to separate the a11y text with breaks for the reader. Hidden to users --> <span :id="commaTextId" aria-hidden="true" class="b-file-uploader__invisible-comma"> , </span> <div v-if="!hideArea" ref="dropAreaRef" :aria-labelledby="fileUploadAriaLabelledBy" class="b-file-uploader__area" role="button" :tabindex="disabled ? -1 : 0" @click="inputRef.click()" @dragenter.stop.prevent="onDragEnter" @dragleave.stop.prevent="onDragLeave" @drop.stop.prevent="onDrop" @dragover="onDragOver" @keydown.enter.prevent.capture="inputRef.click()" @keydown.space.prevent.capture="inputRef.click()" > <div class="b-file-uploader__icon" aria-hidden="true"> <warning-filled-icon v-if="hasError" /> <upload-icon v-else /> </div> <template v-if="!condensed"> <bento-typography :id="dropFilesTextId" el="span" strongest class="b-file-uploader__title"> {{ tc('dropFiles', maxCount) }} </bento-typography> <bento-typography v-if="description" :id="customTextId" el="span" class="b-file-uploader__description"> {{ description }} </bento-typography> <file-uploader-restrictions v-bind="fileUploadRestrictionsProps" :id="fileUploaderRestrictionsId" /> </template> <bento-typography :id="browseFilesButtonId" stronger class="b-file-uploader__button"> {{ t('browseFiles') }} </bento-typography> </div> <error-message v-if="hasError && errorMessage" :id="errorId" :error-message="errorMessage" class="b-file-uploader__error-message" /> <bento-typography v-if="condensed && description" :id="customTextId" aria-hidden="true" el="span" class="b-file-uploader__description b-file-uploader__description--condensed" > {{ description }} </bento-typography> <file-uploader-restrictions v-if="condensed" v-bind="fileUploadRestrictionsProps" :id="fileUploaderRestrictionsId" /> <bento-alert v-if="maxFileCountError" type="critical"> {{ t('tooManyFiles') }} <template #description> {{ t('youHaveExceededTheMaximum', { count: n(props.maxCount) }) }} </template> </bento-alert> <div v-show="files?.length" class="b-file-uploader__files"> <file-uploader-file-card v-for="(file, index) in files" :ref="registerFileCardRef(index)" :key="file.id" :file="file" :supported-file-types="supportedFileTypeList" :readonly="readonly" @cancel="removeFile(file)" @remove="removeFile(file)" /> </div> <bento-typography v-if="!files?.length && readonly" class="b-file-uploader__no-files" wide>{{ t('noFilesUploaded') }}</bento-typography> </div> </template> <script setup lang="ts"> import { computed, nextTick, onMounted, reactive, ref, toRef, watch } from 'vue'; import UploadIcon from '@adyen/ui-assets-icons-16/vue/upload'; import WarningFilledIcon from '@adyen/ui-assets-icons-16/vue/warning-filled'; import { BentoAlert } from '@/components/alert'; import { BentoTypography } from '@/components/typography'; import { ErrorMessage, FieldLabel } from '@/internal'; import { FileUploaderFileCard } from './components/file-uploader-file-card'; import { FileUploaderRestrictions } from './components/file-uploader-restrictions'; import { useI18n } from '@/utils/ts/i18n'; import { generateUid } from '@/core/utils/ts'; import { getFileType, isFileTypeAccepted, readFile } from './utils'; import { useFileTypeList } from './composables'; import { useDragDropState } from '@/components/file-uploader/composables/use-drag-drop-state/use-drag-drop-state'; import { deprecate } from '@/utils/ts/deprecate'; import { useFormFieldEmits } from '@/utils/ts/form-field/form-field'; import { type BentoFileUploaderProps, type BentoFileUploaderValue, FileErrorType, type FileMaxSize, type FileObject, FileState, FileUploaderState, } from './file-uploader.types'; import type { FileUploaderRestrictionsProps } from '@/components/file-uploader/components/file-uploader-restrictions/file-uploader-restrictions.types'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const { t, tc, n } = useI18n<{ message: MessageSchema }>({ messages }); const emit = defineEmits<{ /** * Emitted when files are added or removed. * Provides the entire list of files available. * Can be undefined if the list is empty */ (e: 'change', value?: FileList): void; /** * Vue2: Emitted when files are added or removed. Updates the v-model. * Provides the entire list of files available. * Can be undefined if the list is empty * @deprecated Since v2.0.0. Use `v-model` or `update:model-value` instead. */ (e: 'input', value?: FileList): void; /** * Vue3: Emitted when files are added or removed. Updates the v-model. * Provides the entire list of files available. * Can be undefined if the list is empty */ (e: 'update:model-value', value?: FileList): void; /** * Emitted when any file uploaded has state === FileState.ERROR. * Provides parent component with boolean. * Emits false when no files have errors. */ (e: 'error:upload', value: boolean): void; }>(); const props = withDefaults(defineProps<BentoFileUploaderProps>(), { accept: null, condensed: false, description: null, disabled: false, errorMessage: null, label: null, maxCount: 1, // One file by default maxDimensions: null, maxSize: null, modelValue: null, optional: false, required: false, tooltipText: null, }); const { emitValue } = useFormFieldEmits<BentoFileUploaderValue>(emit); /** * Model value checks * Filter out type string, array and file as FileList is the only type un-deprecated for modelValue * */ /** * Type guard to check if the value is a FileList. * @param files The value to check. */ function isFileList(files: BentoFileUploaderValue): files is FileList { return ( !!files && typeof (files as FileList).length === 'number' && typeof (files as FileList).item === 'function' ); } /** * Used as vitest recognizes FileList as an Array of Files which then * causes any tests with pre-uploaded files to fail as isFileList will fail. * */ onMounted(() => { // Model is not a FileList if (!isFileList(props.modelValue)) { deprecate( 'BentoFileUploaderValue types: string | Array<string> | File | Array<File>', 'Use FileList instead.', '2.0.0' ); } }); const { supportedFileTypeList } = useFileTypeList(toRef(props, 'accept')); const browseFilesButtonId = generateUid('browse-button'); const customTextId = generateUid('custom-text'); const dropFilesTextId = generateUid('drop-files-text'); const commaTextId = generateUid('comma-text'); const fileUploaderRestrictionsId = generateUid('file-uploader-restrictions'); const fieldLabelId = generateUid('field-label'); const files = ref<Array<FileObject>>(null); const fileCardRefs = ref<Array<InstanceType<typeof FileUploaderFileCard>>>([]); const inputRef = ref<HTMLInputElement>(null); const dropAreaRef = ref<HTMLDivElement>(null); const inputFieldId = generateUid('input'); const errorId = generateUid('input-error'); const fileUploadAriaLabelledBy = computed(() => { const formattedFieldLabelId = props.label ? `${fieldLabelId} ${commaTextId}` : ''; const formattedDropFileTextId = !props.condensed ? `${dropFilesTextId} ${commaTextId}` : ''; const formattedCustomTextId = props.description ? `${customTextId} ${commaTextId}` : ''; const formattedFileUploaderRestrictionsId = `${fileUploaderRestrictionsId} ${commaTextId}`; return `${formattedFieldLabelId} ${formattedDropFileTextId} ${formattedCustomTextId} ${formattedFileUploaderRestrictionsId} ${browseFilesButtonId}`; }); const { dragState, dragErrors, resetDragState, onDragEnterValidations } = useDragDropState({ accept: toRef(props, 'accept'), maxCount: toRef(props, 'maxCount'), }); const fileUploadRestrictionsProps = computed<FileUploaderRestrictionsProps>(() => ({ condensed: props.condensed, disabled: props.disabled, maxCount: isMultiple.value ? { value: props.maxCount, error: dragErrors.maxCountOverflow || maxFileCountError.value, } : null, maxDimensions: props.maxDimensions ? { value: props.maxDimensions, error: files.value?.some(file => file.errors.includes(FileErrorType.ERROR_DIMENSIONS)), } : null, maxSize: props.maxSize ? { value: props.maxSize, error: files.value?.some(file => file.errors.includes(FileErrorType.ERROR_SIZE)), } : null, supportedFileTypes: supportedFileTypeList.value ? { value: supportedFileTypeList.value, error: dragErrors.invalidTypes || dragErrors.partiallyInvalidTypes || files.value?.some(file => file.errors.includes(FileErrorType.ERROR_TYPE)), } : null, })); // Prevents triggering "dragleave" events on children elements const enterTarget = ref<EventTarget>(); const conditionalClasses = computed(() => ({ [`b-file-uploader--${FileUploaderState.DISABLED}`]: props.disabled, [`b-file-uploader--${FileUploaderState.DRAG_OVER}`]: dragState.dragging, [`b-file-uploader--${FileUploaderState.ERROR}`]: hasError.value, [`b-file-uploader--condensed`]: props.condensed, })); const isMultiple = computed(() => props.maxCount > 1); const hideArea = computed(() => (props.maxCount && files.value?.length >= props.maxCount) || props.readonly); const loadingStateExists = computed(() => files.value?.some(file => file.state === 'loading')); const registerFileCardRef = (cardIndex: number) => fileCardRef => { fileCardRefs.value[cardIndex] = fileCardRef; }; /** * Verifies the new list of files and emits the `change` and `update:modelValue` events. * @param newList The new list of files to process and emit. */ function emitUpdate(newList: Array<FileObject>) { if (newList) { verifyFileCount(newList); verifyFileSize(newList); const value = convertToFileList(newList); emit('change', value); emitValue(value); } else { emit('change', undefined); emitValue(undefined); } } /** Errors */ const hasError = computed(() => !!props.errorMessage || dragState.error); const maxFileCountError = computed(() => files.value?.length > props.maxCount); const maxFileError = ref(false); const hasRestrictionError = computed(() => files.value?.some(file => file.state === 'error')); watch(hasRestrictionError, newValue => { // Without this, when files are initially uploaded the component // will immediately and unnecessarily emit 'false' and then // emit 'true' once processing has done, if errors exists. if (loadingStateExists.value) { return; } emit('error:upload', newValue); }); /** * Places the focus to the "drop area" after adding or removing a file. * If more files can be added, the focus is moved to the "drop area". * If no more files can be added, the focus is moved to the first file card. */ async function resetFocus() { await nextTick(); if (files.value.length === props.maxCount) { fileCardRefs.value[0]?.focus(); return; } dropAreaRef.value.focus(); } /** * Removes the file from the list of files when the * "remove" or "cancel" events are triggered from * the file card component * @param file File to be removed */ async function removeFile(file: FileObject) { files.value = files.value.filter(({ id }) => id !== file.id); await resetFocus(); emitUpdate(files.value); } /** Drag n Drop functions */ /** * Creates a FileObject from a native File and tracks its client-load progress. * @param file The native File object. * @returns A FileObject. */ function processFile(file: File) { const fileObject = reactive<FileObject>({ id: generateUid('file'), data: file, errors: [], progress: null, state: FileState.LOADING, type: getFileType(file), }); readFile(fileObject, props.accept, props.maxDimensions); return fileObject; } /** * Prevent opening the file browser when no * more files can be added (until one file is removed). * @param event Triggered event */ function onInputClick(event: MouseEvent) { if (files.value?.length === props.maxCount) { event.preventDefault(); } } /** * Sets the file/files to track their progress while being processed * @param fileList File or list of files to be processed. */ async function onFileInput(fileList: FileList) { // Prevent adding many files if maxCount is 1 if (props.maxCount === 1 && fileList.length > props.maxCount) { resetEnterTarget(); resetDragState(); return; } if (isMultiple.value) { // Add the new files to the file selection. // Removing files should be done by interacting with the file list below the area. files.value = [...(files.value || []), ...Array.from(fileList).map(file => processFile(file))]; } else { files.value = [processFile(fileList[0])]; await resetFocus(); } emitUpdate(files.value); } /** * Handles the "dragenter" event, triggered when the mouse enters the draggable area. * Enables the dragging state and sets the dragging error to true to * prevent dropping files from being added if there's a general error. * @param event Drag event */ function onDragEnter(event: DragEvent) { // Prevent changing state if disabled if (props.disabled) { return; } /** * Allows internal components as part of the dragable area. * Prevents trigerring "dragleave" events on children elements. * Set the "dragarea" as target */ enterTarget.value = event.target; const totalFilesCount = (event.dataTransfer.items?.length ?? 0) + (files.value?.length ?? 0); onDragEnterValidations(totalFilesCount, event); } /** * Resets the reference to the target. * This helps preventing loosing the state when dragging over children */ function resetEnterTarget() { enterTarget.value = null; } /** * Handles the "dragleave" event, triggered when the mouse leaves the draggable area. * Maintains the dragging state while dragging over children. * Resets the states when the mouse leaves the draggable area * @param event Drag event */ function onDragLeave(event: DragEvent) { // If target enter and leave are the same it means the drag has left the "dragarea" if (enterTarget.value === event.target) { resetEnterTarget(); resetDragState(); } } /** * Handles the "dragover" event, triggered when the mouse moves over the draggable area. * Disables the cursor when the component is disabled. * @param event Drag event */ function onDragOver(event: DragEvent) { event.preventDefault(); // Disable drag n drop and change the cursor if disabled if (props.disabled) { // eslint-disable-next-line no-param-reassign event.dataTransfer.dropEffect = 'none'; } } /** * Hadles the "drop" event, triggered when files are dropped inside the draggable area. * Processes the files that are dropped. * @param event Drag event */ async function onDrop(event: DragEvent) { // Prevent events when the input is disabled if (props.disabled) { return; } if (!dragErrors.invalidTypes && event.dataTransfer.files?.length) { onFileInput(event.dataTransfer.files); } resetEnterTarget(); resetDragState(); } /** * Handles the "change" event over the "input" element. * Processes the files that are selected through the browser's file explorer. */ async function onInputChange() { if (inputRef.value.files?.length) { onFileInput(inputRef.value.files); } // Reset input value inputRef.value.value = ''; await resetFocus(); } /** End of drag and drop functions */ /** * Watches the modelValue and if set * and the type is not deprecated, it processes the files * but doesn't emit them as the user already has those files. */ watch( () => props.modelValue, (newValue: BentoFileUploaderValue) => { // Sets the new value if it is a `FileList`. This ignores deprecated value types. if (newValue && isFileList(newValue)) { const fileList = newValue as FileList; const fileListArray: Array<File> = Array.from(fileList); files.value = fileListArray.map(file => processFile(file)); } else if (!newValue && files.value) { // Clear the file list when the value is programmatically unset. files.value = null; } }, { immediate: true } ); function verifyFileCount(fileList: Array<FileObject>) { const isValid = props.maxCount && fileList.length <= props.maxCount; maxFileError.value = !isValid; } function verifyFileSize(fileList: Array<FileObject>) { if (!props.maxSize) { return; } fileList.forEach(file => { const item = file; let maxSizeLimit: number | undefined; if (Array.isArray(props.maxSize)) { const matchingEntry = (props.maxSize as Array<FileMaxSize>).find(entry => isFileTypeAccepted(item.data, entry.fileType) ); maxSizeLimit = matchingEntry?.maxSize; } else { maxSizeLimit = props.maxSize as number; } if (maxSizeLimit && item.data.size > maxSizeLimit) { item.state = FileState.ERROR; if (!item.errors.includes(FileErrorType.ERROR_SIZE)) { item.errors.push(FileErrorType.ERROR_SIZE); } } }); } function convertToFileList(fileList: Array<FileObject>) { // Filter out files with errors const validFiles = fileList.filter( ({ errors }) => !errors.includes(FileErrorType.ERROR_LOAD) && !errors.includes(FileErrorType.ERROR_SIZE) ); if (validFiles.length) { const dataTransfer = new DataTransfer(); validFiles.forEach(({ data, id }) => { dataTransfer.items.add(data); }); return dataTransfer.files; } return undefined; } </script> <script lang="ts"> /** * The file uploader component enables users to upload one or multiple files from their device to our system. * * @example * import { BentoFileUploader, type BentoFileValue } from '@adyen/bento-vue2'; * * export default { * components: { BentoFileUploader }, * template: ` * <bento-file-uploader * accept="image/*,video/*,.pdf,.png" * condensed * description="Custom supporting text" * disabled * errorMessage="Custom error message" * label="Bento file uploader" * :maxCount="1" * :maxDimensions="{ width: 200, height: 100 }" * :maxSize="1000" * v-model="files" * :optional="false" * required * tooltipText="Useful information" * /> * `, * setup() { * const files = ref<BentoFileValue>() * } * } */ export default { model: { prop: 'modelValue' }, i18n: { messages }, }; </script> <style lang="scss" scoped src="./file-uploader.scss" />
1
+ <template> <div class="b-file-uploader" :class="conditionalClasses"> <field-label :id="fieldLabelId" class="b-file-uploader__label" :for="inputFieldId" :label="label" :optional="optional" :required="required" :tooltip-text="tooltipText" /> <input :id="inputFieldId" ref="inputRef" :aria-required="required" type="file" tabindex="-1" data-testid="input" class="b-file-uploader__input" :accept="accept" :disabled="disabled" :multiple="isMultiple" @change="onInputChange" @click="onInputClick" @invalid.prevent /> <!-- Used to separate the a11y text with breaks for the reader. Hidden to users --> <span :id="commaTextId" aria-hidden="true" class="b-file-uploader__invisible-comma"> , </span> <div v-if="!hideArea" ref="dropAreaRef" :aria-labelledby="fileUploadAriaLabelledBy" class="b-file-uploader__area" role="button" :aria-disabled="disabled" :tabindex="disabled ? -1 : 0" @click="inputRef.click()" @dragenter.stop.prevent="onDragEnter" @dragleave.stop.prevent="onDragLeave" @drop.stop.prevent="onDrop" @dragover="onDragOver" @keydown.enter.prevent.capture="inputRef.click()" @keydown.space.prevent.capture="inputRef.click()" > <div class="b-file-uploader__icon" aria-hidden="true"> <warning-filled-icon v-if="hasError" /> <upload-icon v-else /> </div> <template v-if="!condensed"> <bento-typography :id="dropFilesTextId" el="span" strongest class="b-file-uploader__title"> {{ tc('dropFiles', maxCount) }} </bento-typography> <bento-typography v-if="description" :id="customTextId" el="span" class="b-file-uploader__description"> {{ description }} </bento-typography> <file-uploader-restrictions v-bind="fileUploadRestrictionsProps" :id="fileUploaderRestrictionsId" /> </template> <bento-typography :id="browseFilesButtonId" stronger class="b-file-uploader__button"> {{ t('browseFiles') }} </bento-typography> </div> <error-message v-if="hasError && errorMessage" :id="errorId" :error-message="errorMessage" class="b-file-uploader__error-message" /> <bento-typography v-if="condensed && description" :id="customTextId" aria-hidden="true" el="span" class="b-file-uploader__description b-file-uploader__description--condensed" > {{ description }} </bento-typography> <file-uploader-restrictions v-if="condensed" v-bind="fileUploadRestrictionsProps" :id="fileUploaderRestrictionsId" /> <bento-alert v-if="maxFileCountError" type="critical"> {{ t('tooManyFiles') }} <template #description> {{ t('youHaveExceededTheMaximum', { count: n(props.maxCount) }) }} </template> </bento-alert> <div v-show="files?.length" class="b-file-uploader__files"> <file-uploader-file-card v-for="(file, index) in files" :ref="registerFileCardRef(index)" :key="file.id" :file="file" :supported-file-types="supportedFileTypeList" :readonly="readonly" @cancel="removeFile(file)" @remove="removeFile(file)" /> </div> <bento-typography v-if="!files?.length && readonly" class="b-file-uploader__no-files" wide>{{ t('noFilesUploaded') }}</bento-typography> </div> </template> <script setup lang="ts"> import { computed, nextTick, onMounted, reactive, ref, toRef, watch } from 'vue'; import UploadIcon from '@adyen/ui-assets-icons-16/vue/upload'; import WarningFilledIcon from '@adyen/ui-assets-icons-16/vue/warning-filled'; import { BentoAlert } from '@/components/alert'; import { BentoTypography } from '@/components/typography'; import { ErrorMessage, FieldLabel } from '@/internal'; import { FileUploaderFileCard } from './components/file-uploader-file-card'; import { FileUploaderRestrictions } from './components/file-uploader-restrictions'; import { useI18n } from '@/utils/ts/i18n'; import { generateUid } from '@/core/utils/ts'; import { getFileType, isFileTypeAccepted, readFile } from './utils'; import { useFileTypeList } from './composables'; import { useDragDropState } from '@/components/file-uploader/composables/use-drag-drop-state/use-drag-drop-state'; import { deprecate } from '@/utils/ts/deprecate'; import { useFormFieldEmits } from '@/utils/ts/form-field/form-field'; import { type BentoFileUploaderProps, type BentoFileUploaderValue, FileErrorType, type FileMaxSize, type FileObject, FileState, FileUploaderState, } from './file-uploader.types'; import type { FileUploaderRestrictionsProps } from '@/components/file-uploader/components/file-uploader-restrictions/file-uploader-restrictions.types'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const { t, tc, n } = useI18n<{ message: MessageSchema }>({ messages }); const emit = defineEmits<{ /** * Emitted when files are added or removed. * Provides the entire list of files available. * Can be undefined if the list is empty */ (e: 'change', value?: FileList): void; /** * Vue2: Emitted when files are added or removed. Updates the v-model. * Provides the entire list of files available. * Can be undefined if the list is empty * @deprecated Since v2.0.0. Use `v-model` or `update:model-value` instead. */ (e: 'input', value?: FileList): void; /** * Vue3: Emitted when files are added or removed. Updates the v-model. * Provides the entire list of files available. * Can be undefined if the list is empty */ (e: 'update:model-value', value?: FileList): void; /** * Emitted when any file uploaded has state === FileState.ERROR. * Provides parent component with boolean. * Emits false when no files have errors. */ (e: 'error:upload', value: boolean): void; }>(); const props = withDefaults(defineProps<BentoFileUploaderProps>(), { accept: null, condensed: false, description: null, disabled: false, errorMessage: null, label: null, maxCount: 1, // One file by default maxDimensions: null, maxSize: null, modelValue: null, optional: false, required: false, tooltipText: null, }); const { emitValue } = useFormFieldEmits<BentoFileUploaderValue>(emit); /** * Model value checks * Filter out type string, array and file as FileList is the only type un-deprecated for modelValue * */ /** * Type guard to check if the value is a FileList. * @param files The value to check. */ function isFileList(files: BentoFileUploaderValue): files is FileList { return ( !!files && typeof (files as FileList).length === 'number' && typeof (files as FileList).item === 'function' ); } /** * Used as vitest recognizes FileList as an Array of Files which then * causes any tests with pre-uploaded files to fail as isFileList will fail. * */ onMounted(() => { // Model is not a FileList if (!isFileList(props.modelValue)) { deprecate( 'BentoFileUploaderValue types: string | Array<string> | File | Array<File>', 'Use FileList instead.', '2.0.0' ); } }); const { supportedFileTypeList } = useFileTypeList(toRef(props, 'accept')); const browseFilesButtonId = generateUid('browse-button'); const customTextId = generateUid('custom-text'); const dropFilesTextId = generateUid('drop-files-text'); const commaTextId = generateUid('comma-text'); const fileUploaderRestrictionsId = generateUid('file-uploader-restrictions'); const fieldLabelId = generateUid('field-label'); const files = ref<Array<FileObject>>(null); const fileCardRefs = ref<Array<InstanceType<typeof FileUploaderFileCard>>>([]); const inputRef = ref<HTMLInputElement>(null); const dropAreaRef = ref<HTMLDivElement>(null); const inputFieldId = generateUid('input'); const errorId = generateUid('input-error'); const fileUploadAriaLabelledBy = computed(() => { const formattedFieldLabelId = props.label ? `${fieldLabelId} ${commaTextId}` : ''; const formattedDropFileTextId = !props.condensed ? `${dropFilesTextId} ${commaTextId}` : ''; const formattedCustomTextId = props.description ? `${customTextId} ${commaTextId}` : ''; const formattedFileUploaderRestrictionsId = `${fileUploaderRestrictionsId} ${commaTextId}`; return `${formattedFieldLabelId} ${formattedDropFileTextId} ${formattedCustomTextId} ${formattedFileUploaderRestrictionsId} ${browseFilesButtonId}`; }); const { dragState, dragErrors, resetDragState, onDragEnterValidations } = useDragDropState({ accept: toRef(props, 'accept'), maxCount: toRef(props, 'maxCount'), }); const fileUploadRestrictionsProps = computed<FileUploaderRestrictionsProps>(() => ({ condensed: props.condensed, disabled: props.disabled, maxCount: isMultiple.value ? { value: props.maxCount, error: dragErrors.maxCountOverflow || maxFileCountError.value, } : null, maxDimensions: props.maxDimensions ? { value: props.maxDimensions, error: files.value?.some(file => file.errors.includes(FileErrorType.ERROR_DIMENSIONS)), } : null, maxSize: props.maxSize ? { value: props.maxSize, error: files.value?.some(file => file.errors.includes(FileErrorType.ERROR_SIZE)), } : null, supportedFileTypes: supportedFileTypeList.value ? { value: supportedFileTypeList.value, error: dragErrors.invalidTypes || dragErrors.partiallyInvalidTypes || files.value?.some(file => file.errors.includes(FileErrorType.ERROR_TYPE)), } : null, })); // Prevents triggering "dragleave" events on children elements const enterTarget = ref<EventTarget>(); const conditionalClasses = computed(() => ({ [`b-file-uploader--${FileUploaderState.DISABLED}`]: props.disabled, [`b-file-uploader--${FileUploaderState.DRAG_OVER}`]: dragState.dragging, [`b-file-uploader--${FileUploaderState.ERROR}`]: hasError.value, [`b-file-uploader--condensed`]: props.condensed, })); const isMultiple = computed(() => props.maxCount > 1); const hideArea = computed(() => (props.maxCount && files.value?.length >= props.maxCount) || props.readonly); const loadingStateExists = computed(() => files.value?.some(file => file.state === 'loading')); const registerFileCardRef = (cardIndex: number) => fileCardRef => { fileCardRefs.value[cardIndex] = fileCardRef; }; /** * Verifies the new list of files and emits the `change` and `update:modelValue` events. * @param newList The new list of files to process and emit. */ function emitUpdate(newList: Array<FileObject>) { if (newList) { verifyFileCount(newList); verifyFileSize(newList); const value = convertToFileList(newList); emit('change', value); emitValue(value); } else { emit('change', undefined); emitValue(undefined); } } /** Errors */ const hasError = computed(() => !!props.errorMessage || dragState.error); const maxFileCountError = computed(() => files.value?.length > props.maxCount); const maxFileError = ref(false); const hasRestrictionError = computed(() => files.value?.some(file => file.state === 'error')); watch(hasRestrictionError, newValue => { // Without this, when files are initially uploaded the component // will immediately and unnecessarily emit 'false' and then // emit 'true' once processing has done, if errors exists. if (loadingStateExists.value) { return; } emit('error:upload', newValue); }); /** * Places the focus to the "drop area" after adding or removing a file. * If more files can be added, the focus is moved to the "drop area". * If no more files can be added, the focus is moved to the first file card. */ async function resetFocus() { await nextTick(); if (files.value.length === props.maxCount) { fileCardRefs.value[0]?.focus(); return; } dropAreaRef.value.focus(); } /** * Removes the file from the list of files when the * "remove" or "cancel" events are triggered from * the file card component * @param file File to be removed */ async function removeFile(file: FileObject) { files.value = files.value.filter(({ id }) => id !== file.id); await resetFocus(); emitUpdate(files.value); } /** Drag n Drop functions */ /** * Creates a FileObject from a native File and tracks its client-load progress. * @param file The native File object. * @returns A FileObject. */ function processFile(file: File) { const fileObject = reactive<FileObject>({ id: generateUid('file'), data: file, errors: [], progress: null, state: FileState.LOADING, type: getFileType(file), }); readFile(fileObject, props.accept, props.maxDimensions); return fileObject; } /** * Prevent opening the file browser when no * more files can be added (until one file is removed). * @param event Triggered event */ function onInputClick(event: MouseEvent) { if (files.value?.length === props.maxCount) { event.preventDefault(); } } /** * Sets the file/files to track their progress while being processed * @param fileList File or list of files to be processed. */ async function onFileInput(fileList: FileList) { // Prevent adding many files if maxCount is 1 if (props.maxCount === 1 && fileList.length > props.maxCount) { resetEnterTarget(); resetDragState(); return; } if (isMultiple.value) { // Add the new files to the file selection. // Removing files should be done by interacting with the file list below the area. files.value = [...(files.value || []), ...Array.from(fileList).map(file => processFile(file))]; } else { files.value = [processFile(fileList[0])]; await resetFocus(); } emitUpdate(files.value); } /** * Handles the "dragenter" event, triggered when the mouse enters the draggable area. * Enables the dragging state and sets the dragging error to true to * prevent dropping files from being added if there's a general error. * @param event Drag event */ function onDragEnter(event: DragEvent) { // Prevent changing state if disabled if (props.disabled) { return; } /** * Allows internal components as part of the dragable area. * Prevents trigerring "dragleave" events on children elements. * Set the "dragarea" as target */ enterTarget.value = event.target; const totalFilesCount = (event.dataTransfer.items?.length ?? 0) + (files.value?.length ?? 0); onDragEnterValidations(totalFilesCount, event); } /** * Resets the reference to the target. * This helps preventing loosing the state when dragging over children */ function resetEnterTarget() { enterTarget.value = null; } /** * Handles the "dragleave" event, triggered when the mouse leaves the draggable area. * Maintains the dragging state while dragging over children. * Resets the states when the mouse leaves the draggable area * @param event Drag event */ function onDragLeave(event: DragEvent) { // If target enter and leave are the same it means the drag has left the "dragarea" if (enterTarget.value === event.target) { resetEnterTarget(); resetDragState(); } } /** * Handles the "dragover" event, triggered when the mouse moves over the draggable area. * Disables the cursor when the component is disabled. * @param event Drag event */ function onDragOver(event: DragEvent) { event.preventDefault(); // Disable drag n drop and change the cursor if disabled if (props.disabled) { // eslint-disable-next-line no-param-reassign event.dataTransfer.dropEffect = 'none'; } } /** * Hadles the "drop" event, triggered when files are dropped inside the draggable area. * Processes the files that are dropped. * @param event Drag event */ async function onDrop(event: DragEvent) { // Prevent events when the input is disabled if (props.disabled) { return; } if (!dragErrors.invalidTypes && event.dataTransfer.files?.length) { onFileInput(event.dataTransfer.files); } resetEnterTarget(); resetDragState(); } /** * Handles the "change" event over the "input" element. * Processes the files that are selected through the browser's file explorer. */ async function onInputChange() { if (inputRef.value.files?.length) { onFileInput(inputRef.value.files); } // Reset input value inputRef.value.value = ''; await resetFocus(); } /** End of drag and drop functions */ /** * Watches the modelValue and if set * and the type is not deprecated, it processes the files * but doesn't emit them as the user already has those files. */ watch( () => props.modelValue, (newValue: BentoFileUploaderValue) => { // Sets the new value if it is a `FileList`. This ignores deprecated value types. if (newValue && isFileList(newValue)) { const fileList = newValue as FileList; const fileListArray: Array<File> = Array.from(fileList); files.value = fileListArray.map(file => processFile(file)); } else if (!newValue && files.value) { // Clear the file list when the value is programmatically unset. files.value = null; } }, { immediate: true } ); function verifyFileCount(fileList: Array<FileObject>) { const isValid = props.maxCount && fileList.length <= props.maxCount; maxFileError.value = !isValid; } function verifyFileSize(fileList: Array<FileObject>) { if (!props.maxSize) { return; } fileList.forEach(file => { const item = file; let maxSizeLimit: number | undefined; if (Array.isArray(props.maxSize)) { const matchingEntry = (props.maxSize as Array<FileMaxSize>).find(entry => isFileTypeAccepted(item.data, entry.fileType) ); maxSizeLimit = matchingEntry?.maxSize; } else { maxSizeLimit = props.maxSize as number; } if (maxSizeLimit && item.data.size > maxSizeLimit) { item.state = FileState.ERROR; if (!item.errors.includes(FileErrorType.ERROR_SIZE)) { item.errors.push(FileErrorType.ERROR_SIZE); } } }); } function convertToFileList(fileList: Array<FileObject>) { // Filter out files with errors const validFiles = fileList.filter( ({ errors }) => !errors.includes(FileErrorType.ERROR_LOAD) && !errors.includes(FileErrorType.ERROR_SIZE) ); if (validFiles.length) { const dataTransfer = new DataTransfer(); validFiles.forEach(({ data, id }) => { dataTransfer.items.add(data); }); return dataTransfer.files; } return undefined; } </script> <script lang="ts"> /** * The file uploader component enables users to upload one or multiple files from their device to our system. * * @example * import { BentoFileUploader, type BentoFileValue } from '@adyen/bento-vue2'; * * export default { * components: { BentoFileUploader }, * template: ` * <bento-file-uploader * accept="image/*,video/*,.pdf,.png" * condensed * description="Custom supporting text" * disabled * errorMessage="Custom error message" * label="Bento file uploader" * :maxCount="1" * :maxDimensions="{ width: 200, height: 100 }" * :maxSize="1000" * v-model="files" * :optional="false" * required * tooltipText="Useful information" * /> * `, * setup() { * const files = ref<BentoFileValue>() * } * } */ export default { model: { prop: 'modelValue' }, i18n: { messages }, }; </script> <style lang="scss" scoped src="./file-uploader.scss" />
@@ -1 +1 @@
1
- <template> <bento-base-filter v-bind="$props" :applied-filters-count="appliedFiltersCount" :button-label="buttonLabel" :button-secondary-label="buttonSecondaryLabel" :disable-apply-button="isApplyButtonDisabled" :disable-secondary-button="isSecondaryButtonDisabled" :tooltip-position="options?.tooltipPosition" :tooltip-text="options?.tooltipText" @apply="updateFilter(internalFilterValue)" @clear="clearFilter(updateFilter)" @open="openFilter" @reset="resetFilter(updateFilter)" > <div class="b-checkbox-group-filter" :class="conditionalClasses" @keydown.enter="updateFilter(internalFilterValue)" > <bento-checkbox-group :model-value="internalFilterValue" :items="options?.checkboxItems" :label="label" :disabled="disabled" hide-label @update:model-value="onModelValueUpdate" /> </div> </bento-base-filter> </template> <script setup lang="ts"> import { computed, ref, toRef } from 'vue'; import BentoCheckboxGroup from '../../../checkbox/components/checkbox-group/checkbox-group.vue'; import { BentoBaseFilter } from '../base-filter'; import { useBentoBaseFilter } from '@/composables'; import { useI18n } from '@/utils/ts/i18n'; import { truncate } from '@/utils/ts/truncate'; import { type BentoCheckboxGroupFilterProps, type BentoCheckboxGroupFilterValue, } from './checkbox-group-filter.types'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const props = withDefaults(defineProps<BentoCheckboxGroupFilterProps>(), { disabled: false, defaultValue: undefined, onlySlot: false, options: undefined, value: undefined, }); const emit = defineEmits<{ (e: 'input', payload: BentoCheckboxGroupFilterProps): void; (e: 'update', payload: BentoCheckboxGroupFilterProps): void; }>(); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const { internalFilterValue, isSecondaryButtonDisabled, isApplyButtonDisabled, clearFilter, resetFilter, openFilter, } = useBentoBaseFilter<BentoCheckboxGroupFilterValue>(toRef(props, 'value'), toRef(props, 'defaultValue')); const displayedValues = ref<BentoCheckboxGroupFilterValue>(internalFilterValue.value ?? []); const conditionalClasses = computed(() => ({ [`b-checkbox-group-filter--only-slot`]: props.onlySlot, })); const buttonLabel = computed(() => props.label); const buttonSecondaryLabel = computed(() => displayedValues.value.length === 1 ? truncate( props.options?.checkboxItems.find(item => displayedValues.value[0] === item.value)?.label ?? '', 12 ) : null ); const appliedFiltersCount = computed(() => { if (!displayedValues.value.length || buttonSecondaryLabel.value) { return null; } if (displayedValues.value.length === props.options?.checkboxItems.length) { return t('all'); } return displayedValues.value.length; }); const updateFilter = (value: BentoCheckboxGroupFilterValue) => { displayedValues.value = value ?? []; emit('update', { ...props, value }); }; const onModelValueUpdate = (value: BentoCheckboxGroupFilterValue) => { internalFilterValue.value = value; emit('input', { ...props, value: internalFilterValue.value }); }; </script> <script lang="ts"> /** * The Checkbox Group filter displays a group of checkboxes that allows * to select multiple values. * * @example * import { BentoCheckboxGroupFilter } from '@adyen/bento-vue2'; * * export default { * components: { BentoCheckboxGroupFilter }, * template: ` * <bento-checkbox-group-filter * :field="filter.field" * :label="filter.label" * :options="filter.options" * :value="filter.value" * @update="updateFilterHandler" * /> * ` * } */ export default { name: 'bento-checkbox-group-filter', i18n: { messages }, }; </script> <style lang="scss" scoped src="./checkbox-group-filter.scss" />
1
+ <template> <bento-base-filter v-bind="$props" :applied-filters-count="appliedFiltersCount" :button-label="buttonLabel" :button-secondary-label="buttonSecondaryLabel" :disable-apply-button="isApplyButtonDisabled" :disable-secondary-button="isSecondaryButtonDisabled" :tooltip-position="options?.tooltipPosition" :tooltip-text="options?.tooltipText" @apply="updateFilter(internalFilterValue)" @clear="clearFilter(updateFilter)" @open="openFilter" @reset="resetFilter(updateFilter)" > <div class="b-checkbox-group-filter" :class="conditionalClasses" @keydown.enter="updateFilter(internalFilterValue)" > <bento-checkbox-group :model-value="internalFilterValue" :items="options?.checkboxItems" :label="label" :disabled="disabled" hide-label @update:model-value="onModelValueUpdate" /> </div> </bento-base-filter> </template> <script setup lang="ts"> import { computed, ref, toRef, watch } from 'vue'; import BentoCheckboxGroup from '../../../checkbox/components/checkbox-group/checkbox-group.vue'; import { BentoBaseFilter } from '../base-filter'; import { useBentoBaseFilter } from '@/composables'; import { useI18n } from '@/utils/ts/i18n'; import { truncate } from '@/utils/ts/truncate'; import { type BentoCheckboxGroupFilterProps, type BentoCheckboxGroupFilterValue, } from './checkbox-group-filter.types'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const props = withDefaults(defineProps<BentoCheckboxGroupFilterProps>(), { disabled: false, defaultValue: undefined, onlySlot: false, options: undefined, value: undefined, }); const emit = defineEmits<{ (e: 'input', payload: BentoCheckboxGroupFilterProps): void; (e: 'update', payload: BentoCheckboxGroupFilterProps): void; }>(); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const { internalFilterValue, isSecondaryButtonDisabled, isApplyButtonDisabled, clearFilter, resetFilter, openFilter, } = useBentoBaseFilter<BentoCheckboxGroupFilterValue>(toRef(props, 'value'), toRef(props, 'defaultValue')); const displayedValues = ref<BentoCheckboxGroupFilterValue>(internalFilterValue.value ?? []); watch( () => props.value, newValue => { displayedValues.value = newValue ?? []; } ); const conditionalClasses = computed(() => ({ [`b-checkbox-group-filter--only-slot`]: props.onlySlot, })); const buttonLabel = computed(() => props.label); const buttonSecondaryLabel = computed(() => displayedValues.value.length === 1 ? truncate( props.options?.checkboxItems.find(item => displayedValues.value[0] === item.value)?.label ?? '', 12 ) : null ); const appliedFiltersCount = computed(() => { if (!displayedValues.value.length || buttonSecondaryLabel.value) { return null; } if (displayedValues.value.length === props.options?.checkboxItems.length) { return t('all'); } return displayedValues.value.length; }); const updateFilter = (value: BentoCheckboxGroupFilterValue) => { displayedValues.value = value ?? []; emit('update', { ...props, value }); }; const onModelValueUpdate = (value: BentoCheckboxGroupFilterValue) => { internalFilterValue.value = value; emit('input', { ...props, value: internalFilterValue.value }); }; </script> <script lang="ts"> /** * The Checkbox Group filter displays a group of checkboxes that allows * to select multiple values. * * @example * import { BentoCheckboxGroupFilter } from '@adyen/bento-vue2'; * * export default { * components: { BentoCheckboxGroupFilter }, * template: ` * <bento-checkbox-group-filter * :field="filter.field" * :label="filter.label" * :options="filter.options" * :value="filter.value" * @update="updateFilterHandler" * /> * ` * } */ export default { name: 'bento-checkbox-group-filter', i18n: { messages }, }; </script> <style lang="scss" scoped src="./checkbox-group-filter.scss" />
@@ -1 +1 @@
1
- <template> <bento-base-filter :button-label="buttonLabel" :button-secondary-label="buttonSecondaryLabel" :disabled="disabled" :default-value="defaultValue" :disable-apply-button="isApplyButtonDisabled" :disable-secondary-button="isSecondaryButtonDisabled" :field="field" :label="label" :only-slot="onlySlot" :tooltip-position="options?.tooltipPosition" :tooltip-text="options?.tooltipText" fit-content @apply="updateFilter" @clear="clearFilter" @open="openFilter" @reset="resetFilter" > <bento-date-picker v-if="onlySlot || isTouchDevice" :class="touchDeviceDatePickerConditionalClasses" :aria-labelledby="$attrs?.['aria-labelledby']" :value="internalFilterValue" :first-day-of-week="options?.firstDayOfWeek" :is-date-disabled="options?.isDateDisabled" :variant="options?.variant" :disabled="disabled" @input="onDateInputSelected" /> <template v-else> <bento-divider class="b-date-filter__divider" /> <div class="b-date-filter"> <calendar :first-day-of-week="options?.firstDayOfWeek" :is-date-disabled="options?.isDateDisabled" :number-of-months="1" :value="internalFilterValue" :variant="options?.variant" @input="onDateSelected" /> </div> </template> </bento-base-filter> </template> <script lang="ts"> import { computed, defineComponent, type PropType, toRef } from 'vue'; import { BentoBaseFilter, useBaseFilter } from '../base-filter'; import { type BentoDateFilterOptions, type BentoDateFilterValue, BentoFilterEvent } from '../../filter-bar.types'; import { Calendar } from '@/internal'; import { BentoDivider } from '@/components/divider'; import { BentoDatePicker } from '@/components/date-picker'; import { useI18n } from '@/utils/ts/i18n'; import { getDateTimeFormatter } from '@/utils/ts/format-date'; import { useTouchDevice } from '@/composables'; /** * The Date filter displays a calendar that allows * to select a single date. * * @example * import { BentoDateFilter } from '@adyen/bento-vue2'; * * export default { * components: { BentoDateFilter }, * template: ` * <bento-date-filter * :field="filter.field" * :label="filter.label" * :options="filter.options" * :value="filter.value" * @update="updateFilterHandler" * > * ` * } */ export default defineComponent({ name: 'bento-date-filter', components: { BentoBaseFilter, BentoDatePicker, BentoDivider, Calendar, }, props: { /** * Disables the filter from being interacted with. */ disabled: { type: Boolean, default: false }, isDateInput: { type: Boolean, default: true }, /** * Default value of the filter to reset to. */ defaultValue: { type: Date as PropType<BentoDateFilterValue>, default: null }, /** * Filter name/key referencing the actual property name in the data set. * Should be unique per filter bar. */ field: { type: String, required: true }, /** * Label text to be displayed */ label: { type: String, required: true }, /** * Renders the base filter's slot content only - leaving out the button and the popover. * Used for the allFiltersModal component. */ onlySlot: { type: Boolean, default: false }, /** * List of options that are needed to setup the filter * Available options: * - firstDayOfWeek: Allows you to set the first day of the week. 0 is sunday, 1 is monday, 6 is saturday * - isDateDisabled: Indicate if a date should be disabled or not * - tooltipPosition: The position of the tooltip attached to the filter button * - tooltipText: The content of the tooltip attached to the filter button */ options: { type: Object as PropType<BentoDateFilterOptions>, default: undefined, }, /** * Current value of the filter */ value: { type: Date as PropType<BentoDateFilterValue>, default: undefined }, }, emits: [BentoFilterEvent.INPUT, BentoFilterEvent.UPDATE], setup(props, { emit }) { const { isTouchDevice } = useTouchDevice(); const { locale } = useI18n(undefined, true); const { internalFilterValue, isSecondaryButtonDisabled, isApplyButtonDisabled } = useBaseFilter( toRef(props, 'value'), toRef(props, 'defaultValue') ); const formattedButtonDateText = computed(() => { const dateFormatOptions: Intl.DateTimeFormatOptions = props?.options?.variant === 'month' ? { month: 'long', year: 'numeric' } : { day: 'numeric', month: 'short', year: 'numeric' }; return props.value ? getDateTimeFormatter(locale.value, dateFormatOptions).format(props.value) : null; }); const buttonLabel = computed(() => props.label); const buttonSecondaryLabel = computed(() => formattedButtonDateText.value); const updateFilter = () => { emit(BentoFilterEvent.UPDATE, { ...props, value: internalFilterValue.value }); }; const clearFilter = () => { internalFilterValue.value = null; updateFilter(); }; const resetFilter = () => { internalFilterValue.value = props.defaultValue; updateFilter(); }; const openFilter = () => { internalFilterValue.value = props.value; }; const onDateSelected = (selectedDate: Date) => { internalFilterValue.value = selectedDate; emit(BentoFilterEvent.INPUT, { ...props, value: internalFilterValue.value }); }; // Update internal date and emit selected date when using date-picker input const onDateInputSelected = (selectedDate: Date) => { internalFilterValue.value = selectedDate; onDateSelected(selectedDate); updateFilter(); }; const touchDeviceDatePickerConditionalClasses = computed(() => ({ 'b-date-filter--touch-device': isTouchDevice.value, })); return { // Values internalFilterValue, isApplyButtonDisabled, isSecondaryButtonDisabled, buttonLabel, buttonSecondaryLabel, isTouchDevice, touchDeviceDatePickerConditionalClasses, // Events clearFilter, onDateSelected, openFilter, updateFilter, resetFilter, onDateInputSelected, }; }, }); </script> <style lang="scss" scoped src="./date-filter.scss" />
1
+ <template> <bento-base-filter :button-label="buttonLabel" :button-secondary-label="buttonSecondaryLabel" :disabled="disabled" :default-value="defaultValue" :disable-apply-button="isApplyButtonDisabled" :disable-secondary-button="isSecondaryButtonDisabled" :field="field" :label="label" :only-slot="onlySlot" :tooltip-position="options?.tooltipPosition" :tooltip-text="options?.tooltipText" :value="value" fit-content @apply="updateFilter" @clear="clearFilter" @open="openFilter" @reset="resetFilter" > <bento-date-picker v-if="onlySlot || isTouchDevice" :class="touchDeviceDatePickerConditionalClasses" :aria-labelledby="$attrs?.['aria-labelledby']" :value="internalFilterValue" :first-day-of-week="options?.firstDayOfWeek" :is-date-disabled="options?.isDateDisabled" :variant="options?.variant" :year-range="options?.yearRange" :disabled="disabled" @input="onDateInputSelected" /> <template v-else> <bento-divider class="b-date-filter__divider" /> <div class="b-date-filter"> <calendar :first-day-of-week="options?.firstDayOfWeek" :is-date-disabled="options?.isDateDisabled" :number-of-months="1" :value="internalFilterValue" :variant="options?.variant" :year-range="options?.yearRange" @input="onDateSelected" /> </div> </template> </bento-base-filter> </template> <script lang="ts"> import { computed, defineComponent, type PropType, toRef } from 'vue'; import { BentoBaseFilter, useBaseFilter } from '../base-filter'; import { type BentoDateFilterOptions, type BentoDateFilterValue, BentoFilterEvent } from '../../filter-bar.types'; import { Calendar } from '@/internal'; import { BentoDivider } from '@/components/divider'; import { BentoDatePicker } from '@/components/date-picker'; import { useI18n } from '@/utils/ts/i18n'; import { getDateTimeFormatter } from '@/utils/ts/format-date'; import { useTouchDevice } from '@/composables'; /** * The Date filter displays a calendar that allows * to select a single date. * * @example * import { BentoDateFilter } from '@adyen/bento-vue2'; * * export default { * components: { BentoDateFilter }, * template: ` * <bento-date-filter * :field="filter.field" * :label="filter.label" * :options="filter.options" * :value="filter.value" * @update="updateFilterHandler" * > * ` * } */ export default defineComponent({ name: 'bento-date-filter', components: { BentoBaseFilter, BentoDatePicker, BentoDivider, Calendar, }, props: { /** * Disables the filter from being interacted with. */ disabled: { type: Boolean, default: false }, isDateInput: { type: Boolean, default: true }, /** * Default value of the filter to reset to. */ defaultValue: { type: Date as PropType<BentoDateFilterValue>, default: null }, /** * Filter name/key referencing the actual property name in the data set. * Should be unique per filter bar. */ field: { type: String, required: true }, /** * Label text to be displayed */ label: { type: String, required: true }, /** * Renders the base filter's slot content only - leaving out the button and the popover. * Used for the allFiltersModal component. */ onlySlot: { type: Boolean, default: false }, /** * List of options that are needed to setup the filter * Available options: * - firstDayOfWeek: Allows you to set the first day of the week. 0 is sunday, 1 is monday, 6 is saturday * - isDateDisabled: Indicate if a date should be disabled or not * - tooltipPosition: The position of the tooltip attached to the filter button * - tooltipText: The content of the tooltip attached to the filter button */ options: { type: Object as PropType<BentoDateFilterOptions>, default: undefined, }, /** * Current value of the filter */ value: { type: Date as PropType<BentoDateFilterValue>, default: undefined }, }, emits: [BentoFilterEvent.INPUT, BentoFilterEvent.UPDATE], setup(props, { emit }) { const { isTouchDevice } = useTouchDevice(); const { locale } = useI18n(undefined, true); const { internalFilterValue, isSecondaryButtonDisabled, isApplyButtonDisabled } = useBaseFilter( toRef(props, 'value'), toRef(props, 'defaultValue') ); const formattedButtonDateText = computed(() => { const dateFormatOptions: Intl.DateTimeFormatOptions = props?.options?.variant === 'month' ? { month: 'long', year: 'numeric' } : { day: 'numeric', month: 'short', year: 'numeric' }; return props.value ? getDateTimeFormatter(locale.value, dateFormatOptions).format(props.value) : null; }); const buttonLabel = computed(() => props.label); const buttonSecondaryLabel = computed(() => formattedButtonDateText.value); const updateFilter = () => { emit(BentoFilterEvent.UPDATE, { ...props, value: internalFilterValue.value }); }; const clearFilter = () => { internalFilterValue.value = null; updateFilter(); }; const resetFilter = () => { internalFilterValue.value = props.defaultValue; updateFilter(); }; const openFilter = () => { internalFilterValue.value = props.value; }; const onDateSelected = (selectedDate: Date) => { internalFilterValue.value = selectedDate; emit(BentoFilterEvent.INPUT, { ...props, value: internalFilterValue.value }); }; // Update internal date and emit selected date when using date-picker input const onDateInputSelected = (selectedDate: Date) => { internalFilterValue.value = selectedDate; onDateSelected(selectedDate); updateFilter(); }; const touchDeviceDatePickerConditionalClasses = computed(() => ({ 'b-date-filter--touch-device': isTouchDevice.value, })); return { // Values internalFilterValue, isApplyButtonDisabled, isSecondaryButtonDisabled, buttonLabel, buttonSecondaryLabel, isTouchDevice, touchDeviceDatePickerConditionalClasses, // Events clearFilter, onDateSelected, openFilter, updateFilter, resetFilter, onDateInputSelected, }; }, }); </script> <style lang="scss" scoped src="./date-filter.scss" />
@@ -1 +1 @@
1
- <template> <bento-base-filter ref="baseFilter" class="b-base-filter-date-range-filter" :button-label="buttonLabel" :button-secondary-label="buttonSecondaryLabel" :controlled="!onlySlot" :disabled="disabled" :disable-apply-button="isApplyButtonDisabled" :disable-secondary-button="isSecondaryButtonDisabled" :default-value="defaultValue" :field="field" :label="label" :only-slot="onlySlot" :tooltip-position="options?.tooltipPosition" :tooltip-text="options?.tooltipText" fit-content @open="openFilter" @clear="clearFilter" @reset="resetFilter" @cancel="onCancel" > <bento-date-range-picker v-if="onlySlot" :allow-time-input="options?.allowTimeInput" :model-value="internalFilterValue" :first-day-of-week="options?.firstDayOfWeek" :is-date-disabled="options?.isDateDisabled" :number-of-months="options?.numberOfMonths" :quick-select-ranges="options?.quickSelectRanges" :variant="options?.variant" :disabled="disabled" :granularities="options?.granularities" :max-range="options?.maxRange" :min="options?.min" :max="options?.max" :date-form-data="formData" :show-end-date-on-open="options?.showEndDateOnOpen" :aria-labelledby="$attrs?.['aria-labelledby']" @update:model-value="onDateInputSelected" /> <template v-else> <div class="b-date-range-filter"> <date-range-picker-calendar :allow-time-input="options?.allowTimeInput" :first-day-of-week="options?.firstDayOfWeek" :is-date-disabled="options?.isDateDisabled" :granularities="options?.granularities" :max-range="options?.maxRange" :min="options?.min" :max="options?.max" :number-of-months="options?.numberOfMonths" :quick-select-ranges="adjustedQuickSelectRanges" :value="internalFilterValue" :date-form-data="formData" :show-end-date-on-open="options?.showEndDateOnOpen" :variant="options?.variant" @input="onDateSelected" @custom-range="onRangeSelectorInput" @error="onDatePickerRangeError" @form-date="onDatePickerRangeFormDateInputUpdate" @start-date-selected="isSelectingDate = true" > <template #title> <bento-typography strongest variant="body" el="span"> {{ label }} </bento-typography> </template> <template #actions> <bento-divider class="b-date-range-filter__divider" /> <bento-button-actions class="b-date-range-filter__actions" :actions="actions" layout="space-between" /> </template> </date-range-picker-calendar> </div> </template> </bento-base-filter> </template> <script lang="ts"> import { computed, defineComponent, type PropType, reactive, ref, toRef, watch } from 'vue'; import { BentoBaseFilter, useBaseFilter } from '../base-filter'; import { type BentoDateRangeFilterOptions, type BentoDateRangeFilterValue, BentoFilterEvent, } from '../../filter-bar.types'; import { DateRangePickerCalendar } from '@/components/date-range-picker/components/date-range-picker-calendar'; import { BentoDateRangePicker, type BentoDateRangePickerValue } from '@/components/date-range-picker'; import { BentoDivider } from '@/components/divider'; import { BentoTypography } from '@/components/typography'; import { BentoButtonActions, type BentoButtonActionsList } from '@/components/button/components/button-actions'; import { useI18n } from '@/utils/ts/i18n'; import { getDateLocale } from '@/utils/ts/get-date-locale/get-date-locale'; import { isSameDay } from 'date-fns/isSameDay'; import { format } from 'date-fns/format'; import { isEqual } from 'date-fns/isEqual'; import { isToday } from 'date-fns/isToday'; import { dateToTimeInputString } from '@/utils/ts/format-date'; import { useDateInputFormatter } from '@/composables/use-date-input-formatter/use-date-input-formatter'; import messages from './messages.json'; import { type DateRangePickerCalendarFormData, type DateRangePickerCalendarRangeSelectorItem, type DateRangePickerCalendarRangeSelectorItems, } from '@/components/date-range-picker/components/date-range-picker-calendar/date-range-picker-calendar.types'; type MessageSchema = (typeof messages)['en-US']; /** * The Date filter displays a calendar that allows * to select a single date. * * @example * import { BentoDateRangeFilter } from '@adyen/bento-vue2'; * * export default { * components: { BentoDateRangeFilter }, * template: ` * <bento-date-range-filter * :field="filter.field" * :label="filter.label" * :options="filter.options" * :value="filter.value" * @update="updateFilterHandler" * > * ` * } */ export default defineComponent({ i18n: { messages }, name: 'bento-date-range-filter', components: { BentoBaseFilter, BentoButtonActions, BentoDateRangePicker, BentoTypography, BentoDivider, DateRangePickerCalendar, }, props: { /** * Disables the filter from being interacted with. */ disabled: { type: Boolean, default: false }, /** * Default value of the filter to reset to. */ defaultValue: { type: Object as PropType<BentoDateRangeFilterValue>, default: null }, /** * Filter name/key referencing the actual property name in the data set. * Should be unique per filter bar. */ field: { type: String, required: true }, /** * Label text to be displayed */ label: { type: String, required: true }, /** * Renders the base filter's slot content only - leaving out the button and the popover. * Used for the allFiltersModal component. */ onlySlot: { type: Boolean, default: false }, /** * List of options that are needed to setup the filter * Available options: * - allowTimeInput: Allows user to enter time values in the form * - firstDayOfWeek: Allows user to set the first day of the week. 0 is sunday, 1 is monday, 6 is saturday * - isDateDisabled: Indicate if a date should be disabled or not * - maxRange: Set a maximum number of dates to be selectable by the range * - numberOfMonths: Number of months rendered on pane * - quickSelectRanges: An array that sets the custom range dropdown items. @see BentoDateRangePicker * - showEndDateOnOpen: Upon opening date range picker, end date's month will be pre-selected. * - granularities: A list of available granularities. * - variant: The type of calendar to display. Defaults to showing days. * - tooltipPosition: The position of the tooltip attached to the filter button * - tooltipText: The content of the tooltip attached to the filter button */ options: { type: Object as PropType<BentoDateRangeFilterOptions>, default: undefined, }, /** * Current value of the filter */ value: { type: Object as PropType<BentoDateRangeFilterValue>, default: () => ({ startDate: undefined, endDate: undefined }), }, }, emits: [BentoFilterEvent.INPUT, BentoFilterEvent.UPDATE], setup(props, { emit }) { const { locale, t } = useI18n<{ message: MessageSchema }>({ messages }); const locales = getDateLocale(); const { dateToInputDateString } = useDateInputFormatter(); const { internalFilterValue, isSecondaryButtonDisabled, isApplyButtonDisabled } = useBaseFilter( toRef(props, 'value'), toRef(props, 'defaultValue') ); const isSelectingDate = ref(false); const adjustedQuickSelectRanges = computed<Array<DateRangePickerCalendarRangeSelectorItem>>(() => { return ( props?.options?.quickSelectRanges && props.options.quickSelectRanges.map((range: DateRangePickerCalendarRangeSelectorItem) => { const timeDiff = !range.data.endDate ? new Date(Date.now()).getTime() - range.data.startDate.getTime() : undefined; return { ...range, data: { ...range.data, // Adding timeDifference to calculate an up-to-date endDate on range selection timeDifference: timeDiff, }, }; }) ); }); const baseFilter = ref(null); const isDateIncorrect = ref(false); const formData = reactive({ startDate: dateToInputDateString(props.value?.startDate), endDate: dateToInputDateString(props.value?.endDate), startTime: dateToTimeInputString(props.value?.startDate), endTime: dateToTimeInputString(props.value?.endDate), }); watch( () => props.value, () => { formData.startDate = dateToInputDateString(props.value?.startDate); formData.endDate = dateToInputDateString(props.value?.endDate); formData.startTime = dateToTimeInputString(props.value?.startDate); formData.endTime = dateToTimeInputString(props.value?.endDate); } ); function isQuickSelectAnArray( quickSelectRanges: BentoDateRangeFilterOptions['quickSelectRanges'] ): quickSelectRanges is DateRangePickerCalendarRangeSelectorItems { return (quickSelectRanges as DateRangePickerCalendarRangeSelectorItems)?.length !== undefined; } const calculateSecondaryLabel = (currentValue: BentoDateRangeFilterValue) => { if (!currentValue || (!currentValue?.startDate && !currentValue.endDate)) { return null; } // If quick select range selected then display its label if ( isQuickSelectAnArray(props.options?.quickSelectRanges) && props.options?.quickSelectRanges?.length > 0 ) { const customRangeSelected = props.options?.quickSelectRanges?.find(({ value, data }) => { if (currentValue) { if (currentValue.range) { return value === currentValue.range; } if (!currentValue.startDate || !currentValue.endDate) { return false; } if (!data?.endDate) { // Autoselecting the correct value for open-ended ranges return ( isEqual(data?.startDate, currentValue.startDate) && isToday(currentValue.endDate) ); } // If no range selected but start and end dates correspond to one of the preselected ranges, select it return ( isEqual(data?.startDate, currentValue.startDate) && isEqual(data?.endDate, currentValue.endDate) ); } return false; }); if (customRangeSelected) { return customRangeSelected.label; } } if (!currentValue.startDate || !currentValue.endDate) { return null; } return isSameDay(currentValue.startDate, currentValue.endDate) && props?.options?.allowTimeInput ? `${format(currentValue.startDate, 'dd MMM, yyyy HH:mm:ss', { locale: locales[locale.value?.substring(0, 2)] || locales.enUS, })} - ${format(currentValue.endDate, 'HH:mm:ss', { locale: locales[locale.value?.substring(0, 2)] || locales.enUS, })}` : `${format(currentValue.startDate, 'dd MMM, yyyy', { locale: locales[locale.value?.substring(0, 2)] || locales.enUS, })} - ${format(currentValue.endDate, 'dd MMM, yyyy', { locale: locales[locale.value?.substring(0, 2)] || locales.enUS, })}`; }; const buttonSecondaryLabel = ref(calculateSecondaryLabel(props.value)); const buttonLabel = computed(() => props.label); const updateFilter = () => { baseFilter.value.closePopover(); // Omit the onlySlot prop as it is not needed const { onlySlot, ...cleanedProps } = props; emit(BentoFilterEvent.UPDATE, { ...cleanedProps, value: internalFilterValue.value }); }; const clearFilter = () => { internalFilterValue.value = undefined; updateFilter(); }; const resetFilter = () => { internalFilterValue.value = props.defaultValue; updateFilter(); }; const openFilter = () => { internalFilterValue.value = props.value; }; // If we click outside or press escape this should cancel and reset the form inputs // set all internal states back to their initial values const onCancel = () => { internalFilterValue.value = props.value; isSelectingDate.value = false; formData.startDate = dateToInputDateString(props.value?.startDate); formData.endDate = dateToInputDateString(props.value?.endDate); formData.startTime = dateToTimeInputString(props.value?.startDate); formData.endTime = dateToTimeInputString(props.value?.endDate); }; const onDateSelected = (selectedDate: BentoDateRangePickerValue) => { isSelectingDate.value = false; internalFilterValue.value = selectedDate; formData.startDate = dateToInputDateString(selectedDate.startDate); formData.endDate = dateToInputDateString(selectedDate.endDate); emit(BentoFilterEvent.INPUT, { ...props, value: internalFilterValue.value }); }; const onRangeSelectorInput = (quickSelectRanges?: { startDate: Date; endDate: Date }) => { if (quickSelectRanges?.startDate && quickSelectRanges?.endDate) { internalFilterValue.value = quickSelectRanges; formData.startDate = dateToInputDateString(quickSelectRanges.startDate); formData.endDate = dateToInputDateString(quickSelectRanges.endDate); emit(BentoFilterEvent.INPUT, { ...props, value: internalFilterValue.value }); } }; // Need to update the internal value and emit the date when using the date-picker input const onDateInputSelected = (selectedDate: BentoDateRangePickerValue) => { onDateSelected(selectedDate); emit(BentoFilterEvent.INPUT, { ...props, value: internalFilterValue.value }); }; const actions = computed( () => [ { title: t('apply') as string, event: updateFilter, disabled: isSelectingDate.value || isDateIncorrect.value || isApplyButtonDisabled.value, }, { title: (props.defaultValue ? t('reset') : t('clear')) as string, event: (props.defaultValue ? resetFilter : clearFilter) as () => void, disabled: isDateIncorrect.value && isSecondaryButtonDisabled.value, }, ] as BentoButtonActionsList ); const onDatePickerRangeError = () => { isDateIncorrect.value = true; internalFilterValue.value = { startDate: undefined, endDate: undefined }; emit(BentoFilterEvent.INPUT, { ...props, value: internalFilterValue.value }); }; const onDatePickerRangeFormDateInputUpdate = (dateFormDate: DateRangePickerCalendarFormData) => { isDateIncorrect.value = false; if (dateFormDate.startTime) { formData.startTime = dateFormDate.startTime; } if (dateFormDate.endTime) { formData.endTime = dateFormDate.endTime; } }; watch( () => props.value, newValue => { buttonSecondaryLabel.value = calculateSecondaryLabel(newValue); }, { deep: true } ); return { // Refs baseFilter, // Values actions, formData, internalFilterValue, isApplyButtonDisabled, isSecondaryButtonDisabled, isSelectingDate, buttonLabel, buttonSecondaryLabel, adjustedQuickSelectRanges, // Events onCancel, onDateSelected, openFilter, clearFilter, resetFilter, updateFilter, onDatePickerRangeError, onDatePickerRangeFormDateInputUpdate, onDateInputSelected, onRangeSelectorInput, }; }, }); </script> <style lang="scss" scoped src="./date-range-filter.scss" />
1
+ <template> <bento-base-filter ref="baseFilter" class="b-base-filter-date-range-filter" :button-label="buttonLabel" :button-secondary-label="buttonSecondaryLabel" :controlled="!onlySlot" :disabled="disabled" :disable-apply-button="isApplyButtonDisabled" :disable-secondary-button="isSecondaryButtonDisabled" :default-value="defaultValue" :field="field" :label="label" :only-slot="onlySlot" :tooltip-position="options?.tooltipPosition" :tooltip-text="options?.tooltipText" :value="value" fit-content @open="openFilter" @clear="clearFilter" @reset="resetFilter" @cancel="onCancel" > <bento-date-range-picker v-if="onlySlot" :allow-time-input="options?.allowTimeInput" :model-value="internalFilterValue" :first-day-of-week="options?.firstDayOfWeek" :is-date-disabled="options?.isDateDisabled" :number-of-months="options?.numberOfMonths" :quick-select-ranges="options?.quickSelectRanges" :variant="options?.variant" :year-range="options?.yearRange" :disabled="disabled" :granularities="options?.granularities" :max-range="options?.maxRange" :min="options?.min" :max="options?.max" :date-form-data="formData" :show-end-date-on-open="options?.showEndDateOnOpen" :aria-labelledby="$attrs?.['aria-labelledby']" @update:model-value="onDateInputSelected" /> <template v-else> <div class="b-date-range-filter"> <date-range-picker-calendar :allow-time-input="options?.allowTimeInput" :first-day-of-week="options?.firstDayOfWeek" :is-date-disabled="options?.isDateDisabled" :granularities="options?.granularities" :max-range="options?.maxRange" :min="options?.min" :max="options?.max" :number-of-months="options?.numberOfMonths" :quick-select-ranges="adjustedQuickSelectRanges" :value="internalFilterValue" :date-form-data="formData" :show-end-date-on-open="options?.showEndDateOnOpen" :variant="options?.variant" :year-range="options?.yearRange" @input="onDateSelected" @custom-range="onRangeSelectorInput" @error="onDatePickerRangeError" @form-date="onDatePickerRangeFormDateInputUpdate" @start-date-selected="isSelectingDate = true" > <template #title> <bento-typography strongest variant="body" el="span"> {{ label }} </bento-typography> </template> <template #actions> <bento-divider class="b-date-range-filter__divider" /> <bento-button-actions class="b-date-range-filter__actions" :actions="actions" layout="space-between" /> </template> </date-range-picker-calendar> </div> </template> </bento-base-filter> </template> <script lang="ts"> import { computed, defineComponent, type PropType, reactive, ref, toRef, watch } from 'vue'; import { BentoBaseFilter, useBaseFilter } from '../base-filter'; import { type BentoDateRangeFilterOptions, type BentoDateRangeFilterValue, BentoFilterEvent, } from '../../filter-bar.types'; import { DateRangePickerCalendar } from '@/components/date-range-picker/components/date-range-picker-calendar'; import { BentoDateRangePicker, type BentoDateRangePickerValue } from '@/components/date-range-picker'; import { BentoDivider } from '@/components/divider'; import { BentoTypography } from '@/components/typography'; import { BentoButtonActions, type BentoButtonActionsList } from '@/components/button/components/button-actions'; import { useI18n } from '@/utils/ts/i18n'; import { getDateLocale } from '@/utils/ts/get-date-locale/get-date-locale'; import { isSameDay } from 'date-fns/isSameDay'; import { format } from 'date-fns/format'; import { isEqual } from 'date-fns/isEqual'; import { isToday } from 'date-fns/isToday'; import { dateToTimeInputString } from '@/utils/ts/format-date'; import { useDateInputFormatter } from '@/composables/use-date-input-formatter/use-date-input-formatter'; import messages from './messages.json'; import { type DateRangePickerCalendarFormData, type DateRangePickerCalendarRangeSelectorItem, type DateRangePickerCalendarRangeSelectorItems, } from '@/components/date-range-picker/components/date-range-picker-calendar/date-range-picker-calendar.types'; type MessageSchema = (typeof messages)['en-US']; /** * The Date filter displays a calendar that allows * to select a single date. * * @example * import { BentoDateRangeFilter } from '@adyen/bento-vue2'; * * export default { * components: { BentoDateRangeFilter }, * template: ` * <bento-date-range-filter * :field="filter.field" * :label="filter.label" * :options="filter.options" * :value="filter.value" * @update="updateFilterHandler" * > * ` * } */ export default defineComponent({ i18n: { messages }, name: 'bento-date-range-filter', components: { BentoBaseFilter, BentoButtonActions, BentoDateRangePicker, BentoTypography, BentoDivider, DateRangePickerCalendar, }, props: { /** * Disables the filter from being interacted with. */ disabled: { type: Boolean, default: false }, /** * Default value of the filter to reset to. */ defaultValue: { type: Object as PropType<BentoDateRangeFilterValue>, default: null }, /** * Filter name/key referencing the actual property name in the data set. * Should be unique per filter bar. */ field: { type: String, required: true }, /** * Label text to be displayed */ label: { type: String, required: true }, /** * Renders the base filter's slot content only - leaving out the button and the popover. * Used for the allFiltersModal component. */ onlySlot: { type: Boolean, default: false }, /** * List of options that are needed to setup the filter * Available options: * - allowTimeInput: Allows user to enter time values in the form * - firstDayOfWeek: Allows user to set the first day of the week. 0 is sunday, 1 is monday, 6 is saturday * - isDateDisabled: Indicate if a date should be disabled or not * - maxRange: Set a maximum number of dates to be selectable by the range * - numberOfMonths: Number of months rendered on pane * - quickSelectRanges: An array that sets the custom range dropdown items. @see BentoDateRangePicker * - showEndDateOnOpen: Upon opening date range picker, end date's month will be pre-selected. * - granularities: A list of available granularities. * - variant: The type of calendar to display. Defaults to showing days. * - tooltipPosition: The position of the tooltip attached to the filter button * - tooltipText: The content of the tooltip attached to the filter button */ options: { type: Object as PropType<BentoDateRangeFilterOptions>, default: undefined, }, /** * Current value of the filter */ value: { type: Object as PropType<BentoDateRangeFilterValue>, default: () => ({ startDate: undefined, endDate: undefined }), }, }, emits: [BentoFilterEvent.INPUT, BentoFilterEvent.UPDATE], setup(props, { emit }) { const { locale, t } = useI18n<{ message: MessageSchema }>({ messages }); const locales = getDateLocale(); const { dateToInputDateString } = useDateInputFormatter(); const { internalFilterValue, isSecondaryButtonDisabled, isApplyButtonDisabled } = useBaseFilter( toRef(props, 'value'), toRef(props, 'defaultValue') ); const isSelectingDate = ref(false); const adjustedQuickSelectRanges = computed<Array<DateRangePickerCalendarRangeSelectorItem>>(() => { return ( props?.options?.quickSelectRanges && props.options.quickSelectRanges.map((range: DateRangePickerCalendarRangeSelectorItem) => { const timeDiff = !range.data.endDate ? new Date(Date.now()).getTime() - range.data.startDate.getTime() : undefined; return { ...range, data: { ...range.data, // Adding timeDifference to calculate an up-to-date endDate on range selection timeDifference: timeDiff, }, }; }) ); }); const baseFilter = ref(null); const isDateIncorrect = ref(false); const formData = reactive({ startDate: dateToInputDateString(props.value?.startDate), endDate: dateToInputDateString(props.value?.endDate), startTime: dateToTimeInputString(props.value?.startDate), endTime: dateToTimeInputString(props.value?.endDate), }); watch( () => props.value, () => { formData.startDate = dateToInputDateString(props.value?.startDate); formData.endDate = dateToInputDateString(props.value?.endDate); formData.startTime = dateToTimeInputString(props.value?.startDate); formData.endTime = dateToTimeInputString(props.value?.endDate); } ); function isQuickSelectAnArray( quickSelectRanges: BentoDateRangeFilterOptions['quickSelectRanges'] ): quickSelectRanges is DateRangePickerCalendarRangeSelectorItems { return (quickSelectRanges as DateRangePickerCalendarRangeSelectorItems)?.length !== undefined; } const calculateSecondaryLabel = (currentValue: BentoDateRangeFilterValue) => { if (!currentValue || (!currentValue?.startDate && !currentValue.endDate)) { return null; } // If quick select range selected then display its label if ( isQuickSelectAnArray(props.options?.quickSelectRanges) && props.options?.quickSelectRanges?.length > 0 ) { const customRangeSelected = props.options?.quickSelectRanges?.find(({ value, data }) => { if (currentValue) { if (currentValue.range) { return value === currentValue.range; } if (!currentValue.startDate || !currentValue.endDate) { return false; } if (!data?.endDate) { // Autoselecting the correct value for open-ended ranges return ( isEqual(data?.startDate, currentValue.startDate) && isToday(currentValue.endDate) ); } // If no range selected but start and end dates correspond to one of the preselected ranges, select it return ( isEqual(data?.startDate, currentValue.startDate) && isEqual(data?.endDate, currentValue.endDate) ); } return false; }); if (customRangeSelected) { return customRangeSelected.label; } } if (!currentValue.startDate || !currentValue.endDate) { return null; } return isSameDay(currentValue.startDate, currentValue.endDate) && props?.options?.allowTimeInput ? `${format(currentValue.startDate, 'dd MMM, yyyy HH:mm:ss', { locale: locales[locale.value?.substring(0, 2)] || locales.enUS, })} - ${format(currentValue.endDate, 'HH:mm:ss', { locale: locales[locale.value?.substring(0, 2)] || locales.enUS, })}` : `${format(currentValue.startDate, 'dd MMM, yyyy', { locale: locales[locale.value?.substring(0, 2)] || locales.enUS, })} - ${format(currentValue.endDate, 'dd MMM, yyyy', { locale: locales[locale.value?.substring(0, 2)] || locales.enUS, })}`; }; const buttonSecondaryLabel = ref(calculateSecondaryLabel(props.value)); const buttonLabel = computed(() => props.label); const updateFilter = () => { baseFilter.value.closePopover(); // Omit the onlySlot prop as it is not needed const { onlySlot, ...cleanedProps } = props; emit(BentoFilterEvent.UPDATE, { ...cleanedProps, value: internalFilterValue.value }); }; const clearFilter = () => { internalFilterValue.value = undefined; updateFilter(); }; const resetFilter = () => { internalFilterValue.value = props.defaultValue; updateFilter(); }; const openFilter = () => { internalFilterValue.value = props.value; }; // If we click outside or press escape this should cancel and reset the form inputs // set all internal states back to their initial values const onCancel = () => { internalFilterValue.value = props.value; isSelectingDate.value = false; formData.startDate = dateToInputDateString(props.value?.startDate); formData.endDate = dateToInputDateString(props.value?.endDate); formData.startTime = dateToTimeInputString(props.value?.startDate); formData.endTime = dateToTimeInputString(props.value?.endDate); }; const onDateSelected = (selectedDate: BentoDateRangePickerValue) => { isSelectingDate.value = false; internalFilterValue.value = selectedDate; formData.startDate = dateToInputDateString(selectedDate.startDate); formData.endDate = dateToInputDateString(selectedDate.endDate); emit(BentoFilterEvent.INPUT, { ...props, value: internalFilterValue.value }); }; const onRangeSelectorInput = (quickSelectRanges?: { startDate: Date; endDate: Date }) => { if (quickSelectRanges?.startDate && quickSelectRanges?.endDate) { internalFilterValue.value = quickSelectRanges; formData.startDate = dateToInputDateString(quickSelectRanges.startDate); formData.endDate = dateToInputDateString(quickSelectRanges.endDate); emit(BentoFilterEvent.INPUT, { ...props, value: internalFilterValue.value }); } }; // Need to update the internal value and emit the date when using the date-picker input const onDateInputSelected = (selectedDate: BentoDateRangePickerValue) => { onDateSelected(selectedDate); emit(BentoFilterEvent.INPUT, { ...props, value: internalFilterValue.value }); }; const actions = computed( () => [ { title: t('apply') as string, event: updateFilter, disabled: isSelectingDate.value || isDateIncorrect.value || isApplyButtonDisabled.value, }, { title: (props.defaultValue ? t('reset') : t('clear')) as string, event: (props.defaultValue ? resetFilter : clearFilter) as () => void, disabled: isDateIncorrect.value && isSecondaryButtonDisabled.value, }, ] as BentoButtonActionsList ); const onDatePickerRangeError = () => { isDateIncorrect.value = true; internalFilterValue.value = { startDate: undefined, endDate: undefined }; emit(BentoFilterEvent.INPUT, { ...props, value: internalFilterValue.value }); }; const onDatePickerRangeFormDateInputUpdate = (dateFormDate: DateRangePickerCalendarFormData) => { isDateIncorrect.value = false; if (dateFormDate.startTime) { formData.startTime = dateFormDate.startTime; } if (dateFormDate.endTime) { formData.endTime = dateFormDate.endTime; } }; watch( () => props.value, newValue => { buttonSecondaryLabel.value = calculateSecondaryLabel(newValue); }, { deep: true } ); return { // Refs baseFilter, // Values actions, formData, internalFilterValue, isApplyButtonDisabled, isSecondaryButtonDisabled, isSelectingDate, buttonLabel, buttonSecondaryLabel, adjustedQuickSelectRanges, // Events onCancel, onDateSelected, openFilter, clearFilter, resetFilter, updateFilter, onDatePickerRangeError, onDatePickerRangeFormDateInputUpdate, onDateInputSelected, onRangeSelectorInput, }; }, }); </script> <style lang="scss" scoped src="./date-range-filter.scss" />
@@ -594,6 +594,8 @@ For more details see the `bento-dropdown` documentation.
594
594
  - `firstDayOfWeek: BentoDatePickerFirstDayOfWeek`: Allows you to set the first day of the week: MONDAY, SUNDAY,
595
595
  SATURDAY. (Default: MONDAY)
596
596
  - `isDateDisabled: (date: Date) => boolean`: Function that allows to disable individual dates
597
+ - `yearRange: BentoDatePickerYearRange`: Range of years to be selectable from the year dropdown when using the month
598
+ variant
597
599
 
598
600
  #### Configuration Interface
599
601
 
@@ -605,6 +607,7 @@ For more details see the `bento-dropdown` documentation.
605
607
  options: {
606
608
  firstDayOfWeek?: BentoDatePickerFirstDayOfWeek,
607
609
  isDateDisabled?: (date: Date) => boolean,
610
+ yearRange?: BentoDatePickerYearRange,
608
611
  },
609
612
  }
610
613
  ```
@@ -627,6 +630,8 @@ For more details see the `bento-dropdown` documentation.
627
630
  SATURDAY. (Default: MONDAY)
628
631
  - `isDateDisabled: (date: Date) => boolean`: Function that allows to disable individual dates
629
632
  - `maxRange: number`: Set a maximum number of dates to be selectable by the range.
633
+ - `yearRange: BentoDateRangePickerYearRange`: Range of years to be selectable from the year dropdown when using the
634
+ month variant
630
635
  - `numberOfMonths: number`: Number of months rendered in the filter
631
636
  - `quickSelectRanges`:
632
637
  `Array<{ label: string, value: string, data: { startDate: Date, endDate?: Date, isRelativeToNow?: boolean } }>`: An
@@ -646,6 +651,7 @@ For more details see the `bento-dropdown` documentation.
646
651
  firstDayOfWeek?: BentoDatePickerFirstDayOfWeek;
647
652
  isDateDisabled?: (date: Date) => boolean;
648
653
  maxRange?: number;
654
+ yearRange?: BentoDateRangePickerYearRange;
649
655
  numberOfMonths?: number;
650
656
  quickSelectRanges: { label: string, value: string, data: { startDate: Date, endDate?: Date, isRelativeToNow?: boolean } }[];
651
657
  showEndDateOnOpen?: boolean;
@@ -700,7 +706,7 @@ A filter that displays a group of checkboxes. Provided by the `BentoFilterItemTy
700
706
 
701
707
  ### Radio group filter
702
708
 
703
- - `items: BentoRadioGroupFilterValue`: Sets a placeholder in the input field.
709
+ - `items: BentoRadioGroupItems`: Sets a placeholder in the input field.
704
710
  - `tooltipText?: string`: Attaches a `BentoTooltip` if present. Text showed in the tooltip on hover.
705
711
  - `tooltipPosition?: BentoTooltipPosition | string`: Position the tooltip should take if space is available.
706
712
 
@@ -714,7 +720,7 @@ A filter that displays a group of checkboxes. Provided by the `BentoFilterItemTy
714
720
  defaultValue: BentoRadioGroupFilterValue;
715
721
  value: BentoRadioGroupFilterValue;
716
722
  options: {
717
- items: BentoRadioGroupFilterValue;
723
+ items: BentoRadioGroupItems;
718
724
  tooltipText?: string;
719
725
  tooltipPosition?: BentoTooltipPosition | `${BentoTooltipPosition}`;
720
726
  }