@adyen/bento-mcp 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/dist/assets/components/alert/alert.types.ts +1 -1
  3. package/dist/assets/components/button/button.types.ts +1 -1
  4. package/dist/assets/components/button/components/button-actions/button-actions.types.ts +1 -1
  5. package/dist/assets/components/card/card.types.ts +1 -1
  6. package/dist/assets/components/checkbox/checkbox.vue +1 -1
  7. package/dist/assets/components/checkbox/components/checkbox-group/checkbox-group.types.ts +1 -1
  8. package/dist/assets/components/checkbox/components/checkbox-group/checkbox-group.vue +1 -1
  9. package/dist/assets/components/code-snippet/code-snippet.types.ts +1 -1
  10. package/dist/assets/components/country/country.types.ts +1 -1
  11. package/dist/assets/components/data-grid/components/data-grid-cell-actions/data-grid-cell-actions.types.ts +1 -1
  12. package/dist/assets/components/data-grid/components/data-grid-cell-actions/data-grid-cell-actions.vue +1 -1
  13. package/dist/assets/components/data-grid/components/data-grid-row/data-grid-row.vue +1 -1
  14. package/dist/assets/components/data-grid/data-grid.docs.mdx +1 -1
  15. package/dist/assets/components/data-grid/data-grid.vue +1 -1
  16. package/dist/assets/components/date-picker/date-picker.types.ts +1 -1
  17. package/dist/assets/components/date-picker/date-picker.vue +1 -1
  18. package/dist/assets/components/date-range-picker/components/date-range-picker-calendar/date-range-picker-calendar.vue +1 -1
  19. package/dist/assets/components/date-range-picker/date-range-picker.types.ts +1 -1
  20. package/dist/assets/components/draggable/components/draggable-handle/draggable-handle.stories.ts +1 -0
  21. package/dist/assets/components/draggable/components/draggable-handle/draggable-handle.types.ts +1 -0
  22. package/dist/assets/components/draggable/components/draggable-handle/draggable-handle.vue +1 -0
  23. package/dist/assets/components/draggable/draggable.docs.mdx +178 -0
  24. package/dist/assets/components/draggable/draggable.stories.ts +1 -0
  25. package/dist/assets/components/draggable/draggable.types.ts +1 -0
  26. package/dist/assets/components/draggable/draggable.vue +1 -0
  27. package/dist/assets/components/dropdown/components/variants/dropdown-avatar/dropdown-avatar.vue +1 -1
  28. package/dist/assets/components/dropdown/components/variants/dropdown-country/dropdown-country.vue +1 -1
  29. package/dist/assets/components/dropdown/components/variants/dropdown-currency/dropdown-currency.vue +1 -1
  30. package/dist/assets/components/dropdown/components/variants/dropdown-payment-method/dropdown-payment-method.vue +1 -1
  31. package/dist/assets/components/dropdown/components/variants/dropdown-tag/dropdown-tag.vue +1 -1
  32. package/dist/assets/components/dropdown/composables/use-keyboard-navigation.types.ts +1 -1
  33. package/dist/assets/components/dropdown/dropdown.stories.ts +1 -1
  34. package/dist/assets/components/dropdown/dropdown.types.ts +1 -1
  35. package/dist/assets/components/dropdown/dropdown.vue +1 -1
  36. package/dist/assets/components/file-uploader/file-uploader.vue +1 -1
  37. package/dist/assets/components/filter-bar/components/boolean-filter/boolean-filter.vue +1 -1
  38. package/dist/assets/components/filter-bar/components/date-filter/date-filter.vue +1 -1
  39. package/dist/assets/components/filter-bar/components/input-filter/input-filter.types.ts +1 -1
  40. package/dist/assets/components/filter-bar/filter-bar.docs.mdx +1 -0
  41. package/dist/assets/components/filter-bar/filter-bar.stories.ts +1 -1
  42. package/dist/assets/components/filter-bar/filter-bar.types.ts +1 -1
  43. package/dist/assets/components/filter-bar/filter-bar.vue +1 -1
  44. package/dist/assets/components/form-layout/components/form-layout-group/form-layout-group.vue +1 -1
  45. package/dist/assets/components/form-layout/form-layout.docs.mdx +33 -20
  46. package/dist/assets/components/form-layout/form-layout.stories.ts +1 -1
  47. package/dist/assets/components/header/header.types.ts +1 -1
  48. package/dist/assets/components/input-field/input-field.types.ts +1 -1
  49. package/dist/assets/components/input-field/input-field.vue +1 -1
  50. package/dist/assets/components/input-field-password/input-field-password.types.ts +1 -1
  51. package/dist/assets/components/input-field-password/input-field-password.vue +1 -1
  52. package/dist/assets/components/input-field-phone-number/input-field-phone-number.types.ts +1 -1
  53. package/dist/assets/components/input-field-phone-number/input-field-phone-number.vue +1 -1
  54. package/dist/assets/components/input-time/input-time.vue +1 -1
  55. package/dist/assets/components/internal/calendar/calendar.types.ts +1 -1
  56. package/dist/assets/components/internal/calendar/composables/use-range-pane.types.ts +1 -0
  57. package/dist/assets/components/internal/listbox/components/listbox-multi-select/listbox-multi-select.vue +1 -1
  58. package/dist/assets/components/internal/listbox/components/listbox-single-select/listbox-single-select.vue +1 -1
  59. package/dist/assets/components/internal/listbox/listbox.vue +1 -1
  60. package/dist/assets/components/internal/teleport/teleport.vue +1 -1
  61. package/dist/assets/components/link/link.types.ts +1 -1
  62. package/dist/assets/components/menu/menu.types.ts +1 -1
  63. package/dist/assets/components/menu/menu.vue +1 -1
  64. package/dist/assets/components/modal/modal.types.ts +1 -1
  65. package/dist/assets/components/modal/modal.vue +1 -1
  66. package/dist/assets/components/pagination/pagination.types.ts +1 -1
  67. package/dist/assets/components/payment-method/payment-method.types.ts +1 -1
  68. package/dist/assets/components/popover/popover.types.ts +1 -1
  69. package/dist/assets/components/popper-container/popper-container.types.ts +1 -1
  70. package/dist/assets/components/promo-banner/promo-banner.types.ts +1 -1
  71. package/dist/assets/components/radio-group/radio-group.types.ts +1 -1
  72. package/dist/assets/components/segmented-control/segmented-control.types.ts +1 -1
  73. package/dist/assets/components/status/status.types.ts +1 -1
  74. package/dist/assets/components/summary-grid/components/summary-grid-item/summary-grid-item.types.ts +1 -1
  75. package/dist/assets/components/summary-grid/summary-grid.types.ts +1 -1
  76. package/dist/assets/components/tag/tag.types.ts +1 -1
  77. package/dist/assets/components/textarea/textarea.types.ts +1 -1
  78. package/dist/assets/components/textarea/textarea.vue +1 -1
  79. package/dist/assets/components/timeline/timeline.types.ts +1 -1
  80. package/dist/assets/components/toggle/toggle.types.ts +1 -1
  81. package/dist/assets/components/toggle/toggle.vue +1 -1
  82. package/dist/assets/components/typography/typography.types.ts +1 -1
  83. package/dist/assets/components.json +101 -100
  84. package/dist/assets/composables/use-bento-theme/install.types.ts +1 -1
  85. package/dist/assets/composables/use-form-validate/use-form.types.ts +1 -1
  86. package/dist/assets/deprecations.md +196 -196
  87. package/dist/assets/icons.json +18 -0
  88. package/dist/assets/index.ts +1 -1
  89. package/dist/assets/tailwind/README.md +45 -5
  90. package/dist/assets/usage.json +103 -0
  91. package/dist/main.js +53 -3
  92. package/package.json +2 -3
  93. package/dist/assets/components/draggable-handle/draggable-handle.stories.ts +0 -1
  94. package/dist/assets/components/draggable-handle/draggable-handle.types.ts +0 -1
  95. package/dist/assets/components/draggable-handle/draggable-handle.vue +0 -1
  96. /package/dist/assets/components/{draggable-handle → draggable/components/draggable-handle}/draggable-handle.docs.mdx +0 -0
@@ -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 version 2.0. Use `v-model` or `update:model-value instead. */ (e: 'input', selectedValue: BentoListboxSelectedValue | any); // eslint-disable-line @typescript-eslint/no-explicit-any /** * Emitted when an option is clicked. Updates the selected option from the "modelValue" property linked to the v-model. */ (e: 'update:model-value', selectedValue: BentoListboxSelectedValue | any); // eslint-disable-line @typescript-eslint/no-explicit-any /** * Emitted when: * - `lazyLoad` is `automatic` and the scroll meets the end of the list * - `lazyload` is `button` and the button is clicked */ (e: 'show-more'); /** * Emitted when the dropdown is opened. */ (e: 'open'); }>(); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const { emitValue } = useFormFieldEmits<BentoListboxSelectedValue>(emit); const inputContainerRef = ref(null); const inputContainerRefWrapper = ref(null); const optionsContainerRef: Ref<InstanceType<typeof BentoDropdownOptionsContainer>> = ref(null); const isDropdownOpen = ref(false); const dropdownOptionsContainerId = generateUid('bento-dropdown-options-container'); const labelId = generateUid('dropdown-label'); const descriptionId = generateUid('dropdown-description'); const errorId = generateUid('dropdown-error'); const { isReadOnly } = useFormLayoutFieldLoading(toRef(props, 'readonly')); const dropdownValue = computed(() => props.modelValue ?? props.value); const { cachedSelectedListboxOptions, cachedListboxSelectedValues, setCachedValues } = useCachedSelectedValues( toRef(props, 'items'), dropdownValue, toRef(props, 'multiple'), toRef(props, 'enableValueLabelPair') ); const ariaLabelAttribute = ref(attrs['aria-label']); const ariaDescribedByAttribute = ref(attrs['aria-describedby']); const { ariaLabel, label } = toRefs(props); // vue-i18n issue with t() used in script - direct usage in template works // requires either createI18n({ legacy: false }) or to be wrapped in computed() const computedAriaLabelFallbackMessage = computed(() => t('ariaLabelFallback')); const computedAriaLabel = useAriaLabel({ // TODO: take ariaLabel ref away when aria-label prop is removed ariaLabel: ariaLabel.value || (ariaLabelAttribute as Ref<string>), label, defaultFallback: computedAriaLabelFallbackMessage, }); // Inject input field component key to change the dropdown size const isInputComponent = props.size === BentoDropdownSize.SMALL || inject(INPUT_FIELD_COMPONENT_INJECTION_KEY, false); const computedSize = computed(() => (isInputComponent ? BentoDropdownSize.SMALL : BentoDropdownSize.DEFAULT)); const conditionalClasses = computed(() => ({ 'b-dropdown--condensed': props.condensed, })); // Description styles const descriptionConditionalClasses = computed(() => ({ 'b-dropdown__description--error': !!props.errorMessage, })); // Filtering const searchTerm = ref(''); const items = toRef(props, 'items'); const staticCategories = toRef(props, 'staticCategories'); const filteredItems = useSearchBarFilter( searchTerm, items, props?.search?.searchEvent, staticCategories, props?.search?.debounceTime ); const isSearching = computed(() => props.dynamicFiltering && !!searchTerm.value); const displayValue = useDisplayValue({ items, placeholder: toRef(props, 'placeholder'), isMultiSelect: toRef(props, 'multiple'), selectedValues: cachedSelectedListboxOptions, }); const selectedValueItem = computed(() => { if (props?.multiple && Array.isArray(dropdownValue.value)) { return props.items.filter( ({ value }) => props?.value && (props?.value as BentoListboxMultiSelectValue).includes(value) ); } if (!props.multiple && props.staticCategories) { // Find the category that holds the selected value const category = props.items.find(categoryItem => categoryItem.items.some(item => item.value === dropdownValue.value) ); // Find the specific item within the category const selectedCategoryItem = category ? category.items.find(item => item.value === dropdownValue.value) : null; return selectedCategoryItem; } return props.items.find(({ value }) => value === dropdownValue.value); }); const selectedValueIndex = computed(() => { const index = filteredItems.value.findIndex(({ value }) => { return props.enableValueLabelPair ? (dropdownValue.value as BentoListboxSelectedValueLabelPair).value === value : dropdownValue.value === value; }); return index > -1 ? index : 0; }); const { isMultiLevelSelect, flattenedItemsList } = useMultiLevelItems(items); const isTreePattern = computed(() => isMultiLevelSelect.value && !props.staticCategories); const computedOptionRole = computed<BentoListboxItemRole>(() => (isTreePattern.value ? 'treeitem' : 'option')); const additionalItemsSelected = computed(() => { if (props.multiple) { const selectedItemsCount = Array.isArray(dropdownValue.value) ? dropdownValue.value.length : 0; const total = isMultiLevelSelect.value ? flattenedItemsList.value.length : items.value.length; if (selectedItemsCount === 0) { return null; } // Display count always - show "all" only when is not external filtering (hasMoreItems = true) if (!props.hasMoreItems && total > 0 && total === selectedItemsCount) { return t('all'); } return selectedItemsCount; } return null; }); // ARIA const ariaDescribedBy = computed( () => [ props.description ? descriptionId : null, ariaDescribedByAttribute.value, props.errorMessage ? errorId : null, ] .filter(Boolean) .join(' ') || null ); const listboxRef = computed(() => optionsContainerRef.value?.$el as HTMLDivElement); const { visibleDomOptions, updateVisibleDomOptions } = useVisibleDomOptions( listboxRef, filteredItems, computedOptionRole ); const openDropdown = () => { if (isReadOnly.value) { return; } isDropdownOpen.value = true; nextTick(() => { updateVisibleDomOptions(); }); }; const closeDropdown = () => { isDropdownOpen.value = false; }; const toggleDropdown = () => { if (isReadOnly.value) { return; } isDropdownOpen.value = !isDropdownOpen.value; nextTick(() => { updateVisibleDomOptions(); }); // Clear search if (isSearching.value) { searchTerm.value = ''; } }; const clickOutsideDropdown = () => { if (isSearching.value) { searchTerm.value = ''; } if (isDropdownOpen.value) { isDropdownOpen.value = false; // Reset the internal dropdown options state optionsContainerRef.value.onOutsideDropdownClick(); } }; const onOptionSelected = 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, }, toggleDropdown, openDropdown, closeDropdown ); const listboxKeyboardNavigationListeners = useListboxKeyboardNavigation( { inputContainerRef, isDropdownOpen, }, closeDropdown ); const listboxListeners = { [BentoListboxEvent.CLOSE_DROPDOWN]: closeDropdown, [BentoListboxEvent.SELECT]: onOptionSelected, [BentoListboxEvent.SHOW_MORE]: emitShowMoreEvent, ...listboxKeyboardNavigationListeners, }; if (props.error) { deprecate( 'BentoDropdown "error" property', `Use the BentoDropdown "errorMessage" property instead to indicate that there is a modifier error. Having the field as "undefined" will hide/remove the error. <bento-dropdown errorMessage="Error message text" />`, '2.0.0' ); } if (props.size) { deprecate('BentoDropdown "size" property', `Do not use. Only 'default' size should be used.`, '2.0.0'); } if (props.value) { deprecate( 'BentoDropdown "value" property', `The use of "value" prop in "BentoDropdown" is no longer supported. Use the "v-model" or "model-value" property instead.`, '2.0.0' ); } const focus = () => { inputContainerRef.value.focus(); }; watch( () => isDropdownOpen.value, (isOpen: boolean) => { if (isOpen) { emit('open'); } } ); defineExpose({ focus, }); </script> <script lang="ts"> /** * A Dropdown shows a selected option's. * Use a dropdown when you want users to select options * from a list of pre-defined options. * * @example * import { BentoDropdown } from '@adyen/bento-vue2'; * import type { BentoDropdownOptions } from '@adyen/bento-vue2' * * export default { * components: { BentoDropdown }, * template: ` * <bento-dropdown * v-model="selectedValue" * :disabled="false" * :multiple="true" * :isOptionsDisabled="option => option.value === 2" * :items="options" * /> * `, * setup() { * const selectedValue = ref(2) // Default value * const options: BentoDropdownOptions = [ * { label: 'Option 1', value: 1 }, * { label: 'Option 2', value: 2 }, * ]; * return { options, selectedValue }; * } * } */ export default defineComponent({ i18n: { messages }, name: 'b-dropdown', components: { DropdownInputDefault, DropdownInputSmall, }, model: { prop: 'modelValue' }, }); </script> <style lang="scss" scoped src="./dropdown.scss" />
1
+ <!-- TODO: Remove aria props as these are part of JSX elements /vue/types/jsx.d.ts --> <!-- eslint-disable vue/attribute-hyphenation --> <template> <div v-bento-click-outside-directive="clickOutsideDropdown" class="b-dropdown" :class="conditionalClasses"> <field-label v-if="label" :id="labelId" :condensed="condensed" :label="label" :tooltip-text="tooltipText" :optional="optional" :required="required" /> <div ref="inputContainerRefWrapper" v-bento-keyboard-navigation-directive v-on="textboxKeyboardNavigationListeners" > <component :is="computedSize" ref="inputContainerRef" :condensed="condensed" :value="searchTerm" :additional-items-selected="additionalItemsSelected" :aria-controls="dropdownOptionsContainerId" :ariaExpanded="isDropdownOpen" :aria-labelledby="label ? labelId : null" :aria-describedby="ariaDescribedBy" :aria-label="computedAriaLabel" :aria-readonly="isReadOnly" :aria-required="required" :readonly="isReadOnly" :disabled="disabled" :display-value="displayValue" :dynamic-filtering="dynamicFiltering" :is-invalid="!!errorMessage || error" :multiple="multiple" :open="isDropdownOpen" :selected-value-item="!isInputComponent ? selectedValueItem : undefined" :show-slot-content-in-multiple="showSlotContentInMultiple" @input="searchTerm = $event" @open="toggleDropdown" @close="toggleDropdown" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </component> </div> <bento-dropdown-options-container v-if="inputContainerRef" :id="dropdownOptionsContainerId" ref="optionsContainerRef" :aria-label="computedAriaLabel" :component-loading="componentLoading" :empty-state="emptyState" :disabled="disabled" :is-option-disabled="isOptionDisabled" :items="filteredItems" :multiple="multiple" :static-categories="staticCategories" :open="isDropdownOpen" :selected-value="cachedSelectedListboxOptions" :searching="isSearching" :target-element="inputContainerRefWrapper" :loading="loading" :has-more-items="hasMoreItems" :lazy-load-type="lazyLoadType" :virtual-scroll="virtualScroll" v-on="listboxListeners" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </bento-dropdown-options-container> <error-message v-if="!!errorMessage" :id="errorId" :error-message="errorMessage" class="b-dropdown__error-message" /> <bento-typography v-if="description || hasSlot('description')" :id="descriptionId" class="b-dropdown__description" :class="descriptionConditionalClasses" el="span" > <slot name="description"> {{ description }} </slot> </bento-typography> </div> </template> <script setup lang="ts"> import { computed, defineComponent, inject, nextTick, type Ref, ref, toRef, toRefs, useAttrs, useSlots, watch, } from 'vue'; // Components import { BentoDropdownOptionsContainer } from './components/dropdown-options-container'; import { BentoTypography } from '@/components/typography'; import { DropdownInputDefault, DropdownInputSmall } from './components'; import { BentoListbox, ErrorMessage, FieldLabel, useCachedSelectedValues, useMultiLevelItems } from '@/internal'; // Utils import { deprecate } from '@/utils/ts/deprecate'; import { generateUid } from '@/core/utils/ts'; import { useAriaLabel } from '@/utils/ts/aria-label'; import { useI18n } from '@/utils/ts/i18n'; import { useFormFieldEmits } from '@/utils/ts/form-field/form-field'; // Composables import { useDisplayValue, useListboxKeyboardNavigation, useTextboxKeyboardNavigation } from './composables'; import { useVisibleDomOptions } from '@/internal/listbox/composables/use-visible-dom-options/use-visible-dom-options'; import { useFormLayoutFieldLoading } from '@/composables/use-form-layout-loading/use-form-layout-loading'; import { useSearchBarFilter } from '@/components/search-bar/useSearchBarDataFilter'; // Directives import { BentoClickOutsideDirective as vBentoClickOutsideDirective } from '@/directives/click-outside'; import { BentoKeyboardNavigationDirective as vBentoKeyboardNavigationDirective } from '@/directives'; // Types import { BentoDropdownEvent, type BentoDropdownProps, BentoDropdownSize } from './dropdown.types'; import { BentoListboxEvent, type BentoListboxItemRole, type BentoListboxMultiSelectValue, type BentoListboxOptions, type BentoListboxSelectedValue, type BentoListboxSelectedValueLabelPair, } from '@/types/listbox'; import messages from './messages.json'; import { useHasSlot } from '@/composables'; import { INPUT_FIELD_COMPONENT_INJECTION_KEY } from '@/components/input-field/input-field.keys'; type MessageSchema = (typeof messages)['en-US']; const attrs = useAttrs(); const slots = useSlots(); const hasSlot = useHasSlot(slots); const props = withDefaults(defineProps<BentoDropdownProps>(), { ariaLabel: null, condensed: false, emptyState: undefined, disabled: false, dynamicFiltering: false, error: false, errorMessage: null, description: null, hasMoreItems: false, isOptionDisabled: undefined, items: () => [], label: null, loading: false, componentLoading: false, lazyLoadType: BentoListbox.props.lazyLoadType.default, multiple: false, optional: false, placeholder: null, readonly: false, required: false, search: undefined, showSlotContentInMultiple: false, size: null, staticCategories: false, tooltipText: null, value: null, modelValue: null, virtualScroll: false, enableValueLabelPair: false, }); const emit = defineEmits<{ /** * Emitted when an option is clicked. Updates the selected option from the "value" property linked to the v-model. * @deprecated Since 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 +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 from version 2.0.0. Use `v-model` or `update:model-value` instead. */ (e: 'input', value?: FileList): void; /** * Vue3: Emitted when files are added or removed. Updates the v-model. * Provides the entire list of files available. * Can be undefined if the list is empty */ (e: 'update:model-value', value?: FileList): void; /** * Emitted when any file uploaded has state === FileState.ERROR. * Provides parent component with boolean. * Emits false when no files have errors. */ (e: 'error:upload', value: boolean): void; }>(); const props = withDefaults(defineProps<BentoFileUploaderProps>(), { accept: null, condensed: false, description: null, disabled: false, errorMessage: null, label: null, maxCount: 1, // One file by default maxDimensions: null, maxSize: null, modelValue: null, optional: false, required: false, tooltipText: null, }); const { emitValue } = useFormFieldEmits<BentoFileUploaderValue>(emit); /** * Model value checks * Filter out type string, array and file as FileList is the only type un-deprecated for modelValue * */ /** * Type guard to check if the value is a FileList. * @param files The value to check. */ function isFileList(files: BentoFileUploaderValue): files is FileList { return ( !!files && typeof (files as FileList).length === 'number' && typeof (files as FileList).item === 'function' ); } /** * Used as vitest recognizes FileList as an Array of Files which then * causes any tests with pre-uploaded files to fail as isFileList will fail. * */ onMounted(() => { // Model is not a FileList if (!isFileList(props.modelValue)) { deprecate( 'BentoFileUploaderValue types: string | Array<string> | File | Array<File>', 'Use FileList instead.', '2.0.0' ); } }); const { supportedFileTypeList } = useFileTypeList(toRef(props, 'accept')); const browseFilesButtonId = generateUid('browse-button'); const customTextId = generateUid('custom-text'); const dropFilesTextId = generateUid('drop-files-text'); const commaTextId = generateUid('comma-text'); const fileUploaderRestrictionsId = generateUid('file-uploader-restrictions'); const fieldLabelId = generateUid('field-label'); const files = ref<Array<FileObject>>(null); const fileCardRefs = ref<Array<InstanceType<typeof FileUploaderFileCard>>>([]); const inputRef = ref<HTMLInputElement>(null); const dropAreaRef = ref<HTMLDivElement>(null); const inputFieldId = generateUid('input'); const errorId = generateUid('input-error'); const fileUploadAriaLabelledBy = computed(() => { const formattedFieldLabelId = props.label ? `${fieldLabelId} ${commaTextId}` : ''; const formattedDropFileTextId = !props.condensed ? `${dropFilesTextId} ${commaTextId}` : ''; const formattedCustomTextId = props.description ? `${customTextId} ${commaTextId}` : ''; const formattedFileUploaderRestrictionsId = `${fileUploaderRestrictionsId} ${commaTextId}`; return `${formattedFieldLabelId} ${formattedDropFileTextId} ${formattedCustomTextId} ${formattedFileUploaderRestrictionsId} ${browseFilesButtonId}`; }); const { dragState, dragErrors, resetDragState, onDragEnterValidations } = useDragDropState({ accept: toRef(props, 'accept'), maxCount: toRef(props, 'maxCount'), }); const fileUploadRestrictionsProps = computed<FileUploaderRestrictionsProps>(() => ({ condensed: props.condensed, disabled: props.disabled, maxCount: isMultiple.value ? { value: props.maxCount, error: dragErrors.maxCountOverflow || maxFileCountError.value, } : null, maxDimensions: props.maxDimensions ? { value: props.maxDimensions, error: files.value?.some(file => file.errors.includes(FileErrorType.ERROR_DIMENSIONS)), } : null, maxSize: props.maxSize ? { value: props.maxSize, error: files.value?.some(file => file.errors.includes(FileErrorType.ERROR_SIZE)), } : null, supportedFileTypes: supportedFileTypeList.value ? { value: supportedFileTypeList.value, error: dragErrors.invalidTypes || dragErrors.partiallyInvalidTypes || files.value?.some(file => file.errors.includes(FileErrorType.ERROR_TYPE)), } : null, })); // Prevents triggering "dragleave" events on children elements const enterTarget = ref<EventTarget>(); const conditionalClasses = computed(() => ({ [`b-file-uploader--${FileUploaderState.DISABLED}`]: props.disabled, [`b-file-uploader--${FileUploaderState.DRAG_OVER}`]: dragState.dragging, [`b-file-uploader--${FileUploaderState.ERROR}`]: hasError.value, [`b-file-uploader--condensed`]: props.condensed, })); const isMultiple = computed(() => props.maxCount > 1); const hideArea = computed(() => (props.maxCount && files.value?.length >= props.maxCount) || props.readonly); const loadingStateExists = computed(() => files.value?.some(file => file.state === 'loading')); const registerFileCardRef = (cardIndex: number) => fileCardRef => { fileCardRefs.value[cardIndex] = fileCardRef; }; /** * Verifies the new list of files and emits the `change` and `update:modelValue` events. * @param newList The new list of files to process and emit. */ function emitUpdate(newList: Array<FileObject>) { if (newList) { verifyFileCount(newList); verifyFileSize(newList); const value = convertToFileList(newList); emit('change', value); emitValue(value); } else { emit('change', undefined); emitValue(undefined); } } /** Errors */ const hasError = computed(() => !!props.errorMessage || dragState.error); const maxFileCountError = computed(() => files.value?.length > props.maxCount); const maxFileError = ref(false); const hasRestrictionError = computed(() => files.value?.some(file => file.state === 'error')); watch(hasRestrictionError, newValue => { // Without this, when files are initially uploaded the component // will immediately and unnecessarily emit 'false' and then // emit 'true' once processing has done, if errors exists. if (loadingStateExists.value) { return; } emit('error:upload', newValue); }); /** * Places the focus to the "drop area" after adding or removing a file. * If more files can be added, the focus is moved to the "drop area". * If no more files can be added, the focus is moved to the first file card. */ async function resetFocus() { await nextTick(); if (files.value.length === props.maxCount) { fileCardRefs.value[0]?.focus(); return; } dropAreaRef.value.focus(); } /** * Removes the file from the list of files when the * "remove" or "cancel" events are triggered from * the file card component * @param file File to be removed */ async function removeFile(file: FileObject) { files.value = files.value.filter(({ id }) => id !== file.id); await resetFocus(); emitUpdate(files.value); } /** Drag n Drop functions */ /** * Creates a FileObject from a native File and tracks its client-load progress. * @param file The native File object. * @returns A FileObject. */ function processFile(file: File) { const fileObject = reactive<FileObject>({ id: generateUid('file'), data: file, errors: [], progress: null, state: FileState.LOADING, type: getFileType(file), }); readFile(fileObject, props.accept, props.maxDimensions); return fileObject; } /** * Prevent opening the file browser when no * more files can be added (until one file is removed). * @param event Triggered event */ function onInputClick(event: MouseEvent) { if (files.value?.length === props.maxCount) { event.preventDefault(); } } /** * Sets the file/files to track their progress while being processed * @param fileList File or list of files to be processed. */ async function onFileInput(fileList: FileList) { // Prevent adding many files if maxCount is 1 if (props.maxCount === 1 && fileList.length > props.maxCount) { resetEnterTarget(); resetDragState(); return; } if (isMultiple.value) { // Add the new files to the file selection. // Removing files should be done by interacting with the file list below the area. files.value = [...(files.value || []), ...Array.from(fileList).map(file => processFile(file))]; } else { files.value = [processFile(fileList[0])]; await resetFocus(); } emitUpdate(files.value); } /** * Handles the "dragenter" event, triggered when the mouse enters the draggable area. * Enables the dragging state and sets the dragging error to true to * prevent dropping files from being added if there's a general error. * @param event Drag event */ function onDragEnter(event: DragEvent) { // Prevent changing state if disabled if (props.disabled) { return; } /** * Allows internal components as part of the dragable area. * Prevents trigerring "dragleave" events on children elements. * Set the "dragarea" as target */ enterTarget.value = event.target; const totalFilesCount = (event.dataTransfer.items?.length ?? 0) + (files.value?.length ?? 0); onDragEnterValidations(totalFilesCount, event); } /** * Resets the reference to the target. * This helps preventing loosing the state when dragging over children */ function resetEnterTarget() { enterTarget.value = null; } /** * Handles the "dragleave" event, triggered when the mouse leaves the draggable area. * Maintains the dragging state while dragging over children. * Resets the states when the mouse leaves the draggable area * @param event Drag event */ function onDragLeave(event: DragEvent) { // If target enter and leave are the same it means the drag has left the "dragarea" if (enterTarget.value === event.target) { resetEnterTarget(); resetDragState(); } } /** * Handles the "dragover" event, triggered when the mouse moves over the draggable area. * Disables the cursor when the component is disabled. * @param event Drag event */ function onDragOver(event: DragEvent) { event.preventDefault(); // Disable drag n drop and change the cursor if disabled if (props.disabled) { // eslint-disable-next-line no-param-reassign event.dataTransfer.dropEffect = 'none'; } } /** * Hadles the "drop" event, triggered when files are dropped inside the draggable area. * Processes the files that are dropped. * @param event Drag event */ async function onDrop(event: DragEvent) { // Prevent events when the input is disabled if (props.disabled) { return; } if (!dragErrors.invalidTypes && event.dataTransfer.files?.length) { onFileInput(event.dataTransfer.files); } resetEnterTarget(); resetDragState(); } /** * Handles the "change" event over the "input" element. * Processes the files that are selected through the browser's file explorer. */ async function onInputChange() { if (inputRef.value.files?.length) { onFileInput(inputRef.value.files); } // Reset input value inputRef.value.value = ''; await resetFocus(); } /** End of drag and drop functions */ /** * Watches the modelValue and if set * and the type is not deprecated, it processes the files * but doesn't emit them as the user already has those files. */ watch( () => props.modelValue, (newValue: BentoFileUploaderValue) => { // Sets the new value if it is a `FileList`. This ignores deprecated value types. if (newValue && isFileList(newValue)) { const fileList = newValue as FileList; const fileListArray: Array<File> = Array.from(fileList); files.value = fileListArray.map(file => processFile(file)); } else if (!newValue && files.value) { // Clear the file list when the value is programmatically unset. files.value = null; } }, { immediate: true } ); function verifyFileCount(fileList: Array<FileObject>) { const isValid = props.maxCount && fileList.length <= props.maxCount; maxFileError.value = !isValid; } function verifyFileSize(fileList: Array<FileObject>) { if (!props.maxSize) { return; } fileList.forEach(file => { const item = file; let maxSizeLimit: number | undefined; if (Array.isArray(props.maxSize)) { const matchingEntry = (props.maxSize as Array<FileMaxSize>).find(entry => isFileTypeAccepted(item.data, entry.fileType) ); maxSizeLimit = matchingEntry?.maxSize; } else { maxSizeLimit = props.maxSize as number; } if (maxSizeLimit && item.data.size > maxSizeLimit) { item.state = FileState.ERROR; if (!item.errors.includes(FileErrorType.ERROR_SIZE)) { item.errors.push(FileErrorType.ERROR_SIZE); } } }); } function convertToFileList(fileList: Array<FileObject>) { // Filter out files with errors const validFiles = fileList.filter( ({ errors }) => !errors.includes(FileErrorType.ERROR_LOAD) && !errors.includes(FileErrorType.ERROR_SIZE) ); if (validFiles.length) { const dataTransfer = new DataTransfer(); validFiles.forEach(({ data, id }) => { dataTransfer.items.add(data); }); return dataTransfer.files; } return undefined; } </script> <script lang="ts"> /** * The file uploader component enables users to upload one or multiple files from their device to our system. * * @example * import { BentoFileUploader, type BentoFileValue } from '@adyen/bento-vue2'; * * export default { * components: { BentoFileUploader }, * template: ` * <bento-file-uploader * accept="image/*,video/*,.pdf,.png" * condensed * description="Custom supporting text" * disabled * errorMessage="Custom error message" * label="Bento file uploader" * :maxCount="1" * :maxDimensions="{ width: 200, height: 100 }" * :maxSize="1000" * v-model="files" * :optional="false" * required * tooltipText="Useful information" * /> * `, * setup() { * const files = ref<BentoFileValue>() * } * } */ export default { model: { prop: 'modelValue' }, i18n: { messages }, }; </script> <style lang="scss" scoped src="./file-uploader.scss" />
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 +1 @@
1
- <template> <bento-base-filter v-bind="$props" :value="translatedValue" :default-value="translatedDefaultValue" :button-label="buttonLabel" :button-secondary-label="buttonSecondaryLabel" :disable-apply-button="isApplyButtonDisabled" :disable-secondary-button="isSecondaryButtonDisabled" :only-slot="onlySlot" :tooltip-position="options?.tooltipPosition" :tooltip-text="options?.tooltipText" @apply="updateFilter" @clear="clearFilter" @open="openFilter" @reset="resetFilter" > <div class="b-boolean-filter" :class="conditionalClasses" @keydown.enter="updateFilter"> <bento-radio-group :items="items" :label="label" :model-value="internalFilterValue" :disabled="disabled" hide-label @update:model-value="onInput" ></bento-radio-group> </div> </bento-base-filter> </template> <script lang="ts"> import { computed, defineComponent, onBeforeUpdate, onMounted, type PropType } from 'vue'; import { BentoBaseFilter, useBaseFilter } from '@/components/filter-bar/components/base-filter'; import { BentoRadioGroup } from '@/components/radio-group'; import { useI18n } from '@/utils/ts/i18n'; import { truncate } from '@/utils/ts/truncate'; import { deprecate } from '@/utils/ts/deprecate'; import { type BentoBooleanFilterOptions, BentoFilterEvent } from '../../filter-bar.types'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; /** * Boolean filter * * @deprecated Use `BentoFilterItemType.RADIO_GROUP` instead. * * @example * import { BentoBooleanFilter } from '@adyen/bento-vue2'; * * export default { * components: { BentoBooleanFilter }, * template: ` * <bento-boolean-filter> * ...Example code of the component * </bento-boolean-filter> * ` * } */ export default defineComponent({ name: 'bento-boolean-filter', i18n: { messages }, components: { BentoBaseFilter, BentoRadioGroup }, props: { /** * Disables the filter from being interacted with. */ disabled: { type: Boolean, default: false }, /** * Default value of the filter to reset to. */ defaultValue: { type: [String, Boolean], 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 }, /** * Current value of the filter */ value: { type: [String, Boolean], default: null }, /** * 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: * - positiveOption: The positive option label/value * - negativeOption: The positive option label/value * - 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<BentoBooleanFilterOptions>, default: undefined, }, }, emits: [BentoFilterEvent.INPUT, BentoFilterEvent.UPDATE], setup(props, { emit }) { const { t } = useI18n<{ message: MessageSchema }>({ messages }); /** * Translates a value, that possibly is a boolean, always to a string. */ const translateBooleanToString = (aStringOrBooleanValue?: string | boolean) => { if (typeof aStringOrBooleanValue === 'boolean') { return aStringOrBooleanValue?.toString(); } return aStringOrBooleanValue; }; /** * Translates a value that back to a boolean if the value property was given as a boolean. */ const translateBackToOriginalValueType = (aStringOrBooleanValue: string | boolean) => { if ( props?.options?.positiveOption?.value !== undefined && props?.options?.negativeOption?.value !== undefined && typeof props.options.positiveOption.value === 'boolean' && typeof props.options.negativeOption.value === 'boolean' && typeof aStringOrBooleanValue === 'string' ) { return aStringOrBooleanValue === 'true'; } return aStringOrBooleanValue; }; const translatedDefaultValue = computed(() => translateBooleanToString(props.defaultValue)); const translatedValue = computed(() => translateBooleanToString(props.value)); const items = computed(() => { if (props.options && props.options.positiveOption && props.options.negativeOption) { return [ { ...props.options.positiveOption, value: translateBooleanToString(props.options.positiveOption.value), }, { ...props.options.negativeOption, value: translateBooleanToString(props.options.negativeOption.value), }, ]; } return [ { value: 'yes', label: t('yes') as string }, { value: 'no', label: t('no') as string }, ]; }); const { internalFilterValue, isSecondaryButtonDisabled, isApplyButtonDisabled } = useBaseFilter( translatedValue, translatedDefaultValue ); const buttonLabel = computed(() => props.label); const buttonSecondaryLabel = computed(() => truncate(items.value.find(({ value }) => value === translatedValue.value)?.label, 12) ); const conditionalClasses = computed(() => ({ [`b-boolean-filter--only-slot`]: props.onlySlot, })); onBeforeUpdate(() => { // Set value to null when the value is unset if (!internalFilterValue.value) { internalFilterValue.value = null; } }); const onInput = event => { internalFilterValue.value = event; emit(BentoFilterEvent.INPUT, { ...props, value: translateBackToOriginalValueType(internalFilterValue.value), }); }; const openFilter = () => { internalFilterValue.value = translatedValue.value; }; const updateFilter = () => { emit(BentoFilterEvent.UPDATE, { ...props, value: translateBackToOriginalValueType(internalFilterValue.value), }); }; const resetFilter = () => emit(BentoFilterEvent.UPDATE, { ...props, value: props.defaultValue }); const clearFilter = () => { internalFilterValue.value = null; updateFilter(); }; /** * Deprecations */ onMounted(() => { deprecate( 'Boolean filter "BentoFilterItemType.BOOLEAN"', `Use the Radio group filter type "BentoFilterItemType.RADIO_GROUP" instead.`, '2.0.0' ); }); return { // Values items, conditionalClasses, buttonLabel, buttonSecondaryLabel, internalFilterValue, isApplyButtonDisabled, isSecondaryButtonDisabled, translatedValue, translatedDefaultValue, // Events onInput, updateFilter, clearFilter, openFilter, resetFilter, }; }, }); </script> <style lang="scss" scoped src="./boolean-filter.scss" />
1
+ <template> <bento-base-filter v-bind="$props" :value="translatedValue" :default-value="translatedDefaultValue" :button-label="buttonLabel" :button-secondary-label="buttonSecondaryLabel" :disable-apply-button="isApplyButtonDisabled" :disable-secondary-button="isSecondaryButtonDisabled" :only-slot="onlySlot" :tooltip-position="options?.tooltipPosition" :tooltip-text="options?.tooltipText" @apply="updateFilter" @clear="clearFilter" @open="openFilter" @reset="resetFilter" > <div class="b-boolean-filter" :class="conditionalClasses" @keydown.enter="updateFilter"> <bento-radio-group :items="items" :label="label" :model-value="internalFilterValue" :disabled="disabled" hide-label @update:model-value="onInput" ></bento-radio-group> </div> </bento-base-filter> </template> <script lang="ts"> import { computed, defineComponent, onBeforeUpdate, onMounted, type PropType } from 'vue'; import { BentoBaseFilter, useBaseFilter } from '@/components/filter-bar/components/base-filter'; import { BentoRadioGroup } from '@/components/radio-group'; import { useI18n } from '@/utils/ts/i18n'; import { truncate } from '@/utils/ts/truncate'; import { deprecate } from '@/utils/ts/deprecate'; import { type BentoBooleanFilterOptions, BentoFilterEvent } from '../../filter-bar.types'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; /** * Boolean filter * * @deprecated Since v2.0.0. Use `BentoFilterItemType.RADIO_GROUP` instead. * * @example * import { BentoBooleanFilter } from '@adyen/bento-vue2'; * * export default { * components: { BentoBooleanFilter }, * template: ` * <bento-boolean-filter> * ...Example code of the component * </bento-boolean-filter> * ` * } */ export default defineComponent({ name: 'bento-boolean-filter', i18n: { messages }, components: { BentoBaseFilter, BentoRadioGroup }, props: { /** * Disables the filter from being interacted with. */ disabled: { type: Boolean, default: false }, /** * Default value of the filter to reset to. */ defaultValue: { type: [String, Boolean], 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 }, /** * Current value of the filter */ value: { type: [String, Boolean], default: null }, /** * 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: * - positiveOption: The positive option label/value * - negativeOption: The positive option label/value * - 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<BentoBooleanFilterOptions>, default: undefined, }, }, emits: [BentoFilterEvent.INPUT, BentoFilterEvent.UPDATE], setup(props, { emit }) { const { t } = useI18n<{ message: MessageSchema }>({ messages }); /** * Translates a value, that possibly is a boolean, always to a string. */ const translateBooleanToString = (aStringOrBooleanValue?: string | boolean) => { if (typeof aStringOrBooleanValue === 'boolean') { return aStringOrBooleanValue?.toString(); } return aStringOrBooleanValue; }; /** * Translates a value that back to a boolean if the value property was given as a boolean. */ const translateBackToOriginalValueType = (aStringOrBooleanValue: string | boolean) => { if ( props?.options?.positiveOption?.value !== undefined && props?.options?.negativeOption?.value !== undefined && typeof props.options.positiveOption.value === 'boolean' && typeof props.options.negativeOption.value === 'boolean' && typeof aStringOrBooleanValue === 'string' ) { return aStringOrBooleanValue === 'true'; } return aStringOrBooleanValue; }; const translatedDefaultValue = computed(() => translateBooleanToString(props.defaultValue)); const translatedValue = computed(() => translateBooleanToString(props.value)); const items = computed(() => { if (props.options && props.options.positiveOption && props.options.negativeOption) { return [ { ...props.options.positiveOption, value: translateBooleanToString(props.options.positiveOption.value), }, { ...props.options.negativeOption, value: translateBooleanToString(props.options.negativeOption.value), }, ]; } return [ { value: 'yes', label: t('yes') as string }, { value: 'no', label: t('no') as string }, ]; }); const { internalFilterValue, isSecondaryButtonDisabled, isApplyButtonDisabled } = useBaseFilter( translatedValue, translatedDefaultValue ); const buttonLabel = computed(() => props.label); const buttonSecondaryLabel = computed(() => truncate(items.value.find(({ value }) => value === translatedValue.value)?.label, 12) ); const conditionalClasses = computed(() => ({ [`b-boolean-filter--only-slot`]: props.onlySlot, })); onBeforeUpdate(() => { // Set value to null when the value is unset if (!internalFilterValue.value) { internalFilterValue.value = null; } }); const onInput = event => { internalFilterValue.value = event; emit(BentoFilterEvent.INPUT, { ...props, value: translateBackToOriginalValueType(internalFilterValue.value), }); }; const openFilter = () => { internalFilterValue.value = translatedValue.value; }; const updateFilter = () => { emit(BentoFilterEvent.UPDATE, { ...props, value: translateBackToOriginalValueType(internalFilterValue.value), }); }; const resetFilter = () => emit(BentoFilterEvent.UPDATE, { ...props, value: props.defaultValue }); const clearFilter = () => { internalFilterValue.value = null; updateFilter(); }; /** * Deprecations */ onMounted(() => { deprecate( 'Boolean filter "BentoFilterItemType.BOOLEAN"', `Use the Radio group filter type "BentoFilterItemType.RADIO_GROUP" instead.`, '2.0.0' ); }); return { // Values items, conditionalClasses, buttonLabel, buttonSecondaryLabel, internalFilterValue, isApplyButtonDisabled, isSecondaryButtonDisabled, translatedValue, translatedDefaultValue, // Events onInput, updateFilter, clearFilter, openFilter, resetFilter, }; }, }); </script> <style lang="scss" scoped src="./boolean-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" :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'; /** * 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 { 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(); }; return { // Values internalFilterValue, isApplyButtonDisabled, isSecondaryButtonDisabled, buttonLabel, buttonSecondaryLabel, // 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" 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 +1 @@
1
- import type { BentoInputFieldProps } from '../../../input-field/input-field.types'; import type { BentoTooltipPosition } from '@/directives/tooltip/tooltip.types'; /** * @deprecated from version 2.0.0. Use BentoFilterItemType.INPUT_DROPDOWN instead. */ export interface BentoInputFilterInputAndDropdownValues { inputValue: string | null; dropdownValue?: string; } export type BentoInputFilterValue = string | null | BentoInputFilterInputAndDropdownValues; export interface BentoInputFilterOptions { /** * @deprecated BentoInputFilter "options" no longer supports "dropdown". Use the "BentoFilterItemType.INPUT_DROPDOWN" instead of "BentoFilterItemType.INPUT" */ dropdown?: any; /** * @deprecated BentoInputFilter "options" no longer supports "dropdownPosition". Use the "BentoFilterItemType.INPUT_DROPDOWN" instead of "BentoFilterItemType.INPUT" */ dropdownPosition?: any; /** * Sets a placeholder in the input field */ placeholder?: BentoInputFieldProps['placeholder']; /** * @deprecated from version 2.0.0. BentoInputFilter "options" no longer supports "variant". Use the "BentoFilterItemType.INPUT_DROPDOWN" instead of "BentoFilterItemType.INPUT" */ variant?: 'dropdown'; /** * Attaches a `BentoTooltip` if present. Text showed in the tooltip on hover. */ tooltipText?: string; /** * Position the tooltip should take if space is available. */ tooltipPosition?: BentoTooltipPosition | `${BentoTooltipPosition}`; } export interface BentoInputFilterProps { /** * Default value of the filter to reset to. */ defaultValue?: string; /** * Disables the filter from being interacted with. */ disabled?: boolean; /** * Filter name/key referencing the actual property name in the data set. * Should be unique per filter bar. */ field: string; /** * Label text to be displayed */ label: string; /** * Renders the base filter's slot content only - leaving out the button and the popover. * Used for the allFiltersModal component. */ onlySlot?: boolean; /** * List of options that are needed to setup the filter * Available options: * - placeholder: allows to set a placeholder to the search bar * - dropdown (deprecated): all the dropdown properties that can be set in the inner dropdown of the input field. @see BentoDropdown for more info. * - dropdownPosition (deprecated): allow the user to set the dropdown on the left or right side. Accepts the `start` or `end` values. * - variant (deprecated): can be `undefined` or set to `dropdown`. Enables dropdown input variant. * - tooltipPosition: The position of the tooltip attached to the filter button * - tooltipText: The content of the tooltip attached to the filter button */ options?: BentoInputFilterOptions; /** * Current value of the filter. * If the options.variant is equal to 'dropdown', the value should be of type `{ inputValue: string | null; dropdownValue?: string }`. */ value?: BentoInputFilterValue; }
1
+ import type { BentoInputFieldProps } from '../../../input-field/input-field.types'; import type { BentoTooltipPosition } from '@/directives/tooltip/tooltip.types'; /** * @deprecated Since v2.0.0. Use `BentoFilterItemType.INPUT_DROPDOWN` instead. */ export interface BentoInputFilterInputAndDropdownValues { inputValue: string | null; dropdownValue?: string; } export type BentoInputFilterValue = string | null | BentoInputFilterInputAndDropdownValues; export interface BentoInputFilterOptions { /** * @deprecated Since v2.0.0. Do not use. Use `BentoFilterItemType.INPUT_DROPDOWN` instead if `dropdown` is needed. */ dropdown?: any; /** * @deprecated Since v2.0.0. Do not use. Use `BentoFilterItemType.INPUT_DROPDOWN` instead if `dropdownPosition` is needed. */ dropdownPosition?: any; /** * Sets a placeholder in the input field */ placeholder?: BentoInputFieldProps['placeholder']; /** * @deprecated Since v2.0.0. Do not use. Use `BentoFilterItemType.INPUT_DROPDOWN` instead if `variant` is needed. */ variant?: 'dropdown'; /** * Attaches a `BentoTooltip` if present. Text showed in the tooltip on hover. */ tooltipText?: string; /** * Position the tooltip should take if space is available. */ tooltipPosition?: BentoTooltipPosition | `${BentoTooltipPosition}`; } export interface BentoInputFilterProps { /** * Default value of the filter to reset to. */ defaultValue?: string; /** * Disables the filter from being interacted with. */ disabled?: boolean; /** * Filter name/key referencing the actual property name in the data set. * Should be unique per filter bar. */ field: string; /** * Label text to be displayed */ label: string; /** * Renders the base filter's slot content only - leaving out the button and the popover. * Used for the allFiltersModal component. */ onlySlot?: boolean; /** * List of options that are needed to setup the filter * Available options: * - placeholder: allows to set a placeholder to the search bar * - dropdown (deprecated): all the dropdown properties that can be set in the inner dropdown of the input field. @see BentoDropdown for more info. * - dropdownPosition (deprecated): allow the user to set the dropdown on the left or right side. Accepts the `start` or `end` values. * - variant (deprecated): can be `undefined` or set to `dropdown`. Enables dropdown input variant. * - tooltipPosition: The position of the tooltip attached to the filter button * - tooltipText: The content of the tooltip attached to the filter button */ options?: BentoInputFilterOptions; /** * Current value of the filter. * If the options.variant is equal to 'dropdown', the value should be of type `{ inputValue: string | null; dropdownValue?: string }`. */ value?: BentoInputFilterValue; }
@@ -118,6 +118,7 @@ Properties:
118
118
  debounceTime: 500,
119
119
  hint: 'Hint',
120
120
  placeholder: 'Placeholder content',
121
+ width: '320px', // CSS width for the search box (e.g. '200px', '50%')
121
122
  }
122
123
  const onSearchUpdate = (newTerm: string) => {
123
124
  // ...
@@ -1 +1 @@
1
- import { computed, onMounted, ref } from 'vue'; import { action } from '@storybook/addon-actions'; import { type BentoDateRangeFilterOptions, type BentoFilterBarContainerSizeLayout, type BentoFilterBarModel, type BentoFilterBarSearchBarProps, type BentoFilterBarValue, BentoFilterItemType, type BentoFilterValueObject, type BentoFilterValues, } from './filter-bar.types'; import { getPokemonFromApi, storybookDocsParameter, withToastDecorator } from '@/utils/ts/storybook'; import { type BentoListboxOptions } from '@/types/listbox'; import { BentoCountryCode } from '@/components/country/country.types'; import { BentoCurrencyISOCode } from '@/components/currency/currency.types'; import { isVue2 } from 'vue-demi'; import { type Meta, type StoryObj } from '@storybook/vue'; import { BentoToggle } from '@/components/toggle'; import BentoFilterBar from './filter-bar.vue'; import { endOfWeek } from 'date-fns/endOfWeek'; import { startOfWeek } from 'date-fns/startOfWeek'; import { subDays } from 'date-fns/subDays'; import { subWeeks } from 'date-fns/subWeeks'; import { subMonths } from 'date-fns/subMonths'; import { startOfMonth } from 'date-fns/startOfMonth'; import { endOfDay, startOfDay, subHours, subMinutes } from 'date-fns'; // Examples import FilterBarCustomFilterSubComponentSingleValueExample from './__tests__/filter-bar-custom-filter-sub-component-single-value-example.vue'; import FilterBarCustomFilterSubComponentMultiValueExample from './__tests__/filter-bar-custom-filter-sub-component-multiple-value-example.vue'; import FilterBarCustomFilterSubComponentWithPopoverExample from './__tests__/filter-bar-custom-filter-sub-component-with-popover-example.vue'; import FilterBarCustomFiltersExample from './__tests__/filter-bar-custom-filters-example.vue?raw'; // Generate array from 1 to # // { label: 'Option #', value: # }, const DEFAULT_OPTIONS: BentoListboxOptions = new Array(10) .fill(0) .map((_, index) => ({ label: `Option ${index + 1}`, value: index + 1 })); // To be used by as the current time across all date filters const CURRENT_DATE_AND_TIME = new Date(); const meta: Meta = { title: 'Filter bar', component: BentoFilterBar, decorators: [withToastDecorator], argTypes: { // Props searchTerm: { name: 'searchTerm', defaultValue: undefined, description: "Search term useful to filter all the data by text. If the value is non-existant (`null` or `undefined`) the search bar won't be enabled.", control: { type: 'text', }, }, // Events input: { name: 'input', defaultValue: '', description: 'Triggered whenever the filters are updated.', table: { type: { summary: '([{ field, label, value, type }]) => void' } }, control: { disable: true }, }, // Search Events 'search-input': { name: 'search-input', defaultValue: '', description: "Triggered whenever the filter's search is updated.", table: { type: { summary: '(newSearchTerm) => void' } }, control: { disable: true }, }, 'search-clear': { name: 'search-clear', defaultValue: '', description: "Triggered whenever the filter's search is cleared.", table: { type: { summary: '() => void' } }, control: { disable: true }, }, 'update:search-term': { name: 'update:search-term', defaultValue: '', description: "Triggered whenever the filter's search is updated to sync the model.", table: { type: { summary: '(newSearchTerm) => void' } }, control: { disable: true }, }, }, }; export default meta; type Story = StoryObj<typeof BentoFilterBar>; const FILTER_VALUES: BentoFilterValues = [ { field: 'input', value: undefined, }, { field: 'boolean', value: 'yes', }, { field: 'input-with-dropdown', value: undefined, }, { field: 'hidden-input', value: 'Active value of a hidden filter', }, { field: 'merchant', value: 2, }, { field: 'company', value: [1, 3, 5], }, { field: 'nested-select', value: ['amsterdam', 'den-haag'], }, { field: 'currency', value: [], }, { field: 'range', value: ['', ''], }, { field: 'date', value: CURRENT_DATE_AND_TIME, }, { field: 'date-range', value: { startDate: subDays(startOfDay(CURRENT_DATE_AND_TIME), 2), endDate: endOfDay(CURRENT_DATE_AND_TIME) }, }, { field: 'granular-date-range', value: { startDate: subDays(CURRENT_DATE_AND_TIME, 2), endDate: CURRENT_DATE_AND_TIME }, }, { field: 'date-range-month', value: { startDate: startOfMonth(subMonths(CURRENT_DATE_AND_TIME, 2)), endDate: startOfMonth(CURRENT_DATE_AND_TIME), }, }, { field: 'paymentMethod', value: undefined, }, { field: 'checkbox-group', value: [], }, { field: 'toggle', value: true, }, ]; const FILTERS: BentoFilterBarModel = [ { field: 'input', label: 'Input filter', type: BentoFilterItemType.INPUT, options: { placeholder: 'Input filter placeholder', tooltipPosition: 'bottom', tooltipText: 'Input filter tooltip text', }, visible: true, }, { field: 'input-with-dropdown', label: 'Input with dropdown filter', type: BentoFilterItemType.INPUT_DROPDOWN, visible: true, options: { dropdown: { items: [ { label: 'EUR', value: 'EUR' }, { label: 'USD', value: 'USD' }, ], modelValue: 'EUR', dynamicFiltering: true, }, }, }, { field: 'hidden-input', label: 'Hidden input filter', description: 'This is a hidden input filter only visible through the modal', type: BentoFilterItemType.INPUT, }, { field: 'country', label: 'Country/Region', options: { listboxItems: [ { label: 'Option 1', value: BentoCountryCode.NL, }, { label: 'Option 2', value: BentoCountryCode.BE, }, ], multiple: true, variant: 'capital', }, type: BentoFilterItemType.SELECT_COUNTRY, }, { field: 'avatar', label: 'Avatar', options: { listboxItems: [ { label: 'Avatar 1', value: 'avatar-1', }, { label: 'Avatar 2', value: 'avatar-2', }, ], }, type: BentoFilterItemType.SELECT_AVATAR, }, { field: 'merchant', label: 'Merchant', options: { listboxItems: DEFAULT_OPTIONS, enableValueLabelPair: true, isOptionDisabled: option => option.value === 3, }, type: BentoFilterItemType.SELECT, visible: true, }, { field: 'company', label: 'Company', options: { listboxItems: DEFAULT_OPTIONS, enableValueLabelPair: true, isOptionDisabled: option => option.value === 5, multiple: true, }, type: BentoFilterItemType.SELECT, visible: true, }, { field: 'nested-select', label: 'Nested select', options: { listboxItems: [ { label: 'Belgium', description: 'Country/Region', value: 'belgium', }, { label: 'Netherlands', description: 'Country/Region', value: 'netherlands', items: [ { label: 'Amsterdam', description: 'City', value: 'amsterdam', }, { label: 'Den Haag', description: 'City', value: 'den-haag', }, ], }, ], multiple: true, }, type: BentoFilterItemType.SELECT, visible: true, }, { field: 'currency', label: 'Currency select', options: { listboxItems: [ { label: 'Euro', value: BentoCurrencyISOCode.EUR, }, { label: 'US Dollars', value: BentoCurrencyISOCode.USD, }, ], multiple: true, }, type: BentoFilterItemType.SELECT_CURRENCY, }, { field: 'range', label: 'Range', type: BentoFilterItemType.RANGE, visible: true, }, { field: 'date', label: 'Date', type: BentoFilterItemType.DATE, visible: true, }, { field: 'date-range', label: 'Date range', options: { allowTimeInput: true, numberOfMonths: 1, quickSelectRanges: [ { label: 'Last 15 min', value: 'lastFifteenMin', data: { startDate: subMinutes(CURRENT_DATE_AND_TIME, 15), isRelativeToNow: true, }, }, { label: 'Last 24 hours', value: 'lastTwentyFourHours', data: { startDate: subHours(CURRENT_DATE_AND_TIME, 24), isRelativeToNow: true, }, }, { label: 'Last three days', value: 'lastThreeDays', data: { startDate: subDays(startOfDay(CURRENT_DATE_AND_TIME), 2), }, }, { label: 'Last week', value: 'lastWeek', data: { startDate: subWeeks(startOfDay(CURRENT_DATE_AND_TIME), 1), }, }, ], } as BentoDateRangeFilterOptions, type: BentoFilterItemType.DATE_RANGE, visible: true, }, { field: 'granular-date-range', label: 'Granular date range', options: { allowTimeInput: true, numberOfMonths: 2, granularities: [{ type: 'daily' }, { type: 'weekly' }, { type: 'monthly', disabled: true }], min: subWeeks(startOfWeek(CURRENT_DATE_AND_TIME, { weekStartsOn: 1 }), 12), max: endOfWeek(CURRENT_DATE_AND_TIME, { weekStartsOn: 1 }), } as BentoDateRangeFilterOptions, type: BentoFilterItemType.DATE_RANGE, visible: true, }, { field: 'date-range-month', label: 'Date range (month)', options: { numberOfMonths: 2, variant: 'month', } as BentoDateRangeFilterOptions, type: BentoFilterItemType.DATE_RANGE, visible: true, }, { field: 'paymentMethod', label: 'Payment method', options: { listboxItems: [ { label: 'iDEAL', value: 'ideal' }, { label: 'Visa', value: 'visa' }, ], multiple: true, }, type: BentoFilterItemType.SELECT_PAYMENT_METHOD, }, { field: 'tag', label: 'Tag', options: { listboxItems: [ { label: 'Offline', value: 'unique-id-1', variant: 'red' }, { label: 'Online', value: 'unique-id-2', variant: 'green' }, { label: 'Standby', value: 'unique-id-3', variant: 'orange' }, { label: 'Busy', value: 'unique-id-4', variant: 'blue' }, ], multiple: true, }, type: BentoFilterItemType.SELECT_TAG, }, { field: 'radio-group', label: 'Radio group', options: { items: [ { label: 'Option 1', value: 'option-1' }, { label: 'Option 2', value: 'option-2' }, { label: 'Option 3', value: 'option-3' }, ], }, visible: false, type: BentoFilterItemType.RADIO_GROUP, }, { field: 'checkbox-group', label: 'Checkbox group', options: { checkboxItems: [ { label: 'Item 1', value: 'unique-id-1' }, { label: 'Item 2', value: 'unique-id-2' }, { label: 'Item 3', value: 'unique-id-3' }, { label: 'Item with a very long name', value: 'unique-id-4' }, ], }, type: BentoFilterItemType.CHECKBOX_GROUP, visible: true, }, { field: 'toggle', label: 'Toggle', type: BentoFilterItemType.TOGGLE, visible: true, description: 'Optional description for the toggle filter', options: { tooltipPosition: 'bottom', tooltipText: 'Toggle filter tooltip text', }, }, ]; const FILTERS_WITH_DEFAULTS: BentoFilterBarModel = [ { field: 'input', label: 'Input filter', defaultValue: 'hello', type: BentoFilterItemType.INPUT, }, { field: 'input-with-dropdown', label: 'Input with dropdown filter', type: BentoFilterItemType.INPUT_DROPDOWN, visible: true, defaultValue: { inputValue: 'Peristent input filter', dropdownValue: 'EUR', }, options: { dropdown: { items: [ { label: 'EUR', value: 'EUR' }, { label: 'USD', value: 'USD' }, ], }, }, }, { field: 'range', label: 'Range', defaultValue: ['1', '4'], type: BentoFilterItemType.RANGE, }, { field: 'merchant', label: 'Merchants', defaultValue: ['merchant_1'], options: { listboxItems: [ { label: 'Merchant 1', value: 'merchant_1', }, { label: 'Merchant 2', value: 'merchant_2', }, { label: 'Merchant 3', value: 'merchant_3', }, ], multiple: true, enableValueLabelPair: true, }, type: BentoFilterItemType.SELECT, }, { field: 'thing', label: 'Things', defaultValue: 'merchant_1', options: { listboxItems: [ { label: 'Thing 1', value: 'merchant_1', }, { label: 'Thing 2', value: 'merchant_2', }, { label: 'Thing 3', value: 'merchant_3', }, ], }, type: BentoFilterItemType.SELECT, }, { field: 'country', label: 'Country/Region', options: { listboxItems: [ { label: 'Option 1', value: BentoCountryCode.NL, }, { label: 'Option 2', value: BentoCountryCode.BE, }, ], multiple: true, variant: 'capital', }, type: BentoFilterItemType.SELECT_COUNTRY, }, { field: 'radio-group', label: 'Radio group', defaultValue: 'option-1', options: { items: [ { label: 'Option 1', value: 'option-1' }, { label: 'Option 2', value: 'option-2' }, { label: 'Option 3', value: 'option-3' }, ], }, visible: false, type: BentoFilterItemType.RADIO_GROUP, }, { field: 'checkbox-group', label: 'Checkbox group', defaultValue: ['unique-id-1'], options: { checkboxItems: [ { label: 'Item 1', value: 'unique-id-1' }, { label: 'Item 2', value: 'unique-id-2' }, { label: 'Item 3', value: 'unique-id-3' }, { label: 'Item 4', value: 'unique-id-4' }, ], }, type: BentoFilterItemType.CHECKBOX_GROUP, }, ]; const defaultFilterTemplate = storybookDocsParameter(` <template> <bento-filter-bar :config="config" :filter-values="filterValues" :search-term.sync="searchTerm" @input="onFilter" @search-input="onSearchInput" @search-clear="onSearchClear" /> </template> <script setup lang="ts"> import { BentoFilterBar, BentoFilterItemType } from '@adyen/bento-vue2' import type { BentoFilterBarModel, BentoFilterValues } from '@adyen/bento-vue2' const searchTerm = ref<string>(undefined) const config = [{ field: 'name', label: 'Name', type: BentoFilterItemType.INPUT, visible: true, }, { field: 'merchant', label: 'Merchant', options: { listboxItems: DEFAULT_OPTIONS, }, type: BentoFilterItemType.SELECT, }, { field: 'company', label: 'Company', options: { listboxItems: DEFAULT_OPTIONS, isOptionDisabled: (option) => option.value === 5, multiple: true, }, type: BentoFilterItemType.SELECT, }] const filterValues = ref<BentoFilterValues>([{ field: 'merchant', value: 2, }, { field: 'company', value: [1, 3, 5], }]) const onFilter = (newFilters: BentoFilterBarModel) => { } const onSearchInput = (newTerm: string) => { } const onSearchClear = () => { } </script> `); export const Default: Story = { render: (_args, { argTypes }) => ({ components: { BentoFilterBar }, props: Object.keys(argTypes), setup(props) { const args = isVue2 ? props : _args; const filtersConfig = ref<BentoFilterBarModel>(args.config); const filterValues = ref<BentoFilterValues>(args.filterValues); const filterValuesUpdate = (newFilterValues: BentoFilterValues) => { filterValues.value = newFilterValues; action('update:filter-values')(newFilterValues); }; const filterValuesChanged = (newFilterValues: BentoFilterValues) => { action('input')(newFilterValues); }; const searchTermField = computed(() => args.searchTerm); const searchInput = (newSearchTerm: string) => action('search-input')(newSearchTerm); const searchClear = () => action('search-clear')(); const searchTermSync = (newSearchTerm: string) => action('update:search-term')(newSearchTerm); return { // Props args, // Values filtersConfig, filterValues, searchTermField, // Events filterValuesChanged, filterValuesUpdate, searchInput, searchClear, searchTermSync, }; }, template: ` <div style="padding-top: 24px"> <bento-filter-bar v-bind="args" :config="filtersConfig" :searchTerm="searchTermField" :filter-values="filterValues" @input="filterValuesChanged" @update:filter-values="filterValuesUpdate" @update:search-term="searchTermSync" @search-input="searchInput" @search-clear="searchClear" /> </div> `, }), args: { config: FILTERS, filterValues: FILTER_VALUES, containerSizeLayout: { medium: 'multi-line', small: 'one-line', } satisfies BentoFilterBarContainerSizeLayout, search: { inputFieldAriaLabel: 'Input field aria label', debounceTime: 500, hint: 'Hint', placeholder: 'Placeholder content', } satisfies BentoFilterBarSearchBarProps, searchTerm: 'something', }, parameters: defaultFilterTemplate, }; const persistentFilterTemplate = storybookDocsParameter(` <bento-filter-bar :config="[ { field: 'input', label: 'Input filter', defaultValue: 'hello', type: BentoFilterItemType.INPUT, }, { field: 'range', label: 'Range', defaultValue: ['1', '4'], type: BentoFilterItemType.RANGE, }, { field: 'merchant', label: 'Merchants', defaultValue: ['merchant_1'], options: { listboxItems: [ { label: 'Merchant 1', value: 'merchant_1', }, { label: 'Merchant 2', value: 'merchant_2', }, { label: 'Merchant 3', value: 'merchant_3', }, ], multiple: true, }, type: BentoFilterItemType.SELECT, }, { field: 'thing', label: 'Things', defaultValue: 'merchant_1', options: { listboxItems: [ { label: 'Thing 1', value: 'merchant_1', }, { label: 'Thing 2', value: 'merchant_2', }, { label: 'Thing 3', value: 'merchant_3', }, ], }, type: BentoFilterItemType.SELECT, }, { field: 'country', label: 'Country/Region', options: { listboxItems: [ { label: 'Option 1', value: BentoCountryCode.NL, }, { label: 'Option 2', value: BentoCountryCode.BE, }, ], multiple: true, variant: 'capital', }, type: BentoFilterItemType.SELECT_COUNTRY, }, ]" :filter-values="[ { field: 'input', value: 'non-default value', }, ]" :search="{ inputFieldAriaLabel: 'Input field aria label', debounceTime: 500, hint: 'Hint', placeholder: 'Placeholder content', }" :search-term.sync="searchTerm" @input="onFilter" @search-input="onSearchInput" @search-clear="onSearchClear" /> `); export const Persistent: Story = { ...Default, args: { config: FILTERS_WITH_DEFAULTS, filterValues: [ { field: 'input', value: 'non-default value', }, { field: 'checkbox-group', value: ['unique-id-2'], }, ], searchTerm: null, }, parameters: persistentFilterTemplate, }; const externalFilteringTemplate = storybookDocsParameter(` <template> <bento-filter-bar :config="filtersConfig" :filter-values.sync="filterValues" /> </template> <script setup lang="ts"> import { ref, computed, onMounted } from 'vue'; import { BentoFilterBar, BentoFilterItemType, BentoFilterBarModel, BentoFilterValues, BentoSelectFilterOptions, } from '@adyen/bento-vue2'; // --- Mock API for demonstration --- const getPokemonFromApi = async (searchTerm?: string) => { // In a real application, this would be an API call. // We're mocking it with a timeout and static data. console.log(\`Searching for: \${searchTerm}\`); await new Promise(resolve => setTimeout(resolve, 1000)); const allPokemon = [ { label: 'Bulbasaur', value: 'bulbasaur' }, { label: 'Ivysaur', value: 'ivysaur' }, { label: 'Venusaur', value: 'venusaur' }, { label: 'Charmander', value: 'charmander' }, { label: 'Charmeleon', value: 'charmeleon' }, { label: 'Charizard', value: 'charizard' }, ]; const items = searchTerm ? allPokemon.filter(p => p.label.toLowerCase().includes(searchTerm.toLowerCase())) : allPokemon; return { items }; }; // --- End Mock API --- const filterValues = ref<BentoFilterValues>([{ field: 'pokemon', value: 'bulbasaur' }]); const apiItems = ref([]); const isLoading = ref(false); const getAndSetPokemonFromApi = async (val?: string) => { isLoading.value = true; const { items } = await getPokemonFromApi(val); apiItems.value = items; isLoading.value = false; }; onMounted(() => { getAndSetPokemonFromApi(); }); const filtersConfig = computed<BentoFilterBarModel>(() => [ { field: 'name', label: 'Name', type: BentoFilterItemType.INPUT }, { field: 'pokemon', label: 'Pokemon', options: { isOptionDisabled: option => ['ivysaur', 'charmeleon'].includes(option.value as string), listboxItems: apiItems.value, search: { debounceTime: 500, searchEvent: getAndSetPokemonFromApi }, loading: isLoading.value, }, type: BentoFilterItemType.SELECT, }, ]); </script> `); export const ExternalFilteringSelectFilter: Story = { render: (_args, { argTypes }) => ({ components: { BentoFilterBar, }, props: Object.keys(argTypes), setup(props) { const apiItems = ref([]); const filterValues = ref<BentoFilterValues>([{ field: 'pokemon', value: 'bulbasaur' }]); const isLoading = ref(false); const getAndSetPokemonFromApi = async (val?: string) => { isLoading.value = true; await new Promise(resolve => setTimeout(resolve, 1000)); const { items } = await getPokemonFromApi(val); apiItems.value = items; isLoading.value = false; }; const search = { debounceTime: 500, searchEvent: getAndSetPokemonFromApi, }; onMounted(() => { getAndSetPokemonFromApi(); }); const filtersConfig = computed(() => [ { field: 'name', label: 'Name', type: BentoFilterItemType.INPUT, }, { field: 'pokemon', label: 'Pokemon', options: { isOptionDisabled: option => ['ivysaur', 'charmeleon'].includes(option.value as string), listboxItems: apiItems.value, search, loading: isLoading.value, }, type: BentoFilterItemType.SELECT, visible: false, }, ]); return { args: isVue2 ? props : _args, // Values filtersConfig, filterValues, }; }, template: ` <div style="padding-top: 24px"> <bento-filter-bar v-bind="args" :config="filtersConfig" :filter-values.sync="filterValues" /> </div> `, }), args: { config: FILTERS, searchTerm: null }, parameters: externalFilteringTemplate, }; const SearchAndLazyLoadTemplate = storybookDocsParameter(` <template> <bento-filter-bar :config="filtersConfig" :filter-values.sync="filterValues" /> </template> <script setup lang="ts"> import { ref, computed, onMounted } from 'vue'; import { BentoFilterBar, BentoFilterItemType, BentoFilterBarModel, BentoFilterValues, BentoSelectFilterOptions, BentoListboxOptions, } from '@adyen/bento-vue2'; // --- Mock API for demonstration --- const ALL_ITEMS = Array.from({ length: 100 }, (_, i) => ({ label: \`Item \${i + 1}\`, value: i + 1, })); const fetchItems = (searchTerm: string, page: number, pageSize: number = 20) => { console.log(\`Fetching items with searchTerm: "\${searchTerm}", page: \${page}\`); return new Promise<{ items: BentoListboxOptions; hasMore: boolean }>(resolve => { setTimeout(() => { const filtered = ALL_ITEMS.filter(item => item.label.toLowerCase().includes(searchTerm.toLowerCase()) ); const start = (page - 1) * pageSize; const end = start + pageSize; const pageItems = filtered.slice(start, end); resolve({ items: pageItems, hasMore: end < filtered.length, }); }, 500); }); }; // --- End Mock API --- const filterValues = ref<BentoFilterValues>([]); // State for the select filter const listboxItems = ref<BentoListboxOptions>([]); const isLoading = ref(false); const hasMoreItems = ref(true); const currentPage = ref(1); const currentSearchTerm = ref(''); /** * Handles the search event from the filter. * This should reset pagination and fetch a new list of items. */ const onSearch = async (searchTerm: string) => { currentSearchTerm.value = searchTerm; currentPage.value = 1; isLoading.value = true; const { items, hasMore } = await fetchItems(searchTerm, currentPage.value); listboxItems.value = items; hasMoreItems.value = hasMore; isLoading.value = false; }; /** * Handles the lazy load event. * This should fetch the next page of items and append them. */ const onLazyLoad = async () => { if (isLoading.value || !hasMoreItems.value) { return; } currentPage.value++; isLoading.value = true; const { items, hasMore } = await fetchItems(currentSearchTerm.value, currentPage.value); // Append new items to the existing list listboxItems.value = [...listboxItems.value, ...items]; hasMoreItems.value = hasMore; isLoading.value = false; }; // Load initial data when the component mounts onMounted(() => { onSearch(''); }); const filtersConfig = computed<BentoFilterBarModel>(() => [ { field: 'lazy-select', label: 'Lazy Load Select', type: BentoFilterItemType.SELECT, options: { listboxItems: listboxItems.value, multiple: true, // External filtering configuration search: { searchEvent: onSearch, }, // Lazy loading configuration lazy: { hasMoreItems: hasMoreItems.value, lazyLoadType: 'automatic', // or 'button' loading: isLoading.value, showMoreEvent: onLazyLoad, }, }, }, ]); </script> `); export const ExternalFilteringAndLazyLoadingSelectFilter: Story = { ...ExternalFilteringSelectFilter, render: (_args, { argTypes }) => ({ components: { BentoFilterBar, }, props: Object.keys(argTypes), setup(props) { const args = isVue2 ? props : _args; const filterValues = ref<BentoFilterValues>([]); const lazyLoadingPageSize = 20; // State for the select filter const listboxItems = ref<BentoListboxOptions>([]); const isSearchLoading = ref(false); const isLazyLoading = ref(false); const hasMoreItems = ref(true); const currentPage = ref(1); const currentSearchTerm = ref(''); /** * Handles the search event from the filter. * This should reset pagination and fetch a new list of items. */ const onSearch = async (searchTerm: string) => { currentSearchTerm.value = searchTerm; currentPage.value = 1; isSearchLoading.value = true; const { items, hasMore } = await getPokemonFromApi(searchTerm, currentPage.value, lazyLoadingPageSize); listboxItems.value = items; hasMoreItems.value = hasMore; isSearchLoading.value = false; }; /** * Handles the lazy load event. * This should fetch the next page of items and append them. */ const onLazyLoad = async () => { if (isLazyLoading.value || !hasMoreItems.value) { return; } currentPage.value++; isLazyLoading.value = true; const { items, hasMore } = await getPokemonFromApi( currentSearchTerm.value, currentPage.value, lazyLoadingPageSize ); // Append new items to the existing list listboxItems.value = [...listboxItems.value, ...items]; hasMoreItems.value = hasMore; isLazyLoading.value = false; }; // Load initial data when the component mounts onMounted(() => { onSearch(''); }); const filtersConfig = computed<BentoFilterBarModel>(() => [ { field: 'lazy-select', label: 'External filtering and lazy loading', type: BentoFilterItemType.SELECT, options: { listboxItems: listboxItems.value, multiple: true, loading: isSearchLoading.value, // External filtering configuration search: { searchEvent: onSearch, }, // Lazy loading configuration lazy: { hasMoreItems: hasMoreItems.value, lazyLoadType: 'automatic', // or 'button' loading: isLazyLoading.value, showMoreEvent: onLazyLoad, }, }, }, ]); return { args, filtersConfig, filterValues, }; }, template: ` <div style="padding-top: 24px"> <bento-filter-bar v-bind="args" :config="filtersConfig" :filter-values.sync="filterValues" /> </div> `, }), args: { ...ExternalFilteringSelectFilter.args, }, parameters: SearchAndLazyLoadTemplate, }; export const CustomFilters: Story = { render: (_args, { argTypes }) => ({ components: { BentoFilterBar, BentoToggle, FilterBarCustomFilterSubComponentSingleValueExample, FilterBarCustomFilterSubComponentMultiValueExample, }, props: Object.keys(argTypes), template: ` <div style="padding-top: 24px"> <bento-filter-bar :config="filtersConfig" :filter-values-object="filterValues" @update:filter-values-object="filterValuesChanged" /> </div> `, setup() { interface CustomFilterOptions { required?: boolean; optional?: boolean; disabled?: boolean; } const filtersConfig: BentoFilterBarModel<CustomFilterOptions> = [ { field: 'input', label: 'Input filter', type: BentoFilterItemType.INPUT, options: { placeholder: 'Input filter placeholder', }, visible: true, }, { field: 'custom-single', label: 'Custom single value filter', type: FilterBarCustomFilterSubComponentSingleValueExample, visible: true, }, { field: 'custom-multi', label: 'Custom multi value filter', type: FilterBarCustomFilterSubComponentMultiValueExample, options: { // ICustomFilterOptions disabled: false, optional: false, required: false, }, visible: false, }, { field: 'custom-popover', label: 'Custom filter with popover', type: FilterBarCustomFilterSubComponentWithPopoverExample, visible: true, }, ]; /** * Custom filter values */ type FilterKeys = 'input' | 'custom-single' | 'custom-multi' | 'custom-popover'; type CustomFilterValues = | BentoFilterBarValue | boolean | { // ICustomFilterValues first: boolean; second: boolean; } | undefined; const filterValues = ref<BentoFilterValueObject<FilterKeys, CustomFilterValues>>({ input: undefined, 'custom-single': undefined, 'custom-multi': { first: false, second: false, }, 'custom-popover': undefined, }); const filterValuesChanged = (newFilterValues: BentoFilterValueObject<FilterKeys, CustomFilterValues>) => { filterValues.value = newFilterValues; action('input')(newFilterValues); }; return { filtersConfig, filterValues, filterValuesChanged, }; }, }), args: false, parameters: storybookDocsParameter(FilterBarCustomFiltersExample), };
1
+ import { computed, onMounted, ref } from 'vue'; import { action } from '@storybook/addon-actions'; import { type BentoDateRangeFilterOptions, type BentoFilterBarContainerSizeLayout, type BentoFilterBarModel, type BentoFilterBarSearchBarProps, type BentoFilterBarValue, BentoFilterItemType, type BentoFilterValueObject, type BentoFilterValues, } from './filter-bar.types'; import { getPokemonFromApi, storybookDocsParameter, withToastDecorator } from '@/utils/ts/storybook'; import { type BentoListboxOptions } from '@/types/listbox'; import { BentoCountryCode } from '@/components/country/country.types'; import { BentoCurrencyISOCode } from '@/components/currency/currency.types'; import { isVue2 } from 'vue-demi'; import { type Meta, type StoryObj } from '@storybook/vue'; import { BentoToggle } from '@/components/toggle'; import BentoFilterBar from './filter-bar.vue'; import { endOfWeek } from 'date-fns/endOfWeek'; import { startOfWeek } from 'date-fns/startOfWeek'; import { subDays } from 'date-fns/subDays'; import { subWeeks } from 'date-fns/subWeeks'; import { subMonths } from 'date-fns/subMonths'; import { startOfMonth } from 'date-fns/startOfMonth'; import { endOfDay, startOfDay, subHours, subMinutes } from 'date-fns'; // Examples import FilterBarCustomFilterSubComponentSingleValueExample from './__tests__/filter-bar-custom-filter-sub-component-single-value-example.vue'; import FilterBarCustomFilterSubComponentMultiValueExample from './__tests__/filter-bar-custom-filter-sub-component-multiple-value-example.vue'; import FilterBarCustomFilterSubComponentWithPopoverExample from './__tests__/filter-bar-custom-filter-sub-component-with-popover-example.vue'; import FilterBarCustomFiltersExample from './__tests__/filter-bar-custom-filters-example.vue?raw'; // Generate array from 1 to # // { label: 'Option #', value: # }, const DEFAULT_OPTIONS: BentoListboxOptions = new Array(10) .fill(0) .map((_, index) => ({ label: `Option ${index + 1}`, value: index + 1 })); // To be used by as the current time across all date filters const CURRENT_DATE_AND_TIME = new Date(); const meta: Meta = { title: 'Filter bar', component: BentoFilterBar, decorators: [withToastDecorator], argTypes: { // Props searchTerm: { name: 'searchTerm', defaultValue: undefined, description: "Search term useful to filter all the data by text. If the value is non-existant (`null` or `undefined`) the search bar won't be enabled.", control: { type: 'text', }, }, // Events input: { name: 'input', defaultValue: '', description: 'Triggered whenever the filters are updated.', table: { type: { summary: '([{ field, label, value, type }]) => void' } }, control: { disable: true }, }, // Search Events 'search-input': { name: 'search-input', defaultValue: '', description: "Triggered whenever the filter's search is updated.", table: { type: { summary: '(newSearchTerm) => void' } }, control: { disable: true }, }, 'search-clear': { name: 'search-clear', defaultValue: '', description: "Triggered whenever the filter's search is cleared.", table: { type: { summary: '() => void' } }, control: { disable: true }, }, 'update:search-term': { name: 'update:search-term', defaultValue: '', description: "Triggered whenever the filter's search is updated to sync the model.", table: { type: { summary: '(newSearchTerm) => void' } }, control: { disable: true }, }, }, }; export default meta; type Story = StoryObj<typeof BentoFilterBar>; const FILTER_VALUES: BentoFilterValues = [ { field: 'input', value: undefined, }, { field: 'boolean', value: 'yes', }, { field: 'input-with-dropdown', value: undefined, }, { field: 'hidden-input', value: 'Active value of a hidden filter', }, { field: 'merchant', value: 2, }, { field: 'company', value: [1, 3, 5], }, { field: 'nested-select', value: ['amsterdam', 'den-haag'], }, { field: 'currency', value: [], }, { field: 'range', value: ['', ''], }, { field: 'date', value: CURRENT_DATE_AND_TIME, }, { field: 'date-range', value: { startDate: subDays(startOfDay(CURRENT_DATE_AND_TIME), 2), endDate: endOfDay(CURRENT_DATE_AND_TIME) }, }, { field: 'granular-date-range', value: { startDate: subDays(CURRENT_DATE_AND_TIME, 2), endDate: CURRENT_DATE_AND_TIME }, }, { field: 'date-range-month', value: { startDate: startOfMonth(subMonths(CURRENT_DATE_AND_TIME, 2)), endDate: startOfMonth(CURRENT_DATE_AND_TIME), }, }, { field: 'paymentMethod', value: undefined, }, { field: 'checkbox-group', value: [], }, { field: 'toggle', value: true, }, ]; const FILTERS: BentoFilterBarModel = [ { field: 'input', label: 'Input filter', type: BentoFilterItemType.INPUT, options: { placeholder: 'Input filter placeholder', tooltipPosition: 'bottom', tooltipText: 'Input filter tooltip text', }, visible: true, }, { field: 'input-with-dropdown', label: 'Input with dropdown filter', type: BentoFilterItemType.INPUT_DROPDOWN, visible: true, options: { dropdown: { items: [ { label: 'EUR', value: 'EUR' }, { label: 'USD', value: 'USD' }, ], modelValue: 'EUR', dynamicFiltering: true, }, }, }, { field: 'hidden-input', label: 'Hidden input filter', description: 'This is a hidden input filter only visible through the modal', type: BentoFilterItemType.INPUT, }, { field: 'country', label: 'Country/Region', options: { listboxItems: [ { label: 'Option 1', value: BentoCountryCode.NL, }, { label: 'Option 2', value: BentoCountryCode.BE, }, ], multiple: true, variant: 'capital', }, type: BentoFilterItemType.SELECT_COUNTRY, }, { field: 'avatar', label: 'Avatar', options: { listboxItems: [ { label: 'Avatar 1', value: 'avatar-1', }, { label: 'Avatar 2', value: 'avatar-2', }, ], }, type: BentoFilterItemType.SELECT_AVATAR, }, { field: 'merchant', label: 'Merchant', options: { listboxItems: DEFAULT_OPTIONS, enableValueLabelPair: true, isOptionDisabled: option => option.value === 3, }, type: BentoFilterItemType.SELECT, visible: true, }, { field: 'company', label: 'Company', options: { listboxItems: DEFAULT_OPTIONS, enableValueLabelPair: true, isOptionDisabled: option => option.value === 5, multiple: true, }, type: BentoFilterItemType.SELECT, visible: true, }, { field: 'nested-select', label: 'Nested select', options: { listboxItems: [ { label: 'Belgium', description: 'Country/Region', value: 'belgium', }, { label: 'Netherlands', description: 'Country/Region', value: 'netherlands', items: [ { label: 'Amsterdam', description: 'City', value: 'amsterdam', }, { label: 'Den Haag', description: 'City', value: 'den-haag', }, ], }, ], multiple: true, }, type: BentoFilterItemType.SELECT, visible: true, }, { field: 'currency', label: 'Currency select', options: { listboxItems: [ { label: 'Euro', value: BentoCurrencyISOCode.EUR, }, { label: 'US Dollars', value: BentoCurrencyISOCode.USD, }, ], multiple: true, }, type: BentoFilterItemType.SELECT_CURRENCY, }, { field: 'range', label: 'Range', type: BentoFilterItemType.RANGE, visible: true, }, { field: 'date', label: 'Date', type: BentoFilterItemType.DATE, visible: true, }, { field: 'date-range', label: 'Date range', options: { allowTimeInput: true, numberOfMonths: 1, quickSelectRanges: [ { label: 'Last 15 min', value: 'lastFifteenMin', data: { startDate: subMinutes(CURRENT_DATE_AND_TIME, 15), isRelativeToNow: true, }, }, { label: 'Last 24 hours', value: 'lastTwentyFourHours', data: { startDate: subHours(CURRENT_DATE_AND_TIME, 24), isRelativeToNow: true, }, }, { label: 'Last three days', value: 'lastThreeDays', data: { startDate: subDays(startOfDay(CURRENT_DATE_AND_TIME), 2), }, }, { label: 'Last week', value: 'lastWeek', data: { startDate: subWeeks(startOfDay(CURRENT_DATE_AND_TIME), 1), }, }, ], } as BentoDateRangeFilterOptions, type: BentoFilterItemType.DATE_RANGE, visible: true, }, { field: 'granular-date-range', label: 'Granular date range', options: { allowTimeInput: true, numberOfMonths: 2, granularities: [{ type: 'daily' }, { type: 'weekly' }, { type: 'monthly', disabled: true }], min: subWeeks(startOfWeek(CURRENT_DATE_AND_TIME, { weekStartsOn: 1 }), 12), max: endOfWeek(CURRENT_DATE_AND_TIME, { weekStartsOn: 1 }), } as BentoDateRangeFilterOptions, type: BentoFilterItemType.DATE_RANGE, visible: true, }, { field: 'date-range-month', label: 'Date range (month)', options: { numberOfMonths: 2, variant: 'month', } as BentoDateRangeFilterOptions, type: BentoFilterItemType.DATE_RANGE, visible: true, }, { field: 'paymentMethod', label: 'Payment method', options: { listboxItems: [ { label: 'iDEAL', value: 'ideal' }, { label: 'Visa', value: 'visa' }, ], multiple: true, }, type: BentoFilterItemType.SELECT_PAYMENT_METHOD, }, { field: 'tag', label: 'Tag', options: { listboxItems: [ { label: 'Offline', value: 'unique-id-1', variant: 'red' }, { label: 'Online', value: 'unique-id-2', variant: 'green' }, { label: 'Standby', value: 'unique-id-3', variant: 'orange' }, { label: 'Busy', value: 'unique-id-4', variant: 'blue' }, ], multiple: true, }, type: BentoFilterItemType.SELECT_TAG, }, { field: 'radio-group', label: 'Radio group', options: { items: [ { label: 'Option 1', value: 'option-1' }, { label: 'Option 2', value: 'option-2' }, { label: 'Option 3', value: 'option-3' }, ], }, visible: false, type: BentoFilterItemType.RADIO_GROUP, }, { field: 'checkbox-group', label: 'Checkbox group', options: { checkboxItems: [ { label: 'Item 1', value: 'unique-id-1' }, { label: 'Item 2', value: 'unique-id-2' }, { label: 'Item 3', value: 'unique-id-3' }, { label: 'Item with a very long name', value: 'unique-id-4' }, ], }, type: BentoFilterItemType.CHECKBOX_GROUP, visible: true, }, { field: 'toggle', label: 'Toggle', type: BentoFilterItemType.TOGGLE, visible: true, description: 'Optional description for the toggle filter', options: { tooltipPosition: 'bottom', tooltipText: 'Toggle filter tooltip text', }, }, ]; const FILTERS_WITH_DEFAULTS: BentoFilterBarModel = [ { field: 'input', label: 'Input filter', defaultValue: 'hello', type: BentoFilterItemType.INPUT, }, { field: 'input-with-dropdown', label: 'Input with dropdown filter', type: BentoFilterItemType.INPUT_DROPDOWN, visible: true, defaultValue: { inputValue: 'Peristent input filter', dropdownValue: 'EUR', }, options: { dropdown: { items: [ { label: 'EUR', value: 'EUR' }, { label: 'USD', value: 'USD' }, ], }, }, }, { field: 'range', label: 'Range', defaultValue: ['1', '4'], type: BentoFilterItemType.RANGE, }, { field: 'merchant', label: 'Merchants', defaultValue: ['merchant_1'], options: { listboxItems: [ { label: 'Merchant 1', value: 'merchant_1', }, { label: 'Merchant 2', value: 'merchant_2', }, { label: 'Merchant 3', value: 'merchant_3', }, ], multiple: true, enableValueLabelPair: true, }, type: BentoFilterItemType.SELECT, }, { field: 'thing', label: 'Things', defaultValue: 'merchant_1', options: { listboxItems: [ { label: 'Thing 1', value: 'merchant_1', }, { label: 'Thing 2', value: 'merchant_2', }, { label: 'Thing 3', value: 'merchant_3', }, ], }, type: BentoFilterItemType.SELECT, }, { field: 'country', label: 'Country/Region', options: { listboxItems: [ { label: 'Option 1', value: BentoCountryCode.NL, }, { label: 'Option 2', value: BentoCountryCode.BE, }, ], multiple: true, variant: 'capital', }, type: BentoFilterItemType.SELECT_COUNTRY, }, { field: 'radio-group', label: 'Radio group', defaultValue: 'option-1', options: { items: [ { label: 'Option 1', value: 'option-1' }, { label: 'Option 2', value: 'option-2' }, { label: 'Option 3', value: 'option-3' }, ], }, visible: false, type: BentoFilterItemType.RADIO_GROUP, }, { field: 'checkbox-group', label: 'Checkbox group', defaultValue: ['unique-id-1'], options: { checkboxItems: [ { label: 'Item 1', value: 'unique-id-1' }, { label: 'Item 2', value: 'unique-id-2' }, { label: 'Item 3', value: 'unique-id-3' }, { label: 'Item 4', value: 'unique-id-4' }, ], }, type: BentoFilterItemType.CHECKBOX_GROUP, }, ]; const defaultFilterTemplate = storybookDocsParameter(` <template> <bento-filter-bar :config="config" :filter-values="filterValues" :search-term.sync="searchTerm" @input="onFilter" @search-input="onSearchInput" @search-clear="onSearchClear" /> </template> <script setup lang="ts"> import { BentoFilterBar, BentoFilterItemType } from '@adyen/bento-vue2' import type { BentoFilterBarModel, BentoFilterValues } from '@adyen/bento-vue2' const searchTerm = ref<string>(undefined) const config = [{ field: 'name', label: 'Name', type: BentoFilterItemType.INPUT, visible: true, }, { field: 'merchant', label: 'Merchant', options: { listboxItems: DEFAULT_OPTIONS, }, type: BentoFilterItemType.SELECT, }, { field: 'company', label: 'Company', options: { listboxItems: DEFAULT_OPTIONS, isOptionDisabled: (option) => option.value === 5, multiple: true, }, type: BentoFilterItemType.SELECT, }] const filterValues = ref<BentoFilterValues>([{ field: 'merchant', value: 2, }, { field: 'company', value: [1, 3, 5], }]) const onFilter = (newFilters: BentoFilterBarModel) => { } const onSearchInput = (newTerm: string) => { } const onSearchClear = () => { } </script> `); export const Default: Story = { render: (_args, { argTypes }) => ({ components: { BentoFilterBar }, props: Object.keys(argTypes), setup(props) { const args = isVue2 ? props : _args; const filtersConfig = ref<BentoFilterBarModel>(args.config); const filterValues = ref<BentoFilterValues>(args.filterValues); const filterValuesUpdate = (newFilterValues: BentoFilterValues) => { filterValues.value = newFilterValues; action('update:filter-values')(newFilterValues); }; const filterValuesChanged = (newFilterValues: BentoFilterValues) => { action('input')(newFilterValues); }; const searchTermField = computed(() => args.searchTerm); const searchInput = (newSearchTerm: string) => action('search-input')(newSearchTerm); const searchClear = () => action('search-clear')(); const searchTermSync = (newSearchTerm: string) => action('update:search-term')(newSearchTerm); return { // Props args, // Values filtersConfig, filterValues, searchTermField, // Events filterValuesChanged, filterValuesUpdate, searchInput, searchClear, searchTermSync, }; }, template: ` <div style="padding-top: 24px"> <bento-filter-bar v-bind="args" :config="filtersConfig" :search="{width: '800px'}" :searchTerm="searchTermField" :filter-values="filterValues" @input="filterValuesChanged" @update:filter-values="filterValuesUpdate" @update:search-term="searchTermSync" @search-input="searchInput" @search-clear="searchClear" /> </div> `, }), args: { config: FILTERS, filterValues: FILTER_VALUES, containerSizeLayout: { medium: 'multi-line', small: 'one-line', } satisfies BentoFilterBarContainerSizeLayout, search: { inputFieldAriaLabel: 'Input field aria label', debounceTime: 500, hint: 'Hint', placeholder: 'Placeholder content', } satisfies BentoFilterBarSearchBarProps, searchTerm: 'something', }, parameters: defaultFilterTemplate, }; const persistentFilterTemplate = storybookDocsParameter(` <bento-filter-bar :config="[ { field: 'input', label: 'Input filter', defaultValue: 'hello', type: BentoFilterItemType.INPUT, }, { field: 'range', label: 'Range', defaultValue: ['1', '4'], type: BentoFilterItemType.RANGE, }, { field: 'merchant', label: 'Merchants', defaultValue: ['merchant_1'], options: { listboxItems: [ { label: 'Merchant 1', value: 'merchant_1', }, { label: 'Merchant 2', value: 'merchant_2', }, { label: 'Merchant 3', value: 'merchant_3', }, ], multiple: true, }, type: BentoFilterItemType.SELECT, }, { field: 'thing', label: 'Things', defaultValue: 'merchant_1', options: { listboxItems: [ { label: 'Thing 1', value: 'merchant_1', }, { label: 'Thing 2', value: 'merchant_2', }, { label: 'Thing 3', value: 'merchant_3', }, ], }, type: BentoFilterItemType.SELECT, }, { field: 'country', label: 'Country/Region', options: { listboxItems: [ { label: 'Option 1', value: BentoCountryCode.NL, }, { label: 'Option 2', value: BentoCountryCode.BE, }, ], multiple: true, variant: 'capital', }, type: BentoFilterItemType.SELECT_COUNTRY, }, ]" :filter-values="[ { field: 'input', value: 'non-default value', }, ]" :search="{ inputFieldAriaLabel: 'Input field aria label', debounceTime: 500, hint: 'Hint', placeholder: 'Placeholder content', }" :search-term.sync="searchTerm" @input="onFilter" @search-input="onSearchInput" @search-clear="onSearchClear" /> `); export const Persistent: Story = { ...Default, args: { config: FILTERS_WITH_DEFAULTS, filterValues: [ { field: 'input', value: 'non-default value', }, { field: 'checkbox-group', value: ['unique-id-2'], }, ], searchTerm: null, }, parameters: persistentFilterTemplate, }; const externalFilteringTemplate = storybookDocsParameter(` <template> <bento-filter-bar :config="filtersConfig" :filter-values.sync="filterValues" /> </template> <script setup lang="ts"> import { ref, computed, onMounted } from 'vue'; import { BentoFilterBar, BentoFilterItemType, BentoFilterBarModel, BentoFilterValues, BentoSelectFilterOptions, } from '@adyen/bento-vue2'; // --- Mock API for demonstration --- const getPokemonFromApi = async (searchTerm?: string) => { // In a real application, this would be an API call. // We're mocking it with a timeout and static data. console.log(\`Searching for: \${searchTerm}\`); await new Promise(resolve => setTimeout(resolve, 1000)); const allPokemon = [ { label: 'Bulbasaur', value: 'bulbasaur' }, { label: 'Ivysaur', value: 'ivysaur' }, { label: 'Venusaur', value: 'venusaur' }, { label: 'Charmander', value: 'charmander' }, { label: 'Charmeleon', value: 'charmeleon' }, { label: 'Charizard', value: 'charizard' }, ]; const items = searchTerm ? allPokemon.filter(p => p.label.toLowerCase().includes(searchTerm.toLowerCase())) : allPokemon; return { items }; }; // --- End Mock API --- const filterValues = ref<BentoFilterValues>([{ field: 'pokemon', value: 'bulbasaur' }]); const apiItems = ref([]); const isLoading = ref(false); const getAndSetPokemonFromApi = async (val?: string) => { isLoading.value = true; const { items } = await getPokemonFromApi(val); apiItems.value = items; isLoading.value = false; }; onMounted(() => { getAndSetPokemonFromApi(); }); const filtersConfig = computed<BentoFilterBarModel>(() => [ { field: 'name', label: 'Name', type: BentoFilterItemType.INPUT }, { field: 'pokemon', label: 'Pokemon', options: { isOptionDisabled: option => ['ivysaur', 'charmeleon'].includes(option.value as string), listboxItems: apiItems.value, search: { debounceTime: 500, searchEvent: getAndSetPokemonFromApi }, loading: isLoading.value, }, type: BentoFilterItemType.SELECT, }, ]); </script> `); export const ExternalFilteringSelectFilter: Story = { render: (_args, { argTypes }) => ({ components: { BentoFilterBar, }, props: Object.keys(argTypes), setup(props) { const apiItems = ref([]); const filterValues = ref<BentoFilterValues>([{ field: 'pokemon', value: 'bulbasaur' }]); const isLoading = ref(false); const getAndSetPokemonFromApi = async (val?: string) => { isLoading.value = true; await new Promise(resolve => setTimeout(resolve, 1000)); const { items } = await getPokemonFromApi(val); apiItems.value = items; isLoading.value = false; }; const search = { debounceTime: 500, searchEvent: getAndSetPokemonFromApi, }; onMounted(() => { getAndSetPokemonFromApi(); }); const filtersConfig = computed(() => [ { field: 'name', label: 'Name', type: BentoFilterItemType.INPUT, }, { field: 'pokemon', label: 'Pokemon', options: { isOptionDisabled: option => ['ivysaur', 'charmeleon'].includes(option.value as string), listboxItems: apiItems.value, search, loading: isLoading.value, }, type: BentoFilterItemType.SELECT, visible: false, }, ]); return { args: isVue2 ? props : _args, // Values filtersConfig, filterValues, }; }, template: ` <div style="padding-top: 24px"> <bento-filter-bar v-bind="args" :config="filtersConfig" :filter-values.sync="filterValues" /> </div> `, }), args: { config: FILTERS, searchTerm: null }, parameters: externalFilteringTemplate, }; const SearchAndLazyLoadTemplate = storybookDocsParameter(` <template> <bento-filter-bar :config="filtersConfig" :filter-values.sync="filterValues" /> </template> <script setup lang="ts"> import { ref, computed, onMounted } from 'vue'; import { BentoFilterBar, BentoFilterItemType, BentoFilterBarModel, BentoFilterValues, BentoSelectFilterOptions, BentoListboxOptions, } from '@adyen/bento-vue2'; // --- Mock API for demonstration --- const ALL_ITEMS = Array.from({ length: 100 }, (_, i) => ({ label: \`Item \${i + 1}\`, value: i + 1, })); const fetchItems = (searchTerm: string, page: number, pageSize: number = 20) => { console.log(\`Fetching items with searchTerm: "\${searchTerm}", page: \${page}\`); return new Promise<{ items: BentoListboxOptions; hasMore: boolean }>(resolve => { setTimeout(() => { const filtered = ALL_ITEMS.filter(item => item.label.toLowerCase().includes(searchTerm.toLowerCase()) ); const start = (page - 1) * pageSize; const end = start + pageSize; const pageItems = filtered.slice(start, end); resolve({ items: pageItems, hasMore: end < filtered.length, }); }, 500); }); }; // --- End Mock API --- const filterValues = ref<BentoFilterValues>([]); // State for the select filter const listboxItems = ref<BentoListboxOptions>([]); const isLoading = ref(false); const hasMoreItems = ref(true); const currentPage = ref(1); const currentSearchTerm = ref(''); /** * Handles the search event from the filter. * This should reset pagination and fetch a new list of items. */ const onSearch = async (searchTerm: string) => { currentSearchTerm.value = searchTerm; currentPage.value = 1; isLoading.value = true; const { items, hasMore } = await fetchItems(searchTerm, currentPage.value); listboxItems.value = items; hasMoreItems.value = hasMore; isLoading.value = false; }; /** * Handles the lazy load event. * This should fetch the next page of items and append them. */ const onLazyLoad = async () => { if (isLoading.value || !hasMoreItems.value) { return; } currentPage.value++; isLoading.value = true; const { items, hasMore } = await fetchItems(currentSearchTerm.value, currentPage.value); // Append new items to the existing list listboxItems.value = [...listboxItems.value, ...items]; hasMoreItems.value = hasMore; isLoading.value = false; }; // Load initial data when the component mounts onMounted(() => { onSearch(''); }); const filtersConfig = computed<BentoFilterBarModel>(() => [ { field: 'lazy-select', label: 'Lazy Load Select', type: BentoFilterItemType.SELECT, options: { listboxItems: listboxItems.value, multiple: true, // External filtering configuration search: { searchEvent: onSearch, }, // Lazy loading configuration lazy: { hasMoreItems: hasMoreItems.value, lazyLoadType: 'automatic', // or 'button' loading: isLoading.value, showMoreEvent: onLazyLoad, }, }, }, ]); </script> `); export const ExternalFilteringAndLazyLoadingSelectFilter: Story = { ...ExternalFilteringSelectFilter, render: (_args, { argTypes }) => ({ components: { BentoFilterBar, }, props: Object.keys(argTypes), setup(props) { const args = isVue2 ? props : _args; const filterValues = ref<BentoFilterValues>([]); const lazyLoadingPageSize = 20; // State for the select filter const listboxItems = ref<BentoListboxOptions>([]); const isSearchLoading = ref(false); const isLazyLoading = ref(false); const hasMoreItems = ref(true); const currentPage = ref(1); const currentSearchTerm = ref(''); /** * Handles the search event from the filter. * This should reset pagination and fetch a new list of items. */ const onSearch = async (searchTerm: string) => { currentSearchTerm.value = searchTerm; currentPage.value = 1; isSearchLoading.value = true; const { items, hasMore } = await getPokemonFromApi(searchTerm, currentPage.value, lazyLoadingPageSize); listboxItems.value = items; hasMoreItems.value = hasMore; isSearchLoading.value = false; }; /** * Handles the lazy load event. * This should fetch the next page of items and append them. */ const onLazyLoad = async () => { if (isLazyLoading.value || !hasMoreItems.value) { return; } currentPage.value++; isLazyLoading.value = true; const { items, hasMore } = await getPokemonFromApi( currentSearchTerm.value, currentPage.value, lazyLoadingPageSize ); // Append new items to the existing list listboxItems.value = [...listboxItems.value, ...items]; hasMoreItems.value = hasMore; isLazyLoading.value = false; }; // Load initial data when the component mounts onMounted(() => { onSearch(''); }); const filtersConfig = computed<BentoFilterBarModel>(() => [ { field: 'lazy-select', label: 'External filtering and lazy loading', type: BentoFilterItemType.SELECT, options: { listboxItems: listboxItems.value, multiple: true, loading: isSearchLoading.value, // External filtering configuration search: { searchEvent: onSearch, }, // Lazy loading configuration lazy: { hasMoreItems: hasMoreItems.value, lazyLoadType: 'automatic', // or 'button' loading: isLazyLoading.value, showMoreEvent: onLazyLoad, }, }, }, ]); return { args, filtersConfig, filterValues, }; }, template: ` <div style="padding-top: 24px"> <bento-filter-bar v-bind="args" :config="filtersConfig" :filter-values.sync="filterValues" /> </div> `, }), args: { ...ExternalFilteringSelectFilter.args, }, parameters: SearchAndLazyLoadTemplate, }; export const CustomFilters: Story = { render: (_args, { argTypes }) => ({ components: { BentoFilterBar, BentoToggle, FilterBarCustomFilterSubComponentSingleValueExample, FilterBarCustomFilterSubComponentMultiValueExample, }, props: Object.keys(argTypes), template: ` <div style="padding-top: 24px"> <bento-filter-bar :config="filtersConfig" :filter-values-object="filterValues" @update:filter-values-object="filterValuesChanged" /> </div> `, setup() { interface CustomFilterOptions { required?: boolean; optional?: boolean; disabled?: boolean; } const filtersConfig: BentoFilterBarModel<CustomFilterOptions> = [ { field: 'input', label: 'Input filter', type: BentoFilterItemType.INPUT, options: { placeholder: 'Input filter placeholder', }, visible: true, }, { field: 'custom-single', label: 'Custom single value filter', type: FilterBarCustomFilterSubComponentSingleValueExample, visible: true, }, { field: 'custom-multi', label: 'Custom multi value filter', type: FilterBarCustomFilterSubComponentMultiValueExample, options: { // ICustomFilterOptions disabled: false, optional: false, required: false, }, visible: false, }, { field: 'custom-popover', label: 'Custom filter with popover', type: FilterBarCustomFilterSubComponentWithPopoverExample, visible: true, }, ]; /** * Custom filter values */ type FilterKeys = 'input' | 'custom-single' | 'custom-multi' | 'custom-popover'; type CustomFilterValues = | BentoFilterBarValue | boolean | { // ICustomFilterValues first: boolean; second: boolean; } | undefined; const filterValues = ref<BentoFilterValueObject<FilterKeys, CustomFilterValues>>({ input: undefined, 'custom-single': undefined, 'custom-multi': { first: false, second: false, }, 'custom-popover': undefined, }); const filterValuesChanged = (newFilterValues: BentoFilterValueObject<FilterKeys, CustomFilterValues>) => { filterValues.value = newFilterValues; action('input')(newFilterValues); }; return { filtersConfig, filterValues, filterValuesChanged, }; }, }), args: false, parameters: storybookDocsParameter(FilterBarCustomFiltersExample), };
@@ -1 +1 @@
1
- import { type BentoAvatarListboxOptions } from '@/components/dropdown/components/variants/dropdown-avatar/dropdown-avatar.types'; import { type BentoListboxEmptyStateProps, type BentoListboxIsOptionDisabled, type BentoListboxLazyLoadType, type BentoListboxOptions, type BentoListboxSearchOptions, type BentoListboxSelectedValue, type BentoListboxVirtulisationOptions, } from '@/types/listbox'; import { type OmitStrict } from '@/types/omit-strict'; import { type VueNodeElement } from '@/types/elements'; import { type BentoCountryListboxOptions } from '@/components/dropdown/components/variants/dropdown-country/dropdown-country.types'; import { type BentoTagListboxOptions } from '@/components/dropdown/components/variants/dropdown-tag/dropdown-tag.types'; import { type BentoCountryVariant } from '@/components/country'; import { type BentoCurrencyListboxOptions } from '@/components/dropdown/components/variants/dropdown-currency/dropdown-currency.types'; import { type BentoPaymentMethodListboxOptions } from '@/components/dropdown/components/variants/dropdown-payment-method/dropdown-payment-method.types'; import { type BentoInputDropdownProps, type BentoInputFieldElementPosition, type BentoInputFieldValue, } from '@/components/input-field'; import { type BentoDatePickerFirstDayOfWeek, type BentoDatePickerIsDateDisabled, type BentoDatePickerVariant, } from '@/components/date-picker'; import { type BentoDateRangePickerGranularityConfig, type BentoDateRangePickerValue, type BentoDateRangePickerVariant, } from '@/components/date-range-picker'; import { type BentoRadioGroupFilterOptions, type BentoRadioGroupFilterValue, } from './components/radio-group-filter/radio-group-filter.types'; import { type DateRangePickerCalendarRangeSelectorItems } from '../date-range-picker/components/date-range-picker-calendar/date-range-picker-calendar.types'; import { type BentoSearchBarProps } from '@/components/search-bar/search-bar.types'; import type { BentoInputFilterOptions, BentoInputFilterValue } from './components/input-filter/input-filter.types'; import type { BentoToggleFilterOptions, BentoToggleFilterValue } from './components/toggle-filter/toggle-filter.types'; import type { BentoTooltipPosition } from '@/directives'; import { type BentoCheckboxGroupFilterOptions, type BentoCheckboxGroupFilterValue, } from './components/checkbox-group-filter/checkbox-group-filter.types'; /** * Re-export filter component types */ export type * from './components/input-filter/input-filter.types'; export type * from './components/radio-group-filter/radio-group-filter.types'; export type * from './components/toggle-filter/toggle-filter.types'; export interface BentoSelectFilterOptions { /** * Empty state props that will be used with the inner empty state component to be displayed when no search results are found. */ emptyState?: BentoListboxEmptyStateProps; /** * Option items for the list-box */ listboxItems: BentoListboxOptions; /** * Defines if the list-box should be a multi-select filter */ multiple?: boolean; /** * @deprecated from version 2.0.0. The persistent filter label now uses a counter instead of custom strings. This property is now redundant. * The translation object to handle any custom transations. e.g. perisistent filter labels. */ messages?: { [key: string]: { persistentButtonLabelPartiallySelected?: string; persistentButtonLabelAllSelected?: string; [key: string]: string; }; }; /** * @deprecated from version 2.0.0. The persistent filter label now uses a counter instead of custom strings. This property is now redundant. * The unique key to use in the messages translation object to use for the persistent button label * when the select filter has partially selected items. */ messagesPersistentButtonLabelPartiallySelectedKey?: string; /** * @deprecated from version 2.0.0. The persistent filter label now uses a counter instead of custom strings. This property is now redundant. * The unique key to use in the messages translation object to use for the persistent button label * when the select filter has all items selected. */ messagesPersistentButtonLabelAllSelectedKey?: string; /** * The event fire when the filter is opened. */ onOpen?: () => void; lazy?: { /** * Indicates whether there are more items to load */ hasMoreItems: 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}`; /** * Indicates if new options are loading. */ loading: boolean; /** * The click event that fires when the user press on the "show more" button OR when the user * scrolls down to bottom of the select filter box. */ showMoreEvent?: () => void; }; /** * Toggles the loading state of the listbox. */ loading?: boolean; /** * Search options and events for the listbox filtering. */ search?: BentoListboxSearchOptions; /** * Defines if the list-box should support static categories */ staticCategories?: boolean; /** * A function that defines which option in the list-box is disabled */ isOptionDisabled?: BentoListboxIsOptionDisabled; /** * 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?: BentoListboxVirtulisationOptions; /** * Attaches a `BentoTooltip` if present. Text showed in the tooltip on hover. */ tooltipText?: string; /** * Position the tooltip should take if space is available. */ tooltipPosition?: BentoTooltipPosition | `${BentoTooltipPosition}`; /** * 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; /** * Enables a search bar that dynamically filters values * @default true */ dynamicFiltering?: boolean; } export type BentoSelectAvatarFilterOptions = OmitStrict<BentoSelectFilterOptions, 'listboxItems'> & { listboxItems: BentoAvatarListboxOptions; }; export type BentoSelectCountryFilterOptions = OmitStrict<BentoSelectFilterOptions, 'listboxItems'> & { listboxItems: BentoCountryListboxOptions; variant?: BentoCountryVariant | `${BentoCountryVariant}`; }; export type BentoSelectCurrencyFilterOptions = OmitStrict<BentoSelectFilterOptions, 'listboxItems'> & { listboxItems: BentoCurrencyListboxOptions; }; export type BentoSelectTagFilterOptions = OmitStrict<BentoSelectFilterOptions, 'listboxItems'> & { listboxItems: BentoTagListboxOptions; }; export type BentoSelectPaymentMethodFilterOptions = OmitStrict<BentoSelectFilterOptions, 'listboxItems'> & { listboxItems: BentoPaymentMethodListboxOptions; }; export interface BentoBooleanFilterOption { label: string; value: string | boolean; } export interface BentoBooleanFilterOptions { positiveOption?: BentoBooleanFilterOption; negativeOption?: BentoBooleanFilterOption; tooltipText?: string; tooltipPosition?: BentoTooltipPosition | `${BentoTooltipPosition}`; } export interface BentoInputWithDropdownFilterOptions { dropdown: BentoInputDropdownProps; dropdownPosition?: BentoInputFieldElementPosition | `${BentoInputFieldElementPosition}`; tooltipText?: string; tooltipPosition?: BentoTooltipPosition | `${BentoTooltipPosition}`; } export interface BentoRangeFilterOptions { tooltipText?: string; tooltipPosition?: BentoTooltipPosition | `${BentoTooltipPosition}`; } // Date export interface BentoDateFilterOptions { firstDayOfWeek?: BentoDatePickerFirstDayOfWeek; isDateDisabled?: BentoDatePickerIsDateDisabled; variant?: `${BentoDatePickerVariant}`; tooltipText?: string; tooltipPosition?: BentoTooltipPosition | `${BentoTooltipPosition}`; } export interface BentoDateRangeFilterOptions { allowTimeInput?: boolean; firstDayOfWeek?: BentoDatePickerFirstDayOfWeek; isDateDisabled?: BentoDatePickerIsDateDisabled; granularities?: Array<BentoDateRangePickerGranularityConfig>; maxRange?: number; min?: Date; max?: Date; numberOfMonths?: number; showEndDateOnOpen?: boolean; quickSelectRanges?: DateRangePickerCalendarRangeSelectorItems | boolean; variant?: `${BentoDateRangePickerVariant}`; tooltipText?: string; tooltipPosition?: BentoTooltipPosition | `${BentoTooltipPosition}`; } export interface BentoInpuWithDropdowntFilterValue { inputValue: BentoInputFieldValue; dropdownValue: BentoInputDropdownProps['value']; } export type BentoSelectFilterValue = BentoListboxSelectedValue; export type BentoBooleanFilterValue = string | null; export type BentoDateFilterValue = Date | null; export type BentoDateRangeFilterValue = BentoDateRangePickerValue | null; export type BentoFilterBarValue = | BentoInputFilterValue | BentoInpuWithDropdowntFilterValue | BentoSelectFilterValue | BentoBooleanFilterValue | BentoDateFilterValue | BentoDateRangeFilterValue | BentoRadioGroupFilterValue | BentoCheckboxGroupFilterValue | BentoToggleFilterValue; export type BentoFilterOptions = | BentoSelectFilterOptions | BentoSelectAvatarFilterOptions | BentoSelectCountryFilterOptions | BentoSelectCurrencyFilterOptions | BentoSelectPaymentMethodFilterOptions | BentoSelectTagFilterOptions | BentoDateFilterOptions | BentoDateRangeFilterOptions | BentoBooleanFilterOptions | BentoInputFilterOptions | BentoInputWithDropdownFilterOptions | BentoRadioGroupFilterOptions | BentoCheckboxGroupFilterOptions | BentoToggleFilterOptions | undefined; export enum BentoFilterEvent { // Fires upon the user interacting with the filter INPUT = 'input', // Fires on the (new) filter values being applied UPDATE = 'update', } export enum BentoFilterBarEvent { INPUT = 'input', UPDATE_FILTER_VALUES_OBJECT = 'update:filter-values-object', UPDATE_FILTER_VALUES_ARRAY = 'update:filter-values', } export enum BentoFilterItemType { /** * @deprecated Use "BentoFilterItemType.RADIO_GROUP" instead */ BOOLEAN = 'BentoBooleanFilter', RANGE = 'BentoRangeFilter', // Input INPUT = 'BentoInputFilter', INPUT_DROPDOWN = 'BentoInputWithDropdownFilter', // Select SELECT = 'BentoSelectFilter', SELECT_AVATAR = 'BentoSelectAvatarFilter', SELECT_COUNTRY = 'BentoSelectCountryFilter', SELECT_CURRENCY = 'BentoSelectCurrencyFilter', SELECT_PAYMENT_METHOD = 'BentoSelectPaymentMethodFilter', SELECT_TAG = 'BentoSelectTagFilter', // Date DATE = 'BentoDateFilter', DATE_RANGE = 'BentoDateRangeFilter', // Radio group RADIO_GROUP = 'BentoRadioGroupFilter', // Checkbox CHECKBOX_GROUP = 'BentoCheckboxGroupFilter', // Toggle TOGGLE = 'BentoToggleFilter', } export interface BentoFilterValue { field: string; value?: BentoFilterBarValue; } export interface BentoFilterModel<CustomOptions = any> extends BentoFilterValue { label: string; description?: string; options?: BentoFilterOptions | CustomOptions; disabled?: boolean; defaultValue?: BentoFilterBarValue; visible?: boolean; /** * Defines the filter to be used. * * Can be one filter defined in the {@link BentoFilterItemType} enum or a * custom filter component defined as a {@link VueNodeElement} */ type: BentoFilterItemType | VueNodeElement; /** * @deprecated This property will be removed in version 2.0, please use `config` & `filter-values` properties. */ value?: BentoFilterBarValue; } export type BentoFilterBarModel<CustomOptions = any> = Array<BentoFilterModel<CustomOptions>>; export type BentoFilterValues = Array<BentoFilterValue>; export type BentoFilterValueObject<T extends PropertyKey = string, CustomValue = BentoFilterBarValue> = Record< T, CustomValue >; export type BentoFilterBarSearchBarProps = OmitStrict<BentoSearchBarProps, 'value' | 'condensed'>; export type BentoFilterBarSearchTerm = BentoSearchBarProps['value']; export type BentoFilterBarScreenOptionsLayout = 'one-line' | 'multi-line'; export interface BentoFilterBarContainerSizeLayout { /** * @default 'one-line' */ medium?: BentoFilterBarScreenOptionsLayout; /** * @default 'multi-line' */ small?: BentoFilterBarScreenOptionsLayout; } export interface BentoFilterBarProps<CustomValue = BentoFilterBarValue | any, CustomOptions = any> { /** * Array of filters based on @see {@link BentoFilterBarModel}. * Each one of the elements in the array correspond to a filter that will * be created and rendered in the FilterBar. * * @example * [{ * field: 'company', * label: 'Company', * type: FilterType.TEXT, * }, { * field: 'name', * label: 'Name', * type: FilterType.TEXT, * }] */ config?: BentoFilterBarModel<CustomOptions>; /** * An array of the values for each filter in the filter bar. * @see {@link BentoFilterValues} * * @example * [{ * field: 'company', * value: '200', * }, { * field: 'name', * value: 'some text', * }] */ filterValues?: BentoFilterValues; /** * Defines the layout (multi-line or one-line) to be used in the filter bar for different * container sizes: * - Large (>=700px): Always multi-line * - Medium (>=430px & <700px) * - Small (<430px) * * @example * { * medium: 'multi-line', * small: 'one-line' * } */ containerSizeLayout?: BentoFilterBarContainerSizeLayout; /** * Search bar properties with which the placeholder, hint and other * "bento-search-bar" properties can be set. @see {@link BentoFilterBarSearchBarProps} */ search?: BentoFilterBarSearchBarProps; /** * Search term useful to filter all the data by text. * If the value is non-existant (null or undefined) the search bar won't be enabled. * Can be used with `.sync` */ searchTerm?: BentoFilterBarSearchTerm; /** * Will show all applied filters even if their visibility is set to false. */ showAppliedHiddenFilters?: boolean; /** * Array of filters based on @see {@link BentoFilterBarModel}. * Each one of the elements in the array correspond to a filter that will * be created and rendered in the FilterBar. * * Can be used with v-model or independently with the @input event. * * @example * [{ * field: 'company', * label: 'Company', * value: '200', * type: FilterType.TEXT, * }, { * field: 'name', * label: 'Name', * value: null, * type: FilterType.TEXT, * }] * * @deprecated from version 2.0.0. Use the config property instead. */ value?: BentoFilterBarModel; /** * An object of the values for each filter in the filter bar where key is a the field name and the value is the BentoFilterBarValue. * * @example * { * 'company': '200', * 'name': 'some text' * }, */ filterValuesObject?: BentoFilterValueObject<string, CustomValue>; }
1
+ import { type BentoAvatarListboxOptions } from '@/components/dropdown/components/variants/dropdown-avatar/dropdown-avatar.types'; import { type BentoListboxEmptyStateProps, type BentoListboxIsOptionDisabled, type BentoListboxLazyLoadType, type BentoListboxOptions, type BentoListboxSearchOptions, type BentoListboxSelectedValue, type BentoListboxVirtulisationOptions, } from '@/types/listbox'; import { type OmitStrict } from '@/types/omit-strict'; import { type VueNodeElement } from '@/types/elements'; import { type BentoCountryListboxOptions } from '@/components/dropdown/components/variants/dropdown-country/dropdown-country.types'; import { type BentoTagListboxOptions } from '@/components/dropdown/components/variants/dropdown-tag/dropdown-tag.types'; import { type BentoCountryVariant } from '@/components/country'; import { type BentoCurrencyListboxOptions } from '@/components/dropdown/components/variants/dropdown-currency/dropdown-currency.types'; import { type BentoPaymentMethodListboxOptions } from '@/components/dropdown/components/variants/dropdown-payment-method/dropdown-payment-method.types'; import { type BentoInputDropdownProps, type BentoInputFieldElementPosition, type BentoInputFieldValue, } from '@/components/input-field'; import { type BentoDatePickerFirstDayOfWeek, type BentoDatePickerIsDateDisabled, type BentoDatePickerVariant, } from '@/components/date-picker'; import { type BentoDateRangePickerGranularityConfig, type BentoDateRangePickerValue, type BentoDateRangePickerVariant, } from '@/components/date-range-picker'; import { type BentoRadioGroupFilterOptions, type BentoRadioGroupFilterValue, } from './components/radio-group-filter/radio-group-filter.types'; import { type DateRangePickerCalendarRangeSelectorItems } from '../date-range-picker/components/date-range-picker-calendar/date-range-picker-calendar.types'; import { type BentoSearchBarProps } from '@/components/search-bar/search-bar.types'; import type { BentoInputFilterOptions, BentoInputFilterValue } from './components/input-filter/input-filter.types'; import type { BentoToggleFilterOptions, BentoToggleFilterValue } from './components/toggle-filter/toggle-filter.types'; import type { BentoTooltipPosition } from '@/directives'; import { type BentoCheckboxGroupFilterOptions, type BentoCheckboxGroupFilterValue, } from './components/checkbox-group-filter/checkbox-group-filter.types'; /** * Re-export filter component types */ export type * from './components/input-filter/input-filter.types'; export type * from './components/radio-group-filter/radio-group-filter.types'; export type * from './components/toggle-filter/toggle-filter.types'; export interface BentoSelectFilterOptions { /** * Empty state props that will be used with the inner empty state component to be displayed when no search results are found. */ emptyState?: BentoListboxEmptyStateProps; /** * Option items for the list-box */ listboxItems: BentoListboxOptions; /** * Defines if the list-box should be a multi-select filter */ multiple?: boolean; /** * @deprecated Since v2.0.0. Do not use. The persistent filter label now uses a counter instead of custom strings. This property is now redundant. * The translation object to handle any custom translations. e.g. persistent filter labels. */ messages?: { [key: string]: { persistentButtonLabelPartiallySelected?: string; persistentButtonLabelAllSelected?: string; [key: string]: string; }; }; /** * @deprecated Since v2.0.0. Do not use. The persistent filter label now uses a counter instead of custom strings. This property is now redundant. * The unique key to use in the messages translation object to use for the persistent button label * when the select filter has partially selected items. */ messagesPersistentButtonLabelPartiallySelectedKey?: string; /** * @deprecated Since v2.0.0. Do not use. The persistent filter label now uses a counter instead of custom strings. This property is now redundant. * The unique key to use in the messages translation object to use for the persistent button label * when the select filter has all items selected. */ messagesPersistentButtonLabelAllSelectedKey?: string; /** * The event fire when the filter is opened. */ onOpen?: () => void; lazy?: { /** * Indicates whether there are more items to load */ hasMoreItems: 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}`; /** * Indicates if new options are loading. */ loading: boolean; /** * The click event that fires when the user press on the "show more" button OR when the user * scrolls down to bottom of the select filter box. */ showMoreEvent?: () => void; }; /** * Toggles the loading state of the listbox. */ loading?: boolean; /** * Search options and events for the listbox filtering. */ search?: BentoListboxSearchOptions; /** * Defines if the list-box should support static categories */ staticCategories?: boolean; /** * A function that defines which option in the list-box is disabled */ isOptionDisabled?: BentoListboxIsOptionDisabled; /** * 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?: BentoListboxVirtulisationOptions; /** * Attaches a `BentoTooltip` if present. Text showed in the tooltip on hover. */ tooltipText?: string; /** * Position the tooltip should take if space is available. */ tooltipPosition?: BentoTooltipPosition | `${BentoTooltipPosition}`; /** * 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; /** * Enables a search bar that dynamically filters values * @default true */ dynamicFiltering?: boolean; } export type BentoSelectAvatarFilterOptions = OmitStrict<BentoSelectFilterOptions, 'listboxItems'> & { listboxItems: BentoAvatarListboxOptions; }; export type BentoSelectCountryFilterOptions = OmitStrict<BentoSelectFilterOptions, 'listboxItems'> & { listboxItems: BentoCountryListboxOptions; variant?: BentoCountryVariant | `${BentoCountryVariant}`; }; export type BentoSelectCurrencyFilterOptions = OmitStrict<BentoSelectFilterOptions, 'listboxItems'> & { listboxItems: BentoCurrencyListboxOptions; }; export type BentoSelectTagFilterOptions = OmitStrict<BentoSelectFilterOptions, 'listboxItems'> & { listboxItems: BentoTagListboxOptions; }; export type BentoSelectPaymentMethodFilterOptions = OmitStrict<BentoSelectFilterOptions, 'listboxItems'> & { listboxItems: BentoPaymentMethodListboxOptions; }; export interface BentoBooleanFilterOption { label: string; value: string | boolean; } export interface BentoBooleanFilterOptions { positiveOption?: BentoBooleanFilterOption; negativeOption?: BentoBooleanFilterOption; tooltipText?: string; tooltipPosition?: BentoTooltipPosition | `${BentoTooltipPosition}`; } export interface BentoInputWithDropdownFilterOptions { dropdown: BentoInputDropdownProps; dropdownPosition?: BentoInputFieldElementPosition | `${BentoInputFieldElementPosition}`; tooltipText?: string; tooltipPosition?: BentoTooltipPosition | `${BentoTooltipPosition}`; } export interface BentoRangeFilterOptions { tooltipText?: string; tooltipPosition?: BentoTooltipPosition | `${BentoTooltipPosition}`; } // Date export interface BentoDateFilterOptions { firstDayOfWeek?: BentoDatePickerFirstDayOfWeek; isDateDisabled?: BentoDatePickerIsDateDisabled; variant?: `${BentoDatePickerVariant}`; tooltipText?: string; tooltipPosition?: BentoTooltipPosition | `${BentoTooltipPosition}`; } export interface BentoDateRangeFilterOptions { allowTimeInput?: boolean; firstDayOfWeek?: BentoDatePickerFirstDayOfWeek; isDateDisabled?: BentoDatePickerIsDateDisabled; granularities?: Array<BentoDateRangePickerGranularityConfig>; maxRange?: number; min?: Date; max?: Date; numberOfMonths?: number; showEndDateOnOpen?: boolean; quickSelectRanges?: DateRangePickerCalendarRangeSelectorItems | boolean; variant?: `${BentoDateRangePickerVariant}`; tooltipText?: string; tooltipPosition?: BentoTooltipPosition | `${BentoTooltipPosition}`; } export interface BentoInpuWithDropdowntFilterValue { inputValue: BentoInputFieldValue; dropdownValue: BentoInputDropdownProps['value']; } export type BentoSelectFilterValue = BentoListboxSelectedValue; export type BentoBooleanFilterValue = string | null; export type BentoDateFilterValue = Date | null; export type BentoDateRangeFilterValue = BentoDateRangePickerValue | null; export type BentoFilterBarValue = | BentoInputFilterValue | BentoInpuWithDropdowntFilterValue | BentoSelectFilterValue | BentoBooleanFilterValue | BentoDateFilterValue | BentoDateRangeFilterValue | BentoRadioGroupFilterValue | BentoCheckboxGroupFilterValue | BentoToggleFilterValue; export type BentoFilterOptions = | BentoSelectFilterOptions | BentoSelectAvatarFilterOptions | BentoSelectCountryFilterOptions | BentoSelectCurrencyFilterOptions | BentoSelectPaymentMethodFilterOptions | BentoSelectTagFilterOptions | BentoDateFilterOptions | BentoDateRangeFilterOptions | BentoBooleanFilterOptions | BentoInputFilterOptions | BentoInputWithDropdownFilterOptions | BentoRadioGroupFilterOptions | BentoCheckboxGroupFilterOptions | BentoToggleFilterOptions | undefined; export enum BentoFilterEvent { // Fires upon the user interacting with the filter INPUT = 'input', // Fires on the (new) filter values being applied UPDATE = 'update', } export enum BentoFilterBarEvent { INPUT = 'input', UPDATE_FILTER_VALUES_OBJECT = 'update:filter-values-object', UPDATE_FILTER_VALUES_ARRAY = 'update:filter-values', } export enum BentoFilterItemType { /** * @deprecated Since v2.0.0. Use `BentoFilterItemType.RADIO_GROUP` instead. */ BOOLEAN = 'BentoBooleanFilter', RANGE = 'BentoRangeFilter', // Input INPUT = 'BentoInputFilter', INPUT_DROPDOWN = 'BentoInputWithDropdownFilter', // Select SELECT = 'BentoSelectFilter', SELECT_AVATAR = 'BentoSelectAvatarFilter', SELECT_COUNTRY = 'BentoSelectCountryFilter', SELECT_CURRENCY = 'BentoSelectCurrencyFilter', SELECT_PAYMENT_METHOD = 'BentoSelectPaymentMethodFilter', SELECT_TAG = 'BentoSelectTagFilter', // Date DATE = 'BentoDateFilter', DATE_RANGE = 'BentoDateRangeFilter', // Radio group RADIO_GROUP = 'BentoRadioGroupFilter', // Checkbox CHECKBOX_GROUP = 'BentoCheckboxGroupFilter', // Toggle TOGGLE = 'BentoToggleFilter', } export interface BentoFilterValue { field: string; value?: BentoFilterBarValue; } export interface BentoFilterModel<CustomOptions = any> extends BentoFilterValue { label: string; description?: string; options?: BentoFilterOptions | CustomOptions; disabled?: boolean; defaultValue?: BentoFilterBarValue; visible?: boolean; /** * Defines the filter to be used. * * Can be one filter defined in the {@link BentoFilterItemType} enum or a * custom filter component defined as a {@link VueNodeElement} */ type: BentoFilterItemType | VueNodeElement; /** * @deprecated Since v2.0.0. Use `config` and `filter-values` instead. */ value?: BentoFilterBarValue; } export type BentoFilterBarModel<CustomOptions = any> = Array<BentoFilterModel<CustomOptions>>; export type BentoFilterValues = Array<BentoFilterValue>; export type BentoFilterValueObject<T extends PropertyKey = string, CustomValue = BentoFilterBarValue> = Record< T, CustomValue >; export type BentoFilterBarSearchBarProps = OmitStrict<BentoSearchBarProps, 'value' | 'condensed'> & { /** * CSS width value applied to the search box element (e.g. `'200px'`, `'50%'`). */ width?: string; }; export type BentoFilterBarSearchTerm = BentoSearchBarProps['value']; export type BentoFilterBarScreenOptionsLayout = 'one-line' | 'multi-line'; export interface BentoFilterBarContainerSizeLayout { /** * @default 'one-line' */ medium?: BentoFilterBarScreenOptionsLayout; /** * @default 'multi-line' */ small?: BentoFilterBarScreenOptionsLayout; } export interface BentoFilterBarProps<CustomValue = BentoFilterBarValue | any, CustomOptions = any> { /** * Array of filters based on @see {@link BentoFilterBarModel}. * Each one of the elements in the array correspond to a filter that will * be created and rendered in the FilterBar. * * @example * [{ * field: 'company', * label: 'Company', * type: FilterType.TEXT, * }, { * field: 'name', * label: 'Name', * type: FilterType.TEXT, * }] */ config?: BentoFilterBarModel<CustomOptions>; /** * An array of the values for each filter in the filter bar. * @see {@link BentoFilterValues} * * @example * [{ * field: 'company', * value: '200', * }, { * field: 'name', * value: 'some text', * }] */ filterValues?: BentoFilterValues; /** * Defines the layout (multi-line or one-line) to be used in the filter bar for different * container sizes: * - Large (>=700px): Always multi-line * - Medium (>=430px & <700px) * - Small (<430px) * * @example * { * medium: 'multi-line', * small: 'one-line' * } */ containerSizeLayout?: BentoFilterBarContainerSizeLayout; /** * Search bar properties with which the placeholder, hint and other * "bento-search-bar" properties can be set. @see {@link BentoFilterBarSearchBarProps} */ search?: BentoFilterBarSearchBarProps; /** * Search term useful to filter all the data by text. * If the value is non-existant (null or undefined) the search bar won't be enabled. * Can be used with `.sync` */ searchTerm?: BentoFilterBarSearchTerm; /** * Will show all applied filters even if their visibility is set to false. */ showAppliedHiddenFilters?: boolean; /** * Array of filters based on @see {@link BentoFilterBarModel}. * Each one of the elements in the array correspond to a filter that will * be created and rendered in the FilterBar. * * Can be used with v-model or independently with the @input event. * * @example * [{ * field: 'company', * label: 'Company', * value: '200', * type: FilterType.TEXT, * }, { * field: 'name', * label: 'Name', * value: null, * type: FilterType.TEXT, * }] * * @deprecated Since v2.0.0. Use `config` instead. */ value?: BentoFilterBarModel; /** * An object of the values for each filter in the filter bar where key is a the field name and the value is the BentoFilterBarValue. * * @example * { * 'company': '200', * 'name': 'some text' * }, */ filterValuesObject?: BentoFilterValueObject<string, CustomValue>; }
@@ -1 +1 @@
1
- <template> <div ref="filterBarRef" class="b-filter-bar"> <div v-if="!showSearchbarInsideContainer && isSearchBarEnabled" class="b-filter-bar__search-external-container"> <bento-search-bar class="b-filter-bar__search-bar" v-bind="searchBarProps" :input-field-aria-label="searchBarAriaLabel" :value="searchTerm" condensed @input="onSearchbarInput" @clear="onSearchbarClear" /> </div> <component :is="screenLayoutComponent" :class="containerConditionalClasses" condensed> <div :class="contentConditionalClasses"> <div v-if="showSearchbarInsideContainer && isSearchBarEnabled"> <bento-search-bar class="b-filter-bar__search-bar" v-bind="searchBarProps" :input-field-aria-label="searchBarAriaLabel" :value="searchTerm" condensed @input="onSearchbarInput" @clear="onSearchbarClear" /> </div> <template v-if="persistentFilters.length"> <component :is="filter.type" v-for="filter in persistentFilters" :key="filter.field" :field="filter.field" :label="filter.label" :disabled="filter?.disabled" :options="filter.options" :value="filter.value" :default-value="filter.defaultValue" @update="updateFilterHandler" /> </template> <component :is="filter.type" v-for="filter in regularFilters" :key="filter.field" :field="filter.field" :label="filter.label" :disabled="filter?.disabled" :options="filter.options" :value="filter.value" @update="updateFilterHandler" /> <!-- All filters button --> <all-filters-modal v-if="hasHiddenFilters" class="b-filter-bar__all-filters-modal" :value="valueAndFilterValuesPropsMerged" @input="updateAllFilters" /> <!-- Clear all filters button --> <filter-bar-button v-if="isClearAllVisible" class="b-filter-bar__clear-all" @click="clearAllFilters"> {{ t('clearFilters') }} <template #iconLeft> <refresh-icon aria-hidden="true" /> </template> </filter-bar-button> <!-- Reset all filters button --> <filter-bar-button v-if="isResetAllVisible" class="b-filter-bar__reset-all" @click="resetAllFilters"> {{ t('resetFilters') }} <template #iconLeft> <refresh-icon aria-hidden="true" /> </template> </filter-bar-button> </div> </component> </div> </template> <script setup lang="ts"> import { computed, ref, toRef } from 'vue'; import { FilterBarButton } from './components/filter-bar-button'; import { BentoFilterBarEvent, type BentoFilterBarModel, type BentoFilterBarProps, type BentoFilterModel, } from './filter-bar.types'; import BentoSearchBar from '@/components/search-bar/search-bar.vue'; import { BentoSearchBarEvent } from '@/components/search-bar/search-bar.types'; import { useBentoToastController } from '@/composables'; import { useI18n } from '@/utils/ts/i18n'; import { useContainerSizeOptionsLayout } from './composables/use-container-size-options-layout/use-container-size-options-layout'; import AllFiltersModal from './components/all-filters-modal/all-filters-modal.vue'; import RefreshIcon from '@adyen/ui-assets-icons-16/vue/refresh'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; // TODO: setup Vue3 generics in Bento 2.0.0 const props = withDefaults(defineProps<BentoFilterBarProps>(), { config: () => [], filterValues: () => [], containerSizeLayout: () => ({ medium: 'one-line', small: 'multi-line', }), search: undefined, searchTerm: undefined, value: () => [], showAppliedHiddenFilters: false, filterValuesObject: undefined, }); const emit = defineEmits([ BentoFilterBarEvent.INPUT, BentoFilterBarEvent.UPDATE_FILTER_VALUES_OBJECT, BentoFilterBarEvent.UPDATE_FILTER_VALUES_ARRAY, BentoSearchBarEvent.CLEAR, BentoSearchBarEvent.INPUT, BentoSearchBarEvent.UPDATE, ]); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const filterBarRef = ref<HTMLDivElement>(null); const valueAndFilterValuesPropsMerged = computed<BentoFilterBarModel>(() => (props.config?.length > 0 ? props.config : props.value)?.map(filterModel => { let filterValue = props.filterValues.find(({ field }) => field === filterModel.field)?.value; if (props.filterValuesObject && filterModel.field in props.filterValuesObject) { filterValue = props.filterValuesObject[filterModel.field]; } if (filterValue === undefined) { // If the filter is using the deprecated `config` value prop use that // But if that is also not set then check if a default value exists set it to that if it is defined. filterValue = filterModel?.value === undefined && filterModel?.defaultValue !== undefined ? filterModel?.defaultValue : filterModel?.value; } return { ...filterModel, value: filterValue !== undefined ? filterValue : filterModel?.value }; }) ); const { addToast } = useBentoToastController(); /** * Container size layout. * Allows to change the layout from multi-line to one-line for each container size type: * - large: >=700 * - medium: >=430 & <700 * - small: <430 */ const { screenLayoutComponent, showSearchbarInsideContainer, isOneLineLayout } = useContainerSizeOptionsLayout( filterBarRef, toRef(props, 'containerSizeLayout') ); /** * Classes */ /** * Handles the classes of the swappable component ('div' or 'fixed-scroller) */ const containerConditionalClasses = computed(() => ({ 'b-filter-bar__fixed-scroller': isOneLineLayout.value, 'b-filter-bar__container': !isOneLineLayout.value, // Multi-line })); /** * Handles the classes of the container inside the swappable component. */ const contentConditionalClasses = computed(() => ({ 'b-filter-bar__scroller-content': isOneLineLayout.value, 'b-filter-bar__filters': !isOneLineLayout.value, // Multi-line })); /** * Search bar pops */ const searchBarAriaLabel = computed(() => props.search?.inputFieldAriaLabel || (t('searchBar') as string)); const searchBarProps = computed(() => { if (!props.search) { return undefined; } const { debounceTime, disabled, inputFieldAriaLabel, hint, placeholder } = props.search; return { debounceTime, disabled, inputFieldAriaLabel, hint, placeholder }; }); const hasHiddenFilters = computed(() => { // Has props with visible set const hasVisibleSet = valueAndFilterValuesPropsMerged.value.some( ({ visible }) => visible !== undefined && visible !== null ); return hasVisibleSet ? valueAndFilterValuesPropsMerged.value.some(({ visible }) => !visible) : false; }); // Persistent filters i.e. filters with default value const persistentFilters = computed(() => valueAndFilterValuesPropsMerged.value.filter(({ defaultValue }) => defaultValue !== undefined) ); // Show non-persistent & visible filters const regularFilters = computed(() => { const hasVisibleSet = valueAndFilterValuesPropsMerged.value.some( ({ visible }) => visible !== undefined && visible !== null ); // If no filter has the `visible` prop set then all are visible, otherwise search for the "visible: true" elements return hasVisibleSet ? valueAndFilterValuesPropsMerged.value.filter( ({ defaultValue, visible, value }) => // eslint-disable-next-line eqeqeq (defaultValue === undefined && visible) || (props.showAppliedHiddenFilters && value != undefined) ) : valueAndFilterValuesPropsMerged.value.filter( ({ defaultValue, value }) => // eslint-disable-next-line eqeqeq defaultValue === undefined || (props.showAppliedHiddenFilters && value != undefined) ); }); // Enable search bar. // Soft equality to check for "null" and "undefined" // eslint-disable-next-line eqeqeq const isSearchBarEnabled = computed(() => props.searchTerm != undefined); const hasActiveFilters = computed(() => valueAndFilterValuesPropsMerged.value .filter(({ defaultValue }) => defaultValue === undefined || defaultValue === null) // Disabling strict equality to check for both undefined and null // eslint-disable-next-line eqeqeq .some(({ value }) => value != undefined) ); // Show the "Clear all" button (true) if at least one filter is active (contains a value) // and there are not persistent filters present. const isClearAllVisible = computed(() => persistentFilters.value.length === 0 && hasActiveFilters.value); const isResetAllVisible = computed(() => { if (persistentFilters.value.length === 0) { return false; } const areAllValuesEqualToDefaults = valueAndFilterValuesPropsMerged.value .filter(({ defaultValue }) => defaultValue !== undefined || defaultValue !== null) .every(({ value, defaultValue }) => JSON.stringify(value) === JSON.stringify(defaultValue)); return !areAllValuesEqualToDefaults || hasActiveFilters.value; }); const updateAllFilters = (newFilters: BentoFilterBarModel) => { emit(BentoFilterBarEvent.INPUT, newFilters); emit( BentoFilterBarEvent.UPDATE_FILTER_VALUES_ARRAY, newFilters ?.filter(({ value }) => value !== null || value !== undefined) .map(({ field, value }) => ({ field, value })) ); emit( BentoFilterBarEvent.UPDATE_FILTER_VALUES_OBJECT, newFilters ?.filter(({ value }) => value !== null || value !== undefined) .reduce((acc, filter) => { acc[filter.field] = filter.value; return acc; }, {}) ); }; const updateFilterHandler = (updatedFilter: BentoFilterModel) => { const filterIndex = valueAndFilterValuesPropsMerged.value.findIndex( filter => filter.field === updatedFilter.field ); const newFilters = valueAndFilterValuesPropsMerged.value.slice(); newFilters[filterIndex].value = updatedFilter.value; updateAllFilters(newFilters); }; const clearAllFilters = () => { const previousFilters = valueAndFilterValuesPropsMerged.value.slice(); const clearedFilters = valueAndFilterValuesPropsMerged.value.map(({ value, ...otherFields }) => ({ ...otherFields, value: undefined, })); addToast({ text: t('filtersHaveBeenCleared') as string, action: { handler: () => updateAllFilters(previousFilters), text: t('undo') as string, }, }); updateAllFilters(clearedFilters); }; const resetAllFilters = () => { const previousFilters = valueAndFilterValuesPropsMerged.value.slice(); const resetFilters = valueAndFilterValuesPropsMerged.value.map(({ defaultValue, ...otherFields }) => ({ ...otherFields, defaultValue, value: defaultValue, })); addToast({ text: t('filtersHaveBeenReset') as string, action: { handler: () => updateAllFilters(previousFilters), text: t('undo') as string, }, }); updateAllFilters(resetFilters); }; const onSearchbarInput = (newSearchTerm: string) => { emit(BentoSearchBarEvent.INPUT, newSearchTerm); emit(BentoSearchBarEvent.UPDATE, newSearchTerm); }; const onSearchbarClear = () => { emit(BentoSearchBarEvent.CLEAR); }; // Expose methods for BentoDataGrid to access defineExpose({ isClearAllVisible, clearAllFilters, isResetAllVisible, resetAllFilters }); </script> <script lang="ts"> import { BentoBooleanFilter, BentoCheckboxGroupFilter, BentoDateFilter, BentoDateRangeFilter, BentoInputFilter, BentoInputWithDropdownFilter, BentoRadioGroupFilter, BentoRangeFilter, BentoSelectAvatarFilter, BentoSelectCountryFilter, BentoSelectCurrencyFilter, BentoSelectFilter, BentoSelectPaymentMethodFilter, BentoSelectTagFilter, BentoToggleFilter, } from './components'; import { BentoTeleport, FixedScroller } from '@/internal'; /** * The filter bar filters item lists and tables according to various filter criteria. * * @example * import { BentoFilterBar } from '@adyen/bento-vue2' * * export default { * components: { BentoFilterBar }, * data: () => ({ * filters: [{ * field: 'name', * label: 'Name', * value: null, * options: { ... }, * type: FilterType.TEXT, * }] * }), * template: ` * <bento-filter-bar * v-model="filter" * /> * ` * } */ export default { i18n: { messages }, name: 'bento-filter-bar', components: { BentoBooleanFilter, BentoCheckboxGroupFilter, BentoDateFilter, BentoDateRangeFilter, BentoInputFilter, BentoInputWithDropdownFilter, BentoRadioGroupFilter, BentoRangeFilter, BentoSelectAvatarFilter, BentoSelectCountryFilter, BentoSelectCurrencyFilter, BentoSelectFilter, BentoSelectPaymentMethodFilter, BentoSelectTagFilter, BentoTeleport, BentoToggleFilter, FixedScroller, }, }; </script> <style lang="scss" scoped src="./filter-bar.scss" />
1
+ <template> <div ref="filterBarRef" class="b-filter-bar" :class="rootElementConditionalClasses"> <div v-if="!showSearchbarInsideContainer && isSearchBarEnabled" class="b-filter-bar__search-external-container"> <bento-search-bar class="b-filter-bar__search-bar" v-bind="searchBarProps" :input-field-aria-label="searchBarAriaLabel" :value="searchTerm" condensed :style="searchBarWidthStyle" @input="onSearchbarInput" @clear="onSearchbarClear" /> </div> <component :is="screenLayoutComponent" :class="containerConditionalClasses" condensed> <div :class="contentConditionalClasses"> <div v-if="showSearchbarInsideContainer && isSearchBarEnabled"> <bento-search-bar class="b-filter-bar__search-bar" v-bind="searchBarProps" :input-field-aria-label="searchBarAriaLabel" :value="searchTerm" condensed :style="searchBarWidthStyle" @input="onSearchbarInput" @clear="onSearchbarClear" /> </div> <template v-if="persistentFilters.length"> <component :is="filter.type" v-for="filter in persistentFilters" :key="filter.field" :field="filter.field" :label="filter.label" :disabled="filter?.disabled" :options="filter.options" :value="filter.value" :default-value="filter.defaultValue" @update="updateFilterHandler" /> </template> <component :is="filter.type" v-for="filter in regularFilters" :key="filter.field" :field="filter.field" :label="filter.label" :disabled="filter?.disabled" :options="filter.options" :value="filter.value" @update="updateFilterHandler" /> <!-- All filters button --> <all-filters-modal v-if="hasHiddenFilters" class="b-filter-bar__all-filters-modal" :value="dynamicFilters" @input="updateFilterHandler" /> <!-- Clear all filters button --> <filter-bar-button v-if="isClearAllVisible" class="b-filter-bar__clear-all" @click="clearAllFilters"> {{ t('clearFilters') }} <template #iconLeft> <refresh-icon aria-hidden="true" /> </template> </filter-bar-button> <!-- Reset all filters button --> <filter-bar-button v-if="isResetAllVisible" class="b-filter-bar__reset-all" @click="resetAllFilters"> {{ t('resetFilters') }} <template #iconLeft> <refresh-icon aria-hidden="true" /> </template> </filter-bar-button> </div> </component> </div> </template> <script setup lang="ts"> import { computed, ref, toRef } from 'vue'; import { FilterBarButton } from './components/filter-bar-button'; import { BentoFilterBarEvent, type BentoFilterBarModel, type BentoFilterBarProps, type BentoFilterModel, } from './filter-bar.types'; import BentoSearchBar from '@/components/search-bar/search-bar.vue'; import { BentoSearchBarEvent } from '@/components/search-bar/search-bar.types'; import { useBentoToastController } from '@/composables'; import { useI18n } from '@/utils/ts/i18n'; import { useContainerSizeOptionsLayout } from './composables/use-container-size-options-layout/use-container-size-options-layout'; import AllFiltersModal from './components/all-filters-modal/all-filters-modal.vue'; import RefreshIcon from '@adyen/ui-assets-icons-16/vue/refresh'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; // TODO: setup Vue3 generics in Bento 2.0.0 const props = withDefaults(defineProps<BentoFilterBarProps>(), { config: () => [], filterValues: () => [], containerSizeLayout: () => ({ medium: 'one-line', small: 'multi-line', }), search: undefined, searchTerm: undefined, value: () => [], showAppliedHiddenFilters: false, filterValuesObject: undefined, }); const emit = defineEmits([ BentoFilterBarEvent.INPUT, BentoFilterBarEvent.UPDATE_FILTER_VALUES_OBJECT, BentoFilterBarEvent.UPDATE_FILTER_VALUES_ARRAY, BentoSearchBarEvent.CLEAR, BentoSearchBarEvent.INPUT, BentoSearchBarEvent.UPDATE, ]); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const filterBarRef = ref<HTMLDivElement>(null); const valueAndFilterValuesPropsMerged = computed<BentoFilterBarModel>(() => (props.config?.length > 0 ? props.config : props.value)?.map(filterModel => { let filterValue = props.filterValues.find(({ field }) => field === filterModel.field)?.value; if (props.filterValuesObject && filterModel.field in props.filterValuesObject) { filterValue = props.filterValuesObject[filterModel.field]; } if (filterValue === undefined) { // If the filter is using the deprecated `config` value prop use that // But if that is also not set then check if a default value exists set it to that if it is defined. filterValue = filterModel?.value === undefined && filterModel?.defaultValue !== undefined ? filterModel?.defaultValue : filterModel?.value; } return { ...filterModel, value: filterValue !== undefined ? filterValue : filterModel?.value }; }) ); const { addToast } = useBentoToastController(); /** * Container size layout. * Allows to change the layout from multi-line to one-line for each container size type: * - large: >=700 * - medium: >=430 & <700 * - small: <430 */ const { screenLayoutComponent, showSearchbarInsideContainer, isOneLineLayout, isLargeLayout, rootElementConditionalClasses, } = useContainerSizeOptionsLayout(filterBarRef, toRef(props, 'containerSizeLayout')); /** * Classes */ /** * Handles the classes of the swappable component ('div' or 'fixed-scroller) */ const containerConditionalClasses = computed(() => ({ 'b-filter-bar__fixed-scroller': isOneLineLayout.value, 'b-filter-bar__container': !isOneLineLayout.value, // Multi-line })); /** * Handles the classes of the container inside the swappable component. */ const contentConditionalClasses = computed(() => ({ 'b-filter-bar__scroller-content': isOneLineLayout.value, 'b-filter-bar__filters': !isOneLineLayout.value, // Multi-line })); /** * Search bar pops */ const searchBarAriaLabel = computed(() => props.search?.inputFieldAriaLabel || (t('searchBar') as string)); const searchBarProps = computed(() => { if (!props.search) { return undefined; } const { debounceTime, disabled, inputFieldAriaLabel, hint, placeholder } = props.search; return { debounceTime, disabled, inputFieldAriaLabel, hint, placeholder }; }); const searchBarWidthStyle = computed(() => props.search?.width && isLargeLayout.value ? { minWidth: props.search.width, maxWidth: props.search.width } : undefined ); const hasHiddenFilters = computed(() => { // Has props with visible set const hasVisibleSet = valueAndFilterValuesPropsMerged.value.some( ({ visible }) => visible !== undefined && visible !== null ); return hasVisibleSet ? valueAndFilterValuesPropsMerged.value.some(({ visible }) => !visible) : false; }); // Persistent filters i.e. filters with default value const persistentFilters = computed(() => valueAndFilterValuesPropsMerged.value.filter(({ defaultValue }) => defaultValue !== undefined) ); // Dynamic filters i.e. filters without default value const dynamicFilters = computed(() => valueAndFilterValuesPropsMerged.value.filter(({ defaultValue }) => defaultValue === undefined) ); // Show non-persistent & visible filters const regularFilters = computed(() => { const hasVisibleSet = valueAndFilterValuesPropsMerged.value.some( ({ visible }) => visible !== undefined && visible !== null ); // If no filter has the `visible` prop set then all are visible, otherwise search for the "visible: true" elements return hasVisibleSet ? valueAndFilterValuesPropsMerged.value.filter( ({ defaultValue, visible, value }) => // eslint-disable-next-line eqeqeq (defaultValue === undefined && visible) || (props.showAppliedHiddenFilters && value != undefined) ) : valueAndFilterValuesPropsMerged.value.filter( ({ defaultValue, value }) => // eslint-disable-next-line eqeqeq defaultValue === undefined || (props.showAppliedHiddenFilters && value != undefined) ); }); // Enable search bar. // Soft equality to check for "null" and "undefined" // eslint-disable-next-line eqeqeq const isSearchBarEnabled = computed(() => props.searchTerm != undefined); const hasActiveFilters = computed(() => valueAndFilterValuesPropsMerged.value .filter(({ defaultValue }) => defaultValue === undefined || defaultValue === null) // Disabling strict equality to check for both undefined and null // eslint-disable-next-line eqeqeq .some(({ value }) => value != undefined) ); // Show the "Clear all" button (true) if at least one filter is active (contains a value) // and there are not persistent filters present. const isClearAllVisible = computed(() => persistentFilters.value.length === 0 && hasActiveFilters.value); const isResetAllVisible = computed(() => { if (persistentFilters.value.length === 0) { return false; } const areAllValuesEqualToDefaults = valueAndFilterValuesPropsMerged.value .filter(({ defaultValue }) => defaultValue !== undefined || defaultValue !== null) .every(({ value, defaultValue }) => JSON.stringify(value) === JSON.stringify(defaultValue)); return !areAllValuesEqualToDefaults || hasActiveFilters.value; }); const updateAllFilters = (newFilters: BentoFilterBarModel) => { emit(BentoFilterBarEvent.INPUT, newFilters); emit( BentoFilterBarEvent.UPDATE_FILTER_VALUES_ARRAY, newFilters ?.filter(({ value }) => value !== null || value !== undefined) .map(({ field, value }) => ({ field, value })) ); emit( BentoFilterBarEvent.UPDATE_FILTER_VALUES_OBJECT, newFilters ?.filter(({ value }) => value !== null || value !== undefined) .reduce((acc, filter) => { acc[filter.field] = filter.value; return acc; }, {}) ); }; const updateFilterHandler = (updatedFilters: BentoFilterModel | BentoFilterBarModel) => { const filtersArray = Array.isArray(updatedFilters) ? updatedFilters : [updatedFilters]; const updatedFiltersMap = new Map(filtersArray.map(filter => [filter.field, filter.value])); const newFilters = valueAndFilterValuesPropsMerged.value.map(filter => { if (updatedFiltersMap.has(filter.field)) { return { ...filter, value: updatedFiltersMap.get(filter.field) }; } return filter; }); updateAllFilters(newFilters); }; const clearAllFilters = () => { const previousFilters = valueAndFilterValuesPropsMerged.value.slice(); const clearedFilters = valueAndFilterValuesPropsMerged.value.map(({ value, ...otherFields }) => ({ ...otherFields, value: undefined, })); addToast({ text: t('filtersHaveBeenCleared') as string, action: { handler: () => updateAllFilters(previousFilters), text: t('undo') as string, }, }); updateAllFilters(clearedFilters); }; const resetAllFilters = () => { const previousFilters = valueAndFilterValuesPropsMerged.value.slice(); const resetFilters = valueAndFilterValuesPropsMerged.value.map(({ defaultValue, ...otherFields }) => ({ ...otherFields, defaultValue, value: defaultValue, })); addToast({ text: t('filtersHaveBeenReset') as string, action: { handler: () => updateAllFilters(previousFilters), text: t('undo') as string, }, }); updateAllFilters(resetFilters); }; const onSearchbarInput = (newSearchTerm: string) => { emit(BentoSearchBarEvent.INPUT, newSearchTerm); emit(BentoSearchBarEvent.UPDATE, newSearchTerm); }; const onSearchbarClear = () => { emit(BentoSearchBarEvent.CLEAR); }; // Expose methods for BentoDataGrid to access defineExpose({ isClearAllVisible, clearAllFilters, isResetAllVisible, resetAllFilters }); </script> <script lang="ts"> import { BentoBooleanFilter, BentoCheckboxGroupFilter, BentoDateFilter, BentoDateRangeFilter, BentoInputFilter, BentoInputWithDropdownFilter, BentoRadioGroupFilter, BentoRangeFilter, BentoSelectAvatarFilter, BentoSelectCountryFilter, BentoSelectCurrencyFilter, BentoSelectFilter, BentoSelectPaymentMethodFilter, BentoSelectTagFilter, BentoToggleFilter, } from './components'; import { BentoTeleport, FixedScroller } from '@/internal'; /** * The filter bar filters item lists and tables according to various filter criteria. * * @example * import { BentoFilterBar } from '@adyen/bento-vue2' * * export default { * components: { BentoFilterBar }, * data: () => ({ * filters: [{ * field: 'name', * label: 'Name', * value: null, * options: { ... }, * type: FilterType.TEXT, * }] * }), * template: ` * <bento-filter-bar * v-model="filter" * /> * ` * } */ export default { i18n: { messages }, name: 'bento-filter-bar', components: { BentoBooleanFilter, BentoCheckboxGroupFilter, BentoDateFilter, BentoDateRangeFilter, BentoInputFilter, BentoInputWithDropdownFilter, BentoRadioGroupFilter, BentoRangeFilter, BentoSelectAvatarFilter, BentoSelectCountryFilter, BentoSelectCurrencyFilter, BentoSelectFilter, BentoSelectPaymentMethodFilter, BentoSelectTagFilter, BentoTeleport, BentoToggleFilter, FixedScroller, }, }; </script> <style lang="scss" scoped src="./filter-bar.scss" />