@adyen/bento-mcp 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/dist/assets/components/action-bar/action-bar.docs.mdx +40 -4
- package/dist/assets/components/action-bar/action-bar.stories.ts +1 -1
- package/dist/assets/components/action-bar/action-bar.vue +1 -1
- package/dist/assets/components/anchor-scroller/anchor-scroller.vue +1 -1
- package/dist/assets/components/checkbox/checkbox.vue +1 -1
- package/dist/assets/components/checkbox/components/checkbox-group/checkbox-group.vue +1 -1
- package/dist/assets/components/data-grid/components/data-grid-row/data-grid-row.vue +1 -1
- package/dist/assets/components/data-grid/components/data-grid-tag-with-text-cell/data-grid-tag-with-text-cell.vue +1 -1
- package/dist/assets/components/data-grid/data-grid.vue +1 -1
- package/dist/assets/components/date-picker/date-picker.vue +1 -1
- package/dist/assets/components/date-range-picker/components/date-range-picker-calendar/date-range-picker-calendar.vue +1 -1
- package/dist/assets/components/date-range-picker/date-range-picker.vue +1 -1
- package/dist/assets/components/dropdown/components/dropdown-base-textbox/dropdown-base-textbox.vue +1 -1
- package/dist/assets/components/dropdown/components/dropdown-options-container/dropdown-options-container.vue +1 -1
- package/dist/assets/components/dropdown/dropdown.stories.ts +1 -1
- package/dist/assets/components/dropdown/dropdown.vue +1 -1
- package/dist/assets/components/file-uploader/components/file-uploader-restrictions/file-uploader-restrictions.vue +1 -1
- package/dist/assets/components/file-uploader/file-uploader.docs.mdx +9 -0
- package/dist/assets/components/file-uploader/file-uploader.stories.ts +1 -1
- package/dist/assets/components/file-uploader/file-uploader.vue +1 -1
- package/dist/assets/components/filter-bar/components/all-filters-modal/all-filters-modal.vue +1 -1
- package/dist/assets/components/filter-bar/components/date-range-filter/date-range-filter.vue +1 -1
- package/dist/assets/components/filter-bar/filter-bar.vue +1 -1
- package/dist/assets/components/form-layout/components/form-layout-title/form-layout-title.vue +1 -1
- package/dist/assets/components/form-layout/form-layout.docs.mdx +4 -0
- package/dist/assets/components/input-field/input-field.stories.ts +1 -1
- package/dist/assets/components/input-field/input-field.vue +1 -1
- package/dist/assets/components/input-field-password/input-field-password.stories.ts +1 -1
- package/dist/assets/components/input-field-phone-number/input-field-phone-number.stories.ts +1 -1
- package/dist/assets/components/input-field-phone-number/input-field-phone-number.vue +1 -1
- package/dist/assets/components/internal/controls-group/controls-group.vue +1 -1
- package/dist/assets/components/internal/field-label/field-label.vue +1 -1
- package/dist/assets/components/internal/radio-input/radio-input.vue +1 -1
- package/dist/assets/components/modal-fullscreen/components/modal-fullscreen-page.vue +1 -1
- package/dist/assets/components/modal-fullscreen/modal-fullscreen.vue +1 -1
- package/dist/assets/components/pagination/components/pagination-context/pagination-context.vue +1 -1
- package/dist/assets/components/pagination/components/pagination-results-per-page/pagination-results-per-page.vue +1 -1
- package/dist/assets/components/pagination/pagination.vue +1 -1
- package/dist/assets/components/radio-group/radio-group.vue +1 -1
- package/dist/assets/components/rich-text-editor/rich-text-editor.vue +1 -1
- package/dist/assets/components/selection-card/components/selection-card-group/selection-card-group.vue +1 -1
- package/dist/assets/components/textarea/textarea.vue +1 -1
- package/dist/main.js +145 -136
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
<!-- TODO: Remove aria props as these are part of JSX elements /vue/types/jsx.d.ts --> <!-- eslint-disable vue/attribute-hyphenation --> <template> <div v-bento-click-outside-directive="clickOutsideDropdown" class="b-dropdown" :class="conditionalClasses"> <field-label v-if="label" :id="labelId" :condensed="condensed" :label="label" :tooltip-text="tooltipText" :optional="optional" :required="required" /> <div ref="inputContainerRefWrapper" v-bento-keyboard-navigation-directive v-on="textboxKeyboardNavigationListeners" > <component :is="computedSize" ref="inputContainerRef" :condensed="condensed" :value="searchTerm" :additional-items-selected="additionalItemsSelected" :aria-controls="dropdownOptionsContainerId" :ariaExpanded="isDropdownOpen" :aria-labelledby="label ? labelId : null" :aria-describedby="ariaDescribedBy" :aria-label="computedAriaLabel" :aria-readonly="isReadOnly" :readonly="isReadOnly" :required="required" :disabled="disabled" :display-value="displayValue" :dynamic-filtering="dynamicFiltering" :is-invalid="!!errorMessage || error" :multiple="multiple" :open="isDropdownOpen" :selected-value-item="!isInputComponent ? selectedValueItem : undefined" :show-slot-content-in-multiple="showSlotContentInMultiple" @input="searchTerm = $event" @open="toggleDropdown" @close="toggleDropdown" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </component> </div> <bento-dropdown-options-container v-if="inputContainerRef" :id="dropdownOptionsContainerId" ref="optionsContainerRef" :aria-label="computedAriaLabel" :component-loading="componentLoading" :empty-state="emptyState" :disabled="disabled" :is-option-disabled="isOptionDisabled" :items="filteredItems" :multiple="multiple" :static-categories="staticCategories" :open="isDropdownOpen" :selected-value="cachedSelectedListboxOptions" :searching="isSearching" :target-element="inputContainerRefWrapper" :loading="loading" :has-more-items="hasMoreItems" :lazy-load-type="lazyLoadType" :virtual-scroll="virtualScroll" v-on="listboxListeners" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </bento-dropdown-options-container> <error-message v-if="!!errorMessage" :id="errorId" :error-message="errorMessage" class="b-dropdown__error-message" /> <bento-typography v-if="description || hasSlot('description')" :id="descriptionId" class="b-dropdown__description" :class="descriptionConditionalClasses" el="span" > <slot name="description"> {{ description }} </slot> </bento-typography> </div> </template> <script setup lang="ts"> import { computed, defineComponent, inject, nextTick, type Ref, ref, toRef, toRefs, useAttrs, useSlots, watch, } from 'vue'; // Components import { BentoDropdownOptionsContainer } from './components/dropdown-options-container'; import { BentoTypography } from '@/components/typography'; import { DropdownInputDefault, DropdownInputSmall } from './components'; import { BentoListbox, ErrorMessage, FieldLabel, useCachedSelectedValues, useMultiLevelItems } from '@/internal'; // Utils import { deprecate } from '@/utils/ts/deprecate'; import { generateUid } from '@/core/utils/ts'; import { useAriaLabel } from '@/utils/ts/aria-label'; import { useI18n } from '@/utils/ts/i18n'; import { useFormFieldEmits } from '@/utils/ts/form-field/form-field'; // Composables import { useDisplayValue, useListboxKeyboardNavigation, useTextboxKeyboardNavigation } from './composables'; import { useVisibleDomOptions } from '@/internal/listbox/composables/use-visible-dom-options/use-visible-dom-options'; import { useFormLayoutFieldLoading } from '@/composables/use-form-layout-loading/use-form-layout-loading'; import { useSearchBarFilter } from '@/components/search-bar/useSearchBarDataFilter'; // Directives import { BentoClickOutsideDirective as vBentoClickOutsideDirective } from '@/directives/click-outside'; import { BentoKeyboardNavigationDirective as vBentoKeyboardNavigationDirective } from '@/directives'; // Types import { BentoDropdownEvent, type BentoDropdownProps, BentoDropdownSize } from './dropdown.types'; import { BentoListboxEvent, type BentoListboxItemRole, type BentoListboxMultiSelectValue, type BentoListboxOptions, type BentoListboxSelectedValue, type BentoListboxSelectedValueLabelPair, } from '@/types/listbox'; import messages from './messages.json'; import { useHasSlot } from '@/composables'; import { INPUT_FIELD_COMPONENT_INJECTION_KEY } from '@/components/input-field/input-field.keys'; type MessageSchema = (typeof messages)['en-US']; const attrs = useAttrs(); const slots = useSlots(); const hasSlot = useHasSlot(slots); const props = withDefaults(defineProps<BentoDropdownProps>(), { ariaLabel: null, condensed: false, emptyState: undefined, disabled: false, dynamicFiltering: false, error: false, errorMessage: null, description: null, hasMoreItems: false, isOptionDisabled: undefined, items: () => [], label: null, loading: false, componentLoading: false, lazyLoadType: BentoListbox.props.lazyLoadType.default, multiple: false, optional: false, placeholder: null, readonly: false, required: false, search: undefined, showSlotContentInMultiple: false, size: null, staticCategories: false, tooltipText: null, value: null, modelValue: null, virtualScroll: false, enableValueLabelPair: false, }); const emit = defineEmits<{ /** * Emitted when an option is clicked. Updates the selected option from the "value" property linked to the v-model. * @deprecated since version 2.0. Use `v-model` or `update:model-value instead. */ (e: 'input', selectedValue: BentoListboxSelectedValue | any); // eslint-disable-line @typescript-eslint/no-explicit-any /** * Emitted when an option is clicked. Updates the selected option from the "modelValue" property linked to the v-model. */ (e: 'update:model-value', selectedValue: BentoListboxSelectedValue | any); // eslint-disable-line @typescript-eslint/no-explicit-any /** * Emitted when: * - `lazyLoad` is `automatic` and the scroll meets the end of the list * - `lazyload` is `button` and the button is clicked */ (e: 'show-more'); /** * Emitted when the dropdown is opened. */ (e: 'open'); }>(); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const { emitValue } = useFormFieldEmits<BentoListboxSelectedValue>(emit); const inputContainerRef = ref(null); const inputContainerRefWrapper = ref(null); const optionsContainerRef: Ref<InstanceType<typeof BentoDropdownOptionsContainer>> = ref(null); const isDropdownOpen = ref(false); const dropdownOptionsContainerId = generateUid('bento-dropdown-options-container'); const labelId = generateUid('dropdown-label'); const descriptionId = generateUid('dropdown-description'); const errorId = generateUid('dropdown-error'); const { isReadOnly } = useFormLayoutFieldLoading(toRef(props, 'readonly')); const dropdownValue = computed(() => props.modelValue ?? props.value); const { cachedSelectedListboxOptions, cachedListboxSelectedValues, setCachedValues } = useCachedSelectedValues( toRef(props, 'items'), dropdownValue, toRef(props, 'multiple'), toRef(props, 'enableValueLabelPair') ); const ariaLabelAttribute = ref(attrs['aria-label']); const ariaDescribedByAttribute = ref(attrs['aria-describedby']); const { ariaLabel, label } = toRefs(props); // vue-i18n issue with t() used in script - direct usage in template works // requires either createI18n({ legacy: false }) or to be wrapped in computed() const computedAriaLabelFallbackMessage = computed(() => t('ariaLabelFallback')); const computedAriaLabel = useAriaLabel({ // TODO: take ariaLabel ref away when aria-label prop is removed ariaLabel: ariaLabel.value || (ariaLabelAttribute as Ref<string>), label, defaultFallback: computedAriaLabelFallbackMessage, }); // Inject input field component key to change the dropdown size const isInputComponent = props.size === BentoDropdownSize.SMALL || inject(INPUT_FIELD_COMPONENT_INJECTION_KEY, false); const computedSize = computed(() => (isInputComponent ? BentoDropdownSize.SMALL : BentoDropdownSize.DEFAULT)); const conditionalClasses = computed(() => ({ 'b-dropdown--condensed': props.condensed, })); // Description styles const descriptionConditionalClasses = computed(() => ({ 'b-dropdown__description--error': !!props.errorMessage, })); // Filtering const searchTerm = ref(''); const items = toRef(props, 'items'); const staticCategories = toRef(props, 'staticCategories'); const filteredItems = useSearchBarFilter( searchTerm, items, props?.search?.searchEvent, staticCategories, props?.search?.debounceTime ); const isSearching = computed(() => props.dynamicFiltering && !!searchTerm.value); const displayValue = useDisplayValue({ items, placeholder: toRef(props, 'placeholder'), isMultiSelect: toRef(props, 'multiple'), selectedValues: cachedSelectedListboxOptions, }); const selectedValueItem = computed(() => { if (props?.multiple && Array.isArray(dropdownValue.value)) { return props.items.filter( ({ value }) => props?.value && (props?.value as BentoListboxMultiSelectValue).includes(value) ); } if (!props.multiple && props.staticCategories) { // Find the category that holds the selected value const category = props.items.find(categoryItem => categoryItem.items.some(item => item.value === dropdownValue.value) ); // Find the specific item within the category const selectedCategoryItem = category ? category.items.find(item => item.value === dropdownValue.value) : null; return selectedCategoryItem; } return props.items.find(({ value }) => value === dropdownValue.value); }); const selectedValueIndex = computed(() => { const index = filteredItems.value.findIndex(({ value }) => { return props.enableValueLabelPair ? (dropdownValue.value as BentoListboxSelectedValueLabelPair).value === value : dropdownValue.value === value; }); return index > -1 ? index : 0; }); const { isMultiLevelSelect, flattenedItemsList } = useMultiLevelItems(items); const isTreePattern = computed(() => isMultiLevelSelect.value && !props.staticCategories); const computedOptionRole = computed<BentoListboxItemRole>(() => (isTreePattern.value ? 'treeitem' : 'option')); const additionalItemsSelected = computed(() => { if (props.multiple) { const selectedItemsCount = Array.isArray(dropdownValue.value) ? dropdownValue.value.length : 0; const total = isMultiLevelSelect.value ? flattenedItemsList.value.length : items.value.length; if (selectedItemsCount === 0) { return null; } // Display count always - show "all" only when is not external filtering (hasMoreItems = true) if (!props.hasMoreItems && total > 0 && total === selectedItemsCount) { return t('all'); } return selectedItemsCount; } return null; }); // ARIA const ariaDescribedBy = computed( () => [ props.description ? descriptionId : null, ariaDescribedByAttribute.value, props.errorMessage ? errorId : null, ] .filter(Boolean) .join(' ') || null ); const listboxRef = computed(() => optionsContainerRef.value?.$el as HTMLDivElement); const { visibleDomOptions, updateVisibleDomOptions } = useVisibleDomOptions( listboxRef, filteredItems, computedOptionRole ); const openDropdown = () => { if (isReadOnly.value) { return; } isDropdownOpen.value = true; nextTick(() => { updateVisibleDomOptions(); }); }; const closeDropdown = () => { isDropdownOpen.value = false; }; const toggleDropdown = () => { if (isReadOnly.value) { return; } isDropdownOpen.value = !isDropdownOpen.value; nextTick(() => { updateVisibleDomOptions(); }); // Clear search if (isSearching.value) { searchTerm.value = ''; } }; const clickOutsideDropdown = () => { if (isSearching.value) { searchTerm.value = ''; } if (isDropdownOpen.value) { isDropdownOpen.value = false; // Reset the internal dropdown options state optionsContainerRef.value.onOutsideDropdownClick(); } }; const onOptionSelected = (selectedValue: BentoListboxOptions) => { // Clear and close the dropdown on single selection if (!props.multiple) { searchTerm.value = ''; isDropdownOpen.value = false; focus(); } setCachedValues(selectedValue); /** * Emit the value/label pair objects if enableValueLabelPair is enabled, * otherwise just emit the list of selected values (number or string) */ emitValue(cachedListboxSelectedValues.value); }; const emitShowMoreEvent = () => { emit(BentoDropdownEvent.SHOW_MORE); }; const textboxKeyboardNavigationListeners = useTextboxKeyboardNavigation( { inputContainerRef, optionsContainerRef, selectedValueIndex, isDropdownOpen, isDynamicFltering: toRef(props, 'dynamicFiltering'), items: visibleDomOptions, isMultiple: props.multiple, }, toggleDropdown, openDropdown, closeDropdown ); const listboxKeyboardNavigationListeners = useListboxKeyboardNavigation( { inputContainerRef, isDropdownOpen, }, closeDropdown ); const listboxListeners = { [BentoListboxEvent.CLOSE_DROPDOWN]: closeDropdown, [BentoListboxEvent.SELECT]: onOptionSelected, [BentoListboxEvent.SHOW_MORE]: emitShowMoreEvent, ...listboxKeyboardNavigationListeners, }; if (props.error) { deprecate( 'BentoDropdown "error" property', `Use the BentoDropdown "errorMessage" property instead to indicate that there is a modifier error. Having the field as "undefined" will hide/remove the error. <bento-dropdown errorMessage="Error message text" />`, '2.0.0' ); } if (props.size) { deprecate('BentoDropdown "size" property', `Do not use. Only 'default' size should be used.`, '2.0.0'); } if (props.value) { deprecate( 'BentoDropdown "value" property', `The use of "value" prop in "BentoDropdown" is no longer supported. Use the "v-model" or "model-value" property instead.`, '2.0.0' ); } const focus = () => { inputContainerRef.value.focus(); }; watch( () => isDropdownOpen.value, (isOpen: boolean) => { if (isOpen) { emit('open'); } } ); defineExpose({ focus, }); </script> <script lang="ts"> /** * A Dropdown shows a selected option's. * Use a dropdown when you want users to select options * from a list of pre-defined options. * * @example * import { BentoDropdown } from '@adyen/bento-vue2'; * import type { BentoDropdownOptions } from '@adyen/bento-vue2' * * export default { * components: { BentoDropdown }, * template: ` * <bento-dropdown * v-model="selectedValue" * :disabled="false" * :multiple="true" * :isOptionsDisabled="option => option.value === 2" * :items="options" * /> * `, * setup() { * const selectedValue = ref(2) // Default value * const options: BentoDropdownOptions = [ * { label: 'Option 1', value: 1 }, * { label: 'Option 2', value: 2 }, * ]; * return { options, selectedValue }; * } * } */ export default defineComponent({ i18n: { messages }, name: 'b-dropdown', components: { DropdownInputDefault, DropdownInputSmall, }, model: { prop: 'modelValue' }, }); </script> <style lang="scss" scoped src="./dropdown.scss" />
|
|
1
|
+
<!-- TODO: Remove aria props as these are part of JSX elements /vue/types/jsx.d.ts --> <!-- eslint-disable vue/attribute-hyphenation --> <template> <div v-bento-click-outside-directive="clickOutsideDropdown" class="b-dropdown" :class="conditionalClasses"> <field-label v-if="label" :id="labelId" :condensed="condensed" :label="label" :tooltip-text="tooltipText" :optional="optional" :required="required" /> <div ref="inputContainerRefWrapper" v-bento-keyboard-navigation-directive v-on="textboxKeyboardNavigationListeners" > <component :is="computedSize" ref="inputContainerRef" :condensed="condensed" :value="searchTerm" :additional-items-selected="additionalItemsSelected" :aria-controls="dropdownOptionsContainerId" :ariaExpanded="isDropdownOpen" :aria-labelledby="label ? labelId : null" :aria-describedby="ariaDescribedBy" :aria-label="computedAriaLabel" :aria-readonly="isReadOnly" :aria-required="required" :readonly="isReadOnly" :disabled="disabled" :display-value="displayValue" :dynamic-filtering="dynamicFiltering" :is-invalid="!!errorMessage || error" :multiple="multiple" :open="isDropdownOpen" :selected-value-item="!isInputComponent ? selectedValueItem : undefined" :show-slot-content-in-multiple="showSlotContentInMultiple" @input="searchTerm = $event" @open="toggleDropdown" @close="toggleDropdown" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </component> </div> <bento-dropdown-options-container v-if="inputContainerRef" :id="dropdownOptionsContainerId" ref="optionsContainerRef" :aria-label="computedAriaLabel" :component-loading="componentLoading" :empty-state="emptyState" :disabled="disabled" :is-option-disabled="isOptionDisabled" :items="filteredItems" :multiple="multiple" :static-categories="staticCategories" :open="isDropdownOpen" :selected-value="cachedSelectedListboxOptions" :searching="isSearching" :target-element="inputContainerRefWrapper" :loading="loading" :has-more-items="hasMoreItems" :lazy-load-type="lazyLoadType" :virtual-scroll="virtualScroll" v-on="listboxListeners" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </bento-dropdown-options-container> <error-message v-if="!!errorMessage" :id="errorId" :error-message="errorMessage" class="b-dropdown__error-message" /> <bento-typography v-if="description || hasSlot('description')" :id="descriptionId" class="b-dropdown__description" :class="descriptionConditionalClasses" el="span" > <slot name="description"> {{ description }} </slot> </bento-typography> </div> </template> <script setup lang="ts"> import { computed, defineComponent, inject, nextTick, type Ref, ref, toRef, toRefs, useAttrs, useSlots, watch, } from 'vue'; // Components import { BentoDropdownOptionsContainer } from './components/dropdown-options-container'; import { BentoTypography } from '@/components/typography'; import { DropdownInputDefault, DropdownInputSmall } from './components'; import { BentoListbox, ErrorMessage, FieldLabel, useCachedSelectedValues, useMultiLevelItems } from '@/internal'; // Utils import { deprecate } from '@/utils/ts/deprecate'; import { generateUid } from '@/core/utils/ts'; import { useAriaLabel } from '@/utils/ts/aria-label'; import { useI18n } from '@/utils/ts/i18n'; import { useFormFieldEmits } from '@/utils/ts/form-field/form-field'; // Composables import { useDisplayValue, useListboxKeyboardNavigation, useTextboxKeyboardNavigation } from './composables'; import { useVisibleDomOptions } from '@/internal/listbox/composables/use-visible-dom-options/use-visible-dom-options'; import { useFormLayoutFieldLoading } from '@/composables/use-form-layout-loading/use-form-layout-loading'; import { useSearchBarFilter } from '@/components/search-bar/useSearchBarDataFilter'; // Directives import { BentoClickOutsideDirective as vBentoClickOutsideDirective } from '@/directives/click-outside'; import { BentoKeyboardNavigationDirective as vBentoKeyboardNavigationDirective } from '@/directives'; // Types import { BentoDropdownEvent, type BentoDropdownProps, BentoDropdownSize } from './dropdown.types'; import { BentoListboxEvent, type BentoListboxItemRole, type BentoListboxMultiSelectValue, type BentoListboxOptions, type BentoListboxSelectedValue, type BentoListboxSelectedValueLabelPair, } from '@/types/listbox'; import messages from './messages.json'; import { useHasSlot } from '@/composables'; import { INPUT_FIELD_COMPONENT_INJECTION_KEY } from '@/components/input-field/input-field.keys'; type MessageSchema = (typeof messages)['en-US']; const attrs = useAttrs(); const slots = useSlots(); const hasSlot = useHasSlot(slots); const props = withDefaults(defineProps<BentoDropdownProps>(), { ariaLabel: null, condensed: false, emptyState: undefined, disabled: false, dynamicFiltering: false, error: false, errorMessage: null, description: null, hasMoreItems: false, isOptionDisabled: undefined, items: () => [], label: null, loading: false, componentLoading: false, lazyLoadType: BentoListbox.props.lazyLoadType.default, multiple: false, optional: false, placeholder: null, readonly: false, required: false, search: undefined, showSlotContentInMultiple: false, size: null, staticCategories: false, tooltipText: null, value: null, modelValue: null, virtualScroll: false, enableValueLabelPair: false, }); const emit = defineEmits<{ /** * Emitted when an option is clicked. Updates the selected option from the "value" property linked to the v-model. * @deprecated since version 2.0. Use `v-model` or `update:model-value instead. */ (e: 'input', selectedValue: BentoListboxSelectedValue | any); // eslint-disable-line @typescript-eslint/no-explicit-any /** * Emitted when an option is clicked. Updates the selected option from the "modelValue" property linked to the v-model. */ (e: 'update:model-value', selectedValue: BentoListboxSelectedValue | any); // eslint-disable-line @typescript-eslint/no-explicit-any /** * Emitted when: * - `lazyLoad` is `automatic` and the scroll meets the end of the list * - `lazyload` is `button` and the button is clicked */ (e: 'show-more'); /** * Emitted when the dropdown is opened. */ (e: 'open'); }>(); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const { emitValue } = useFormFieldEmits<BentoListboxSelectedValue>(emit); const inputContainerRef = ref(null); const inputContainerRefWrapper = ref(null); const optionsContainerRef: Ref<InstanceType<typeof BentoDropdownOptionsContainer>> = ref(null); const isDropdownOpen = ref(false); const dropdownOptionsContainerId = generateUid('bento-dropdown-options-container'); const labelId = generateUid('dropdown-label'); const descriptionId = generateUid('dropdown-description'); const errorId = generateUid('dropdown-error'); const { isReadOnly } = useFormLayoutFieldLoading(toRef(props, 'readonly')); const dropdownValue = computed(() => props.modelValue ?? props.value); const { cachedSelectedListboxOptions, cachedListboxSelectedValues, setCachedValues } = useCachedSelectedValues( toRef(props, 'items'), dropdownValue, toRef(props, 'multiple'), toRef(props, 'enableValueLabelPair') ); const ariaLabelAttribute = ref(attrs['aria-label']); const ariaDescribedByAttribute = ref(attrs['aria-describedby']); const { ariaLabel, label } = toRefs(props); // vue-i18n issue with t() used in script - direct usage in template works // requires either createI18n({ legacy: false }) or to be wrapped in computed() const computedAriaLabelFallbackMessage = computed(() => t('ariaLabelFallback')); const computedAriaLabel = useAriaLabel({ // TODO: take ariaLabel ref away when aria-label prop is removed ariaLabel: ariaLabel.value || (ariaLabelAttribute as Ref<string>), label, defaultFallback: computedAriaLabelFallbackMessage, }); // Inject input field component key to change the dropdown size const isInputComponent = props.size === BentoDropdownSize.SMALL || inject(INPUT_FIELD_COMPONENT_INJECTION_KEY, false); const computedSize = computed(() => (isInputComponent ? BentoDropdownSize.SMALL : BentoDropdownSize.DEFAULT)); const conditionalClasses = computed(() => ({ 'b-dropdown--condensed': props.condensed, })); // Description styles const descriptionConditionalClasses = computed(() => ({ 'b-dropdown__description--error': !!props.errorMessage, })); // Filtering const searchTerm = ref(''); const items = toRef(props, 'items'); const staticCategories = toRef(props, 'staticCategories'); const filteredItems = useSearchBarFilter( searchTerm, items, props?.search?.searchEvent, staticCategories, props?.search?.debounceTime ); const isSearching = computed(() => props.dynamicFiltering && !!searchTerm.value); const displayValue = useDisplayValue({ items, placeholder: toRef(props, 'placeholder'), isMultiSelect: toRef(props, 'multiple'), selectedValues: cachedSelectedListboxOptions, }); const selectedValueItem = computed(() => { if (props?.multiple && Array.isArray(dropdownValue.value)) { return props.items.filter( ({ value }) => props?.value && (props?.value as BentoListboxMultiSelectValue).includes(value) ); } if (!props.multiple && props.staticCategories) { // Find the category that holds the selected value const category = props.items.find(categoryItem => categoryItem.items.some(item => item.value === dropdownValue.value) ); // Find the specific item within the category const selectedCategoryItem = category ? category.items.find(item => item.value === dropdownValue.value) : null; return selectedCategoryItem; } return props.items.find(({ value }) => value === dropdownValue.value); }); const selectedValueIndex = computed(() => { const index = filteredItems.value.findIndex(({ value }) => { return props.enableValueLabelPair ? (dropdownValue.value as BentoListboxSelectedValueLabelPair).value === value : dropdownValue.value === value; }); return index > -1 ? index : 0; }); const { isMultiLevelSelect, flattenedItemsList } = useMultiLevelItems(items); const isTreePattern = computed(() => isMultiLevelSelect.value && !props.staticCategories); const computedOptionRole = computed<BentoListboxItemRole>(() => (isTreePattern.value ? 'treeitem' : 'option')); const additionalItemsSelected = computed(() => { if (props.multiple) { const selectedItemsCount = Array.isArray(dropdownValue.value) ? dropdownValue.value.length : 0; const total = isMultiLevelSelect.value ? flattenedItemsList.value.length : items.value.length; if (selectedItemsCount === 0) { return null; } // Display count always - show "all" only when is not external filtering (hasMoreItems = true) if (!props.hasMoreItems && total > 0 && total === selectedItemsCount) { return t('all'); } return selectedItemsCount; } return null; }); // ARIA const ariaDescribedBy = computed( () => [ props.description ? descriptionId : null, ariaDescribedByAttribute.value, props.errorMessage ? errorId : null, ] .filter(Boolean) .join(' ') || null ); const listboxRef = computed(() => optionsContainerRef.value?.$el as HTMLDivElement); const { visibleDomOptions, updateVisibleDomOptions } = useVisibleDomOptions( listboxRef, filteredItems, computedOptionRole ); const openDropdown = () => { if (isReadOnly.value) { return; } isDropdownOpen.value = true; nextTick(() => { updateVisibleDomOptions(); }); }; const closeDropdown = () => { isDropdownOpen.value = false; }; const toggleDropdown = () => { if (isReadOnly.value) { return; } isDropdownOpen.value = !isDropdownOpen.value; nextTick(() => { updateVisibleDomOptions(); }); // Clear search if (isSearching.value) { searchTerm.value = ''; } }; const clickOutsideDropdown = () => { if (isSearching.value) { searchTerm.value = ''; } if (isDropdownOpen.value) { isDropdownOpen.value = false; // Reset the internal dropdown options state optionsContainerRef.value.onOutsideDropdownClick(); } }; const onOptionSelected = 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 +1 @@
|
|
|
1
|
-
<template> <div class="b-file-uploader-restrictions" :class="conditionalClasses"> <bento-typography v-if="supportedFileTypes" :class="setRestrictionErrorClass(supportedFileTypes.error)"> {{ t('supportedFileTypes', { list:
|
|
1
|
+
<template> <div class="b-file-uploader-restrictions" :class="conditionalClasses"> <bento-typography v-if="supportedFileTypes" :class="setRestrictionErrorClass(supportedFileTypes.error || (isMaxSizeArray && maxSize.error))" > {{ t('supportedFileTypes', { list: computedConditionalFiles }) }} </bento-typography> <bento-typography v-if="!isMaxSizeArray && maxSize" :class="setRestrictionErrorClass(maxSize.error)"> {{ maxFileSizeLabel }} </bento-typography> <bento-typography v-if="maxDimensions" :class="setRestrictionErrorClass(maxDimensions.error)"> {{ maxDimensionsLabel }} </bento-typography> <bento-typography v-if="maxCount" :class="setRestrictionErrorClass(maxCount.error)"> {{ tc('maxUpload', maxCount.value, { count: n(maxCount.value) }) }} </bento-typography> </div> </template> <script setup lang="ts"> import { computed } from 'vue'; import { BentoTypography } from '@/components/typography'; import { useI18n } from '@/utils/ts/i18n'; import { formatFileSize } from '../../utils'; import type { FileUploaderRestrictionsProps } from './file-uploader-restrictions.types'; import type { FileMaxSize } from '../../file-uploader.types'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const props = withDefaults(defineProps<FileUploaderRestrictionsProps>(), { condensed: null, disabled: null, maxCount: null, maxDimensions: null, maxSize: null, supportedFileTypes: null, }); const { t, tc, n, locale } = useI18n<{ message: MessageSchema }>({ messages }); const conditionalClasses = computed(() => ({ ['b-file-uploader-restrictions--condensed']: props.condensed, ['b-file-uploader-restrictions--disabled']: props.disabled, })); /** * Returns the error class to be set if a restriction has an error * @param error Indicates if the restriction has errored * @returns Conditional error class */ function setRestrictionErrorClass(error: boolean) { return { 'b-file-uploader-restrictions--error': error, }; } const isMaxSizeArray = computed(() => props.maxSize && Array.isArray(props.maxSize.value)); const maxFileSizeLabel = computed(() => { if (!props.maxSize || isMaxSizeArray.value) { return null; } return t('maxFileSize', { size: formatFileSize(locale.value, props.maxSize.value as number) }); }); const computedConditionalFiles = computed(() => { if (!props.supportedFileTypes) { return null; } if (!isMaxSizeArray.value) { return props.supportedFileTypes.value; } let list = props.supportedFileTypes.value; (props.maxSize.value as Array<FileMaxSize>).forEach(entry => { const displayName = entry.fileType.toUpperCase(); const sizeLabel = t('upToSize', { size: formatFileSize(locale.value, entry.maxSize) }); list = list.replace(displayName, `${displayName} ${sizeLabel}`); }); return list; }); const maxDimensionsLabel = computed(() => { if (!props.maxDimensions) { return null; } const { value: { width, height }, } = props.maxDimensions; if (width && height) { return t('maxImageDimensions', { width: n(width), height: n(height) }); } if (width) { return t('maxImageWidth', { width: n(width) }); } if (height) { return t('maxImageHeight', { height: n(height) }); } return null; }); </script> <script lang="ts"> /** * List of restrictions defined for "bento-file-uploader". * * @example * import { FileUploaderRestrictions } from './'; * * export default { * components: { FileUploaderRestrictions }, * template: ` * <file-uploader-restrictions * :maxCount="1" * :maxSize="5000" * :maxDimensions="{ width: 100, height: 200 }" * supportedFileTypes="audio/*,.pdf,.jpg" * /> * ` * } */ export default { i18n: { messages }, }; </script> <style lang="scss" scoped src="./file-uploader-restrictions.scss" />
|
|
@@ -99,6 +99,15 @@ the maximum number of files that can be uploaded.
|
|
|
99
99
|
When this restriction is enabled if user tries to upload more than the allowed number, an error will be shown asking
|
|
100
100
|
user to remove excess files.
|
|
101
101
|
|
|
102
|
+
### Conditional file sizes
|
|
103
|
+
|
|
104
|
+
Specifying file sizes per file type is possible through the `maxSize` property with an array of objects - this
|
|
105
|
+
restriction sets a maximum file size limit for each file type that can be uploaded.
|
|
106
|
+
|
|
107
|
+
In this case, any file that exceeds the specified size limit will be rejected, and an error message will be displayed.
|
|
108
|
+
|
|
109
|
+
<Canvas of={FileUploaderStories.ConditionalFileSizes} />
|
|
110
|
+
|
|
102
111
|
## Accessibility
|
|
103
112
|
|
|
104
113
|
### Keyboard interaction
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import { action } from '@storybook/addon-actions'; import { isVue2 } from 'vue-demi'; import { storybookDocsParameter } from '@/utils/ts/storybook'; import BentoFileUploader from './file-uploader.vue'; import type { Meta, StoryObj } from '@storybook/vue'; const meta: Meta = { title: 'File uploader', component: BentoFileUploader, argTypes: { // Slots accept: { control: 'text', table: { type: { summary: "HTMLInputElement['accept']" }, }, }, // Events change: { table: { type: { summary: '(value?: FileList) => void' } }, control: { disable: true }, }, 'update:model-value': { table: { type: { summary: '(value?: FileList) => void' } }, control: { disable: true }, }, input: { description: 'Deprecated since version 2.0. Use `v-model` or `update:model-value` instead', table: { type: { summary: '(value?: FileList) => void' }, category: 'Deprecated', }, control: { disable: true }, }, 'error:upload': { table: { type: { summary: '(value: boolean) => void' } }, control: { disable: true }, }, placeholder: { table: { disable: true } }, }, }; const DEFAULT_PROPS = { label: 'Label', description: 'Custom supporting text', }; export default meta; type Story = StoryObj<typeof BentoFileUploader>;
|
|
1
|
+
import { action } from '@storybook/addon-actions'; import { isVue2 } from 'vue-demi'; import { storybookDocsParameter } from '@/utils/ts/storybook'; import BentoFileUploader from './file-uploader.vue'; import type { Meta, StoryObj } from '@storybook/vue'; import ConditionalFileSizesCode from './__tests__/file-uploader-conditional-file-sizes.vue?raw'; import EverythingBagelCode from './__tests__/file-uploader-everything-bagel.vue?raw'; const meta: Meta = { title: 'File uploader', component: BentoFileUploader, argTypes: { // Slots accept: { control: 'text', table: { type: { summary: "HTMLInputElement['accept']" }, }, }, // Events change: { table: { type: { summary: '(value?: FileList) => void' } }, control: { disable: true }, }, 'update:model-value': { table: { type: { summary: '(value?: FileList) => void' } }, control: { disable: true }, }, input: { description: 'Deprecated since version 2.0. Use `v-model` or `update:model-value` instead', table: { type: { summary: '(value?: FileList) => void' }, category: 'Deprecated', }, control: { disable: true }, }, 'error:upload': { table: { type: { summary: '(value: boolean) => void' } }, control: { disable: true }, }, placeholder: { table: { disable: true } }, }, }; const DEFAULT_PROPS = { label: 'Label', description: 'Custom supporting text', }; export default meta; type Story = StoryObj<typeof BentoFileUploader>; export const Default: Story = { render: (_args, { argTypes }) => ({ components: { BentoFileUploader }, props: Object.keys(argTypes), template: ` <div style="max-width: 500px"> <bento-file-uploader v-bind="args" @error:upload="errorAction" @change="changeAction" /> </div> `, setup(props) { const changeAction = value => action('change')(value); // As 'value' is a boolean, it needs to be a template literal otherwise // passing 'false' indicates there is no args const errorAction = value => action('error:upload')(`${value}`); return { // Values args: isVue2 ? props : _args, // Events changeAction, errorAction, }; }, }), args: { ...DEFAULT_PROPS, condensed: false, }, parameters: storybookDocsParameter(EverythingBagelCode), }; export const ModelValue: Story = { render: (_args, { argTypes }) => ({ components: { BentoFileUploader }, props: Object.keys(argTypes), template: ` <div style="max-width: 500px"> <bento-file-uploader v-bind="args" v-model="mockFileList" @error:upload="errorAction" @change="changeAction"/> </div> `, setup(props) { const changeAction = fileList => { action('change')(fileList); }; // As 'value' is a boolean, it needs to be a template literal otherwise // passing 'false' indicates there is no args const errorAction = value => action('error:upload')(`${value}`); const dataTransfer = new DataTransfer(); const dummyFile = new File(['foo'], 'foo.pdf', { type: 'text/pdf' }); dataTransfer.items.add(dummyFile); const mockFileList = dataTransfer.files; return { // Values args: isVue2 ? props : _args, mockFileList, // Events changeAction, errorAction, }; }, }), args: { ...DEFAULT_PROPS, condensed: false, }, parameters: storybookDocsParameter(EverythingBagelCode), }; export const ConditionalFileSizes: Story = { render: (_args, { argTypes }) => ({ components: { BentoFileUploader }, props: Object.keys(argTypes), template: ` <div style="max-width: 500px"> <bento-file-uploader v-bind="args" @error:upload="errorAction" @change="changeAction" /> </div> `, setup(props) { const changeAction = value => action('change')(value); // As 'value' is a boolean, it needs to be a template literal otherwise // passing 'false' indicates there is no args const errorAction = value => action('error:upload')(`${value}`); return { // Values args: isVue2 ? props : _args, // Events changeAction, errorAction, }; }, }), args: { ...DEFAULT_PROPS, condensed: false, accept: 'png,pdf', maxSize: [ { fileType: 'pdf', maxSize: 10485760 }, { fileType: 'png', maxSize: 5242880 }, ], }, parameters: storybookDocsParameter(ConditionalFileSizesCode), };
|
|
@@ -1 +1 @@
|
|
|
1
|
-
<template> <div class="b-file-uploader" :class="conditionalClasses"> <field-label class="b-file-uploader__label" :for="inputFieldId" :label="label" :optional="optional" :required="required" :tooltip-text="tooltipText" /> <input :id="inputFieldId" ref="inputRef" aria-hidden="true" type="file" tabindex="-1" data-testid="input" class="b-file-uploader__input" :accept="accept" :disabled="disabled" :multiple="isMultiple" @change="onInputChange" @click="onInputClick" /> <!-- Used to separate the a11y text with breaks for the reader. Hidden to users --> <span :id="commaTextId" aria-hidden="true" class="b-file-uploader__invisible-comma"> , </span> <div v-if="!hideArea" ref="dropAreaRef" :aria-labelledby="fileUploadAriaLabelledBy" class="b-file-uploader__area" role="button" :tabindex="disabled ? -1 : 0" @click="inputRef.click()" @dragenter.stop.prevent="onDragEnter" @dragleave.stop.prevent="onDragLeave" @drop.stop.prevent="onDrop" @dragover="onDragOver" @keydown.enter.prevent.capture="inputRef.click()" @keydown.space.prevent.capture="inputRef.click()" > <div class="b-file-uploader__icon" aria-hidden="true"> <warning-filled-icon v-if="hasError" /> <upload-icon v-else /> </div> <template v-if="!condensed"> <bento-typography :id="dropFilesTextId" el="span" strongest class="b-file-uploader__title"> {{ tc('dropFiles', maxCount) }} </bento-typography> <bento-typography v-if="description" :id="customTextId" el="span" class="b-file-uploader__description"> {{ description }} </bento-typography> <file-uploader-restrictions v-bind="fileUploadRestrictionsProps" :id="fileUploaderRestrictionsId" /> </template> <bento-typography :id="browseFilesButtonId" stronger class="b-file-uploader__button"> {{ t('browseFiles') }} </bento-typography> </div> <error-message v-if="hasError && errorMessage" :id="errorId" :error-message="errorMessage" class="b-file-uploader__error-message" /> <bento-typography v-if="condensed && description" :id="customTextId" aria-hidden="true" el="span" class="b-file-uploader__description b-file-uploader__description--condensed" > {{ description }} </bento-typography> <file-uploader-restrictions v-if="condensed" v-bind="fileUploadRestrictionsProps" :id="fileUploaderRestrictionsId" /> <bento-alert v-if="maxFileCountError" type="critical"> {{ t('tooManyFiles') }} <template #description> {{ t('youHaveExceededTheMaximum', { count: n(props.maxCount) }) }} </template> </bento-alert> <div v-show="files?.length" class="b-file-uploader__files"> <file-uploader-file-card v-for="(file, index) in files" :ref="registerFileCardRef(index)" :key="file.id" :file="file" :supported-file-types="supportedFileTypeList" :readonly="readonly" @cancel="removeFile(file)" @remove="removeFile(file)" /> </div> <bento-typography v-if="!files?.length && readonly" class="b-file-uploader__no-files" wide>{{ t('noFilesUploaded') }}</bento-typography> </div> </template> <script setup lang="ts"> import { computed, nextTick, onMounted, reactive, ref, toRef, watch } from 'vue'; import UploadIcon from '@adyen/ui-assets-icons-16/vue/upload'; import WarningFilledIcon from '@adyen/ui-assets-icons-16/vue/warning-filled'; import { BentoAlert } from '@/components/alert'; import { BentoTypography } from '@/components/typography'; import { ErrorMessage, FieldLabel } from '@/internal'; import { FileUploaderFileCard } from './components/file-uploader-file-card'; import { FileUploaderRestrictions } from './components/file-uploader-restrictions'; import { useI18n } from '@/utils/ts/i18n'; import { generateUid } from '@/core/utils/ts'; import { getFileType, readFile } from './utils'; import { useFileTypeList } from './composables'; import { useDragDropState } from '@/components/file-uploader/composables/use-drag-drop-state/use-drag-drop-state'; import { deprecate } from '@/utils/ts/deprecate'; import { useFormFieldEmits } from '@/utils/ts/form-field/form-field'; import { type BentoFileUploaderProps, type BentoFileUploaderValue, FileErrorType, type FileObject, FileState, FileUploaderState, } from './file-uploader.types'; import type { FileUploaderRestrictionsProps } from '@/components/file-uploader/components/file-uploader-restrictions/file-uploader-restrictions.types'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const { t, tc, n } = useI18n<{ message: MessageSchema }>({ messages }); const emit = defineEmits<{ /** * Emitted when files are added or removed. * Provides the entire list of files available. * Can be undefined if the list is empty */ (e: 'change', value?: FileList): void; /** * Vue2: Emitted when files are added or removed. Updates the v-model. * Provides the entire list of files available. * Can be undefined if the list is empty * @deprecated from version 2.0.0. Use `v-model` or `update:model-value` instead. */ (e: 'input', value?: FileList): void; /** * Vue3: Emitted when files are added or removed. Updates the v-model. * Provides the entire list of files available. * Can be undefined if the list is empty */ (e: 'update:model-value', value?: FileList): void; /** * Emitted when any file uploaded has state === FileState.ERROR. * Provides parent component with boolean. * Emits false when no files have errors. */ (e: 'error:upload', value: boolean): void; }>(); const props = withDefaults(defineProps<BentoFileUploaderProps>(), { accept: null, condensed: false, description: null, disabled: false, errorMessage: null, label: null, maxCount: 1, // One file by default maxDimensions: null, maxSize: null, modelValue: null, optional: false, required: false, tooltipText: null, }); const { emitValue } = useFormFieldEmits<BentoFileUploaderValue>(emit); /** * Model value checks * Filter out type string, array and file as FileList is the only type un-deprecated for modelValue * */ /** * Type guard to check if the value is a FileList. * @param files The value to check. */ function isFileList(files: BentoFileUploaderValue): files is FileList { return ( !!files && typeof (files as FileList).length === 'number' && typeof (files as FileList).item === 'function' ); } /** * Used as vitest recognizes FileList as an Array of Files which then * causes any tests with pre-uploaded files to fail as isFileList will fail. * */ onMounted(() => { // Model is not a FileList if (!isFileList(props.modelValue)) { deprecate( 'BentoFileUploaderValue types: string | Array<string> | File | Array<File>', 'Use FileList instead.', '2.0.0' ); } }); const { supportedFileTypeList } = useFileTypeList(toRef(props, 'accept')); const browseFilesButtonId = generateUid('browse-button'); const customTextId = generateUid('custom-text'); const dropFilesTextId = generateUid('drop-files-text'); const commaTextId = generateUid('comma-text'); const fileUploaderRestrictionsId = generateUid('file-uploader-restrictions'); const files = ref<Array<FileObject>>(null); const fileCardRefs = ref<Array<InstanceType<typeof FileUploaderFileCard>>>([]); const inputRef = ref<HTMLInputElement>(null); const dropAreaRef = ref<HTMLDivElement>(null); const inputFieldId = generateUid('input'); const errorId = generateUid('input-error'); const fileUploadAriaLabelledBy = computed(() => { const formattedDropFileTextId = !props.condensed ? `${dropFilesTextId} ${commaTextId}` : ''; const formattedCustomTextId = props.description ? `${customTextId} ${commaTextId}` : ''; const formattedFileUploaderRestrictionsId = `${fileUploaderRestrictionsId} ${commaTextId}`; return `${formattedDropFileTextId} ${formattedCustomTextId} ${formattedFileUploaderRestrictionsId} ${browseFilesButtonId}`; }); const { dragState, dragErrors, resetDragState, onDragEnterValidations } = useDragDropState({ accept: toRef(props, 'accept'), maxCount: toRef(props, 'maxCount'), }); const fileUploadRestrictionsProps = computed<FileUploaderRestrictionsProps>(() => ({ condensed: props.condensed, disabled: props.disabled, maxCount: isMultiple.value ? { value: props.maxCount, error: dragErrors.maxCountOverflow || maxFileCountError.value, } : null, maxDimensions: props.maxDimensions ? { value: props.maxDimensions, error: files.value?.some(file => file.errors.includes(FileErrorType.ERROR_DIMENSIONS)), } : null, maxSize: props.maxSize ? { value: props.maxSize, error: files.value?.some(file => file.errors.includes(FileErrorType.ERROR_SIZE)), } : null, supportedFileTypes: supportedFileTypeList.value ? { value: supportedFileTypeList.value, error: dragErrors.invalidTypes || dragErrors.partiallyInvalidTypes || files.value?.some(file => file.errors.includes(FileErrorType.ERROR_TYPE)), } : null, })); // Prevents triggering "dragleave" events on children elements const enterTarget = ref<EventTarget>(); const conditionalClasses = computed(() => ({ [`b-file-uploader--${FileUploaderState.DISABLED}`]: props.disabled, [`b-file-uploader--${FileUploaderState.DRAG_OVER}`]: dragState.dragging, [`b-file-uploader--${FileUploaderState.ERROR}`]: hasError.value, [`b-file-uploader--condensed`]: props.condensed, })); const isMultiple = computed(() => props.maxCount > 1); const hideArea = computed(() => (props.maxCount && files.value?.length >= props.maxCount) || props.readonly); const loadingStateExists = computed(() => files.value?.some(file => file.state === 'loading')); const registerFileCardRef = (cardIndex: number) => fileCardRef => { fileCardRefs.value[cardIndex] = fileCardRef; }; /** * Verifies the new list of files and emits the `change` and `update:modelValue` events. * @param newList The new list of files to process and emit. */ function emitUpdate(newList: Array<FileObject>) { if (newList) { verifyFileCount(newList); verifyFileSize(newList); const value = convertToFileList(newList); emit('change', value); emitValue(value); } else { emit('change', undefined); emitValue(undefined); } } /** Errors */ const hasError = computed(() => !!props.errorMessage || dragState.error); const maxFileCountError = computed(() => files.value?.length > props.maxCount); const maxFileError = ref(false); const hasRestrictionError = computed(() => files.value?.some(file => file.state === 'error')); watch(hasRestrictionError, newValue => { // Without this, when files are initially uploaded the component // will immediately and unnecessarily emit 'false' and then // emit 'true' once processing has done, if errors exists. if (loadingStateExists.value) { return; } emit('error:upload', newValue); }); /** * Places the focus to the "drop area" after adding or removing a file. * If more files can be added, the focus is moved to the "drop area". * If no more files can be added, the focus is moved to the first file card. */ async function resetFocus() { await nextTick(); if (files.value.length === props.maxCount) { fileCardRefs.value[0]?.focus(); return; } dropAreaRef.value.focus(); } /** * Removes the file from the list of files when the * "remove" or "cancel" events are triggered from * the file card component * @param file File to be removed */ async function removeFile(file: FileObject) { files.value = files.value.filter(({ id }) => id !== file.id); await resetFocus(); emitUpdate(files.value); } /** Drag n Drop functions */ /** * Creates a FileObject from a native File and tracks its client-load progress. * @param file The native File object. * @returns A FileObject. */ function processFile(file: File) { const fileObject = reactive<FileObject>({ id: generateUid('file'), data: file, errors: [], progress: null, state: FileState.LOADING, type: getFileType(file), }); readFile(fileObject, props.accept, props.maxDimensions); return fileObject; } /** * Prevent opening the file browser when no * more files can be added (until one file is removed). * @param event Triggered event */ function onInputClick(event: MouseEvent) { if (files.value?.length === props.maxCount) { event.preventDefault(); } } /** * Sets the file/files to track their progress while being processed * @param fileList File or list of files to be processed. */ async function onFileInput(fileList: FileList) { // Prevent adding many files if maxCount is 1 if (props.maxCount === 1 && fileList.length > props.maxCount) { resetEnterTarget(); resetDragState(); return; } if (isMultiple.value) { // Add the new files to the file selection. // Removing files should be done by interacting with the file list below the area. files.value = [...(files.value || []), ...Array.from(fileList).map(file => processFile(file))]; } else { files.value = [processFile(fileList[0])]; await resetFocus(); } emitUpdate(files.value); } /** * Handles the "dragenter" event, triggered when the mouse enters the draggable area. * Enables the dragging state and sets the dragging error to true to * prevent dropping files from being added if there's a general error. * @param event Drag event */ function onDragEnter(event: DragEvent) { // Prevent changing state if disabled if (props.disabled) { return; } /** * Allows internal components as part of the dragable area. * Prevents trigerring "dragleave" events on children elements. * Set the "dragarea" as target */ enterTarget.value = event.target; const totalFilesCount = (event.dataTransfer.items?.length ?? 0) + (files.value?.length ?? 0); onDragEnterValidations(totalFilesCount, event); } /** * Resets the reference to the target. * This helps preventing loosing the state when dragging over children */ function resetEnterTarget() { enterTarget.value = null; } /** * Handles the "dragleave" event, triggered when the mouse leaves the draggable area. * Maintains the dragging state while dragging over children. * Resets the states when the mouse leaves the draggable area * @param event Drag event */ function onDragLeave(event: DragEvent) { // If target enter and leave are the same it means the drag has left the "dragarea" if (enterTarget.value === event.target) { resetEnterTarget(); resetDragState(); } } /** * Handles the "dragover" event, triggered when the mouse moves over the draggable area. * Disables the cursor when the component is disabled. * @param event Drag event */ function onDragOver(event: DragEvent) { event.preventDefault(); // Disable drag n drop and change the cursor if disabled if (props.disabled) { // eslint-disable-next-line no-param-reassign event.dataTransfer.dropEffect = 'none'; } } /** * Hadles the "drop" event, triggered when files are dropped inside the draggable area. * Processes the files that are dropped. * @param event Drag event */ async function onDrop(event: DragEvent) { // Prevent events when the input is disabled if (props.disabled) { return; } if (!dragErrors.invalidTypes && event.dataTransfer.files?.length) { onFileInput(event.dataTransfer.files); } resetEnterTarget(); resetDragState(); } /** * Handles the "change" event over the "input" element. * Processes the files that are selected through the browser's file explorer. */ async function onInputChange() { if (inputRef.value.files?.length) { onFileInput(inputRef.value.files); } // Reset input value inputRef.value.value = ''; await resetFocus(); } /** End of drag and drop functions */ /** * Watches the modelValue and if set * and the type is not deprecated, it processes the files * but doesn't emit them as the user already has those files. */ watch( () => props.modelValue, (newValue: BentoFileUploaderValue) => { // Sets the new value if it is a `FileList`. This ignores deprecated value types. if (newValue && isFileList(newValue)) { const fileList = newValue as FileList; const fileListArray: Array<File> = Array.from(fileList); files.value = fileListArray.map(file => processFile(file)); } else if (!newValue && files.value) { // Clear the file list when the value is programmatically unset. files.value = null; } }, { immediate: true } ); function verifyFileCount(fileList: Array<FileObject>) { const isValid = props.maxCount && fileList.length <= props.maxCount; maxFileError.value = !isValid; } function verifyFileSize(fileList: Array<FileObject>) { if (props.maxSize) { fileList.forEach(file => { const item = file; // Prevent eslint no-param-reassign if (item.data.size > props.maxSize) { item.state = FileState.ERROR; if (!item.errors.includes(FileErrorType.ERROR_SIZE)) { item.errors.push(FileErrorType.ERROR_SIZE); } } }); } } function convertToFileList(fileList: Array<FileObject>) { // Filter out files with errors const validFiles = fileList.filter( ({ errors }) => !errors.includes(FileErrorType.ERROR_LOAD) && !errors.includes(FileErrorType.ERROR_SIZE) ); if (validFiles.length) { const dataTransfer = new DataTransfer(); validFiles.forEach(({ data, id }) => { dataTransfer.items.add(data); }); return dataTransfer.files; } return undefined; } </script> <script lang="ts"> /** * The file uploader component enables users to upload one or multiple files from their device to our system. * * @example * import { BentoFileUploader, type BentoFileValue } from '@adyen/bento-vue2'; * * export default { * components: { BentoFileUploader }, * template: ` * <bento-file-uploader * accept="image/*,video/*,.pdf,.png" * condensed * description="Custom supporting text" * disabled * errorMessage="Custom error message" * label="Bento file uploader" * :maxCount="1" * :maxDimensions="{ width: 200, height: 100 }" * :maxSize="1000" * v-model="files" * :optional="false" * required * tooltipText="Useful information" * /> * `, * setup() { * const files = ref<BentoFileValue>() * } * } */ export default { model: { prop: 'modelValue' }, i18n: { messages }, }; </script> <style lang="scss" scoped src="./file-uploader.scss" />
|
|
1
|
+
<template> <div class="b-file-uploader" :class="conditionalClasses"> <field-label :id="fieldLabelId" class="b-file-uploader__label" :for="inputFieldId" :label="label" :optional="optional" :required="required" :tooltip-text="tooltipText" /> <input :id="inputFieldId" ref="inputRef" :aria-required="required" type="file" tabindex="-1" data-testid="input" class="b-file-uploader__input" :accept="accept" :disabled="disabled" :multiple="isMultiple" @change="onInputChange" @click="onInputClick" @invalid.prevent /> <!-- Used to separate the a11y text with breaks for the reader. Hidden to users --> <span :id="commaTextId" aria-hidden="true" class="b-file-uploader__invisible-comma"> , </span> <div v-if="!hideArea" ref="dropAreaRef" :aria-labelledby="fileUploadAriaLabelledBy" class="b-file-uploader__area" role="button" :tabindex="disabled ? -1 : 0" @click="inputRef.click()" @dragenter.stop.prevent="onDragEnter" @dragleave.stop.prevent="onDragLeave" @drop.stop.prevent="onDrop" @dragover="onDragOver" @keydown.enter.prevent.capture="inputRef.click()" @keydown.space.prevent.capture="inputRef.click()" > <div class="b-file-uploader__icon" aria-hidden="true"> <warning-filled-icon v-if="hasError" /> <upload-icon v-else /> </div> <template v-if="!condensed"> <bento-typography :id="dropFilesTextId" el="span" strongest class="b-file-uploader__title"> {{ tc('dropFiles', maxCount) }} </bento-typography> <bento-typography v-if="description" :id="customTextId" el="span" class="b-file-uploader__description"> {{ description }} </bento-typography> <file-uploader-restrictions v-bind="fileUploadRestrictionsProps" :id="fileUploaderRestrictionsId" /> </template> <bento-typography :id="browseFilesButtonId" stronger class="b-file-uploader__button"> {{ t('browseFiles') }} </bento-typography> </div> <error-message v-if="hasError && errorMessage" :id="errorId" :error-message="errorMessage" class="b-file-uploader__error-message" /> <bento-typography v-if="condensed && description" :id="customTextId" aria-hidden="true" el="span" class="b-file-uploader__description b-file-uploader__description--condensed" > {{ description }} </bento-typography> <file-uploader-restrictions v-if="condensed" v-bind="fileUploadRestrictionsProps" :id="fileUploaderRestrictionsId" /> <bento-alert v-if="maxFileCountError" type="critical"> {{ t('tooManyFiles') }} <template #description> {{ t('youHaveExceededTheMaximum', { count: n(props.maxCount) }) }} </template> </bento-alert> <div v-show="files?.length" class="b-file-uploader__files"> <file-uploader-file-card v-for="(file, index) in files" :ref="registerFileCardRef(index)" :key="file.id" :file="file" :supported-file-types="supportedFileTypeList" :readonly="readonly" @cancel="removeFile(file)" @remove="removeFile(file)" /> </div> <bento-typography v-if="!files?.length && readonly" class="b-file-uploader__no-files" wide>{{ t('noFilesUploaded') }}</bento-typography> </div> </template> <script setup lang="ts"> import { computed, nextTick, onMounted, reactive, ref, toRef, watch } from 'vue'; import UploadIcon from '@adyen/ui-assets-icons-16/vue/upload'; import WarningFilledIcon from '@adyen/ui-assets-icons-16/vue/warning-filled'; import { BentoAlert } from '@/components/alert'; import { BentoTypography } from '@/components/typography'; import { ErrorMessage, FieldLabel } from '@/internal'; import { FileUploaderFileCard } from './components/file-uploader-file-card'; import { FileUploaderRestrictions } from './components/file-uploader-restrictions'; import { useI18n } from '@/utils/ts/i18n'; import { generateUid } from '@/core/utils/ts'; import { getFileType, isFileTypeAccepted, readFile } from './utils'; import { useFileTypeList } from './composables'; import { useDragDropState } from '@/components/file-uploader/composables/use-drag-drop-state/use-drag-drop-state'; import { deprecate } from '@/utils/ts/deprecate'; import { useFormFieldEmits } from '@/utils/ts/form-field/form-field'; import { type BentoFileUploaderProps, type BentoFileUploaderValue, FileErrorType, type FileMaxSize, type FileObject, FileState, FileUploaderState, } from './file-uploader.types'; import type { FileUploaderRestrictionsProps } from '@/components/file-uploader/components/file-uploader-restrictions/file-uploader-restrictions.types'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const { t, tc, n } = useI18n<{ message: MessageSchema }>({ messages }); const emit = defineEmits<{ /** * Emitted when files are added or removed. * Provides the entire list of files available. * Can be undefined if the list is empty */ (e: 'change', value?: FileList): void; /** * Vue2: Emitted when files are added or removed. Updates the v-model. * Provides the entire list of files available. * Can be undefined if the list is empty * @deprecated from version 2.0.0. Use `v-model` or `update:model-value` instead. */ (e: 'input', value?: FileList): void; /** * Vue3: Emitted when files are added or removed. Updates the v-model. * Provides the entire list of files available. * Can be undefined if the list is empty */ (e: 'update:model-value', value?: FileList): void; /** * Emitted when any file uploaded has state === FileState.ERROR. * Provides parent component with boolean. * Emits false when no files have errors. */ (e: 'error:upload', value: boolean): void; }>(); const props = withDefaults(defineProps<BentoFileUploaderProps>(), { accept: null, condensed: false, description: null, disabled: false, errorMessage: null, label: null, maxCount: 1, // One file by default maxDimensions: null, maxSize: null, modelValue: null, optional: false, required: false, tooltipText: null, }); const { emitValue } = useFormFieldEmits<BentoFileUploaderValue>(emit); /** * Model value checks * Filter out type string, array and file as FileList is the only type un-deprecated for modelValue * */ /** * Type guard to check if the value is a FileList. * @param files The value to check. */ function isFileList(files: BentoFileUploaderValue): files is FileList { return ( !!files && typeof (files as FileList).length === 'number' && typeof (files as FileList).item === 'function' ); } /** * Used as vitest recognizes FileList as an Array of Files which then * causes any tests with pre-uploaded files to fail as isFileList will fail. * */ onMounted(() => { // Model is not a FileList if (!isFileList(props.modelValue)) { deprecate( 'BentoFileUploaderValue types: string | Array<string> | File | Array<File>', 'Use FileList instead.', '2.0.0' ); } }); const { supportedFileTypeList } = useFileTypeList(toRef(props, 'accept')); const browseFilesButtonId = generateUid('browse-button'); const customTextId = generateUid('custom-text'); const dropFilesTextId = generateUid('drop-files-text'); const commaTextId = generateUid('comma-text'); const fileUploaderRestrictionsId = generateUid('file-uploader-restrictions'); const fieldLabelId = generateUid('field-label'); const files = ref<Array<FileObject>>(null); const fileCardRefs = ref<Array<InstanceType<typeof FileUploaderFileCard>>>([]); const inputRef = ref<HTMLInputElement>(null); const dropAreaRef = ref<HTMLDivElement>(null); const inputFieldId = generateUid('input'); const errorId = generateUid('input-error'); const fileUploadAriaLabelledBy = computed(() => { const formattedFieldLabelId = props.label ? `${fieldLabelId} ${commaTextId}` : ''; const formattedDropFileTextId = !props.condensed ? `${dropFilesTextId} ${commaTextId}` : ''; const formattedCustomTextId = props.description ? `${customTextId} ${commaTextId}` : ''; const formattedFileUploaderRestrictionsId = `${fileUploaderRestrictionsId} ${commaTextId}`; return `${formattedFieldLabelId} ${formattedDropFileTextId} ${formattedCustomTextId} ${formattedFileUploaderRestrictionsId} ${browseFilesButtonId}`; }); const { dragState, dragErrors, resetDragState, onDragEnterValidations } = useDragDropState({ accept: toRef(props, 'accept'), maxCount: toRef(props, 'maxCount'), }); const fileUploadRestrictionsProps = computed<FileUploaderRestrictionsProps>(() => ({ condensed: props.condensed, disabled: props.disabled, maxCount: isMultiple.value ? { value: props.maxCount, error: dragErrors.maxCountOverflow || maxFileCountError.value, } : null, maxDimensions: props.maxDimensions ? { value: props.maxDimensions, error: files.value?.some(file => file.errors.includes(FileErrorType.ERROR_DIMENSIONS)), } : null, maxSize: props.maxSize ? { value: props.maxSize, error: files.value?.some(file => file.errors.includes(FileErrorType.ERROR_SIZE)), } : null, supportedFileTypes: supportedFileTypeList.value ? { value: supportedFileTypeList.value, error: dragErrors.invalidTypes || dragErrors.partiallyInvalidTypes || files.value?.some(file => file.errors.includes(FileErrorType.ERROR_TYPE)), } : null, })); // Prevents triggering "dragleave" events on children elements const enterTarget = ref<EventTarget>(); const conditionalClasses = computed(() => ({ [`b-file-uploader--${FileUploaderState.DISABLED}`]: props.disabled, [`b-file-uploader--${FileUploaderState.DRAG_OVER}`]: dragState.dragging, [`b-file-uploader--${FileUploaderState.ERROR}`]: hasError.value, [`b-file-uploader--condensed`]: props.condensed, })); const isMultiple = computed(() => props.maxCount > 1); const hideArea = computed(() => (props.maxCount && files.value?.length >= props.maxCount) || props.readonly); const loadingStateExists = computed(() => files.value?.some(file => file.state === 'loading')); const registerFileCardRef = (cardIndex: number) => fileCardRef => { fileCardRefs.value[cardIndex] = fileCardRef; }; /** * Verifies the new list of files and emits the `change` and `update:modelValue` events. * @param newList The new list of files to process and emit. */ function emitUpdate(newList: Array<FileObject>) { if (newList) { verifyFileCount(newList); verifyFileSize(newList); const value = convertToFileList(newList); emit('change', value); emitValue(value); } else { emit('change', undefined); emitValue(undefined); } } /** Errors */ const hasError = computed(() => !!props.errorMessage || dragState.error); const maxFileCountError = computed(() => files.value?.length > props.maxCount); const maxFileError = ref(false); const hasRestrictionError = computed(() => files.value?.some(file => file.state === 'error')); watch(hasRestrictionError, newValue => { // Without this, when files are initially uploaded the component // will immediately and unnecessarily emit 'false' and then // emit 'true' once processing has done, if errors exists. if (loadingStateExists.value) { return; } emit('error:upload', newValue); }); /** * Places the focus to the "drop area" after adding or removing a file. * If more files can be added, the focus is moved to the "drop area". * If no more files can be added, the focus is moved to the first file card. */ async function resetFocus() { await nextTick(); if (files.value.length === props.maxCount) { fileCardRefs.value[0]?.focus(); return; } dropAreaRef.value.focus(); } /** * Removes the file from the list of files when the * "remove" or "cancel" events are triggered from * the file card component * @param file File to be removed */ async function removeFile(file: FileObject) { files.value = files.value.filter(({ id }) => id !== file.id); await resetFocus(); emitUpdate(files.value); } /** Drag n Drop functions */ /** * Creates a FileObject from a native File and tracks its client-load progress. * @param file The native File object. * @returns A FileObject. */ function processFile(file: File) { const fileObject = reactive<FileObject>({ id: generateUid('file'), data: file, errors: [], progress: null, state: FileState.LOADING, type: getFileType(file), }); readFile(fileObject, props.accept, props.maxDimensions); return fileObject; } /** * Prevent opening the file browser when no * more files can be added (until one file is removed). * @param event Triggered event */ function onInputClick(event: MouseEvent) { if (files.value?.length === props.maxCount) { event.preventDefault(); } } /** * Sets the file/files to track their progress while being processed * @param fileList File or list of files to be processed. */ async function onFileInput(fileList: FileList) { // Prevent adding many files if maxCount is 1 if (props.maxCount === 1 && fileList.length > props.maxCount) { resetEnterTarget(); resetDragState(); return; } if (isMultiple.value) { // Add the new files to the file selection. // Removing files should be done by interacting with the file list below the area. files.value = [...(files.value || []), ...Array.from(fileList).map(file => processFile(file))]; } else { files.value = [processFile(fileList[0])]; await resetFocus(); } emitUpdate(files.value); } /** * Handles the "dragenter" event, triggered when the mouse enters the draggable area. * Enables the dragging state and sets the dragging error to true to * prevent dropping files from being added if there's a general error. * @param event Drag event */ function onDragEnter(event: DragEvent) { // Prevent changing state if disabled if (props.disabled) { return; } /** * Allows internal components as part of the dragable area. * Prevents trigerring "dragleave" events on children elements. * Set the "dragarea" as target */ enterTarget.value = event.target; const totalFilesCount = (event.dataTransfer.items?.length ?? 0) + (files.value?.length ?? 0); onDragEnterValidations(totalFilesCount, event); } /** * Resets the reference to the target. * This helps preventing loosing the state when dragging over children */ function resetEnterTarget() { enterTarget.value = null; } /** * Handles the "dragleave" event, triggered when the mouse leaves the draggable area. * Maintains the dragging state while dragging over children. * Resets the states when the mouse leaves the draggable area * @param event Drag event */ function onDragLeave(event: DragEvent) { // If target enter and leave are the same it means the drag has left the "dragarea" if (enterTarget.value === event.target) { resetEnterTarget(); resetDragState(); } } /** * Handles the "dragover" event, triggered when the mouse moves over the draggable area. * Disables the cursor when the component is disabled. * @param event Drag event */ function onDragOver(event: DragEvent) { event.preventDefault(); // Disable drag n drop and change the cursor if disabled if (props.disabled) { // eslint-disable-next-line no-param-reassign event.dataTransfer.dropEffect = 'none'; } } /** * Hadles the "drop" event, triggered when files are dropped inside the draggable area. * Processes the files that are dropped. * @param event Drag event */ async function onDrop(event: DragEvent) { // Prevent events when the input is disabled if (props.disabled) { return; } if (!dragErrors.invalidTypes && event.dataTransfer.files?.length) { onFileInput(event.dataTransfer.files); } resetEnterTarget(); resetDragState(); } /** * Handles the "change" event over the "input" element. * Processes the files that are selected through the browser's file explorer. */ async function onInputChange() { if (inputRef.value.files?.length) { onFileInput(inputRef.value.files); } // Reset input value inputRef.value.value = ''; await resetFocus(); } /** End of drag and drop functions */ /** * Watches the modelValue and if set * and the type is not deprecated, it processes the files * but doesn't emit them as the user already has those files. */ watch( () => props.modelValue, (newValue: BentoFileUploaderValue) => { // Sets the new value if it is a `FileList`. This ignores deprecated value types. if (newValue && isFileList(newValue)) { const fileList = newValue as FileList; const fileListArray: Array<File> = Array.from(fileList); files.value = fileListArray.map(file => processFile(file)); } else if (!newValue && files.value) { // Clear the file list when the value is programmatically unset. files.value = null; } }, { immediate: true } ); function verifyFileCount(fileList: Array<FileObject>) { const isValid = props.maxCount && fileList.length <= props.maxCount; maxFileError.value = !isValid; } function verifyFileSize(fileList: Array<FileObject>) { if (!props.maxSize) { return; } fileList.forEach(file => { const item = file; let maxSizeLimit: number | undefined; if (Array.isArray(props.maxSize)) { const matchingEntry = (props.maxSize as Array<FileMaxSize>).find(entry => isFileTypeAccepted(item.data, entry.fileType) ); maxSizeLimit = matchingEntry?.maxSize; } else { maxSizeLimit = props.maxSize as number; } if (maxSizeLimit && item.data.size > maxSizeLimit) { item.state = FileState.ERROR; if (!item.errors.includes(FileErrorType.ERROR_SIZE)) { item.errors.push(FileErrorType.ERROR_SIZE); } } }); } function convertToFileList(fileList: Array<FileObject>) { // Filter out files with errors const validFiles = fileList.filter( ({ errors }) => !errors.includes(FileErrorType.ERROR_LOAD) && !errors.includes(FileErrorType.ERROR_SIZE) ); if (validFiles.length) { const dataTransfer = new DataTransfer(); validFiles.forEach(({ data, id }) => { dataTransfer.items.add(data); }); return dataTransfer.files; } return undefined; } </script> <script lang="ts"> /** * The file uploader component enables users to upload one or multiple files from their device to our system. * * @example * import { BentoFileUploader, type BentoFileValue } from '@adyen/bento-vue2'; * * export default { * components: { BentoFileUploader }, * template: ` * <bento-file-uploader * accept="image/*,video/*,.pdf,.png" * condensed * description="Custom supporting text" * disabled * errorMessage="Custom error message" * label="Bento file uploader" * :maxCount="1" * :maxDimensions="{ width: 200, height: 100 }" * :maxSize="1000" * v-model="files" * :optional="false" * required * tooltipText="Useful information" * /> * `, * setup() { * const files = ref<BentoFileValue>() * } * } */ export default { model: { prop: 'modelValue' }, i18n: { messages }, }; </script> <style lang="scss" scoped src="./file-uploader.scss" />
|
package/dist/assets/components/filter-bar/components/all-filters-modal/all-filters-modal.vue
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
<template> <div class="b-all-filters-modal"> <filter-bar-button :is-filter-open="isModalOpen" :applied-filters-count="numberOfActiveFilters" no-applied-state-styles @click="onClickAllFiltersModal" > {{ t('allFilters') }} <template #iconLeft> <sliders-icon aria-hidden="true" /> </template> </filter-bar-button> <bento-modal class="'b-all-filters-modal__modal'" :is-open="isModalOpen" :with-column-layout="true" size="large" :actions="modalActions" :is-dismissible="false" hide-close-button @close-modal="cancel" > <template #default> {{ t('allFilters') }} </template> <template #content-col-1> <div ref="allFiltersModalFiltersSection" class="b-all-filters-modal__filters" @keydown.right="onKeydownRight" > <bento-search-bar class="b-all-filters-modal__search-bar" :value="searchTerm" :input-field-aria-label="t('searchBar').toString()" @input="searchTerm = $event" @clear="onSearchbarClear" /> <div v-if="isEmptyResultForSearchFilter" class="b-all-filters-modal__empty-search-message"> <bento-typography el="span"> {{ t('noFiltersMatchThisSearch') }}</bento-typography> </div> <div v-if="activeFilters.length" class="b-all-filters-modal__filters-section"> <bento-typography :id="`active-filters-${allFiltersModalUniqueId}`" class="b-all-filters-modal__filters-section-title" el="span" variant="caption" > {{ t('activeFilters') }} </bento-typography> <ul class="b-all-filters-modal__filters-list" :aria-labelledby="`active-filters-${allFiltersModalUniqueId}`" > <li v-for="item in activeFilters" :key="`${item.label}-${item.field}`"> <all-filters-modal-button :ref="el => (allFiltersModalFilterButtons[item.label] = el)" :label="item.label" :applied-filters-count="getFilterCount(item)" :is-filter-open="selectedFilter.label === item.label" :disabled="item.disabled" @click="selectFilter(item.label, item.field)" /> </li> </ul> </div> <div v-if="otherFilters.length" class="b-all-filters-modal__filters-section"> <bento-typography v-if="activeFilters.length" :id="`other-filters-${allFiltersModalUniqueId}`" class="b-all-filters-modal__filters-section-title" el="span" variant="caption" > {{ t('otherFilters') }} </bento-typography> <ul class="b-all-filters-modal__filters-list" :aria-labelledby="`other-filters-${allFiltersModalUniqueId}`" > <li v-for="item in otherFilters" :key="`${item.label}-${item.field}`"> <all-filters-modal-button :ref="el => (allFiltersModalFilterButtons[item.label] = el)" :label="item.label" :disabled="item.disabled" :applied-filters-count="getFilterCount(item)" :is-filter-open="selectedFilter.label === item.label" @click="selectFilter(item.label, item.field)" /> </li> </ul> </div> </div> </template> <template #content-col-2> <div class="b-all-filters-modal__selected-filter-content" @keydown.left="onKeydownLeft"> <div v-if="shouldShowFilterTitle" class="b-all-filters-modal__selected-filter-text"> <bento-typography el="div" variant="title" medium> {{ selectedFilter.label }} </bento-typography> <bento-typography v-if="selectedFilter.description" el="div" variant="body" wide> {{ selectedFilter.description }} </bento-typography> </div> <div class="b-all-filters-modal__selected-filter-component"> <component :is="selectedFilter.type" ref="selectedFilterRef" :key="selectedFilter.field" :description="selectedFilter.description" :field="selectedFilter.field" :label="selectedFilter.label" :options="selectedFilter.options" :value="selectedFilter.value" :default-value="selectedFilter.defaultValue" :only-slot="true" :disabled="selectedFilter.disabled" @input="updateFilter" /> </div> <div class="b-all-filters-modal__clear-button"> <bento-button variant="secondary" :disabled="isClearButtonDisabled" @click="clearFilter">{{ t('clear') }}</bento-button> </div> </div> </template> </bento-modal> </div> </template> <script lang="ts"> import { computed, defineComponent, onBeforeUpdate, onMounted, type PropType, ref, toRef, watch } from 'vue'; import BentoModal from '@/components/modal/modal.vue'; import { BentoButton } from '@/components/button'; import { type BentoFilterBarModel, BentoFilterItemType, type BentoFilterModel, type BentoSelectFilterOptions, } from '@/components/filter-bar/filter-bar.types'; import { BentoBooleanFilter, BentoCheckboxGroupFilter, BentoDateFilter, BentoDateRangeFilter, BentoInputFilter, BentoInputWithDropdownFilter, BentoRadioGroupFilter, BentoRangeFilter, BentoSelectAvatarFilter, BentoSelectCountryFilter, BentoSelectCurrencyFilter, BentoSelectFilter, BentoSelectPaymentMethodFilter, BentoSelectTagFilter, BentoToggleFilter, } from '../index'; import { type BentoDateRangePickerValue } from '@/components/date-range-picker'; import BentoSearchBar from '@/components/search-bar/search-bar.vue'; import { BentoTypography } from '@/components/typography'; import { useSearchBarFilter } from '@/components/search-bar'; import { generateUid } from '@/core/utils/ts'; import { getNextFocusableElement } from '@/utils/ts/focus-trap.utils'; import { FilterBarButton } from '../filter-bar-button'; import { AllFiltersModalEvent } from './all-filters-modal.types'; import { useI18n } from '@/utils/ts/i18n'; import AllFiltersModalButton from './components/all-filters-modal-button/all-filters-modal-button.vue'; import SlidersIcon from '@adyen/ui-assets-icons-16/vue/sliders-1'; import messages from './messages.json'; import { type BentoCheckboxGroupFilterOptions } from '../checkbox-group-filter'; type MessageSchema = (typeof messages)['en-US']; /** * A modal that acts as sort of filter manager for the BentoFilterBar. * * @example * import { BentoAllFiltersModal } from '@adyen/bento-vue2'; * * export default { * components: { BentoAllFiltersModal }, * template: ` * <bento-all-filters-modal> * ...Example code of the component * </bento-all-filters-modal> * ` * } */ export default defineComponent({ i18n: { messages }, name: 'all-filters-modal', components: { AllFiltersModalButton, BentoBooleanFilter, BentoButton, BentoCheckboxGroupFilter, BentoDateFilter, BentoDateRangeFilter, BentoInputFilter, BentoInputWithDropdownFilter, BentoModal, BentoRadioGroupFilter, BentoRangeFilter, BentoSearchBar, BentoSelectAvatarFilter, BentoSelectCurrencyFilter, BentoSelectCountryFilter, BentoSelectFilter, BentoSelectPaymentMethodFilter, BentoSelectTagFilter, BentoToggleFilter, BentoTypography, FilterBarButton, SlidersIcon, }, props: { /** * Array of filters based on {@see FilterBarModel}. * Each one of the elements in the array correspond to a filter that will * be created and rendered in the AllFiltersModal. * * 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, * }] */ value: { type: Array as PropType<BentoFilterBarModel>, default: () => [], }, }, emits: [AllFiltersModalEvent.INPUT], setup(props, { emit }) { const filtersInModal = ref(props.value); const allFiltersModalUniqueId = generateUid('all-filters-modal'); const allFiltersModalFiltersSection = ref([]); const isModalOpen = ref(false); const selectedFilterIndex = ref(0); const selectedFilterRef = ref(null); const searchTerm = ref<string>(''); const { t, n } = useI18n<{ message: MessageSchema }>({ messages }); const filteredItems = useSearchBarFilter<BentoFilterModel>(searchTerm, toRef(filtersInModal, 'value')); const allFiltersModalFilterButtons = ref({}); onBeforeUpdate(() => { allFiltersModalFilterButtons.value = []; }); watch( () => props.value, () => { /** * Values need to be preserve when the modal is opened given that * when the "select" filter is enabled with external filtering inside the modal * the "searchTerm" gets reset and doing so resets all the unsaved values of the array */ if (isModalOpen.value) { filtersInModal.value = props.value.map((filter, index) => { return { ...filter, value: filtersInModal.value[index].value }; }); } else { filtersInModal.value = props.value; } } ); const sortAlphabetically = (array: BentoFilterBarModel) => array.sort((a, b) => (a.label > b.label ? 1 : b.label > a.label ? -1 : 0)); const activeFilters = computed(() => sortAlphabetically( filteredItems.value.filter(el => Array.isArray(el.value) ? el.value?.length && el.value?.find(item => item !== '') : el.value ) ) ); const otherFilters = computed(() => sortAlphabetically( filteredItems.value.filter(el => Array.isArray(el.value) ? el.value?.length === 0 || el.value?.every(item => item === '') : !el.value ) ) ); const isEmptyResultForSearchFilter = computed(() => searchTerm.value && filteredItems.value?.length === 0); const selectedFilter = computed(() => filtersInModal.value[selectedFilterIndex.value]); const selectOpenedFilter = () => { const openedFilter = activeFilters.value.length ? activeFilters.value[0] : otherFilters.value[0]; const openedFilterIndex = filtersInModal.value.findIndex( el => el.label === openedFilter.label && el.field === openedFilter.field ); selectedFilterIndex.value = openedFilterIndex; }; onMounted(() => { selectOpenedFilter(); }); const computeCounts = (filterValue: BentoFilterBarModel) => filterValue.map(filter => { switch (filter.type) { case BentoFilterItemType.SELECT: case BentoFilterItemType.SELECT_AVATAR: case BentoFilterItemType.SELECT_COUNTRY: case BentoFilterItemType.SELECT_CURRENCY: case BentoFilterItemType.SELECT_PAYMENT_METHOD: case BentoFilterItemType.SELECT_TAG: switch ((filter.options as BentoSelectFilterOptions)?.multiple) { case true: return { label: filter.label, field: filter.field, count: (filter.value as Array<string | number>)?.length ? (filter.value as Array<string | number>)?.length : null, }; default: return { label: filter.label, field: filter.field, count: filter.value == null ? null : 1, }; } case BentoFilterItemType.DATE_RANGE: return { label: filter.label, field: filter.field, count: (filter.value as BentoDateRangePickerValue)?.startDate != null && (filter.value as BentoDateRangePickerValue)?.endDate != null ? 1 : null, }; case BentoFilterItemType.RANGE: return { label: filter.label, field: filter.field, count: (filter.value as Array<string>)?.find(el => el !== '') ? 1 : null, }; case BentoFilterItemType.CHECKBOX_GROUP: return { label: filter.label, field: filter.field, count: (filter.value as Array<string | number>)?.length ? (filter.value as Array<string | number>)?.length : null, }; case BentoFilterItemType.TOGGLE: return { label: filter.label, field: filter.field, count: filter.value ? 1 : null, }; case BentoFilterItemType.INPUT: case BentoFilterItemType.BOOLEAN: case BentoFilterItemType.DATE: case BentoFilterItemType.RADIO_GROUP: default: return { label: filter.label, field: filter.field, count: filter.value != null ? 1 : null, }; } }); const hasValueSelected = computed(() => Array.isArray(selectedFilter.value.value) ? selectedFilter.value?.value.length && selectedFilter.value.value.find(item => item !== '') : !!filtersInModal.value[selectedFilterIndex.value].value ); /** * Hides filter title if the selected filter is Toggle. * The Toggle filter shows a checkbox next to the "title". */ const shouldShowFilterTitle = computed(() => { return selectedFilter.value.type !== BentoFilterItemType.TOGGLE; }); const isClearButtonDisabled = computed(() => !hasValueSelected.value || selectedFilter.value.disabled); const numberOfActiveFilters = computed( () => computeCounts(props.value)?.filter(({ count }) => !!count).length ); const onClickAllFiltersModal = () => { if (!isModalOpen.value) { isModalOpen.value = true; } else { isModalOpen.value = false; } }; const getFilterCount = (filter: BentoFilterModel) => { const filterCount = computeCounts(filtersInModal.value).find(el => el.field === filter.field).count; const isBentoSelectFilter = typeof filter.type === 'string' && // Type guard to avoid casting filter.type.includes('BentoSelect') && (filter.options as BentoSelectFilterOptions)?.multiple && filterCount === (filter.options as BentoSelectFilterOptions)?.listboxItems?.length; const isBentoCheckboxGroupFilter = typeof filter.type === 'string' && filter.type === BentoFilterItemType.CHECKBOX_GROUP && filterCount === (filter.options as BentoCheckboxGroupFilterOptions)?.checkboxItems?.length; if (isBentoSelectFilter || isBentoCheckboxGroupFilter) { return t('all'); } return filterCount === null ? null : n(filterCount); }; const selectFilter = (filterLabel: string, filterField: string) => { selectedFilterIndex.value = filtersInModal.value.findIndex( el => el.label === filterLabel && el.field === filterField ); }; const onSearchbarClear = () => { searchTerm.value = ''; }; const cancel = () => { isModalOpen.value = false; filtersInModal.value = props.value; onSearchbarClear(); selectOpenedFilter(); // TODO: scroll filters section to top }; const updateFilter = (updatedFilter: BentoFilterModel) => { const updatedFilters = filtersInModal.value.map(({ value, ...otherFields }, index) => { if (index === selectedFilterIndex.value) { return { value: updatedFilter.value, ...otherFields }; } return { value, ...otherFields }; }); filtersInModal.value = updatedFilters; }; const clearFilter = () => { updateFilter({ field: selectedFilter.value.field, label: selectedFilter.value.label, type: selectedFilter.value.type, value: undefined, }); }; const clearAll = () => { filtersInModal.value = props.value.map(({ value, ...otherFields }) => ({ ...otherFields, value: undefined, })); }; const applyAll = () => { emit(AllFiltersModalEvent.INPUT, filtersInModal.value); isModalOpen.value = false; }; const isApplyAllButtonDisabled = computed( () => JSON.stringify(filtersInModal.value) === JSON.stringify(props.value) ); const modalActions = computed(() => [ { title: t('applyAll'), disabled: isApplyAllButtonDisabled.value, event: applyAll, }, { title: t('cancel'), event: cancel, }, { title: t('clearAll'), disabled: !numberOfActiveFilters.value, event: clearAll, }, ]); const onKeydownRight = () => { const focusableEl = getNextFocusableElement(1, selectedFilterRef.value.$el); focusableEl.focus(); }; const onKeydownLeft = () => { allFiltersModalFilterButtons.value[selectedFilter.value.label].$el.focus(); }; return { // Values allFiltersModalUniqueId, allFiltersModalFiltersSection, allFiltersModalFilterButtons, numberOfActiveFilters, isEmptyResultForSearchFilter, isModalOpen, isClearButtonDisabled, activeFilters, otherFilters, modalActions, selectedFilter, selectedFilterRef, searchTerm, shouldShowFilterTitle, // Methods onClickAllFiltersModal, getFilterCount, cancel, selectFilter, clearFilter, updateFilter, onSearchbarClear, onKeydownRight, onKeydownLeft, // Translations t, }; }, }); </script> <style lang="scss" scoped src="./all-filters-modal.scss" />
|
|
1
|
+
<template> <div class="b-all-filters-modal"> <filter-bar-button :is-filter-open="isModalOpen" :applied-filters-count="numberOfActiveFilters" no-applied-state-styles @click="onClickAllFiltersModal" > {{ t('allFilters') }} <template #iconLeft> <sliders-icon aria-hidden="true" /> </template> </filter-bar-button> <bento-modal class="'b-all-filters-modal__modal'" :is-open="isModalOpen" :with-column-layout="true" size="large" :actions="modalActions" :is-dismissible="false" hide-close-button @close-modal="cancel" > <template #default> {{ t('allFilters') }} </template> <template #content-col-1> <div ref="allFiltersModalFiltersSection" class="b-all-filters-modal__filters" @keydown.right="onKeydownRight" > <bento-search-bar class="b-all-filters-modal__search-bar" :value="searchTerm" :input-field-aria-label="t('searchBar').toString()" @input="searchTerm = $event" @clear="onSearchbarClear" /> <div v-if="isEmptyResultForSearchFilter" class="b-all-filters-modal__empty-search-message"> <bento-typography el="span"> {{ t('noFiltersMatchThisSearch') }}</bento-typography> </div> <div v-if="activeFilters.length" class="b-all-filters-modal__filters-section"> <bento-typography :id="`active-filters-${allFiltersModalUniqueId}`" class="b-all-filters-modal__filters-section-title" el="span" variant="caption" > {{ t('activeFilters') }} </bento-typography> <ul class="b-all-filters-modal__filters-list" :aria-labelledby="`active-filters-${allFiltersModalUniqueId}`" > <li v-for="item in activeFilters" :key="`${item.label}-${item.field}`"> <all-filters-modal-button :ref="el => (allFiltersModalFilterButtons[item.label] = el)" :label="item.label" :applied-filters-count="getFilterCount(item)" :is-filter-open="selectedFilter.label === item.label" :disabled="item.disabled" @click="selectFilter(item.label, item.field)" /> </li> </ul> </div> <div v-if="otherFilters.length" class="b-all-filters-modal__filters-section"> <bento-typography v-if="activeFilters.length" :id="`other-filters-${allFiltersModalUniqueId}`" class="b-all-filters-modal__filters-section-title" el="span" variant="caption" > {{ t('otherFilters') }} </bento-typography> <ul class="b-all-filters-modal__filters-list" :aria-labelledby="`other-filters-${allFiltersModalUniqueId}`" > <li v-for="item in otherFilters" :key="`${item.label}-${item.field}`"> <all-filters-modal-button :ref="el => (allFiltersModalFilterButtons[item.label] = el)" :label="item.label" :disabled="item.disabled" :applied-filters-count="getFilterCount(item)" :is-filter-open="selectedFilter.label === item.label" @click="selectFilter(item.label, item.field)" /> </li> </ul> </div> </div> </template> <template #content-col-2> <div class="b-all-filters-modal__selected-filter-content" @keydown.left="onKeydownLeft"> <div v-if="shouldShowFilterTitle" class="b-all-filters-modal__selected-filter-text"> <bento-typography :id="selectedFilter.labelId" el="div" variant="title" medium> {{ selectedFilter.label }} </bento-typography> <bento-typography v-if="selectedFilter.description" el="div" variant="body" wide> {{ selectedFilter.description }} </bento-typography> </div> <div class="b-all-filters-modal__selected-filter-component"> <component :is="selectedFilter.type" ref="selectedFilterRef" :key="selectedFilter.field" :description="selectedFilter.description" :field="selectedFilter.field" :label="selectedFilter.label" :options="selectedFilter.options" :value="selectedFilter.value" :default-value="selectedFilter.defaultValue" :only-slot="true" :disabled="selectedFilter.disabled" :aria-labelledby="selectedFilter.labelId" @input="updateFilter" /> </div> <div class="b-all-filters-modal__clear-button"> <bento-button variant="secondary" :disabled="isClearButtonDisabled" @click="clearFilter">{{ t('clear') }}</bento-button> </div> </div> </template> </bento-modal> </div> </template> <script lang="ts"> import { computed, defineComponent, onBeforeUpdate, onMounted, type PropType, ref, toRef, watch } from 'vue'; import BentoModal from '@/components/modal/modal.vue'; import { BentoButton } from '@/components/button'; import { type BentoFilterBarModel, BentoFilterItemType, type BentoFilterModel, type BentoSelectFilterOptions, } from '@/components/filter-bar/filter-bar.types'; import { BentoBooleanFilter, BentoCheckboxGroupFilter, BentoDateFilter, BentoDateRangeFilter, BentoInputFilter, BentoInputWithDropdownFilter, BentoRadioGroupFilter, BentoRangeFilter, BentoSelectAvatarFilter, BentoSelectCountryFilter, BentoSelectCurrencyFilter, BentoSelectFilter, BentoSelectPaymentMethodFilter, BentoSelectTagFilter, BentoToggleFilter, } from '../index'; import { type BentoDateRangePickerValue } from '@/components/date-range-picker'; import BentoSearchBar from '@/components/search-bar/search-bar.vue'; import { BentoTypography } from '@/components/typography'; import { useSearchBarFilter } from '@/components/search-bar'; import { generateUid } from '@/core/utils/ts'; import { getNextFocusableElement } from '@/utils/ts/focus-trap.utils'; import { FilterBarButton } from '../filter-bar-button'; import { AllFiltersModalEvent } from './all-filters-modal.types'; import { useI18n } from '@/utils/ts/i18n'; import AllFiltersModalButton from './components/all-filters-modal-button/all-filters-modal-button.vue'; import SlidersIcon from '@adyen/ui-assets-icons-16/vue/sliders-1'; import messages from './messages.json'; import { type BentoCheckboxGroupFilterOptions } from '../checkbox-group-filter'; type MessageSchema = (typeof messages)['en-US']; /** * A modal that acts as sort of filter manager for the BentoFilterBar. * * @example * import { BentoAllFiltersModal } from '@adyen/bento-vue2'; * * export default { * components: { BentoAllFiltersModal }, * template: ` * <bento-all-filters-modal> * ...Example code of the component * </bento-all-filters-modal> * ` * } */ export default defineComponent({ i18n: { messages }, name: 'all-filters-modal', components: { AllFiltersModalButton, BentoBooleanFilter, BentoButton, BentoCheckboxGroupFilter, BentoDateFilter, BentoDateRangeFilter, BentoInputFilter, BentoInputWithDropdownFilter, BentoModal, BentoRadioGroupFilter, BentoRangeFilter, BentoSearchBar, BentoSelectAvatarFilter, BentoSelectCurrencyFilter, BentoSelectCountryFilter, BentoSelectFilter, BentoSelectPaymentMethodFilter, BentoSelectTagFilter, BentoToggleFilter, BentoTypography, FilterBarButton, SlidersIcon, }, props: { /** * Array of filters based on {@see FilterBarModel}. * Each one of the elements in the array correspond to a filter that will * be created and rendered in the AllFiltersModal. * * 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, * }] */ value: { type: Array as PropType<BentoFilterBarModel>, default: () => [], }, }, emits: [AllFiltersModalEvent.INPUT], setup(props, { emit }) { const filtersInModal = ref(props.value); const allFiltersModalUniqueId = generateUid('all-filters-modal'); const allFiltersModalFiltersSection = ref([]); const isModalOpen = ref(false); const selectedFilterIndex = ref(0); const selectedFilterRef = ref(null); const searchTerm = ref<string>(''); const { t, n } = useI18n<{ message: MessageSchema }>({ messages }); const filteredItems = useSearchBarFilter<BentoFilterModel>(searchTerm, toRef(filtersInModal, 'value')); const allFiltersModalFilterButtons = ref({}); onBeforeUpdate(() => { allFiltersModalFilterButtons.value = []; }); watch( () => props.value, () => { /** * Values need to be preserve when the modal is opened given that * when the "select" filter is enabled with external filtering inside the modal * the "searchTerm" gets reset and doing so resets all the unsaved values of the array */ if (isModalOpen.value) { filtersInModal.value = props.value.map((filter, index) => { return { ...filter, value: filtersInModal.value[index].value }; }); } else { filtersInModal.value = props.value; } } ); const sortAlphabetically = (array: BentoFilterBarModel) => array.sort((a, b) => (a.label > b.label ? 1 : b.label > a.label ? -1 : 0)); const activeFilters = computed(() => sortAlphabetically( filteredItems.value.filter(el => Array.isArray(el.value) ? el.value?.length && el.value?.find(item => item !== '') : el.value ) ) ); const otherFilters = computed(() => sortAlphabetically( filteredItems.value.filter(el => Array.isArray(el.value) ? el.value?.length === 0 || el.value?.every(item => item === '') : !el.value ) ) ); const isEmptyResultForSearchFilter = computed(() => searchTerm.value && filteredItems.value?.length === 0); const selectedFilter = computed(() => ({ ...filtersInModal.value[selectedFilterIndex.value], labelId: generateUid(filtersInModal.value[selectedFilterIndex.value]?.field), })); const selectOpenedFilter = () => { const openedFilter = activeFilters.value.length ? activeFilters.value[0] : otherFilters.value[0]; const openedFilterIndex = filtersInModal.value.findIndex( el => el.label === openedFilter.label && el.field === openedFilter.field ); selectedFilterIndex.value = openedFilterIndex; }; onMounted(() => { selectOpenedFilter(); }); const computeCounts = (filterValue: BentoFilterBarModel) => filterValue.map(filter => { switch (filter.type) { case BentoFilterItemType.SELECT: case BentoFilterItemType.SELECT_AVATAR: case BentoFilterItemType.SELECT_COUNTRY: case BentoFilterItemType.SELECT_CURRENCY: case BentoFilterItemType.SELECT_PAYMENT_METHOD: case BentoFilterItemType.SELECT_TAG: switch ((filter.options as BentoSelectFilterOptions)?.multiple) { case true: return { label: filter.label, field: filter.field, count: (filter.value as Array<string | number>)?.length ? (filter.value as Array<string | number>)?.length : null, }; default: return { label: filter.label, field: filter.field, count: filter.value == null ? null : 1, }; } case BentoFilterItemType.DATE_RANGE: return { label: filter.label, field: filter.field, count: (filter.value as BentoDateRangePickerValue)?.startDate != null && (filter.value as BentoDateRangePickerValue)?.endDate != null ? 1 : null, }; case BentoFilterItemType.RANGE: return { label: filter.label, field: filter.field, count: (filter.value as Array<string>)?.find(el => el !== '') ? 1 : null, }; case BentoFilterItemType.CHECKBOX_GROUP: return { label: filter.label, field: filter.field, count: (filter.value as Array<string | number>)?.length ? (filter.value as Array<string | number>)?.length : null, }; case BentoFilterItemType.TOGGLE: return { label: filter.label, field: filter.field, count: filter.value ? 1 : null, }; case BentoFilterItemType.INPUT: case BentoFilterItemType.BOOLEAN: case BentoFilterItemType.DATE: case BentoFilterItemType.RADIO_GROUP: default: return { label: filter.label, field: filter.field, count: filter.value != null ? 1 : null, }; } }); const hasValueSelected = computed(() => Array.isArray(selectedFilter.value.value) ? selectedFilter.value?.value.length && selectedFilter.value.value.find(item => item !== '') : !!filtersInModal.value[selectedFilterIndex.value].value ); /** * Hides filter title if the selected filter is Toggle. * The Toggle filter shows a checkbox next to the "title". */ const shouldShowFilterTitle = computed(() => { return selectedFilter.value.type !== BentoFilterItemType.TOGGLE; }); const isClearButtonDisabled = computed(() => !hasValueSelected.value || selectedFilter.value.disabled); const numberOfActiveFilters = computed( () => computeCounts(props.value)?.filter(({ count }) => !!count).length ); const onClickAllFiltersModal = () => { if (!isModalOpen.value) { isModalOpen.value = true; } else { isModalOpen.value = false; } }; const getFilterCount = (filter: BentoFilterModel) => { const filterCount = computeCounts(filtersInModal.value).find(el => el.field === filter.field).count; const isBentoSelectFilter = typeof filter.type === 'string' && // Type guard to avoid casting filter.type.includes('BentoSelect') && (filter.options as BentoSelectFilterOptions)?.multiple && filterCount === (filter.options as BentoSelectFilterOptions)?.listboxItems?.length; const isBentoCheckboxGroupFilter = typeof filter.type === 'string' && filter.type === BentoFilterItemType.CHECKBOX_GROUP && filterCount === (filter.options as BentoCheckboxGroupFilterOptions)?.checkboxItems?.length; if (isBentoSelectFilter || isBentoCheckboxGroupFilter) { return t('all'); } return filterCount === null ? null : n(filterCount); }; const selectFilter = (filterLabel: string, filterField: string) => { selectedFilterIndex.value = filtersInModal.value.findIndex( el => el.label === filterLabel && el.field === filterField ); }; const onSearchbarClear = () => { searchTerm.value = ''; }; const cancel = () => { isModalOpen.value = false; filtersInModal.value = props.value; onSearchbarClear(); selectOpenedFilter(); // TODO: scroll filters section to top }; const updateFilter = (updatedFilter: BentoFilterModel) => { const updatedFilters = filtersInModal.value.map(({ value, ...otherFields }, index) => { if (index === selectedFilterIndex.value) { return { value: updatedFilter.value, ...otherFields }; } return { value, ...otherFields }; }); filtersInModal.value = updatedFilters; }; const clearFilter = () => { updateFilter({ field: selectedFilter.value.field, label: selectedFilter.value.label, type: selectedFilter.value.type, value: undefined, }); }; const clearAll = () => { filtersInModal.value = props.value.map(({ value, ...otherFields }) => ({ ...otherFields, value: undefined, })); }; const applyAll = () => { emit(AllFiltersModalEvent.INPUT, filtersInModal.value); isModalOpen.value = false; }; const isApplyAllButtonDisabled = computed( () => JSON.stringify(filtersInModal.value) === JSON.stringify(props.value) ); const modalActions = computed(() => [ { title: t('applyAll'), disabled: isApplyAllButtonDisabled.value, event: applyAll, }, { title: t('cancel'), event: cancel, }, { title: t('clearAll'), disabled: !numberOfActiveFilters.value, event: clearAll, }, ]); const onKeydownRight = () => { const focusableEl = getNextFocusableElement(1, selectedFilterRef.value.$el); focusableEl.focus(); }; const onKeydownLeft = () => { allFiltersModalFilterButtons.value[selectedFilter.value.label].$el.focus(); }; return { // Values allFiltersModalUniqueId, allFiltersModalFiltersSection, allFiltersModalFilterButtons, numberOfActiveFilters, isEmptyResultForSearchFilter, isModalOpen, isClearButtonDisabled, activeFilters, otherFilters, modalActions, selectedFilter, selectedFilterRef, searchTerm, shouldShowFilterTitle, // Methods onClickAllFiltersModal, getFilterCount, cancel, selectFilter, clearFilter, updateFilter, onSearchbarClear, onKeydownRight, onKeydownLeft, // Translations t, }; }, }); </script> <style lang="scss" scoped src="./all-filters-modal.scss" />
|
package/dist/assets/components/filter-bar/components/date-range-filter/date-range-filter.vue
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
<template> <bento-base-filter ref="baseFilter" class="b-base-filter-date-range-filter" :button-label="buttonLabel" :button-secondary-label="buttonSecondaryLabel" :controlled="!onlySlot" :disabled="disabled" :disable-apply-button="isApplyButtonDisabled" :disable-secondary-button="isSecondaryButtonDisabled" :default-value="defaultValue" :field="field" :label="label" :only-slot="onlySlot" :tooltip-position="options?.tooltipPosition" :tooltip-text="options?.tooltipText" fit-content @open="openFilter" @clear="clearFilter" @reset="resetFilter" @cancel="onCancel" > <bento-date-range-picker v-if="onlySlot" :allow-time-input="options?.allowTimeInput" :model-value="internalFilterValue" :first-day-of-week="options?.firstDayOfWeek" :is-date-disabled="options?.isDateDisabled" :number-of-months="options?.numberOfMonths" :quick-select-ranges="options?.quickSelectRanges" :variant="options?.variant" :disabled="disabled" :granularities="options?.granularities" :max-range="options?.maxRange" :min="options?.min" :max="options?.max" :date-form-data="formData" :show-end-date-on-open="options?.showEndDateOnOpen" @update:model-value="onDateInputSelected" /> <template v-else> <div class="b-date-range-filter"> <date-range-picker-calendar :allow-time-input="options?.allowTimeInput" :first-day-of-week="options?.firstDayOfWeek" :is-date-disabled="options?.isDateDisabled" :granularities="options?.granularities" :max-range="options?.maxRange" :min="options?.min" :max="options?.max" :number-of-months="options?.numberOfMonths" :quick-select-ranges="adjustedQuickSelectRanges" :value="internalFilterValue" :date-form-data="formData" :show-end-date-on-open="options?.showEndDateOnOpen" :variant="options?.variant" @input="onDateSelected" @custom-range="onRangeSelectorInput" @error="onDatePickerRangeError" @form-date="onDatePickerRangeFormDateInputUpdate" @start-date-selected="isSelectingDate = true" > <template #title> <bento-typography strongest variant="body" el="span"> {{ label }} </bento-typography> </template> <template #actions> <bento-divider class="b-date-range-filter__divider" /> <bento-button-actions class="b-date-range-filter__actions" :actions="actions" layout="space-between" /> </template> </date-range-picker-calendar> </div> </template> </bento-base-filter> </template> <script lang="ts"> import { computed, defineComponent, type PropType, reactive, ref, toRef, watch } from 'vue'; import { BentoBaseFilter, useBaseFilter } from '../base-filter'; import { type BentoDateRangeFilterOptions, type BentoDateRangeFilterValue, BentoFilterEvent, } from '../../filter-bar.types'; import { DateRangePickerCalendar } from '@/components/date-range-picker/components/date-range-picker-calendar'; import { BentoDateRangePicker, type BentoDateRangePickerValue } from '@/components/date-range-picker'; import { BentoDivider } from '@/components/divider'; import { BentoTypography } from '@/components/typography'; import { BentoButtonActions, type BentoButtonActionsList } from '@/components/button/components/button-actions'; import { useI18n } from '@/utils/ts/i18n'; import { getDateLocale } from '@/utils/ts/get-date-locale/get-date-locale'; import { isSameDay } from 'date-fns/isSameDay'; import { format } from 'date-fns/format'; import { isEqual } from 'date-fns/isEqual'; import { isToday } from 'date-fns/isToday'; import { dateToInputDateString, dateToTimeInputString } from '@/utils/ts/format-date'; import messages from './messages.json'; import { type DateRangePickerCalendarFormData, type DateRangePickerCalendarRangeSelectorItem, type DateRangePickerCalendarRangeSelectorItems, } from '@/components/date-range-picker/components/date-range-picker-calendar/date-range-picker-calendar.types'; type MessageSchema = (typeof messages)['en-US']; /** * The Date filter displays a calendar that allows * to select a single date. * * @example * import { BentoDateRangeFilter } from '@adyen/bento-vue2'; * * export default { * components: { BentoDateRangeFilter }, * template: ` * <bento-date-range-filter * :field="filter.field" * :label="filter.label" * :options="filter.options" * :value="filter.value" * @update="updateFilterHandler" * > * ` * } */ export default defineComponent({ i18n: { messages }, name: 'bento-date-range-filter', components: { BentoBaseFilter, BentoButtonActions, BentoDateRangePicker, BentoTypography, BentoDivider, DateRangePickerCalendar, }, props: { /** * Disables the filter from being interacted with. */ disabled: { type: Boolean, default: false }, /** * Default value of the filter to reset to. */ defaultValue: { type: Object as PropType<BentoDateRangeFilterValue>, default: null }, /** * Filter name/key referencing the actual property name in the data set. * Should be unique per filter bar. */ field: { type: String, required: true }, /** * Label text to be displayed */ label: { type: String, required: true }, /** * Renders the base filter's slot content only - leaving out the button and the popover. * Used for the allFiltersModal component. */ onlySlot: { type: Boolean, default: false }, /** * List of options that are needed to setup the filter * Available options: * - allowTimeInput: Allows user to enter time values in the form * - firstDayOfWeek: Allows user to set the first day of the week. 0 is sunday, 1 is monday, 6 is saturday * - isDateDisabled: Indicate if a date should be disabled or not * - maxRange: Set a maximum number of dates to be selectable by the range * - numberOfMonths: Number of months rendered on pane * - quickSelectRanges: An array that sets the custom range dropdown items. @see BentoDateRangePicker * - showEndDateOnOpen: Upon opening date range picker, end date's month will be pre-selected. * - granularities: A list of available granularities. * - variant: The type of calendar to display. Defaults to showing days. * - tooltipPosition: The position of the tooltip attached to the filter button * - tooltipText: The content of the tooltip attached to the filter button */ options: { type: Object as PropType<BentoDateRangeFilterOptions>, default: undefined, }, /** * Current value of the filter */ value: { type: Object as PropType<BentoDateRangeFilterValue>, default: () => ({ startDate: undefined, endDate: undefined }), }, }, emits: [BentoFilterEvent.INPUT, BentoFilterEvent.UPDATE], setup(props, { emit }) { const { locale, t } = useI18n<{ message: MessageSchema }>({ messages }); const locales = getDateLocale(); const { internalFilterValue, isSecondaryButtonDisabled, isApplyButtonDisabled } = useBaseFilter( toRef(props, 'value'), toRef(props, 'defaultValue') ); const isSelectingDate = ref(false); const adjustedQuickSelectRanges = computed<Array<DateRangePickerCalendarRangeSelectorItem>>(() => { return ( props?.options?.quickSelectRanges && props.options.quickSelectRanges.map((range: DateRangePickerCalendarRangeSelectorItem) => { const timeDiff = !range.data.endDate ? new Date(Date.now()).getTime() - range.data.startDate.getTime() : undefined; return { ...range, data: { ...range.data, // Adding timeDifference to calculate an up-to-date endDate on range selection timeDifference: timeDiff, }, }; }) ); }); const baseFilter = ref(null); const isDateIncorrect = ref(false); const formData = reactive({ startDate: dateToInputDateString(props.value?.startDate), endDate: dateToInputDateString(props.value?.endDate), startTime: dateToTimeInputString(props.value?.startDate), endTime: dateToTimeInputString(props.value?.endDate), }); watch( () => props.value, () => { formData.startDate = dateToInputDateString(props.value?.startDate); formData.endDate = dateToInputDateString(props.value?.endDate); formData.startTime = dateToTimeInputString(props.value?.startDate); formData.endTime = dateToTimeInputString(props.value?.endDate); } ); function isQuickSelectAnArray( quickSelectRanges: BentoDateRangeFilterOptions['quickSelectRanges'] ): quickSelectRanges is DateRangePickerCalendarRangeSelectorItems { return (quickSelectRanges as DateRangePickerCalendarRangeSelectorItems)?.length !== undefined; } const calculateSecondaryLabel = (currentValue: BentoDateRangeFilterValue) => { if (!currentValue || (!currentValue?.startDate && !currentValue.endDate)) { return null; } // If quick select range selected then display its label if ( isQuickSelectAnArray(props.options?.quickSelectRanges) && props.options?.quickSelectRanges?.length > 0 ) { const customRangeSelected = props.options?.quickSelectRanges?.find(({ value, data }) => { if (currentValue) { if (currentValue.range) { return value === currentValue.range; } if (!currentValue.startDate || !currentValue.endDate) { return false; } if (!data?.endDate) { // Autoselecting the correct value for open-ended ranges return ( isEqual(data?.startDate, currentValue.startDate) && isToday(currentValue.endDate) ); } // If no range selected but start and end dates correspond to one of the preselected ranges, select it return ( isEqual(data?.startDate, currentValue.startDate) && isEqual(data?.endDate, currentValue.endDate) ); } return false; }); if (customRangeSelected) { return customRangeSelected.label; } } if (!currentValue.startDate || !currentValue.endDate) { return null; } return isSameDay(currentValue.startDate, currentValue.endDate) && props?.options?.allowTimeInput ? `${format(currentValue.startDate, 'dd MMM, yyyy HH:mm:ss', { locale: locales[locale.value?.substring(0, 2)] || locales.enUS, })} - ${format(currentValue.endDate, 'HH:mm:ss', { locale: locales[locale.value?.substring(0, 2)] || locales.enUS, })}` : `${format(currentValue.startDate, 'dd MMM, yyyy', { locale: locales[locale.value?.substring(0, 2)] || locales.enUS, })} - ${format(currentValue.endDate, 'dd MMM, yyyy', { locale: locales[locale.value?.substring(0, 2)] || locales.enUS, })}`; }; const buttonSecondaryLabel = ref(calculateSecondaryLabel(props.value)); const buttonLabel = computed(() => props.label); const updateFilter = () => { baseFilter.value.closePopover(); // Omit the onlySlot prop as it is not needed const { onlySlot, ...cleanedProps } = props; emit(BentoFilterEvent.UPDATE, { ...cleanedProps, value: internalFilterValue.value }); }; const clearFilter = () => { internalFilterValue.value = undefined; updateFilter(); }; const resetFilter = () => { internalFilterValue.value = props.defaultValue; updateFilter(); }; const openFilter = () => { internalFilterValue.value = props.value; }; // If we click outside or press escape this should cancel and reset the form inputs // set all internal states back to their initial values const onCancel = () => { internalFilterValue.value = props.value; isSelectingDate.value = false; formData.startDate = dateToInputDateString(props.value?.startDate); formData.endDate = dateToInputDateString(props.value?.endDate); formData.startTime = dateToTimeInputString(props.value?.startDate); formData.endTime = dateToTimeInputString(props.value?.endDate); }; const onDateSelected = (selectedDate: BentoDateRangePickerValue) => { isSelectingDate.value = false; internalFilterValue.value = selectedDate; formData.startDate = dateToInputDateString(selectedDate.startDate); formData.endDate = dateToInputDateString(selectedDate.endDate); emit(BentoFilterEvent.INPUT, { ...props, value: internalFilterValue.value }); }; const onRangeSelectorInput = (quickSelectRanges?: { startDate: Date; endDate: Date }) => { if (quickSelectRanges?.startDate && quickSelectRanges?.endDate) { internalFilterValue.value = quickSelectRanges; formData.startDate = dateToInputDateString(quickSelectRanges.startDate); formData.endDate = dateToInputDateString(quickSelectRanges.endDate); emit(BentoFilterEvent.INPUT, { ...props, value: internalFilterValue.value }); } }; // Need to update the internal value and emit the date when using the date-picker input const onDateInputSelected = (selectedDate: BentoDateRangePickerValue) => { onDateSelected(selectedDate); emit(BentoFilterEvent.INPUT, { ...props, value: internalFilterValue.value }); }; const actions = computed( () => [ { title: t('apply') as string, event: updateFilter, disabled: isSelectingDate.value || isDateIncorrect.value || isApplyButtonDisabled.value, }, { title: (props.defaultValue ? t('reset') : t('clear')) as string, event: (props.defaultValue ? resetFilter : clearFilter) as () => void, disabled: isDateIncorrect.value && isSecondaryButtonDisabled.value, }, ] as BentoButtonActionsList ); const onDatePickerRangeError = () => { isDateIncorrect.value = true; internalFilterValue.value = { startDate: undefined, endDate: undefined }; emit(BentoFilterEvent.INPUT, { ...props, value: internalFilterValue.value }); }; const onDatePickerRangeFormDateInputUpdate = (dateFormDate: DateRangePickerCalendarFormData) => { isDateIncorrect.value = false; if (dateFormDate.startTime) { formData.startTime = dateFormDate.startTime; } if (dateFormDate.endTime) { formData.endTime = dateFormDate.endTime; } }; watch( () => props.value, newValue => { buttonSecondaryLabel.value = calculateSecondaryLabel(newValue); }, { deep: true } ); return { // Refs baseFilter, // Values actions, formData, internalFilterValue, isApplyButtonDisabled, isSecondaryButtonDisabled, isSelectingDate, buttonLabel, buttonSecondaryLabel, adjustedQuickSelectRanges, // Events onCancel, onDateSelected, openFilter, clearFilter, resetFilter, updateFilter, onDatePickerRangeError, onDatePickerRangeFormDateInputUpdate, onDateInputSelected, onRangeSelectorInput, }; }, }); </script> <style lang="scss" scoped src="./date-range-filter.scss" />
|
|
1
|
+
<template> <bento-base-filter ref="baseFilter" class="b-base-filter-date-range-filter" :button-label="buttonLabel" :button-secondary-label="buttonSecondaryLabel" :controlled="!onlySlot" :disabled="disabled" :disable-apply-button="isApplyButtonDisabled" :disable-secondary-button="isSecondaryButtonDisabled" :default-value="defaultValue" :field="field" :label="label" :only-slot="onlySlot" :tooltip-position="options?.tooltipPosition" :tooltip-text="options?.tooltipText" fit-content @open="openFilter" @clear="clearFilter" @reset="resetFilter" @cancel="onCancel" > <bento-date-range-picker v-if="onlySlot" :allow-time-input="options?.allowTimeInput" :model-value="internalFilterValue" :first-day-of-week="options?.firstDayOfWeek" :is-date-disabled="options?.isDateDisabled" :number-of-months="options?.numberOfMonths" :quick-select-ranges="options?.quickSelectRanges" :variant="options?.variant" :disabled="disabled" :granularities="options?.granularities" :max-range="options?.maxRange" :min="options?.min" :max="options?.max" :date-form-data="formData" :show-end-date-on-open="options?.showEndDateOnOpen" :aria-labelledby="$attrs?.['aria-labelledby']" @update:model-value="onDateInputSelected" /> <template v-else> <div class="b-date-range-filter"> <date-range-picker-calendar :allow-time-input="options?.allowTimeInput" :first-day-of-week="options?.firstDayOfWeek" :is-date-disabled="options?.isDateDisabled" :granularities="options?.granularities" :max-range="options?.maxRange" :min="options?.min" :max="options?.max" :number-of-months="options?.numberOfMonths" :quick-select-ranges="adjustedQuickSelectRanges" :value="internalFilterValue" :date-form-data="formData" :show-end-date-on-open="options?.showEndDateOnOpen" :variant="options?.variant" @input="onDateSelected" @custom-range="onRangeSelectorInput" @error="onDatePickerRangeError" @form-date="onDatePickerRangeFormDateInputUpdate" @start-date-selected="isSelectingDate = true" > <template #title> <bento-typography strongest variant="body" el="span"> {{ label }} </bento-typography> </template> <template #actions> <bento-divider class="b-date-range-filter__divider" /> <bento-button-actions class="b-date-range-filter__actions" :actions="actions" layout="space-between" /> </template> </date-range-picker-calendar> </div> </template> </bento-base-filter> </template> <script lang="ts"> import { computed, defineComponent, type PropType, reactive, ref, toRef, watch } from 'vue'; import { BentoBaseFilter, useBaseFilter } from '../base-filter'; import { type BentoDateRangeFilterOptions, type BentoDateRangeFilterValue, BentoFilterEvent, } from '../../filter-bar.types'; import { DateRangePickerCalendar } from '@/components/date-range-picker/components/date-range-picker-calendar'; import { BentoDateRangePicker, type BentoDateRangePickerValue } from '@/components/date-range-picker'; import { BentoDivider } from '@/components/divider'; import { BentoTypography } from '@/components/typography'; import { BentoButtonActions, type BentoButtonActionsList } from '@/components/button/components/button-actions'; import { useI18n } from '@/utils/ts/i18n'; import { getDateLocale } from '@/utils/ts/get-date-locale/get-date-locale'; import { isSameDay } from 'date-fns/isSameDay'; import { format } from 'date-fns/format'; import { isEqual } from 'date-fns/isEqual'; import { isToday } from 'date-fns/isToday'; import { dateToTimeInputString } from '@/utils/ts/format-date'; import { useDateInputFormatter } from '@/composables/use-date-input-formatter/use-date-input-formatter'; import messages from './messages.json'; import { type DateRangePickerCalendarFormData, type DateRangePickerCalendarRangeSelectorItem, type DateRangePickerCalendarRangeSelectorItems, } from '@/components/date-range-picker/components/date-range-picker-calendar/date-range-picker-calendar.types'; type MessageSchema = (typeof messages)['en-US']; /** * The Date filter displays a calendar that allows * to select a single date. * * @example * import { BentoDateRangeFilter } from '@adyen/bento-vue2'; * * export default { * components: { BentoDateRangeFilter }, * template: ` * <bento-date-range-filter * :field="filter.field" * :label="filter.label" * :options="filter.options" * :value="filter.value" * @update="updateFilterHandler" * > * ` * } */ export default defineComponent({ i18n: { messages }, name: 'bento-date-range-filter', components: { BentoBaseFilter, BentoButtonActions, BentoDateRangePicker, BentoTypography, BentoDivider, DateRangePickerCalendar, }, props: { /** * Disables the filter from being interacted with. */ disabled: { type: Boolean, default: false }, /** * Default value of the filter to reset to. */ defaultValue: { type: Object as PropType<BentoDateRangeFilterValue>, default: null }, /** * Filter name/key referencing the actual property name in the data set. * Should be unique per filter bar. */ field: { type: String, required: true }, /** * Label text to be displayed */ label: { type: String, required: true }, /** * Renders the base filter's slot content only - leaving out the button and the popover. * Used for the allFiltersModal component. */ onlySlot: { type: Boolean, default: false }, /** * List of options that are needed to setup the filter * Available options: * - allowTimeInput: Allows user to enter time values in the form * - firstDayOfWeek: Allows user to set the first day of the week. 0 is sunday, 1 is monday, 6 is saturday * - isDateDisabled: Indicate if a date should be disabled or not * - maxRange: Set a maximum number of dates to be selectable by the range * - numberOfMonths: Number of months rendered on pane * - quickSelectRanges: An array that sets the custom range dropdown items. @see BentoDateRangePicker * - showEndDateOnOpen: Upon opening date range picker, end date's month will be pre-selected. * - granularities: A list of available granularities. * - variant: The type of calendar to display. Defaults to showing days. * - tooltipPosition: The position of the tooltip attached to the filter button * - tooltipText: The content of the tooltip attached to the filter button */ options: { type: Object as PropType<BentoDateRangeFilterOptions>, default: undefined, }, /** * Current value of the filter */ value: { type: Object as PropType<BentoDateRangeFilterValue>, default: () => ({ startDate: undefined, endDate: undefined }), }, }, emits: [BentoFilterEvent.INPUT, BentoFilterEvent.UPDATE], setup(props, { emit }) { const { locale, t } = useI18n<{ message: MessageSchema }>({ messages }); const locales = getDateLocale(); const { dateToInputDateString } = useDateInputFormatter(); const { internalFilterValue, isSecondaryButtonDisabled, isApplyButtonDisabled } = useBaseFilter( toRef(props, 'value'), toRef(props, 'defaultValue') ); const isSelectingDate = ref(false); const adjustedQuickSelectRanges = computed<Array<DateRangePickerCalendarRangeSelectorItem>>(() => { return ( props?.options?.quickSelectRanges && props.options.quickSelectRanges.map((range: DateRangePickerCalendarRangeSelectorItem) => { const timeDiff = !range.data.endDate ? new Date(Date.now()).getTime() - range.data.startDate.getTime() : undefined; return { ...range, data: { ...range.data, // Adding timeDifference to calculate an up-to-date endDate on range selection timeDifference: timeDiff, }, }; }) ); }); const baseFilter = ref(null); const isDateIncorrect = ref(false); const formData = reactive({ startDate: dateToInputDateString(props.value?.startDate), endDate: dateToInputDateString(props.value?.endDate), startTime: dateToTimeInputString(props.value?.startDate), endTime: dateToTimeInputString(props.value?.endDate), }); watch( () => props.value, () => { formData.startDate = dateToInputDateString(props.value?.startDate); formData.endDate = dateToInputDateString(props.value?.endDate); formData.startTime = dateToTimeInputString(props.value?.startDate); formData.endTime = dateToTimeInputString(props.value?.endDate); } ); function isQuickSelectAnArray( quickSelectRanges: BentoDateRangeFilterOptions['quickSelectRanges'] ): quickSelectRanges is DateRangePickerCalendarRangeSelectorItems { return (quickSelectRanges as DateRangePickerCalendarRangeSelectorItems)?.length !== undefined; } const calculateSecondaryLabel = (currentValue: BentoDateRangeFilterValue) => { if (!currentValue || (!currentValue?.startDate && !currentValue.endDate)) { return null; } // If quick select range selected then display its label if ( isQuickSelectAnArray(props.options?.quickSelectRanges) && props.options?.quickSelectRanges?.length > 0 ) { const customRangeSelected = props.options?.quickSelectRanges?.find(({ value, data }) => { if (currentValue) { if (currentValue.range) { return value === currentValue.range; } if (!currentValue.startDate || !currentValue.endDate) { return false; } if (!data?.endDate) { // Autoselecting the correct value for open-ended ranges return ( isEqual(data?.startDate, currentValue.startDate) && isToday(currentValue.endDate) ); } // If no range selected but start and end dates correspond to one of the preselected ranges, select it return ( isEqual(data?.startDate, currentValue.startDate) && isEqual(data?.endDate, currentValue.endDate) ); } return false; }); if (customRangeSelected) { return customRangeSelected.label; } } if (!currentValue.startDate || !currentValue.endDate) { return null; } return isSameDay(currentValue.startDate, currentValue.endDate) && props?.options?.allowTimeInput ? `${format(currentValue.startDate, 'dd MMM, yyyy HH:mm:ss', { locale: locales[locale.value?.substring(0, 2)] || locales.enUS, })} - ${format(currentValue.endDate, 'HH:mm:ss', { locale: locales[locale.value?.substring(0, 2)] || locales.enUS, })}` : `${format(currentValue.startDate, 'dd MMM, yyyy', { locale: locales[locale.value?.substring(0, 2)] || locales.enUS, })} - ${format(currentValue.endDate, 'dd MMM, yyyy', { locale: locales[locale.value?.substring(0, 2)] || locales.enUS, })}`; }; const buttonSecondaryLabel = ref(calculateSecondaryLabel(props.value)); const buttonLabel = computed(() => props.label); const updateFilter = () => { baseFilter.value.closePopover(); // Omit the onlySlot prop as it is not needed const { onlySlot, ...cleanedProps } = props; emit(BentoFilterEvent.UPDATE, { ...cleanedProps, value: internalFilterValue.value }); }; const clearFilter = () => { internalFilterValue.value = undefined; updateFilter(); }; const resetFilter = () => { internalFilterValue.value = props.defaultValue; updateFilter(); }; const openFilter = () => { internalFilterValue.value = props.value; }; // If we click outside or press escape this should cancel and reset the form inputs // set all internal states back to their initial values const onCancel = () => { internalFilterValue.value = props.value; isSelectingDate.value = false; formData.startDate = dateToInputDateString(props.value?.startDate); formData.endDate = dateToInputDateString(props.value?.endDate); formData.startTime = dateToTimeInputString(props.value?.startDate); formData.endTime = dateToTimeInputString(props.value?.endDate); }; const onDateSelected = (selectedDate: BentoDateRangePickerValue) => { isSelectingDate.value = false; internalFilterValue.value = selectedDate; formData.startDate = dateToInputDateString(selectedDate.startDate); formData.endDate = dateToInputDateString(selectedDate.endDate); emit(BentoFilterEvent.INPUT, { ...props, value: internalFilterValue.value }); }; const onRangeSelectorInput = (quickSelectRanges?: { startDate: Date; endDate: Date }) => { if (quickSelectRanges?.startDate && quickSelectRanges?.endDate) { internalFilterValue.value = quickSelectRanges; formData.startDate = dateToInputDateString(quickSelectRanges.startDate); formData.endDate = dateToInputDateString(quickSelectRanges.endDate); emit(BentoFilterEvent.INPUT, { ...props, value: internalFilterValue.value }); } }; // Need to update the internal value and emit the date when using the date-picker input const onDateInputSelected = (selectedDate: BentoDateRangePickerValue) => { onDateSelected(selectedDate); emit(BentoFilterEvent.INPUT, { ...props, value: internalFilterValue.value }); }; const actions = computed( () => [ { title: t('apply') as string, event: updateFilter, disabled: isSelectingDate.value || isDateIncorrect.value || isApplyButtonDisabled.value, }, { title: (props.defaultValue ? t('reset') : t('clear')) as string, event: (props.defaultValue ? resetFilter : clearFilter) as () => void, disabled: isDateIncorrect.value && isSecondaryButtonDisabled.value, }, ] as BentoButtonActionsList ); const onDatePickerRangeError = () => { isDateIncorrect.value = true; internalFilterValue.value = { startDate: undefined, endDate: undefined }; emit(BentoFilterEvent.INPUT, { ...props, value: internalFilterValue.value }); }; const onDatePickerRangeFormDateInputUpdate = (dateFormDate: DateRangePickerCalendarFormData) => { isDateIncorrect.value = false; if (dateFormDate.startTime) { formData.startTime = dateFormDate.startTime; } if (dateFormDate.endTime) { formData.endTime = dateFormDate.endTime; } }; watch( () => props.value, newValue => { buttonSecondaryLabel.value = calculateSecondaryLabel(newValue); }, { deep: true } ); return { // Refs baseFilter, // Values actions, formData, internalFilterValue, isApplyButtonDisabled, isSecondaryButtonDisabled, isSelectingDate, buttonLabel, buttonSecondaryLabel, adjustedQuickSelectRanges, // Events onCancel, onDateSelected, openFilter, clearFilter, resetFilter, updateFilter, onDatePickerRangeError, onDatePickerRangeFormDateInputUpdate, onDateInputSelected, onRangeSelectorInput, }; }, }); </script> <style lang="scss" scoped src="./date-range-filter.scss" />
|
|
@@ -1 +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" generic=""> 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"> <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" />
|
package/dist/assets/components/form-layout/components/form-layout-title/form-layout-title.vue
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
<template> <div class="b-form-layout-title" :class="conditionalClasses"> <bento-typography :el="el" variant="title" :medium="isSection"> <slot /> </bento-typography> <bento-typography v-if="hasSlot('description')" class="b-form-layout-title__description" el="span" wide> <slot name="description" /> </bento-typography> </div> </template> <script setup lang="ts"> import { computed, useSlots } from 'vue'; import { BentoTypography, BentoTypographyElement } from '@/components/typography'; import { useHasSlot } from '@/composables'; const slots = useSlots(); const props = defineProps({ /** * Defines if the Form Layout title is * a "section" or a "subsection". * Default is "section" (false). Turn into "true" for "subsection" */ subsection: { type: Boolean, default: false, }, /** * Sets the HTML DOM element. * @values h1, h2, h3, h4, h5, h6, div, p, span */ el: { type: BentoTypography.props.el.type, required: true, validator: (value: BentoTypographyElement) => Object.values(BentoTypographyElement).includes(value), }, }); // Uses "medium" typography if it is a section title const isSection = computed(() => !props.subsection); const hasSlot = useHasSlot(slots); const conditionalClasses = computed(() => ({ 'b-form-layout-title--subsection': props.subsection, })); </script> <script lang="ts"> /** * If you’re building a lengthy form, organize related * fields into sections and, if needed, subsections. * * A form can have a "Section Title" and multiple "Subsection titles". * * One of the values provided in @see BentoTypographyElement must always be provided as a string * to define correct HTML sementic from the application's context * * @example * import { BentoFormLayoutTitle } from '@adyen/bento-vue2'; * * export default { * components: { BentoFormLayoutTitle }, * template: ` * <bento-form-layout-title subsection el="h2"> * Title * <template #description>Description text</template> * </bento-form-layout-title> * ` * } */ export default {}; </script> <style lang="scss" scoped src="./form-layout-title.scss" />
|
|
1
|
+
<template> <div class="b-form-layout-title" :class="conditionalClasses"> <bento-typography :el="el" variant="title" :medium="isSection"> <slot /> </bento-typography> <bento-typography v-if="hasSlot('description') || allFieldsRequired" class="b-form-layout-title__description" el="span" wide > <slot name="description" /> <template v-if="allFieldsRequired"> {{ hasSlot('description') ? ` ${t('requiredFields')}` : t('requiredFields') }} </template> </bento-typography> </div> </template> <script setup lang="ts"> import { computed, useSlots } from 'vue'; import { BentoTypography, BentoTypographyElement } from '@/components/typography'; import { useHasSlot } from '@/composables'; import { useI18n } from '@/utils/ts/i18n'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const slots = useSlots(); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const props = defineProps({ /** * Defines if the Form Layout title is * a "section" or a "subsection". * Default is "section" (false). Turn into "true" for "subsection" */ subsection: { type: Boolean, default: false, }, /** * Sets the HTML DOM element. * @values h1, h2, h3, h4, h5, h6, div, p, span */ el: { type: BentoTypography.props.el.type, required: true, validator: (value: BentoTypographyElement) => Object.values(BentoTypographyElement).includes(value), }, /** * Defines if the Form Layout title displays * a message indicating that all fields are required. */ allFieldsRequired: { type: Boolean, default: false, }, }); // Uses "medium" typography if it is a section title const isSection = computed(() => !props.subsection); const hasSlot = useHasSlot(slots); const conditionalClasses = computed(() => ({ 'b-form-layout-title--subsection': props.subsection, })); </script> <script lang="ts"> /** * If you’re building a lengthy form, organize related * fields into sections and, if needed, subsections. * * A form can have a "Section Title" and multiple "Subsection titles". * * One of the values provided in @see BentoTypographyElement must always be provided as a string * to define correct HTML sementic from the application's context * * @example * import { BentoFormLayoutTitle } from '@adyen/bento-vue2'; * * export default { * components: { BentoFormLayoutTitle }, * template: ` * <bento-form-layout-title subsection el="h2"> * Title * <template #description>Description text</template> * </bento-form-layout-title> * ` * } */ export default { name: 'bento-form-layout-title', i18n: { messages }, }; </script> <style lang="scss" scoped src="./form-layout-title.scss" />
|
|
@@ -51,6 +51,10 @@ directly, as spacing here is optimized for larger forms/configuration flows.
|
|
|
51
51
|
If you're building a lengthy form, organize related fields into sections and, if needed, subsections. This adds
|
|
52
52
|
additional context and makes filling it out easier.
|
|
53
53
|
|
|
54
|
+
If all fields in a section or subsection are required, use the `allFieldsRequired` prop on the `BentoFormLayoutTitle`
|
|
55
|
+
component to display a localized "All fields are required." instruction. If only some fields are required, mark those
|
|
56
|
+
individual components as `required` instead.
|
|
57
|
+
|
|
54
58
|
An element (`el`) of type string corresponding to one of the values contained in `BentoTypographyElement` must always be
|
|
55
59
|
provided to define correct HTML sementic from the application's context.
|
|
56
60
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import { action } from '@storybook/addon-actions'; import { storybookDocsParameter } from '@/utils/ts/storybook'; import { type BentoInputDropdownProps, BentoInputFieldElementPosition, BentoInputFieldType, BentoInputFieldVariant, } from './input-field.types'; import { type BentoListboxSelectedValue } from '@/types/listbox'; import { ref, watch } from 'vue'; import type { Meta, StoryObj } from '@storybook/vue'; import { isVue2 } from 'vue-demi'; import BentoInputField from './input-field.vue'; import CheckmarkCircleFill from '@adyen/ui-assets-icons-16/vue/checkmark-circle-fill'; import PaymentMethodTypes from '@adyen/ui-assets-icons-16/vue/payment-method-types'; import './input-field.stories.scss?module'; const meta: Meta = { title: 'Input field', component: BentoInputField, argTypes: { variant: { options: Object.values(BentoInputFieldVariant), mapping: BentoInputFieldVariant, defaultValue: BentoInputFieldVariant.DEFAULT, control: { type: 'select', }, }, type: { options: Object.values(BentoInputFieldType), mapping: BentoInputFieldType, defaultValue: BentoInputFieldType.TEXT, control: { type: 'select', }, }, default: { name: 'default', defaultValue: '', description: 'Label for the input field', table: { type: { summary: 'VNode[]' }, }, control: { type: 'text', }, }, description: { name: 'description', defaultValue: '', description: 'Description below input', table: { type: { summary: 'VNode[]' }, }, control: { type: 'text', }, }, dropdownPosition: { options: Object.values(BentoInputFieldElementPosition), mapping: BentoInputFieldElementPosition, defaultValue: BentoInputFieldElementPosition.START, control: { type: 'select', }, }, staticValue: { name: 'staticValue', defaultValue: '', description: 'Static text that displayed before or after input value', table: { type: { summary: 'VNode[]' }, }, control: { type: 'text', }, }, staticValuePosition: { options: Object.values(BentoInputFieldElementPosition), mapping: BentoInputFieldElementPosition, defaultValue: BentoInputFieldElementPosition.START, control: { type: 'select', }, }, defaultIconBefore: { name: 'defaultIconBefore', defaultValue: '', description: 'Optional `icon` at the left side', table: { type: { summary: 'VNode[]' }, category: 'Deprecated', }, control: { disable: true, }, }, iconBefore: { name: 'iconBefore', defaultValue: '', description: 'Optional `icon` at the left side', table: { type: { summary: 'VNode[]' }, }, control: { disable: true, }, }, iconAfter: { name: 'iconAfter', defaultValue: '', description: 'Optional `icon` at the right side', table: { type: { summary: 'VNode[]' }, }, control: { disable: true, }, }, error: { control: { type: 'boolean' }, table: { category: 'Deprecated' }, }, value: { control: { type: 'text' }, table: { category: 'Deprecated' }, description: 'Deprecated for usage with properties since version 2.0. Use `v-model` or `model-value` property instead', }, // Events input: { table: { type: { summary: '(value: BentoInputFieldValue) => void' }, category: 'Deprecated', }, control: { disable: true }, description: 'Deprecated since version 2.0. Use `v-model` or `update:model-value` instead', }, 'update:model-value': { table: { type: { summary: '(value: BentoInputFieldValue) => void' }, }, control: { disable: true }, }, }, }; export default meta; type Story = StoryObj<typeof BentoInputField>; const INPUT_FIELD_DEFAULT_LABEL = 'Label above'; const INPUT_FIELD_DEFAULT_VALUE = 'Entered Text'; const INPUT_FIELD_DEFAULT_DESCRIPTION = 'Description below'; const DEFAULT_INPUT_FIELD_PROPS = { disabled: false, variant: 'default', description: INPUT_FIELD_DEFAULT_DESCRIPTION, modelValue: INPUT_FIELD_DEFAULT_VALUE, staticValue: 'EUR', }; export const Default: Story = { render: (_args, { argTypes }) => ({ components: { BentoInputField }, props: Object.keys(argTypes), template: ` <bento-input-field class="b-input-field-story" v-bind="args" :model-value="inputValue" @update:model-value="inputAction" @change="changeAction" @focus="focusAction" @blur="blurAction" > <template #default v-if="args.default">{{ args.default }}</template> <template #description>{{ args.description }}</template> </bento-input-field> `, setup(props) { const args = isVue2 ? props : _args; const inputValue = ref(args.modelValue); const inputAction = (value: string) => { inputValue.value = value; action('input')(value); }; const changeAction = (value: string) => action('change')(value); const focusAction = (value: string) => action('focus')(value); const blurAction = (value: string) => action('blur')(value); watch( () => args.modelValue, () => { inputValue.value = args.modelValue; } ); return { args, inputValue, inputAction, changeAction, focusAction, blurAction, }; }, }), args: { ...DEFAULT_INPUT_FIELD_PROPS, default: INPUT_FIELD_DEFAULT_LABEL, }, parameters: storybookDocsParameter(` <bento-input-field variant="default" :model-value="inputValue" @update:model-value="inputAction" @change="changeAction" @focus="focusAction" @blur="blurAction" > ${INPUT_FIELD_DEFAULT_LABEL} <template #description> ${INPUT_FIELD_DEFAULT_DESCRIPTION} </template> </bento-input-field> `), }; export const DefaultWithIcons = { render: (_args, { argTypes }) => ({ components: { BentoInputField, CheckmarkCircleFill }, props: Object.keys(argTypes), template: ` <bento-input-field class="b-input-field-story" v-bind="args" @update:model-value="inputAction" @change="changeAction" @focus="focusAction" @blur="blurAction" > <template #default v-if="args.default">{{ args.default }}</template> <template #iconBefore> <checkmark-circle-fill svg-title="checkmark-circle-fill" /> </template> <template #iconAfter> <checkmark-circle-fill svg-title="checkmark-circle-fill" /> </template> <template #description>{{ args.description }}</template> </bento-input-field> `, setup(props) { const inputAction = (value: string) => action('input')(value); const changeAction = (value: string) => action('change')(value); const focusAction = (value: string) => action('focus')(value); const blurAction = (value: string) => action('blur')(value); return { args: isVue2 ? props : _args, inputAction, changeAction, focusAction, blurAction, }; }, }), args: { ...DEFAULT_INPUT_FIELD_PROPS, default: 'With Icons', }, parameters: storybookDocsParameter(` <bento-input-field variant="default" :model-value="inputValue" @update:model-value="inputAction" @change="changeAction" @focus="focusAction" @blur="blurAction" > ${INPUT_FIELD_DEFAULT_LABEL} <template #description> ${INPUT_FIELD_DEFAULT_DESCRIPTION} </template> <template #iconBefore> <checkmark-circle-fill svg-title="checkmark-circle-fill" /> </template> <template #iconAfter> <checkmark-circle-fill svg-title="checkmark-circle-fill" /> </template> </bento-input-field> `), }; export const PaymentsMethod = { render: (_args, { argTypes }) => ({ components: { BentoInputField, CheckmarkCircleFill, PaymentMethodTypes }, props: Object.keys(argTypes), template: ` <bento-input-field class="b-input-field-story" v-bind="args" @update:model-value="inputAction" @change="changeAction" @focus="focusAction" @blur="blurAction" > <template #default v-if="args.default">{{ args.default }}</template> <template #paymentMethod> <payment-method-types svg-title="payment-method-types" /> </template> <template #iconAfter> <checkmark-circle-fill svg-title="checkmark-circle-fill" /> </template> <template #description>{{ args.description }}</template> <template #staticValue> EUR </template> </bento-input-field> `, setup(props) { const inputAction = (value: string) => action('input')(value); const changeAction = (value: string) => action('change')(value); const focusAction = (value: string) => action('focus')(value); const blurAction = (value: string) => action('blur')(value); return { args: isVue2 ? props : _args, inputAction, changeAction, focusAction, blurAction, }; }, }), args: { ...DEFAULT_INPUT_FIELD_PROPS, default: 'Payment Method Variant', variant: 'payment-method', }, parameters: storybookDocsParameter(` <bento-input-field variant="payment-method" :model-value="inputValue" @update:model-value="inputAction" @change="changeAction" @focus="focusAction" @blur="blurAction" > ${INPUT_FIELD_DEFAULT_LABEL} <template #description> ${INPUT_FIELD_DEFAULT_DESCRIPTION} </template> <template #paymentMethod> <bento-payment-method svg-title="payment-method" /> </template> <template #iconAfter> <checkmark-circle-fill svg-title="checkmark-circle-fill" /> </template> </bento-input-field> `), }; export const StaticValue = { ...PaymentsMethod, args: { ...DEFAULT_INPUT_FIELD_PROPS, default: 'Static Value Variant', variant: 'static-value', }, parameters: storybookDocsParameter(` <bento-input-field variant="static-value" :model-value="inputValue" @update:model-value="inputAction" @change="changeAction" @focus="focusAction" @blur="blurAction" > ${INPUT_FIELD_DEFAULT_LABEL} <template #description> ${INPUT_FIELD_DEFAULT_DESCRIPTION} </template> <template #staticValue> EUR </template> <template #iconAfter> <checkmark-circle-fill svg-title="checkmark-circle-fill" /> </template> </bento-input-field> `), }; const DROPDOWN_DEFAULT_OPTIONS = [ { label: 'EUR', value: 'EUR' }, { label: 'USD', value: 'USD' }, ]; export const Dropdown: Story = { render: (_args, { argTypes }) => ({ components: { BentoInputField, CheckmarkCircleFill, PaymentMethodTypes }, props: Object.keys(argTypes), template: ` <bento-input-field class="b-input-field-story" v-bind="args" :dropdown.sync="dropdownProps" @update:model-value="inputAction" @change="changeAction" @focus="focusAction" @blur="blurAction" > <template #default v-if="args.default">{{ args.default }}</template> <template #paymentMethod> <payment-method-types svg-title="payment-method-types" /> </template> <template #iconAfter> <checkmark-circle-fill svg-title="checkmark-circle-fill" /> </template> <template #description>{{ args.description }}</template> </bento-input-field> `, setup(props) { const args = isVue2 ? props : _args; const dropdownProps = ref({ ...args?.dropdown }); const inputAction = (value: string) => action('input')(value); const changeAction = (value: string) => action('change')(value); const focusAction = (value: string) => action('focus')(value); const blurAction = (value: string) => action('blur')(value); const dropdownInputAction = (value: BentoListboxSelectedValue) => { action('dropdown-input')(value); }; watch( () => args.dropdown, () => { Object.assign(dropdownProps.value, args.dropdown); } ); return { args, inputAction, changeAction, focusAction, blurAction, dropdownInputAction, dropdownProps, }; }, }), args: { default: 'Dropdown Variant', description: INPUT_FIELD_DEFAULT_DESCRIPTION, modelValue: INPUT_FIELD_DEFAULT_VALUE, variant: 'dropdown', dropdown: { items: DROPDOWN_DEFAULT_OPTIONS, modelValue: DROPDOWN_DEFAULT_OPTIONS[0].value, readonly: false, 'aria-label': 'Dropdown aria label', dynamicFiltering: false, } satisfies BentoInputDropdownProps, }, };
|
|
1
|
+
import { action } from '@storybook/addon-actions'; import { storybookDocsParameter } from '@/utils/ts/storybook'; import { type BentoInputDropdownProps, BentoInputFieldElementPosition, BentoInputFieldType, BentoInputFieldVariant, } from './input-field.types'; import { type BentoListboxSelectedValue } from '@/types/listbox'; import { ref, watch } from 'vue'; import type { Meta, StoryObj } from '@storybook/vue'; import { isVue2 } from 'vue-demi'; import BentoInputField from './input-field.vue'; import CheckmarkCircleFill from '@adyen/ui-assets-icons-16/vue/checkmark-circle-fill'; import PaymentMethodTypes from '@adyen/ui-assets-icons-16/vue/payment-method-types'; import './input-field.stories.scss?module'; const meta: Meta = { title: 'Input field', component: BentoInputField, argTypes: { variant: { options: Object.values(BentoInputFieldVariant), mapping: BentoInputFieldVariant, defaultValue: BentoInputFieldVariant.DEFAULT, control: { type: 'select', }, }, type: { options: Object.values(BentoInputFieldType), mapping: BentoInputFieldType, defaultValue: BentoInputFieldType.TEXT, control: { type: 'select', }, }, default: { name: 'default', defaultValue: '', description: 'Label for the input field', table: { type: { summary: 'VNode[]' }, }, control: { type: 'text', }, }, description: { name: 'description', description: 'If set, displays a description text for the input.', table: { category: 'props', type: { summary: 'string' }, }, control: { type: 'text', }, }, 'slot:description': { name: 'description', defaultValue: '', description: 'If set, displays a description text for the input. Overrides the `description` prop when provided.', table: { type: { summary: 'VNode[]' }, category: 'slots', }, control: { type: 'text', }, }, dropdownPosition: { options: Object.values(BentoInputFieldElementPosition), mapping: BentoInputFieldElementPosition, defaultValue: BentoInputFieldElementPosition.START, control: { type: 'select', }, }, staticValue: { name: 'staticValue', defaultValue: '', description: 'Static text that displayed before or after input value', table: { type: { summary: 'VNode[]' }, }, control: { type: 'text', }, }, staticValuePosition: { options: Object.values(BentoInputFieldElementPosition), mapping: BentoInputFieldElementPosition, defaultValue: BentoInputFieldElementPosition.START, control: { type: 'select', }, }, defaultIconBefore: { name: 'defaultIconBefore', defaultValue: '', description: 'Optional `icon` at the left side', table: { type: { summary: 'VNode[]' }, category: 'Deprecated', }, control: { disable: true, }, }, iconBefore: { name: 'iconBefore', defaultValue: '', description: 'Optional `icon` at the left side', table: { type: { summary: 'VNode[]' }, }, control: { disable: true, }, }, iconAfter: { name: 'iconAfter', defaultValue: '', description: 'Optional `icon` at the right side', table: { type: { summary: 'VNode[]' }, }, control: { disable: true, }, }, error: { control: { type: 'boolean' }, table: { category: 'Deprecated' }, }, value: { control: { type: 'text' }, table: { category: 'Deprecated' }, description: 'Deprecated for usage with properties since version 2.0. Use `v-model` or `model-value` property instead', }, // Events input: { table: { type: { summary: '(value: BentoInputFieldValue) => void' }, category: 'Deprecated', }, control: { disable: true }, description: 'Deprecated since version 2.0. Use `v-model` or `update:model-value` instead', }, 'update:model-value': { table: { type: { summary: '(value: BentoInputFieldValue) => void' }, }, control: { disable: true }, }, }, }; export default meta; type Story = StoryObj<typeof BentoInputField>; const INPUT_FIELD_DEFAULT_LABEL = 'Label above'; const INPUT_FIELD_DEFAULT_VALUE = 'Entered Text'; const INPUT_FIELD_DEFAULT_DESCRIPTION = 'Description below'; const DEFAULT_INPUT_FIELD_PROPS = { disabled: false, variant: 'default', description: INPUT_FIELD_DEFAULT_DESCRIPTION, modelValue: INPUT_FIELD_DEFAULT_VALUE, staticValue: 'EUR', }; export const Default: Story = { render: (_args, { argTypes }) => ({ components: { BentoInputField }, props: Object.keys(argTypes), template: ` <bento-input-field class="b-input-field-story" v-bind="args" :model-value="inputValue" @update:model-value="inputAction" @change="changeAction" @focus="focusAction" @blur="blurAction" > <template #default v-if="args.default">{{ args.default }}</template> <template #description v-if="args['slot:description']">{{ args['slot:description'] }}</template> </bento-input-field> `, setup(props) { const args = isVue2 ? props : _args; const inputValue = ref(args.modelValue); const inputAction = (value: string) => { inputValue.value = value; action('input')(value); }; const changeAction = (value: string) => action('change')(value); const focusAction = (value: string) => action('focus')(value); const blurAction = (value: string) => action('blur')(value); watch( () => args.modelValue, () => { inputValue.value = args.modelValue; } ); return { args, inputValue, inputAction, changeAction, focusAction, blurAction, }; }, }), args: { ...DEFAULT_INPUT_FIELD_PROPS, default: INPUT_FIELD_DEFAULT_LABEL, }, parameters: storybookDocsParameter(` <bento-input-field variant="default" :model-value="inputValue" @update:model-value="inputAction" @change="changeAction" @focus="focusAction" @blur="blurAction" > ${INPUT_FIELD_DEFAULT_LABEL} <template #description> ${INPUT_FIELD_DEFAULT_DESCRIPTION} </template> </bento-input-field> `), }; export const DefaultWithIcons = { render: (_args, { argTypes }) => ({ components: { BentoInputField, CheckmarkCircleFill }, props: Object.keys(argTypes), template: ` <bento-input-field class="b-input-field-story" v-bind="args" @update:model-value="inputAction" @change="changeAction" @focus="focusAction" @blur="blurAction" > <template #default v-if="args.default">{{ args.default }}</template> <template #iconBefore> <checkmark-circle-fill svg-title="checkmark-circle-fill" /> </template> <template #iconAfter> <checkmark-circle-fill svg-title="checkmark-circle-fill" /> </template> <template #description v-if="args['slot:description']">{{ args['slot:description'] }}</template> </bento-input-field> `, setup(props) { const inputAction = (value: string) => action('input')(value); const changeAction = (value: string) => action('change')(value); const focusAction = (value: string) => action('focus')(value); const blurAction = (value: string) => action('blur')(value); return { args: isVue2 ? props : _args, inputAction, changeAction, focusAction, blurAction, }; }, }), args: { ...DEFAULT_INPUT_FIELD_PROPS, default: 'With Icons', }, parameters: storybookDocsParameter(` <bento-input-field variant="default" :model-value="inputValue" @update:model-value="inputAction" @change="changeAction" @focus="focusAction" @blur="blurAction" > ${INPUT_FIELD_DEFAULT_LABEL} <template #description> ${INPUT_FIELD_DEFAULT_DESCRIPTION} </template> <template #iconBefore> <checkmark-circle-fill svg-title="checkmark-circle-fill" /> </template> <template #iconAfter> <checkmark-circle-fill svg-title="checkmark-circle-fill" /> </template> </bento-input-field> `), }; export const PaymentsMethod = { render: (_args, { argTypes }) => ({ components: { BentoInputField, CheckmarkCircleFill, PaymentMethodTypes }, props: Object.keys(argTypes), template: ` <bento-input-field class="b-input-field-story" v-bind="args" @update:model-value="inputAction" @change="changeAction" @focus="focusAction" @blur="blurAction" > <template #default v-if="args.default">{{ args.default }}</template> <template #paymentMethod> <payment-method-types svg-title="payment-method-types" /> </template> <template #iconAfter> <checkmark-circle-fill svg-title="checkmark-circle-fill" /> </template> <template #description v-if="args['slot:description']">{{ args['slot:description'] }}</template> <template #staticValue> EUR </template> </bento-input-field> `, setup(props) { const inputAction = (value: string) => action('input')(value); const changeAction = (value: string) => action('change')(value); const focusAction = (value: string) => action('focus')(value); const blurAction = (value: string) => action('blur')(value); return { args: isVue2 ? props : _args, inputAction, changeAction, focusAction, blurAction, }; }, }), args: { ...DEFAULT_INPUT_FIELD_PROPS, default: 'Payment Method Variant', variant: 'payment-method', }, parameters: storybookDocsParameter(` <bento-input-field variant="payment-method" :model-value="inputValue" @update:model-value="inputAction" @change="changeAction" @focus="focusAction" @blur="blurAction" > ${INPUT_FIELD_DEFAULT_LABEL} <template #description> ${INPUT_FIELD_DEFAULT_DESCRIPTION} </template> <template #paymentMethod> <bento-payment-method svg-title="payment-method" /> </template> <template #iconAfter> <checkmark-circle-fill svg-title="checkmark-circle-fill" /> </template> </bento-input-field> `), }; export const StaticValue = { ...PaymentsMethod, args: { ...DEFAULT_INPUT_FIELD_PROPS, default: 'Static Value Variant', variant: 'static-value', }, parameters: storybookDocsParameter(` <bento-input-field variant="static-value" :model-value="inputValue" @update:model-value="inputAction" @change="changeAction" @focus="focusAction" @blur="blurAction" > ${INPUT_FIELD_DEFAULT_LABEL} <template #description> ${INPUT_FIELD_DEFAULT_DESCRIPTION} </template> <template #staticValue> EUR </template> <template #iconAfter> <checkmark-circle-fill svg-title="checkmark-circle-fill" /> </template> </bento-input-field> `), }; const DROPDOWN_DEFAULT_OPTIONS = [ { label: 'EUR', value: 'EUR' }, { label: 'USD', value: 'USD' }, ]; export const Dropdown: Story = { render: (_args, { argTypes }) => ({ components: { BentoInputField, CheckmarkCircleFill, PaymentMethodTypes }, props: Object.keys(argTypes), template: ` <bento-input-field class="b-input-field-story" v-bind="args" :dropdown.sync="dropdownProps" @update:model-value="inputAction" @change="changeAction" @focus="focusAction" @blur="blurAction" > <template #default v-if="args.default">{{ args.default }}</template> <template #paymentMethod> <payment-method-types svg-title="payment-method-types" /> </template> <template #iconAfter> <checkmark-circle-fill svg-title="checkmark-circle-fill" /> </template> <template #description v-if="args['slot:description']">{{ args['slot:description'] }}</template> </bento-input-field> `, setup(props) { const args = isVue2 ? props : _args; const dropdownProps = ref({ ...args?.dropdown }); const inputAction = (value: string) => action('input')(value); const changeAction = (value: string) => action('change')(value); const focusAction = (value: string) => action('focus')(value); const blurAction = (value: string) => action('blur')(value); const dropdownInputAction = (value: BentoListboxSelectedValue) => { action('dropdown-input')(value); }; watch( () => args.dropdown, () => { Object.assign(dropdownProps.value, args.dropdown); } ); return { args, inputAction, changeAction, focusAction, blurAction, dropdownInputAction, dropdownProps, }; }, }), args: { default: 'Dropdown Variant', description: INPUT_FIELD_DEFAULT_DESCRIPTION, modelValue: INPUT_FIELD_DEFAULT_VALUE, variant: 'dropdown', dropdown: { items: DROPDOWN_DEFAULT_OPTIONS, modelValue: DROPDOWN_DEFAULT_OPTIONS[0].value, readonly: false, 'aria-label': 'Dropdown aria label', dynamicFiltering: false, } satisfies BentoInputDropdownProps, }, };
|