@adyen/bento-mcp 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/README.md +38 -12
- package/dist/assets/components/avatar/avatar.stories.ts +1 -1
- package/dist/assets/components/avatar/avatar.types.ts +1 -0
- package/dist/assets/components/avatar/avatar.vue +1 -1
- package/dist/assets/components/avatar/components/avatar-image/avatar-image.vue +1 -1
- package/dist/assets/components/data-grid/components/data-grid-cell-actions/data-grid-cell-actions.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.stories.ts +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-default-textbox/dropdown-default-textbox.vue +1 -1
- package/dist/assets/components/dropdown/components/dropdown-options-container/dropdown-options-container.vue +1 -1
- package/dist/assets/components/dropdown/components/dropdown-small-textbox/dropdown-small-textbox.vue +1 -1
- package/dist/assets/components/dropdown/composables/use-keyboard-navigation.types.ts +1 -1
- package/dist/assets/components/dropdown/dropdown.vue +1 -1
- package/dist/assets/components/header-with-views/components/header-with-views-all-views/header-with-views-all-views.vue +1 -1
- package/dist/assets/components/header-with-views/header-with-views.vue +1 -1
- package/dist/assets/components/internal/calendar/composables/use-granularity-adjustments.types.ts +1 -1
- package/dist/assets/components/internal/listbox/components/listbox-option/listbox-option.vue +1 -1
- package/dist/assets/components/internal/listbox/components/listbox-single-select/listbox-single-select.vue +1 -1
- package/dist/assets/components/internal/listbox/components/listbox-single-select-option/listbox-single-select-option.vue +1 -1
- package/dist/assets/components/internal/listbox/listbox.vue +1 -1
- package/dist/assets/components/menu/menu.docs.mdx +8 -7
- package/dist/assets/components/menu/menu.vue +1 -1
- package/dist/assets/components/popover/popover.types.ts +1 -1
- package/dist/assets/components/popover/popover.vue +1 -1
- package/dist/assets/components/popper-container/popper-container.types.ts +1 -1
- package/dist/assets/components/popper-container/popper-container.vue +1 -1
- package/dist/assets/components/progress-bar/progress-bar.docs.mdx +102 -0
- package/dist/assets/components/progress-bar/progress-bar.stories.ts +1 -0
- package/dist/assets/components/progress-bar/progress-bar.types.ts +1 -0
- package/dist/assets/components/progress-bar/progress-bar.vue +1 -0
- package/dist/assets/components.json +1 -0
- package/dist/assets/deprecations.md +157 -157
- package/dist/assets/index.ts +1 -1
- package/dist/assets/usage.json +80 -79
- package/dist/assets/variables.css +42 -41
- package/dist/main.js +1 -1
- package/package.json +1 -1
- package/dist/assets/components/avatar/components/avatar-image/avatar-image.types.ts +0 -1
- package/dist/assets/components/internal/calendar/composables/use-range-pane.types.ts +0 -1
package/dist/assets/components/dropdown/components/dropdown-base-textbox/dropdown-base-textbox.vue
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
<template> <div :class="$attrs.class" data-testid="textbox-wrapper" @click="onToggleDropdown" @keydown.escape="onCloseDropdown" > <bento-typography class="b-dropdown-base-textbox" :class="conditionalClasses" :variant="isUnderlineStyle ? 'title' : 'body'" el="div" data-testid="base-textbox" > <input v-if="shouldShowInput" v-bind="ariaAttributes" :id="textboxId" ref="textbox" class="b-dropdown-base-textbox__input" role="combobox" type="text" aria-autocomplete="list" size="1" :value="open ? value : displayValue" :disabled="disabled ? true : null" :readonly="readonly" :required="showRequiredAttribute" @keydown="$emit(DropdownBaseTextboxEvent.KEYDOWN, $event)" @keydown.enter="onToggleDropdown" @input="onInputEvent" /> <div v-else :id="textboxId" v-bind="ariaAttributes" ref="textbox" class="b-dropdown-base-textbox__input" :class="comboboxConditionalClasses" :aria-readonly="readonly ? 'true' : null" role="combobox" :tabindex="disabled ? -1 : 0" > <template v-if="shouldShowSingleSelectSlot"> <slot v-if="hasSlot('display-value')" v-bind="selectedValueItem" name="display-value"></slot> <slot v-else v-bind="selectedValueItem"> {{ displayValue }} </slot> </template> <template v-else-if="shouldShowMultipleSlot"> <div class="b-dropdown-base-textbox__input-multiple-selected"> <div v-for="singleItem of selectedValueItems" :key="singleItem.value"> <div class="b-dropdown-base-textbox__input-selected-element"> <slot v-bind="singleItem"> {{ displayValue }} </slot> </div> </div> </div> </template> <template v-else>{{ displayValue }}</template> </div> <span v-if="additionalItemsSelected" class="b-dropdown-base-textbox__additional-items"> {{ additionalItemsSelected }} </span> <span v-if="hasSlot('icon') && !isFiltering" class="b-dropdown-base-textbox__icon" aria-hidden="true"> <slot name="icon"></slot> </span> <span v-if="isFiltering" class="b-dropdown-base-textbox__clear-search" :tabindex="disabled ? -1 : 0" data-testid="clear-search-button" @click.stop="onClearSearch" @keydown.enter.stop="onClearSearch" @keyup.space="onClearSearch" > <cross-circle-fill-small-icon v-if="size === BentoDropdownSize.SMALL" :svg-title="t('clearSearch')" /> <cross-circle-fill-icon v-else :svg-title="t('clearSearch')" /> </span> </bento-typography> </div> </template> <script setup lang="ts"> import { computed, defineComponent, inject, nextTick, type PropType, ref, useAttrs, useSlots, watch } from 'vue'; import { generateUid } from '@/core/utils/ts'; import { useI18n } from '@/utils/ts/i18n'; import { stopEventPropagation } from '@/utils/ts/events'; import { useHasSlot } from '@/composables'; import { BentoTypography } from '@/components/typography'; import { type Booleanish } from '@/types/prop-types'; import { type BentoListboxOptionItem } from '@/types/listbox'; import type { HTMLAttributes } from 'vue/types/jsx.d.ts'; import { DropdownBaseTextboxEvent, DropdownBaseTextboxState } from './dropdown-base-textbox.types'; import CrossCircleFillIcon from '@adyen/ui-assets-icons-16/vue/cross-circle-fill'; import CrossCircleFillSmallIcon from '@adyen/ui-assets-icons-16/vue/cross-circle-fill-small'; import { BentoDropdownSize } from '@/components/dropdown/dropdown.types'; import { DROPDOWN_UNDERLINE_STYLE_INJECTION_KEY } from '../../dropdown.keys'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const { t } = useI18n<{ message: MessageSchema }>({ messages }); const attrs = useAttrs(); const slots = useSlots(); const hasSlot = useHasSlot(slots); const props = defineProps({ /** * The number of additional items that have been selected in the dropdown. */ additionalItemsSelected: { type: [Number, String] as PropType<number | string>, default: 0 }, /** * If the combobox DOM element should be an `input` element. */ alwaysComboboxIsInput: { type: Boolean, default: false, }, /** * Sets the aria-controls attribute on the textbox. This should correspond to the popover's id that the textbox triggers. */ ariaControls: { type: String, required: true }, /** * Indicates whether the content the textbox triggers is visible or not. Needs to be set accordingly when the textbox is used and changed every time the popover is shown/hidden. */ ariaExpanded: { type: [String, Boolean] as PropType<Booleanish>, required: true, }, /** * If no label element is used then the aria-label is be set should be set. */ ariaLabel: { type: String, default: null }, /** * Corresponds to the label's id that labels the textbox element. */ ariaLabelledby: { type: String, default: null }, /** * Disables any textbox functionality */ disabled: { type: Boolean, default: false }, /** * Selected option's label */ displayValue: { type: String, default: null }, /** * Sets the possibility to type into the textbox. */ dynamicFiltering: { type: Boolean, default: false }, /** * Marks the textbox as invalid, rendering an error state */ isInvalid: { type: Boolean, default: false }, /* * If enabled, the user will be able to select multiple items. */ multiple: { type: Boolean, default: false }, /** * Indicates if the dropdown is open so that * the respective event can be emitted */ open: { type: Boolean, default: false }, /** * Sets the dropdown to readonly state */ readonly: { type: Boolean, default: false }, /** * The input value */ value: { type: String, default: '' }, /** * The selected value's list box item. */ selectedValueItem: { type: [Object, Array] as PropType<BentoListboxOptionItem | Array<BentoListboxOptionItem>>, default: undefined, }, /** * Toggles whether to use the slot content select items shown in the textbox in multiple mode. */ showSlotContentInMultiple: { type: Boolean, default: false }, /** * Dropdown size */ size: { type: String as PropType<BentoDropdownSize | `${BentoDropdownSize}`>, default: null, }, }); const emit = defineEmits<{ /** * Emitted when the "clear" button is clicked */ (e: DropdownBaseTextboxEvent.CLEAR); /** * Emitted on click, space, enter and esc user events. It will close the dropdown. */ (e: DropdownBaseTextboxEvent.CLOSE); /** * Emitted when `dynamicFiltering` is enabled and a search input is entered */ (e: DropdownBaseTextboxEvent.INPUT, searchValue: string); /** * Emitted when any key is pressed and the focus is on the input field */ (e: DropdownBaseTextboxEvent.KEYDOWN, event: KeyboardEvent); /** * Emitted on click, space and enter user events. It will open the dropdown. */ (e: DropdownBaseTextboxEvent.OPEN); }>(); const textbox = ref(null); const textboxId = generateUid('textbox'); // Inject underline style key to change the dropdown layout const isUnderlineStyle = inject(DROPDOWN_UNDERLINE_STYLE_INJECTION_KEY, false); const conditionalClasses = computed(() => ({ [`b-dropdown-base-textbox--${DropdownBaseTextboxState.DISABLED}`]: props.disabled, [`b-dropdown-base-textbox--${DropdownBaseTextboxState.READONLY}`]: props.readonly, [`b-dropdown-base-textbox--${DropdownBaseTextboxState.HAS_ITEMS}`]: !!props.additionalItemsSelected, 'b-dropdown-base-textbox--underline': isUnderlineStyle, })); const showRequiredAttribute = computed(() => shouldShowInput.value && attrs['aria-required'] ? ('true' as Booleanish) : null ); const comboboxConditionalClasses = computed(() => ({ [`b-dropdown-base-textbox__input--dynamic-filtering`]: props.dynamicFiltering, })); const isFiltering = computed(() => props.dynamicFiltering && !!props.value && props.open); const ariaAttributes = computed( () => ({ 'aria-haspopup': 'listbox', 'aria-controls': props.ariaControls, 'aria-describedby': attrs['aria-describedby']?.toString() as string, 'aria-expanded': props.ariaExpanded ? 'true' : 'false', 'aria-labelledby': props.ariaLabelledby, 'aria-label': props.ariaLabelledby ? null : props.ariaLabel, 'aria-invalid': props.isInvalid, 'aria-disabled': props.disabled ? true : null, 'aria-required': attrs['aria-required'] as Booleanish, }) as HTMLAttributes ); // Cast selectedValueItem if it is an array of selected items const selectedValueItems = computed(() => props.multiple ? (props.selectedValueItem as Array<BentoListboxOptionItem>) : [] ); const shouldShowInput = computed(() => props.dynamicFiltering && (props.open || props.alwaysComboboxIsInput)); const shouldShowSingleSelectSlot = computed(() => props.selectedValueItem && !props.multiple); const shouldShowMultipleSlot = computed( () => props.multiple && props.selectedValueItem && selectedValueItems.value.length > 0 && props.showSlotContentInMultiple ); watch( () => shouldShowInput.value, () => { if (shouldShowInput.value && !props.alwaysComboboxIsInput) { nextTick(() => { textbox.value.focus(); }); } } ); const onOpenDropdown = async () => { if (!props.disabled && !props.readonly) { emit(DropdownBaseTextboxEvent.OPEN); // wait for popover to open before setting focus await nextTick(); textbox.value.focus(); } }; const onCloseDropdown = (event?: Event) => { if (props.open) { if (event) { stopEventPropagation(event); } textbox.value.focus(); emit(DropdownBaseTextboxEvent.CLOSE); } }; const onToggleDropdown = (event: Event) => { const isDynamicFilteringInputClickEvent = props.open && (event.target as HTMLDivElement).tagName === 'INPUT'; if (isDynamicFilteringInputClickEvent) { /** * When there's a click even on the input during filtering * and the popover is open, do not toggle the popover. * Allow for click events inside the input field */ return null; } return props.open ? onCloseDropdown() : onOpenDropdown(); }; const onClearSearch = () => { emit(DropdownBaseTextboxEvent.CLEAR); emit(DropdownBaseTextboxEvent.INPUT, ''); textbox.value.focus(); }; const onInputEvent = (event: Event) => { if (!props.readonly && !props.disabled) { emit(DropdownBaseTextboxEvent.INPUT, (event.target as HTMLInputElement).value); } else { event.preventDefault(); event.stopPropagation(); } }; const focus = () => { textbox.value.focus(); }; defineExpose({ focus, }); </script> <script lang="ts"> /** * Only for internal use, implements the logic of the textbox element to be implemented in the combobox. */ export default defineComponent({ i18n: { messages }, name: 'dropdown-base-textbox', inheritAttrs: false, }); </script> <style lang="scss" scoped src="./dropdown-base-textbox.scss" />
|
|
1
|
+
<template> <div :class="$attrs.class" data-testid="textbox-wrapper" @click="onToggleDropdown" @keydown.escape="onCloseDropdown" > <bento-typography class="b-dropdown-base-textbox" :class="conditionalClasses" :variant="isUnderlineStyle ? 'title' : 'body'" el="div" data-testid="base-textbox" > <span v-if="shouldShowAutocompleteSuggestion(hasSlot('autocomplete-suggestion'))" class="b-dropdown-base-textbox__autocomplete-suggestion" aria-hidden="true" data-testid="autocomplete-suggestion" > <slot name="autocomplete-suggestion"></slot> </span> <input v-if="shouldShowInput" v-bind="ariaAttributes" :id="textboxId" ref="textbox" class="b-dropdown-base-textbox__input" role="combobox" type="text" :aria-autocomplete=" shouldShowAutocompleteSuggestion(hasSlot('autocomplete-suggestion')) ? 'both' : 'list' " size="1" :value="inputValue" :disabled="disabled ? true : null" :readonly="readonly" :required="showRequiredAttribute" @keydown="$emit(DropdownBaseTextboxEvent.KEYDOWN, $event)" @keydown.enter="onTextboxEnterKey" @focus="onTextboxFocus" @blur="onTextboxBlur" @input="onInputEvent" /> <div v-else :id="textboxId" v-bind="ariaAttributes" ref="textbox" class="b-dropdown-base-textbox__input" :class="comboboxConditionalClasses" :aria-readonly="readonly ? 'true' : null" role="combobox" :tabindex="disabled ? -1 : 0" @focus="onTextboxFocus" @mousedown="onClosedComboboxMouseDown" > <template v-if="shouldShowSingleSelectSlot"> <slot v-if="hasSlot('display-value')" v-bind="selectedValueItem" name="display-value"></slot> <slot v-else v-bind="selectedValueItem"> {{ displayValue }} </slot> </template> <template v-else-if="shouldShowMultipleSlot"> <div class="b-dropdown-base-textbox__input-multiple-selected"> <div v-for="singleItem of selectedValueItems" :key="singleItem.value"> <div class="b-dropdown-base-textbox__input-selected-element"> <slot v-bind="singleItem"> {{ displayValue }} </slot> </div> </div> </div> </template> <template v-else>{{ displayValue }}</template> </div> <span v-if="additionalItemsSelected" class="b-dropdown-base-textbox__additional-items"> {{ additionalItemsSelected }} </span> <span v-if="hasSlot('icon') && !isFiltering" class="b-dropdown-base-textbox__icon" aria-hidden="true"> <slot name="icon"></slot> </span> <span v-if="isFiltering" class="b-dropdown-base-textbox__clear-search" :tabindex="disabled ? -1 : 0" data-testid="clear-search-button" @click.stop="onClearSearch" @keydown.enter.stop="onClearSearch" @keyup.space="onClearSearch" > <cross-circle-fill-small-icon v-if="size === BentoDropdownSize.SMALL" :svg-title="t('clearSearch')" /> <cross-circle-fill-icon v-else :svg-title="t('clearSearch')" /> </span> </bento-typography> </div> </template> <script setup lang="ts"> import { computed, defineComponent, inject, nextTick, type PropType, ref, useAttrs, useSlots, watch } from 'vue'; import { generateUid } from '@/core/utils/ts'; import { useI18n } from '@/utils/ts/i18n'; import { stopEventPropagation } from '@/utils/ts/events'; import { useHasSlot } from '@/composables'; import { BentoTypography } from '@/components/typography'; import { type Booleanish } from '@/types/prop-types'; import { type BentoListboxOptionItem } from '@/types/listbox'; import type { HTMLAttributes } from 'vue/types/jsx.d.ts'; import { DropdownBaseTextboxEvent, DropdownBaseTextboxState } from './dropdown-base-textbox.types'; import CrossCircleFillIcon from '@adyen/ui-assets-icons-16/vue/cross-circle-fill'; import CrossCircleFillSmallIcon from '@adyen/ui-assets-icons-16/vue/cross-circle-fill-small'; import { BentoDropdownSize } from '@/components/dropdown/dropdown.types'; import { DROPDOWN_UNDERLINE_STYLE_INJECTION_KEY } from '../../dropdown.keys'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const { t } = useI18n<{ message: MessageSchema }>({ messages }); const attrs = useAttrs(); const slots = useSlots(); const hasSlot = useHasSlot(slots); const props = defineProps({ /** * The number of additional items that have been selected in the dropdown. */ additionalItemsSelected: { type: [Number, String] as PropType<number | string>, default: 0 }, /** * If the combobox DOM element should be an `input` element. */ alwaysComboboxIsInput: { type: Boolean, default: false, }, /** * Sets the aria-controls attribute on the textbox. This should correspond to the popover's id that the textbox triggers. */ ariaControls: { type: String, required: true }, /** * Indicates whether the content the textbox triggers is visible or not. Needs to be set accordingly when the textbox is used and changed every time the popover is shown/hidden. */ ariaExpanded: { type: [String, Boolean] as PropType<Booleanish>, required: true, }, /** * If no label element is used then the aria-label is be set should be set. */ ariaLabel: { type: String, default: null }, /** * Identifies the currently active option in the listbox. */ ariaActiveDescendant: { type: String, default: null }, /** * Corresponds to the label's id that labels the textbox element. */ ariaLabelledby: { type: String, default: null }, /** * Disables any textbox functionality */ disabled: { type: Boolean, default: false }, /** * Selected option's label */ displayValue: { type: String, default: null }, /** * Sets the possibility to type into the textbox. */ dynamicFiltering: { type: Boolean, default: false }, /** * Indicates whether the dropdown currently has a selected value. */ hasSelectedValue: { type: Boolean, default: false }, /** * Marks the textbox as invalid, rendering an error state */ isInvalid: { type: Boolean, default: false }, /* * If enabled, the user will be able to select multiple items. */ multiple: { type: Boolean, default: false }, /** * Indicates if the dropdown is open so that * the respective event can be emitted */ open: { type: Boolean, default: false }, /** * Sets the dropdown to readonly state */ readonly: { type: Boolean, default: false }, /** * The input value */ value: { type: String, default: '' }, /** * The selected value's list box item. */ selectedValueItem: { type: [Object, Array] as PropType<BentoListboxOptionItem | Array<BentoListboxOptionItem>>, default: undefined, }, /** * Toggles whether to use the slot content select items shown in the textbox in multiple mode. */ showSlotContentInMultiple: { type: Boolean, default: false }, /** * Dropdown size */ size: { type: String as PropType<BentoDropdownSize | `${BentoDropdownSize}`>, default: null, }, }); const emit = defineEmits<{ /** * Emitted when the "clear" button is clicked */ (e: DropdownBaseTextboxEvent.CLEAR); /** * Emitted on click, space, enter and esc user events. It will close the dropdown. */ (e: DropdownBaseTextboxEvent.CLOSE); /** * Emitted when `dynamicFiltering` is enabled and a search input is entered */ (e: DropdownBaseTextboxEvent.INPUT, searchValue: string); /** * Emitted when any key is pressed and the focus is on the input field */ (e: DropdownBaseTextboxEvent.KEYDOWN, event: KeyboardEvent); /** * Emitted on click, space and enter user events. It will open the dropdown. */ (e: DropdownBaseTextboxEvent.OPEN); }>(); const textbox = ref(null); const textboxId = generateUid('textbox'); const isTextboxFocused = ref(false); const localInputValue = ref(props.value); // Inject underline style key to change the dropdown layout const isUnderlineStyle = inject(DROPDOWN_UNDERLINE_STYLE_INJECTION_KEY, false); const conditionalClasses = computed(() => ({ [`b-dropdown-base-textbox--${DropdownBaseTextboxState.DISABLED}`]: props.disabled, [`b-dropdown-base-textbox--${DropdownBaseTextboxState.READONLY}`]: props.readonly, [`b-dropdown-base-textbox--${DropdownBaseTextboxState.HAS_ITEMS}`]: !!props.additionalItemsSelected, 'b-dropdown-base-textbox--underline': isUnderlineStyle, })); const showRequiredAttribute = computed(() => shouldShowInput.value && attrs['aria-required'] ? ('true' as Booleanish) : null ); const comboboxConditionalClasses = computed(() => ({ [`b-dropdown-base-textbox__input--dynamic-filtering`]: props.dynamicFiltering, })); const isFiltering = computed(() => props.dynamicFiltering && !!props.value && props.open); const shouldShowAutocompleteSuggestion = (hasAutocompleteSuggestionSlot: boolean) => shouldShowInput.value && !props.multiple && !!props.value && hasAutocompleteSuggestionSlot; const ariaAttributes = computed( () => ({ 'aria-haspopup': 'listbox', 'aria-controls': props.ariaControls, 'aria-describedby': attrs['aria-describedby']?.toString() as string, 'aria-expanded': props.ariaExpanded ? 'true' : 'false', 'aria-activedescendant': props.ariaActiveDescendant, 'aria-labelledby': props.ariaLabelledby, 'aria-label': props.ariaLabelledby ? null : props.ariaLabel, 'aria-invalid': props.isInvalid, 'aria-disabled': props.disabled ? true : null, 'aria-required': attrs['aria-required'] as Booleanish, }) as HTMLAttributes ); // Cast selectedValueItem if it is an array of selected items const selectedValueItems = computed(() => props.multiple ? (props.selectedValueItem as Array<BentoListboxOptionItem>) : [] ); const shouldUseDynamicFilteringInputMode = computed( () => props.dynamicFiltering && !props.multiple && !props.alwaysComboboxIsInput ); const shouldShowSearchValue = computed( () => props.open || props.alwaysComboboxIsInput || (shouldUseDynamicFilteringInputMode.value && isTextboxFocused.value) ); const shouldShowInput = computed(() => props.dynamicFiltering && shouldShowSearchValue.value); const getInitialInputValue = () => { if (!props.value && props.hasSelectedValue && shouldUseDynamicFilteringInputMode.value) { return props.displayValue ?? ''; } return props.value; }; const inputValue = computed(() => { if (!shouldShowSearchValue.value || (props.alwaysComboboxIsInput && !props.open)) { return props.displayValue; } if (shouldUseDynamicFilteringInputMode.value) { return localInputValue.value; } return props.value; }); const shouldShowSingleSelectSlot = computed(() => props.selectedValueItem && !props.multiple); const shouldShowMultipleSlot = computed( () => props.multiple && props.selectedValueItem && selectedValueItems.value.length > 0 && props.showSlotContentInMultiple ); watch( () => shouldShowInput.value, (shouldShowInput, wasShowingInput) => { if (!shouldShowInput) { localInputValue.value = props.value; } else if (!wasShowingInput && shouldUseDynamicFilteringInputMode.value) { localInputValue.value = getInitialInputValue(); } if (shouldShowInput && !props.alwaysComboboxIsInput) { nextTick(() => { textbox.value?.focus(); if ( !props.open && !props.value && props.hasSelectedValue && shouldUseDynamicFilteringInputMode.value ) { textbox.value?.select(); } }); } } ); watch( () => props.value, newValue => { if (!shouldUseDynamicFilteringInputMode.value || newValue !== localInputValue.value) { localInputValue.value = newValue; } } ); watch( () => props.displayValue, () => { if (shouldUseDynamicFilteringInputMode.value && props.hasSelectedValue) { localInputValue.value = getInitialInputValue(); } } ); watch( () => props.open, open => { if (!open) { isTextboxFocused.value = false; } } ); const onOpenDropdown = async () => { if (!props.disabled && !props.readonly) { emit(DropdownBaseTextboxEvent.OPEN); // wait for popover to open before setting focus await nextTick(); textbox.value.focus(); } }; const onCloseDropdown = (event?: Event) => { if (props.open) { if (event) { stopEventPropagation(event); } textbox.value.focus(); emit(DropdownBaseTextboxEvent.CLOSE); } }; const onToggleDropdown = (event: Event) => { const isDynamicFilteringInputClickEvent = props.open && (event.target as HTMLDivElement).tagName === 'INPUT'; if (isDynamicFilteringInputClickEvent) { /** * When there's a click even on the input during filtering * and the popover is open, do not toggle the popover. * Allow for click events inside the input field */ return null; } return props.open ? onCloseDropdown() : onOpenDropdown(); }; const onTextboxEnterKey = (event: KeyboardEvent) => { if (props.dynamicFiltering) { return null; } return onToggleDropdown(event); }; const onClearSearch = () => { emit(DropdownBaseTextboxEvent.CLEAR); emit(DropdownBaseTextboxEvent.INPUT, ''); textbox.value.focus(); }; const onTextboxFocus = () => { isTextboxFocused.value = true; }; const onTextboxBlur = () => { isTextboxFocused.value = false; }; const onClosedComboboxMouseDown = (event: MouseEvent) => { if (!shouldUseDynamicFilteringInputMode.value || props.open || props.readonly || props.disabled) { return; } event.preventDefault(); event.stopPropagation(); isTextboxFocused.value = true; emit(DropdownBaseTextboxEvent.OPEN); }; const onInputEvent = (event: Event) => { if (!props.readonly && !props.disabled) { if (shouldUseDynamicFilteringInputMode.value) { localInputValue.value = (event.target as HTMLInputElement).value; } if (props.dynamicFiltering && !props.open) { emit(DropdownBaseTextboxEvent.OPEN); } emit(DropdownBaseTextboxEvent.INPUT, (event.target as HTMLInputElement).value); nextTick(() => { if (shouldShowInput.value) { textbox.value?.focus(); } }); } else { event.preventDefault(); event.stopPropagation(); } }; const focus = () => { textbox.value.focus(); }; defineExpose({ focus, }); </script> <script lang="ts"> /** * Only for internal use, implements the logic of the textbox element to be implemented in the combobox. */ export default defineComponent({ i18n: { messages }, name: 'dropdown-base-textbox', inheritAttrs: false, }); </script> <style lang="scss" scoped src="./dropdown-base-textbox.scss" />
|
|
@@ -1 +1 @@
|
|
|
1
|
-
<template> <div class="b-dropdown-default-textbox" :class="conditionalClasses" data-testid="default-textbox"> <dropdown-base-textbox ref="baseTextbox" v-bind="{ ...$attrs, ...$props }" class="b-dropdown-default-textbox__textbox" :value="value" v-on="listeners" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope ?? {}" /> </template> <template #icon> <div class="b-dropdown-default-textbox__chevron"> <chevron-up-icon v-show="isDropdownOpen" svg-title="opened" /> <chevron-down-icon v-show="!isDropdownOpen" svg-title="closed" /> </div> </template> </dropdown-base-textbox> </div> </template> <script lang="ts" setup> import { computed, defineComponent, type PropType, ref, useSlots } from 'vue'; import DropdownBaseTextbox from '@/components/dropdown/components/dropdown-base-textbox/dropdown-base-textbox.vue'; import { type Booleanish } from '@/types/prop-types'; import { type BentoListboxOptionItem } from '@/types/listbox'; import { stopEventPropagation } from '@/utils/ts/events'; import { DropdownBaseTextboxEvent, DropdownBaseTextboxState, } from '../dropdown-base-textbox/dropdown-base-textbox.types'; import ChevronDownIcon from '@adyen/ui-assets-icons-16/vue/chevron-down'; import ChevronUpIcon from '@adyen/ui-assets-icons-16/vue/chevron-up'; const emit = defineEmits<{ /** * Emitted on clearing the input. */ (e: 'clear'); /** * Emitted on click, space and enter user events. It will open the dropdown. */ (e: 'close'); /** * Emitted on input event. */ (e: 'input', newInput: string); /** * Emitted on keydown event. */ (e: 'keydown', keydownEvent: KeyboardEvent); /** * Emitted on click, space, enter and esc user events. It will close the dropdown. */ (e: 'open'); }>(); const slots = useSlots(); const props = defineProps({ /** * The number of additional items that have been selected in the dropdown. */ additionalItemsSelected: { type: [Number, String] as PropType<number | string>, default: 0 }, /** * Sets the aria-controls attribute on the textbox. This should correspond to the popover's id that the textbox triggers. */ ariaControls: { type: String, required: true, }, /** * Indicates whether the content the textbox triggers is visible or not. Needs to be set accordingly when the textbox is used and changed every time the popover is shown/hidden. */ ariaExpanded: { type: [String, Boolean] as PropType<Booleanish>, required: true, }, /** * If no label element is used then the aria-label is be set should be set. */ ariaLabel: { type: String, default: null, }, /** * Corresponds to the label's id that labels the textbox element. */ ariaLabelledby: { type: String, default: null, }, /** * Renders the dropdown with a condensed style. */ condensed: { type: Boolean, default: false }, /** * Disables any textbox functionality */ disabled: { type: Boolean, default: false }, /** * Selected option's label */ displayValue: { type: String, default: null }, /** * Sets the possibility to type into the textbox */ dynamicFiltering: { type: Boolean, default: false, }, /** * Marks the textbox as invalid, rendering an error state */ isInvalid: { type: Boolean, default: false }, /** * Indicates if the dropdown is open so that * the respective event can be emitted */ open: { type: Boolean, default: false }, /** * Sets the dropdown to readonly state */ readonly: { type: Boolean, default: false }, /** * The selected value's list box item. */ selectedValueItem: { type: [Object, Array] as PropType<BentoListboxOptionItem | Array<BentoListboxOptionItem>>, default: undefined, }, /** * Toggles whether to use the slot content select items shown in the textbox in multiple mode. */ showSlotContentInMultiple: { type: Boolean, default: false }, /** * The input value */ value: { type: String, default: '' }, /* * If enabled, the user will be able to select multiple items. */ multiple: { type: Boolean, default: false }, }); const baseTextbox = ref(null); const conditionalClasses = computed(() => ({ [`b-dropdown-default-textbox--${DropdownBaseTextboxState.ERROR}`]: !props.disabled && !props.readonly && props.isInvalid, [`b-dropdown-default-textbox--${DropdownBaseTextboxState.DISABLED}`]: props.disabled, [`b-dropdown-default-textbox--${DropdownBaseTextboxState.READONLY}`]: props.readonly, [`b-dropdown-default-textbox--${DropdownBaseTextboxState.OPENED}`]: props.open, 'b-dropdown-default-textbox--condensed': props.condensed, })); const isDropdownOpen = computed(() => props.open && !props.disabled && !props.readonly); const onInput = (newInput: string) => { emit('input', newInput); }; const onKeyDown = (e: KeyboardEvent) => { emit('keydown', e); }; const openDropdown = (e: Event) => { stopEventPropagation(e); emit('open'); }; const closeDropdown = (e: Event) => { stopEventPropagation(e); emit('close'); }; const clearValue = (e: Event) => { stopEventPropagation(e); emit('clear'); }; const listeners = { [DropdownBaseTextboxEvent.INPUT]: onInput, [DropdownBaseTextboxEvent.OPEN]: openDropdown, [DropdownBaseTextboxEvent.CLOSE]: closeDropdown, [DropdownBaseTextboxEvent.CLEAR]: clearValue, [DropdownBaseTextboxEvent.KEYDOWN]: onKeyDown, }; const focus = () => { baseTextbox.value.focus(); }; defineExpose({ focus, }); </script> <script lang="ts"> /** * For internal use only, textbox component used in the dropdown component. */ export default defineComponent({ name: 'dropdown-default-textbox', inheritAttrs: false, }); </script> <style lang="scss" scoped src="./dropdown-default-textbox.scss" />
|
|
1
|
+
<template> <div class="b-dropdown-default-textbox" :class="conditionalClasses" data-testid="default-textbox"> <dropdown-base-textbox ref="baseTextbox" v-bind="{ ...$attrs, ...$props }" class="b-dropdown-default-textbox__textbox" :value="value" v-on="listeners" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope ?? {}" /> </template> <template #icon> <div class="b-dropdown-default-textbox__chevron"> <chevron-up-icon v-show="isDropdownOpen" svg-title="opened" /> <chevron-down-icon v-show="!isDropdownOpen" svg-title="closed" /> </div> </template> </dropdown-base-textbox> </div> </template> <script lang="ts" setup> import { computed, defineComponent, type PropType, ref, useSlots } from 'vue'; import DropdownBaseTextbox from '@/components/dropdown/components/dropdown-base-textbox/dropdown-base-textbox.vue'; import { type Booleanish } from '@/types/prop-types'; import { type BentoListboxOptionItem } from '@/types/listbox'; import { stopEventPropagation } from '@/utils/ts/events'; import { DropdownBaseTextboxEvent, DropdownBaseTextboxState, } from '../dropdown-base-textbox/dropdown-base-textbox.types'; import ChevronDownIcon from '@adyen/ui-assets-icons-16/vue/chevron-down'; import ChevronUpIcon from '@adyen/ui-assets-icons-16/vue/chevron-up'; const emit = defineEmits<{ /** * Emitted on clearing the input. */ (e: 'clear'); /** * Emitted on click, space and enter user events. It will open the dropdown. */ (e: 'close'); /** * Emitted on input event. */ (e: 'input', newInput: string); /** * Emitted on keydown event. */ (e: 'keydown', keydownEvent: KeyboardEvent); /** * Emitted on click, space, enter and esc user events. It will close the dropdown. */ (e: 'open'); }>(); const slots = useSlots(); const props = defineProps({ /** * The number of additional items that have been selected in the dropdown. */ additionalItemsSelected: { type: [Number, String] as PropType<number | string>, default: 0 }, /** * Sets the aria-controls attribute on the textbox. This should correspond to the popover's id that the textbox triggers. */ ariaControls: { type: String, required: true, }, /** * Identifies the currently active option in the listbox. */ ariaActiveDescendant: { type: String, default: null, }, /** * Indicates whether the content the textbox triggers is visible or not. Needs to be set accordingly when the textbox is used and changed every time the popover is shown/hidden. */ ariaExpanded: { type: [String, Boolean] as PropType<Booleanish>, required: true, }, /** * If no label element is used then the aria-label is be set should be set. */ ariaLabel: { type: String, default: null, }, /** * Corresponds to the label's id that labels the textbox element. */ ariaLabelledby: { type: String, default: null, }, /** * Renders the dropdown with a condensed style. */ condensed: { type: Boolean, default: false }, /** * Disables any textbox functionality */ disabled: { type: Boolean, default: false }, /** * Selected option's label */ displayValue: { type: String, default: null }, /** * Sets the possibility to type into the textbox */ dynamicFiltering: { type: Boolean, default: false, }, /** * Marks the textbox as invalid, rendering an error state */ isInvalid: { type: Boolean, default: false }, /** * Indicates if the dropdown is open so that * the respective event can be emitted */ open: { type: Boolean, default: false }, /** * Sets the dropdown to readonly state */ readonly: { type: Boolean, default: false }, /** * The selected value's list box item. */ selectedValueItem: { type: [Object, Array] as PropType<BentoListboxOptionItem | Array<BentoListboxOptionItem>>, default: undefined, }, /** * Toggles whether to use the slot content select items shown in the textbox in multiple mode. */ showSlotContentInMultiple: { type: Boolean, default: false }, /** * The input value */ value: { type: String, default: '' }, /* * If enabled, the user will be able to select multiple items. */ multiple: { type: Boolean, default: false }, }); const baseTextbox = ref(null); const conditionalClasses = computed(() => ({ [`b-dropdown-default-textbox--${DropdownBaseTextboxState.ERROR}`]: !props.disabled && !props.readonly && props.isInvalid, [`b-dropdown-default-textbox--${DropdownBaseTextboxState.DISABLED}`]: props.disabled, [`b-dropdown-default-textbox--${DropdownBaseTextboxState.READONLY}`]: props.readonly, [`b-dropdown-default-textbox--${DropdownBaseTextboxState.OPENED}`]: props.open, 'b-dropdown-default-textbox--condensed': props.condensed, })); const isDropdownOpen = computed(() => props.open && !props.disabled && !props.readonly); const onInput = (newInput: string) => { emit('input', newInput); }; const onKeyDown = (e: KeyboardEvent) => { emit('keydown', e); }; const openDropdown = (e: Event) => { stopEventPropagation(e); emit('open'); }; const closeDropdown = (e: Event) => { stopEventPropagation(e); emit('close'); }; const clearValue = (e: Event) => { stopEventPropagation(e); emit('clear'); }; const listeners = { [DropdownBaseTextboxEvent.INPUT]: onInput, [DropdownBaseTextboxEvent.OPEN]: openDropdown, [DropdownBaseTextboxEvent.CLOSE]: closeDropdown, [DropdownBaseTextboxEvent.CLEAR]: clearValue, [DropdownBaseTextboxEvent.KEYDOWN]: onKeyDown, }; const focus = () => { baseTextbox.value.focus(); }; defineExpose({ focus, }); </script> <script lang="ts"> /** * For internal use only, textbox component used in the dropdown component. */ export default defineComponent({ name: 'dropdown-default-textbox', inheritAttrs: false, }); </script> <style lang="scss" scoped src="./dropdown-default-textbox.scss" />
|
|
@@ -1 +1 @@
|
|
|
1
|
-
<template> <component :is="popperContainerOrPopoverComponent" v-if="isDropdownOpen" class="b-dropdown-options-container" :class="conditionalClasses" v-bind="popperContainerOrPopoverProps" > <div @keydown.stop @keydown.esc="onEscapeKey"> <bento-listbox :id="id" ref="dropdownOptionsListboxRef" :aria-label="ariaLabel" class="b-dropdown-options-container__listbox" :class="conditionalListboxClasses" :component-loading="componentLoading" :empty-state="emptyState" :is-option-disabled="isOptionDisabled" :items="items" :selected-value="internalSelectedValue" :multiple="multiple" :static-categories="staticCategories" :no-results-message="noResultsMessage" :searching="searching" :loading="loading" :lazy-load-type="lazyLoadType" :has-more-items="hasMoreItems" :virtual-scroll="virtualScroll" v-on="listeners" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </bento-listbox> </div> </component> </template> <script setup lang="ts"> import { computed, defineComponent, type PropType, ref, toRefs, useSlots } from 'vue'; import { BentoListbox } from '@/internal/listbox'; import { type BentoListboxEvent, type BentoListboxIsOptionDisabled, type BentoListboxOptions, } from '@/types/listbox'; import { PopperContainer } from '@/components/popper-container'; import { type BentoDropdownEmptyStateProps, type BentoDropdownEvent, BentoDropdownLazyLoadType, type BentoDropdownVirtulisationOptions, } from '../../dropdown.types'; import { useI18n } from '@/utils/ts/i18n'; import { BentoPopover } from '@/components/popover'; import { useDropdownOptionsListbox } from './composables/use-dropdown-options-listbox'; import { generateUid } from '@/core/utils/ts'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const slots = useSlots(); const emit = defineEmits<{ /** * Emitted when the dropdown is closing. */ (e: BentoDropdownEvent.CLOSE_DROPDOWN); /** * Emitted when the `ENTER` key is pressed */ (e: BentoListboxEvent.ENTER, newSelectedValue: BentoListboxOptions); /** * Emitted when the `ESCAPE` key is pressed */ (e: BentoListboxEvent.ESCAPE); /** * Emitted when an item is selected */ (e: BentoListboxEvent.SELECT, newSelectedValue: BentoListboxOptions); /** * Emitted when: * - `lazyLoad` is `automatic` and the scroll meets the end of the list * - `lazyload` is `button` and the button is clicked */ (e: BentoDropdownEvent.SHOW_MORE); /** * Emitted when the `SPACE` key is pressed */ (e: BentoListboxEvent.SPACE, newSelectedValue: BentoListboxOptions); /** * Emitted when the `TAB` key is pressed */ (e: BentoListboxEvent.TAB, newSelectedValue: BentoListboxOptions); }>(); const props = defineProps({ /** * Defines a string value that labels an interactive element. */ ariaLabel: { type: String, default: null }, /** * Indicates that dropdown items are loading */ componentLoading: { type: Boolean, default: false }, /** * Empty state props used with the inner empty state component which is displayed when no search results are found. */ emptyState: { type: Object as PropType<BentoDropdownEmptyStateProps>, default: undefined, }, /** * Disables dropdown functionality */ disabled: { type: Boolean, default: false }, /** * Indicates whether there are more items to load */ hasMoreItems: { type: Boolean, default: false }, /** * Identifies the listbox whose contents are controlled by the the combobox on which the aria-controls attribute is set. */ id: { type: String, required: true }, /** * Function that allows the options to be disabled * * @type {BentoDropdownIsOptionDisabled} * @param {BentoDropdownOptionItem} option - Dropdown option object * @param {string} option.label - Option's label text * @param {string|number} option.value - Option's value * @param {unknown} option.data - Option's extra data to be passed to the default slot */ isOptionDisabled: { type: Function as PropType<BentoListboxIsOptionDisabled>, default: undefined, }, /** * The option elements to populate the dropdown with. * It must be an array of {@see BentoDropdownOptionItem } * * @property {string} value.label - Text to be displayed in the option * * @property {string,number} value.value - Value of the option */ items: { type: Array as PropType<BentoListboxOptions>, required: true, }, /** * The type of `Lazy Load`. * Type "automatic" will enable infinite scrolling * Type "button" will enable lazy loading with "Show more" button */ lazyLoadType: { type: String as PropType<BentoDropdownLazyLoadType | `${BentoDropdownLazyLoadType}`>, default: BentoDropdownLazyLoadType.AUTOMATIC, validator: (value: BentoDropdownLazyLoadType) => Object.values(BentoDropdownLazyLoadType).includes(value), }, /** * Indicates if new options are lazy loading. */ loading: { type: Boolean, default: false }, /** * If enabled, the user will be able to select multiple items. */ multiple: { type: Boolean, default: false }, /** * Indicates if the dropdown is open so that * the container can be displayed. */ open: { type: Boolean, required: true }, /** * The `input` value. * Providing an empty string or empty array will select no options. * Set to an empty string `''` if you don't want any of the available options to be selected for single select */ selectedValue: { type: Array as PropType<BentoListboxOptions>, default: null, }, /** * Flag that indicates that an external search it's being made * to filter out the items inside the the multi-select listbox * * If enabled, hides the "Select all" checkbox in "multiple" mode. */ searching: BentoListbox.props.searching, /** * If enabled, dropdown will display non-selectable, static categories */ staticCategories: { type: Boolean, default: false }, /** * Reference to the DOM element or Vue component for positioning * the Dropdown container. */ targetElement: { type: PopperContainer.props.targetElement.type, required: true }, /** * Enables virtual scrolling if set to true or by providing an object with itemHeight function. * The itemHeight function is used to calculate the height of rendered item given it's index. */ virtualScroll: { type: [Boolean, Object] as PropType<BentoDropdownVirtulisationOptions>, default: false, }, }); const { selectedValue, multiple } = toRefs(props); const dropdownOptionsListboxRef = ref(null); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const isDropdownOpen = computed(() => props.open && !props.disabled); const noResultsMessage = computed(() => t('noOptionsMatchThisSearch') as string); const conditionalClasses = computed(() => ({ 'b-dropdown-options-container--single': !props?.multiple, })); const conditionalListboxClasses = computed(() => ({ 'b-dropdown-options-container__listbox--single': !props?.multiple, })); const { actions, internalSelectedValue, listeners, onEscapeKey, onOutsideDropdownClick } = useDropdownOptionsListbox(selectedValue, multiple, isDropdownOpen, emit); // Use different components depending on if the dropdown is a single or multi-select const popperContainerOrPopoverComponent = computed(() => (props?.multiple ? BentoPopover : PopperContainer)); const popperContainerOrPopoverProps = computed(() => { const containerMinAndMaxWidth = { 'min-width': `${(props?.targetElement as HTMLElement)?.clientWidth}px`, 'max-width': 'min(500px, 95%)', }; return props?.multiple ? ({ actionsLayout: 'space-between', ariaLabel: props.ariaLabel, actions: actions.value, divider: true, fallbackPosition: ['top-start'], id: generateUid(`dropdown-options-${props.id}`), open: props.open, position: 'bottom-start', style: { width: 'auto', ...containerMinAndMaxWidth, }, targetElement: props.targetElement, } as InstanceType<typeof BentoPopover>['$props']) : ({ fallbackPosition: ['top-start'], offset: [0, 8], position: 'bottom-start', style: { display: isDropdownOpen.value ? 'block' : 'none', ...containerMinAndMaxWidth, }, targetElement: props.targetElement, } as InstanceType<typeof PopperContainer>['$props']); }); defineExpose({ onOutsideDropdownClick, dropdownOptionsListboxRef, }); </script> <script lang="ts"> /** * Dropdown options container. * It lists all the available options. * * @example * <bento-dropdown-options-container * v-if="inputContainerRef" <!-- Ref to the input to match the width --> * :id="dropdownOptionsContainerId" * :target-element="inputContainerRef" * :aria-label="ariaLabel" * :open="isDropdownOpen" * :disabled="disabled" * :multiple="multiple" * :selected="value" * :isOptionDisabled="option => option.value === 2" * :items="[{ * { label: 'Option 1', value: 1 }, * { label: 'Option 2', value: 2 }, * }]" * @select="onOptionSelected" * /> */ export default defineComponent({ i18n: { messages }, name: 'bento-dropdown-options-container', }); </script> <style lang="scss" scoped src="./dropdown-options-container.scss" />
|
|
1
|
+
<template> <component :is="popperContainerOrPopoverComponent" v-if="isDropdownOpen" class="b-dropdown-options-container" :class="conditionalClasses" v-bind="popperContainerOrPopoverProps" > <div @keydown.stop @keydown.esc="onEscapeKey"> <bento-listbox :id="id" ref="dropdownOptionsListboxRef" :active-descendant-id="activeDescendantId" :aria-label="ariaLabel" class="b-dropdown-options-container__listbox" :class="conditionalListboxClasses" :component-loading="componentLoading" :empty-state="emptyState" :is-option-disabled="isOptionDisabled" :items="items" :selected-value="internalSelectedValue" :multiple="multiple" :static-categories="staticCategories" :no-results-message="noResultsMessage" :searching="searching" :loading="loading" :lazy-load-type="lazyLoadType" :has-more-items="hasMoreItems" :virtual-scroll="virtualScroll" v-on="listeners" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </bento-listbox> </div> </component> </template> <script setup lang="ts"> import { computed, defineComponent, type PropType, ref, toRefs, useSlots } from 'vue'; import { BentoListbox } from '@/internal/listbox'; import { type BentoListboxEvent, type BentoListboxIsOptionDisabled, type BentoListboxOptions, } from '@/types/listbox'; import { PopperContainer } from '@/components/popper-container'; import { type BentoDropdownEmptyStateProps, type BentoDropdownEvent, BentoDropdownLazyLoadType, type BentoDropdownVirtulisationOptions, } from '../../dropdown.types'; import { useI18n } from '@/utils/ts/i18n'; import { BentoPopover } from '@/components/popover'; import { useDropdownOptionsListbox } from './composables/use-dropdown-options-listbox'; import { generateUid } from '@/core/utils/ts'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const slots = useSlots(); const emit = defineEmits<{ /** * Emitted when the dropdown is closing. */ (e: BentoDropdownEvent.CLOSE_DROPDOWN); /** * Emitted when the `ENTER` key is pressed */ (e: BentoListboxEvent.ENTER, newSelectedValue: BentoListboxOptions); /** * Emitted when the `ESCAPE` key is pressed */ (e: BentoListboxEvent.ESCAPE); /** * Emitted when an item is selected */ (e: BentoListboxEvent.SELECT, newSelectedValue: BentoListboxOptions); /** * Emitted when: * - `lazyLoad` is `automatic` and the scroll meets the end of the list * - `lazyload` is `button` and the button is clicked */ (e: BentoDropdownEvent.SHOW_MORE); /** * Emitted when the `SPACE` key is pressed */ (e: BentoListboxEvent.SPACE, newSelectedValue: BentoListboxOptions); /** * Emitted when the `TAB` key is pressed */ (e: BentoListboxEvent.TAB, newSelectedValue: BentoListboxOptions); }>(); const props = defineProps({ /** * Defines a string value that labels an interactive element. */ ariaLabel: { type: String, default: null }, /** * Identifies the currently active option in the listbox. */ activeDescendantId: { type: String, default: null }, /** * Indicates that dropdown items are loading */ componentLoading: { type: Boolean, default: false }, /** * Empty state props used with the inner empty state component which is displayed when no search results are found. */ emptyState: { type: Object as PropType<BentoDropdownEmptyStateProps>, default: undefined, }, /** * Disables dropdown functionality */ disabled: { type: Boolean, default: false }, /** * Indicates whether there are more items to load */ hasMoreItems: { type: Boolean, default: false }, /** * Identifies the listbox whose contents are controlled by the the combobox on which the aria-controls attribute is set. */ id: { type: String, required: true }, /** * Function that allows the options to be disabled * * @type {BentoDropdownIsOptionDisabled} * @param {BentoDropdownOptionItem} option - Dropdown option object * @param {string} option.label - Option's label text * @param {string|number} option.value - Option's value * @param {unknown} option.data - Option's extra data to be passed to the default slot */ isOptionDisabled: { type: Function as PropType<BentoListboxIsOptionDisabled>, default: undefined, }, /** * The option elements to populate the dropdown with. * It must be an array of {@see BentoDropdownOptionItem } * * @property {string} value.label - Text to be displayed in the option * * @property {string,number} value.value - Value of the option */ items: { type: Array as PropType<BentoListboxOptions>, required: true, }, /** * The type of `Lazy Load`. * Type "automatic" will enable infinite scrolling * Type "button" will enable lazy loading with "Show more" button */ lazyLoadType: { type: String as PropType<BentoDropdownLazyLoadType | `${BentoDropdownLazyLoadType}`>, default: BentoDropdownLazyLoadType.AUTOMATIC, validator: (value: BentoDropdownLazyLoadType) => Object.values(BentoDropdownLazyLoadType).includes(value), }, /** * Indicates if new options are lazy loading. */ loading: { type: Boolean, default: false }, /** * If enabled, the user will be able to select multiple items. */ multiple: { type: Boolean, default: false }, /** * Indicates if the dropdown is open so that * the container can be displayed. */ open: { type: Boolean, required: true }, /** * The `input` value. * Providing an empty string or empty array will select no options. * Set to an empty string `''` if you don't want any of the available options to be selected for single select */ selectedValue: { type: Array as PropType<BentoListboxOptions>, default: null, }, /** * Flag that indicates that an external search it's being made * to filter out the items inside the the multi-select listbox * * If enabled, hides the "Select all" checkbox in "multiple" mode. */ searching: BentoListbox.props.searching, /** * If enabled, dropdown will display non-selectable, static categories */ staticCategories: { type: Boolean, default: false }, /** * Reference to the DOM element or Vue component for positioning * the Dropdown container. */ targetElement: { type: PopperContainer.props.targetElement.type, required: true }, /** * Enables virtual scrolling if set to true or by providing an object with itemHeight function. * The itemHeight function is used to calculate the height of rendered item given it's index. */ virtualScroll: { type: [Boolean, Object] as PropType<BentoDropdownVirtulisationOptions>, default: false, }, }); const { selectedValue, multiple } = toRefs(props); const dropdownOptionsListboxRef = ref(null); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const isDropdownOpen = computed(() => props.open && !props.disabled); const noResultsMessage = computed(() => t('noOptionsMatchThisSearch') as string); const conditionalClasses = computed(() => ({ 'b-dropdown-options-container--single': !props?.multiple, })); const conditionalListboxClasses = computed(() => ({ 'b-dropdown-options-container__listbox--single': !props?.multiple, })); const { actions, internalSelectedValue, listeners, onEscapeKey, onOutsideDropdownClick } = useDropdownOptionsListbox(selectedValue, multiple, isDropdownOpen, emit); // Use different components depending on if the dropdown is a single or multi-select const popperContainerOrPopoverComponent = computed(() => (props?.multiple ? BentoPopover : PopperContainer)); const popperContainerOrPopoverProps = computed(() => { const containerMinAndMaxWidth = { 'min-width': `${(props?.targetElement as HTMLElement)?.clientWidth}px`, 'max-width': 'min(500px, 95%)', }; return props?.multiple ? ({ actionsLayout: 'space-between', ariaLabel: props.ariaLabel, actions: actions.value, divider: true, fallbackPosition: ['top-start'], id: generateUid(`dropdown-options-${props.id}`), open: props.open, position: 'bottom-start', style: { width: 'auto', ...containerMinAndMaxWidth, }, targetElement: props.targetElement, } as InstanceType<typeof BentoPopover>['$props']) : ({ fallbackPosition: ['top-start'], offset: [0, 8], position: 'bottom-start', style: { display: isDropdownOpen.value ? 'block' : 'none', ...containerMinAndMaxWidth, }, targetElement: props.targetElement, } as InstanceType<typeof PopperContainer>['$props']); }); defineExpose({ onOutsideDropdownClick, dropdownOptionsListboxRef, }); </script> <script lang="ts"> /** * Dropdown options container. * It lists all the available options. * * @example * <bento-dropdown-options-container * v-if="inputContainerRef" <!-- Ref to the input to match the width --> * :id="dropdownOptionsContainerId" * :target-element="inputContainerRef" * :aria-label="ariaLabel" * :open="isDropdownOpen" * :disabled="disabled" * :multiple="multiple" * :selected="value" * :isOptionDisabled="option => option.value === 2" * :items="[{ * { label: 'Option 1', value: 1 }, * { label: 'Option 2', value: 2 }, * }]" * @select="onOptionSelected" * /> */ export default defineComponent({ i18n: { messages }, name: 'bento-dropdown-options-container', }); </script> <style lang="scss" scoped src="./dropdown-options-container.scss" />
|
package/dist/assets/components/dropdown/components/dropdown-small-textbox/dropdown-small-textbox.vue
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
<template> <div class="b-dropdown-small-textbox" :class="conditionalClasses"> <dropdown-base-textbox ref="baseTextbox" v-bind="{ ...$attrs, ...$props }" class="b-dropdown-small-textbox__textbox" :size="BentoDropdownSize.SMALL" v-on="listeners" > <template #icon> <div class="b-dropdown-small-textbox__chevron"> <chevron-up-small-icon v-show="isDropdownOpen" svg-title="opened" /> <chevron-down-small-icon v-show="!isDropdownOpen" svg-title="closed" /> </div> </template> </dropdown-base-textbox> </div> </template> <script setup lang="ts"> import { computed, defineComponent, inject, type PropType, ref } from 'vue'; import { DropdownBaseTextboxEvent, DropdownBaseTextboxState, } from '@/components/dropdown/components/dropdown-base-textbox/dropdown-base-textbox.types'; import { BentoDropdownSize } from '@/components/dropdown/dropdown.types'; import { type Booleanish } from '@/types/prop-types'; import DropdownBaseTextbox from '@/components/dropdown/components/dropdown-base-textbox/dropdown-base-textbox.vue'; import ChevronDownSmallIcon from '@adyen/ui-assets-icons-16/vue/chevron-down-small'; import ChevronUpSmallIcon from '@adyen/ui-assets-icons-16/vue/chevron-up-small'; import { stopEventPropagation } from '@/utils/ts/events'; import { DROPDOWN_UNDERLINE_STYLE_INJECTION_KEY } from '../../dropdown.keys'; const emit = defineEmits<{ /** * Emitted on clearing the input. */ (e: 'clear'); /** * Emitted on click, space and enter user events. It will open the dropdown. */ (e: 'close'); /** * Emitted on input event. */ (e: 'input', newInput: string); /** * Emitted on keydown event. */ (e: 'keydown', keydownEvent: KeyboardEvent); /** * Emitted on click, space, enter and esc user events. It will close the dropdown. */ (e: 'open'); }>(); const props = defineProps({ /** * The number of additional items that have been selected in the dropdown. */ additionalItemsSelected: { type: [Number, String] as PropType<number | string>, default: 0 }, /** * Sets the aria-controls attribute on the textbox. This should correspond to the popover's id that the textbox triggers. */ ariaControls: { type: String, required: true }, /** * Indicates whether the content the textbox triggers is visible or not. Needs to be set accordingly when the textbox is used and changed every time the popover is shown/hidden. */ ariaExpanded: { type: [String, Boolean] as PropType<Booleanish>, required: true, }, /** * If no label element is used then the aria-label is be set should be set. */ ariaLabel: { type: String, default: null }, /** * Corresponds to the label's id that labels the textbox element. */ ariaLabelledby: { type: String, default: null }, /** * Disables any textbox functionality */ disabled: { type: Boolean, default: false }, /** * Selected option's label */ displayValue: { type: String, default: null }, /** * Sets the possibility to type into the textbox */ dynamicFiltering: { type: Boolean, default: false }, /** * Marks the textbox as invalid, rendering an error state */ isInvalid: { type: Boolean, default: false }, /** * Indicates if the dropdown is open so that * the respective event can be emitted */ open: { type: Boolean, default: false }, /** * Sets the dropdown to readonly state */ readonly: { type: Boolean, default: false }, /** * The input value */ value: { type: String, default: '' }, }); const baseTextbox = ref(null); // Inject underline style key to change the dropdown layout const isUnderlineStyle = inject(DROPDOWN_UNDERLINE_STYLE_INJECTION_KEY, false); const conditionalClasses = computed(() => ({ [`b-dropdown-small-textbox--${DropdownBaseTextboxState.DISABLED}`]: props.disabled, [`b-dropdown-small-textbox--${DropdownBaseTextboxState.READONLY}`]: props.readonly, [`b-dropdown-small-textbox--${DropdownBaseTextboxState.OPENED}`]: props.open, 'b-dropdown-small-textbox--underline': isUnderlineStyle, })); const isDropdownOpen = computed(() => props.open && !props.disabled && !props.readonly); const onInput = (newInput: string) => { emit('input', newInput); }; const onKeyDown = (e: KeyboardEvent) => { emit('keydown', e); }; const openDropdown = (e: Event) => { stopEventPropagation(e); emit('open'); }; const closeDropdown = (e: Event) => { stopEventPropagation(e); emit('close'); }; const clearValue = (e: Event) => { stopEventPropagation(e); emit('clear'); }; const listeners = { [DropdownBaseTextboxEvent.INPUT]: onInput, [DropdownBaseTextboxEvent.OPEN]: openDropdown, [DropdownBaseTextboxEvent.CLOSE]: closeDropdown, [DropdownBaseTextboxEvent.CLEAR]: clearValue, [DropdownBaseTextboxEvent.KEYDOWN]: onKeyDown, }; const focus = () => { baseTextbox.value.focus(); }; defineExpose({ focus, }); </script> <script lang="ts"> /** * For internal use only, textbox component in its small variant used in the dropdown component. */ export default defineComponent({ name: 'dropdown-small-textbox', inheritAttrs: false, }); </script> <style lang="scss" scoped src="./dropdown-small-textbox.scss" />
|
|
1
|
+
<template> <div class="b-dropdown-small-textbox" :class="conditionalClasses"> <dropdown-base-textbox ref="baseTextbox" v-bind="{ ...$attrs, ...$props }" class="b-dropdown-small-textbox__textbox" :size="BentoDropdownSize.SMALL" v-on="listeners" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope ?? {}" /> </template> <template #icon> <div class="b-dropdown-small-textbox__chevron"> <chevron-up-small-icon v-show="isDropdownOpen" svg-title="opened" /> <chevron-down-small-icon v-show="!isDropdownOpen" svg-title="closed" /> </div> </template> </dropdown-base-textbox> </div> </template> <script setup lang="ts"> import { computed, defineComponent, inject, type PropType, ref, useSlots } from 'vue'; import { DropdownBaseTextboxEvent, DropdownBaseTextboxState, } from '@/components/dropdown/components/dropdown-base-textbox/dropdown-base-textbox.types'; import { BentoDropdownSize } from '@/components/dropdown/dropdown.types'; import { type Booleanish } from '@/types/prop-types'; import DropdownBaseTextbox from '@/components/dropdown/components/dropdown-base-textbox/dropdown-base-textbox.vue'; import ChevronDownSmallIcon from '@adyen/ui-assets-icons-16/vue/chevron-down-small'; import ChevronUpSmallIcon from '@adyen/ui-assets-icons-16/vue/chevron-up-small'; import { stopEventPropagation } from '@/utils/ts/events'; import { DROPDOWN_UNDERLINE_STYLE_INJECTION_KEY } from '../../dropdown.keys'; const emit = defineEmits<{ /** * Emitted on clearing the input. */ (e: 'clear'); /** * Emitted on click, space and enter user events. It will open the dropdown. */ (e: 'close'); /** * Emitted on input event. */ (e: 'input', newInput: string); /** * Emitted on keydown event. */ (e: 'keydown', keydownEvent: KeyboardEvent); /** * Emitted on click, space, enter and esc user events. It will close the dropdown. */ (e: 'open'); }>(); const slots = useSlots(); const props = defineProps({ /** * The number of additional items that have been selected in the dropdown. */ additionalItemsSelected: { type: [Number, String] as PropType<number | string>, default: 0 }, /** * Sets the aria-controls attribute on the textbox. This should correspond to the popover's id that the textbox triggers. */ ariaControls: { type: String, required: true }, /** * Identifies the currently active option in the listbox. */ ariaActiveDescendant: { type: String, default: null }, /** * Indicates whether the content the textbox triggers is visible or not. Needs to be set accordingly when the textbox is used and changed every time the popover is shown/hidden. */ ariaExpanded: { type: [String, Boolean] as PropType<Booleanish>, required: true, }, /** * If no label element is used then the aria-label is be set should be set. */ ariaLabel: { type: String, default: null }, /** * Corresponds to the label's id that labels the textbox element. */ ariaLabelledby: { type: String, default: null }, /** * Disables any textbox functionality */ disabled: { type: Boolean, default: false }, /** * Selected option's label */ displayValue: { type: String, default: null }, /** * Sets the possibility to type into the textbox */ dynamicFiltering: { type: Boolean, default: false }, /** * Marks the textbox as invalid, rendering an error state */ isInvalid: { type: Boolean, default: false }, /** * Indicates if the dropdown is open so that * the respective event can be emitted */ open: { type: Boolean, default: false }, /** * Sets the dropdown to readonly state */ readonly: { type: Boolean, default: false }, /** * The input value */ value: { type: String, default: '' }, }); const baseTextbox = ref(null); // Inject underline style key to change the dropdown layout const isUnderlineStyle = inject(DROPDOWN_UNDERLINE_STYLE_INJECTION_KEY, false); const conditionalClasses = computed(() => ({ [`b-dropdown-small-textbox--${DropdownBaseTextboxState.DISABLED}`]: props.disabled, [`b-dropdown-small-textbox--${DropdownBaseTextboxState.READONLY}`]: props.readonly, [`b-dropdown-small-textbox--${DropdownBaseTextboxState.OPENED}`]: props.open, 'b-dropdown-small-textbox--underline': isUnderlineStyle, })); const isDropdownOpen = computed(() => props.open && !props.disabled && !props.readonly); const onInput = (newInput: string) => { emit('input', newInput); }; const onKeyDown = (e: KeyboardEvent) => { emit('keydown', e); }; const openDropdown = (e: Event) => { stopEventPropagation(e); emit('open'); }; const closeDropdown = (e: Event) => { stopEventPropagation(e); emit('close'); }; const clearValue = (e: Event) => { stopEventPropagation(e); emit('clear'); }; const listeners = { [DropdownBaseTextboxEvent.INPUT]: onInput, [DropdownBaseTextboxEvent.OPEN]: openDropdown, [DropdownBaseTextboxEvent.CLOSE]: closeDropdown, [DropdownBaseTextboxEvent.CLEAR]: clearValue, [DropdownBaseTextboxEvent.KEYDOWN]: onKeyDown, }; const focus = () => { baseTextbox.value.focus(); }; defineExpose({ focus, }); </script> <script lang="ts"> /** * For internal use only, textbox component in its small variant used in the dropdown component. */ export default defineComponent({ name: 'dropdown-small-textbox', inheritAttrs: false, }); </script> <style lang="scss" scoped src="./dropdown-small-textbox.scss" />
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import { type ComputedRef, type Ref } from 'vue'; export interface UseTextboxKeyboardNavigationOptions { inputContainerRef: Ref; optionsContainerRef: Ref; selectedValueIndex: ComputedRef<number>; isDropdownOpen: Ref<boolean>; isDynamicFltering: Ref<boolean>; items: Ref<Array<HTMLDivElement>>; isMultiple: boolean
|
|
1
|
+
import { type ComputedRef, type Ref } from 'vue'; export interface UseTextboxKeyboardNavigationOptions { inputContainerRef: Ref; optionsContainerRef: Ref; selectedValueIndex: ComputedRef<number>; isDropdownOpen: Ref<boolean>; isDynamicFltering: Ref<boolean>; items: Ref<Array<HTMLDivElement>>; isMultiple: Ref<boolean>; isSearching: ComputedRef<boolean>; activeDescendantIndex?: ComputedRef<number>; selectFirstMatchedOption: () => boolean | Promise<boolean>; totalItemCount: ComputedRef<number>; isVirtualScroll: boolean; removeActiveDescendant?: () => void; } export interface UseListboxKeyboardNavigationOptions { isDropdownOpen: Ref<boolean>; inputContainerRef: Ref; }
|
|
@@ -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="clickOutsideDropdownOptions" class="b-dropdown" :class="conditionalClasses"> <field-label v-if="label" :id="labelId" :condensed="condensed" :label="label" :tooltip-text="tooltipText" :optional="optional" :required="required" /> <div ref="inputContainerRefWrapper" v-bento-keyboard-navigation-directive v-on="textboxKeyboardNavigationListeners" > <component :is="computedSize" ref="inputContainerRef" :condensed="condensed" :value="searchTerm" :additional-items-selected="additionalItemsSelected" :aria-controls="dropdownOptionsContainerId" :ariaExpanded="isDropdownOpen" :aria-labelledby="label ? labelId : null" :aria-describedby="ariaDescribedBy" :aria-label="computedAriaLabel" :aria-readonly="isReadOnly" :aria-required="required" :readonly="isReadOnly" :disabled="disabled" :display-value="displayValue" :dynamic-filtering="dynamicFiltering" :is-invalid="!!errorMessage || error" :multiple="multiple" :open="isDropdownOpen" :selected-value-item="!isInputComponent ? selectedValueItem : undefined" :show-slot-content-in-multiple="showSlotContentInMultiple" @input="searchTerm = $event" @open="toggleDropdown" @close="toggleDropdown" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </component> </div> <bento-teleport v-if="inputContainerRef" :disabled="teleport?.disabled" :to="teleport?.to"> <bento-dropdown-options-container :id="dropdownOptionsContainerId" ref="optionsContainerRef" :aria-label="computedAriaLabel" :component-loading="componentLoading" :empty-state="emptyState" :disabled="disabled" :is-option-disabled="isOptionDisabled" :items="filteredItems" :multiple="multiple" :static-categories="staticCategories" :open="isDropdownOpen" :selected-value="cachedSelectedListboxOptions" :searching="isSearching" :target-element="inputContainerRefWrapper" :loading="loading" :has-more-items="hasMoreItems" :lazy-load-type="lazyLoadType" :virtual-scroll="virtualScroll" v-on="listboxListeners" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </bento-dropdown-options-container> </bento-teleport> <error-message v-if="!!errorMessage" :id="errorId" :error-message="errorMessage" class="b-dropdown__error-message" /> <bento-typography v-if="description || hasSlot('description')" :id="descriptionId" class="b-dropdown__description" :class="descriptionConditionalClasses" el="span" > <slot name="description"> {{ description }} </slot> </bento-typography> </div> </template> <script setup lang="ts"> import { computed, defineComponent, inject, nextTick, type Ref, ref, toRef, toRefs, useAttrs, useSlots, watch, } from 'vue'; // Components import { BentoDropdownOptionsContainer } from './components/dropdown-options-container'; import { BentoTypography } from '@/components/typography'; import { DropdownInputDefault, DropdownInputSmall } from './components'; import { BentoListbox, useCachedSelectedValues, useMultiLevelItems } from '@/internal/listbox'; import { ErrorMessage } from '@/internal/error-message'; import { FieldLabel } from '@/internal/field-label'; import { BentoTeleport } from '@/internal/teleport'; // Utils import { deprecate } from '@/utils/ts/deprecate'; import { generateUid } from '@/core/utils/ts'; import { useAriaLabel } from '@/utils/ts/aria-label'; import { useI18n } from '@/utils/ts/i18n'; import { useFormFieldEmits } from '@/utils/ts/form-field/form-field'; // Composables import { useDisplayValue, useListboxKeyboardNavigation, useTextboxKeyboardNavigation } from './composables'; import { useVisibleDomOptions } from '@/internal/listbox/composables/use-visible-dom-options/use-visible-dom-options'; import { useFormLayoutFieldLoading } from '@/composables/use-form-layout-loading/use-form-layout-loading'; import { useSearchBarFilter } from '@/components/search-bar/useSearchBarDataFilter'; import { useHasSlot } from '@/composables'; // Directives import { BentoClickOutsideDirective as vBentoClickOutsideDirective } from '@/directives/click-outside'; import { BentoKeyboardNavigationDirective as vBentoKeyboardNavigationDirective } from '@/directives'; // Types import { BentoDropdownEvent, type BentoDropdownProps, BentoDropdownSize } from './dropdown.types'; import { BentoListboxEvent, type BentoListboxItemRole, type BentoListboxMultiSelectValue, type BentoListboxOptions, type BentoListboxSelectedValue, type BentoListboxSelectedValueLabelPair, } from '@/types/listbox'; import { DROPDOWN_SMALL_SIZE_INJECTION_KEY } from './dropdown.keys'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const attrs = useAttrs(); const slots = useSlots(); const hasSlot = useHasSlot(slots); const props = withDefaults(defineProps<BentoDropdownProps>(), { ariaLabel: null, condensed: false, emptyState: undefined, disabled: false, dynamicFiltering: false, error: false, errorMessage: null, description: null, hasMoreItems: false, isOptionDisabled: undefined, items: () => [], label: null, loading: false, componentLoading: false, lazyLoadType: BentoListbox.props.lazyLoadType.default, multiple: false, optional: false, placeholder: null, readonly: false, required: false, search: undefined, showSlotContentInMultiple: false, size: null, staticCategories: false, teleport: () => ({ disabled: true }), tooltipText: null, value: null, modelValue: null, virtualScroll: false, enableValueLabelPair: false, }); const emit = defineEmits<{ /** * Emitted when an option is clicked. Updates the selected option from the "value" property linked to the v-model. * @deprecated Since v2.0.0. Use `v-model` or `update:model-value` instead. */ (e: 'input', selectedValue: BentoListboxSelectedValue | any); // eslint-disable-line @typescript-eslint/no-explicit-any /** * Emitted when an option is clicked. Updates the selected option from the "modelValue" property linked to the v-model. */ (e: 'update:model-value', selectedValue: BentoListboxSelectedValue | any); // eslint-disable-line @typescript-eslint/no-explicit-any /** * Emitted when: * - `lazyLoad` is `automatic` and the scroll meets the end of the list * - `lazyload` is `button` and the button is clicked */ (e: 'show-more'); /** * Emitted when the dropdown is opened. */ (e: 'open'); }>(); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const { emitValue } = useFormFieldEmits<BentoListboxSelectedValue>(emit); const inputContainerRef = ref(null); const inputContainerRefWrapper = ref(null); const optionsContainerRef: Ref<InstanceType<typeof BentoDropdownOptionsContainer>> = ref(null); const isDropdownOpen = ref(false); const dropdownOptionsContainerId = generateUid('bento-dropdown-options-container'); const labelId = generateUid('dropdown-label'); const descriptionId = generateUid('dropdown-description'); const errorId = generateUid('dropdown-error'); const { isReadOnly } = useFormLayoutFieldLoading(toRef(props, 'readonly')); const dropdownValue = computed(() => props.modelValue ?? props.value); const { cachedSelectedListboxOptions, cachedListboxSelectedValues, setCachedValues } = useCachedSelectedValues( toRef(props, 'items'), dropdownValue, toRef(props, 'multiple'), toRef(props, 'enableValueLabelPair') ); const ariaLabelAttribute = ref(attrs['aria-label']); const ariaDescribedByAttribute = ref(attrs['aria-describedby']); const { ariaLabel, label } = toRefs(props); // vue-i18n issue with t() used in script - direct usage in template works // requires either createI18n({ legacy: false }) or to be wrapped in computed() const computedAriaLabelFallbackMessage = computed(() => t('ariaLabelFallback')); const computedAriaLabel = useAriaLabel({ // TODO: take ariaLabel ref away when aria-label prop is removed ariaLabel: ariaLabel.value || (ariaLabelAttribute as Ref<string>), label, defaultFallback: computedAriaLabelFallbackMessage, }); // Inject input field component key to change the dropdown size const isInputComponent = props.size === BentoDropdownSize.SMALL || inject(DROPDOWN_SMALL_SIZE_INJECTION_KEY, false); const computedSize = computed(() => (isInputComponent ? BentoDropdownSize.SMALL : BentoDropdownSize.DEFAULT)); const conditionalClasses = computed(() => ({ 'b-dropdown--condensed': props.condensed, })); // Description styles const descriptionConditionalClasses = computed(() => ({ 'b-dropdown__description--error': !!props.errorMessage, })); // Filtering const searchTerm = ref(''); const items = toRef(props, 'items'); const staticCategories = toRef(props, 'staticCategories'); const filteredItems = useSearchBarFilter( searchTerm, items, props?.search?.searchEvent, staticCategories, props?.search?.debounceTime ); const isSearching = computed(() => props.dynamicFiltering && !!searchTerm.value); const displayValue = useDisplayValue({ items, placeholder: toRef(props, 'placeholder'), isMultiSelect: toRef(props, 'multiple'), selectedValues: cachedSelectedListboxOptions, }); const selectedValueItem = computed(() => { if (props?.multiple && Array.isArray(dropdownValue.value)) { return props.items.filter( ({ value }) => props?.value && (props?.value as BentoListboxMultiSelectValue).includes(value) ); } if (!props.multiple && props.staticCategories) { // Find the category that holds the selected value const category = props.items.find(categoryItem => categoryItem.items?.some(item => item.value === dropdownValue.value) ); // Find the specific item within the category const selectedCategoryItem = category ? category.items.find(item => item.value === dropdownValue.value) : props.items.find(({ value }) => value === dropdownValue.value); return selectedCategoryItem ?? null; } return props.items.find(({ value }) => value === dropdownValue.value); }); const selectedValueIndex = computed(() => { const searchList = isMultiLevelSelect.value ? flattenedItemsList.value : filteredItems.value; const index = searchList.findIndex(({ value }) => { return props.enableValueLabelPair ? (dropdownValue.value as BentoListboxSelectedValueLabelPair)?.value === value : dropdownValue.value === value; }); return index > -1 ? index : 0; }); const { isMultiLevelSelect, flattenedItemsList } = useMultiLevelItems(items); const isTreePattern = computed(() => isMultiLevelSelect.value && !props.staticCategories); const computedOptionRole = computed<BentoListboxItemRole>(() => (isTreePattern.value ? 'treeitem' : 'option')); const additionalItemsSelected = computed(() => { if (props.multiple) { const selectedItemsCount = Array.isArray(dropdownValue.value) ? dropdownValue.value.length : 0; const total = isMultiLevelSelect.value ? flattenedItemsList.value.length : items.value.length; if (selectedItemsCount === 0) { return null; } // Display count always - show "all" only when is not external filtering (hasMoreItems = true) if (!props.hasMoreItems && total > 0 && total === selectedItemsCount) { return t('all'); } return selectedItemsCount; } return null; }); // ARIA const ariaDescribedBy = computed( () => [ props.description ? descriptionId : null, ariaDescribedByAttribute.value, props.errorMessage ? errorId : null, ] .filter(Boolean) .join(' ') || null ); const listboxRef = computed(() => { const dropdownListboxRef = optionsContainerRef.value?.dropdownOptionsListboxRef; // Vue 3 exposes the ref object, while Vue 2 exposes the unwrapped DOM element. return (dropdownListboxRef?.listboxRef?.value ?? dropdownListboxRef?.listboxRef) as HTMLDivElement; }); const { visibleDomOptions, updateVisibleDomOptions } = useVisibleDomOptions( listboxRef, filteredItems, computedOptionRole ); const openDropdown = () => { if (isReadOnly.value) { return; } isDropdownOpen.value = true; nextTick(() => { updateVisibleDomOptions(); }); }; const closeDropdown = () => { isDropdownOpen.value = false; }; const toggleDropdown = () => { if (isReadOnly.value) { return; } isDropdownOpen.value = !isDropdownOpen.value; nextTick(() => { updateVisibleDomOptions(); }); // Clear search if (isSearching.value) { searchTerm.value = ''; } }; const clickOutsideDropdown = () => { if (isSearching.value) { searchTerm.value = ''; } if (isDropdownOpen.value) { isDropdownOpen.value = false; // Reset the internal dropdown options state optionsContainerRef.value.onOutsideDropdownClick(); } }; const clickOutsideDropdownOptions = computed(() => [ clickOutsideDropdown, { ignore: [listboxRef], }, ]); const onOptionSelected = async (selectedValue: BentoListboxOptions) => { // Clear and close the dropdown on single selection if (!props.multiple) { searchTerm.value = ''; isDropdownOpen.value = false; } setCachedValues(selectedValue); /** * Emit the value/label pair objects if enableValueLabelPair is enabled, * otherwise just emit the list of selected values (number or string) */ emitValue(cachedListboxSelectedValues.value); // Focus the input after selection and dropdown has closed if (!props.multiple) { await nextTick(); focus(); } }; const emitShowMoreEvent = () => { emit(BentoDropdownEvent.SHOW_MORE); }; const textboxKeyboardNavigationListeners = useTextboxKeyboardNavigation( { inputContainerRef, optionsContainerRef, selectedValueIndex, isDropdownOpen, isDynamicFltering: toRef(props, 'dynamicFiltering'), items: visibleDomOptions, isMultiple: props.multiple, totalItemCount: computed(() => filteredItems.value.length), isVirtualScroll: !props?.multiple && props?.virtualScroll !== false && props?.lazyLoadType === 'none', }, toggleDropdown, openDropdown, closeDropdown ); const listboxKeyboardNavigationListeners = useListboxKeyboardNavigation( { inputContainerRef, isDropdownOpen, }, closeDropdown ); const listboxListeners = { [BentoListboxEvent.CLOSE_DROPDOWN]: closeDropdown, [BentoListboxEvent.SELECT]: onOptionSelected, [BentoListboxEvent.SHOW_MORE]: emitShowMoreEvent, ...listboxKeyboardNavigationListeners, }; if (props.error) { deprecate( 'BentoDropdown "error" property', `Use the BentoDropdown "errorMessage" property instead to indicate that there is a modifier error. Having the field as "undefined" will hide/remove the error. <bento-dropdown errorMessage="Error message text" />`, '2.0.0' ); } if (props.size) { deprecate('BentoDropdown "size" property', `Do not use. Only 'default' size should be used.`, '2.0.0'); } if (props.value) { deprecate( 'BentoDropdown "value" property', `The use of "value" prop in "BentoDropdown" is no longer supported. Use the "v-model" or "model-value" property instead.`, '2.0.0' ); } const focus = () => { inputContainerRef.value?.focus(); }; const scrollToSelectedOption = async () => { await nextTick(); if (!isDropdownOpen.value || props.multiple || !optionsContainerRef.value) { return; } const index = selectedValueIndex.value; if (index <= 0) { return; } const dropdownOptionsListboxRef = optionsContainerRef.value.dropdownOptionsListboxRef; const listbox = dropdownOptionsListboxRef?.listboxRef; const listboxSingleSelect = dropdownOptionsListboxRef?.listboxItemRef; listboxSingleSelect?.scrollToItem?.(index, listbox); }; watch( () => isDropdownOpen.value, (isOpen: boolean) => { if (isOpen) { emit('open'); scrollToSelectedOption(); } } ); defineExpose({ focus, }); </script> <script lang="ts"> /** * A Dropdown shows a selected option's. * Use a dropdown when you want users to select options * from a list of pre-defined options. * * @example * import { BentoDropdown } from '@adyen/bento-vue2'; * import type { BentoDropdownOptions } from '@adyen/bento-vue2' * * export default { * components: { BentoDropdown }, * template: ` * <bento-dropdown * v-model="selectedValue" * :disabled="false" * :multiple="true" * :isOptionsDisabled="option => option.value === 2" * :items="options" * /> * `, * setup() { * const selectedValue = ref(2) // Default value * const options: BentoDropdownOptions = [ * { label: 'Option 1', value: 1 }, * { label: 'Option 2', value: 2 }, * ]; * return { options, selectedValue }; * } * } */ export default defineComponent({ i18n: { messages }, name: 'b-dropdown', components: { DropdownInputDefault, DropdownInputSmall, }, model: { prop: 'modelValue' }, }); </script> <style lang="scss" scoped src="./dropdown.scss" />
|
|
1
|
+
<!-- TODO: Remove aria props as these are part of JSX elements /vue/types/jsx.d.ts --> <!-- eslint-disable vue/attribute-hyphenation --> <template> <div v-bento-click-outside-directive="clickOutsideDropdownOptions" class="b-dropdown" :class="conditionalClasses"> <field-label v-if="label" :id="labelId" :condensed="condensed" :label="label" :tooltip-text="tooltipText" :optional="optional" :required="required" /> <div ref="inputContainerRefWrapper" v-bento-keyboard-navigation-directive v-on="textboxKeyboardNavigationListeners" > <component :is="computedSize" ref="inputContainerRef" :condensed="condensed" :value="searchTerm" :additional-items-selected="additionalItemsSelected" :aria-controls="dropdownOptionsContainerId" :aria-active-descendant="activeDescendantId" :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" :has-selected-value="cachedSelectedListboxOptions.length > 0" :is-invalid="!!errorMessage || error" :multiple="multiple" :open="isDropdownOpen" :selected-value-item="!isInputComponent ? selectedValueItem : undefined" :show-slot-content-in-multiple="showSlotContentInMultiple" @input="onSearchTermInput" @open="toggleDropdown" @close="toggleDropdown" > <template #autocomplete-suggestion> <template v-if="autocompleteSuggestionSuffix"> <span class="b-dropdown__autocomplete-hidden-prefix">{{ searchTerm }}</span >{{ autocompleteSuggestionSuffix }} </template> </template> <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </component> </div> <bento-teleport v-if="inputContainerRef" :disabled="teleport?.disabled" :to="teleport?.to"> <bento-dropdown-options-container :id="dropdownOptionsContainerId" ref="optionsContainerRef" :aria-label="computedAriaLabel" :active-descendant-id="activeDescendantId" :component-loading="componentLoading" :empty-state="emptyState" :disabled="disabled" :is-option-disabled="isOptionDisabled" :items="filteredItems" :multiple="multiple" :static-categories="staticCategories" :open="isDropdownOpen" :selected-value="cachedSelectedListboxOptions" :searching="isSearching" :target-element="inputContainerRefWrapper" :loading="loading" :has-more-items="hasMoreItems" :lazy-load-type="lazyLoadType" :virtual-scroll="virtualScroll" v-on="listboxListeners" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </bento-dropdown-options-container> </bento-teleport> <error-message v-if="!!errorMessage" :id="errorId" :error-message="errorMessage" class="b-dropdown__error-message" /> <bento-typography v-if="description || hasSlot('description')" :id="descriptionId" class="b-dropdown__description" :class="descriptionConditionalClasses" el="span" > <slot name="description"> {{ description }} </slot> </bento-typography> </div> </template> <script setup lang="ts"> import { computed, defineComponent, inject, nextTick, type Ref, ref, toRef, toRefs, useAttrs, useSlots, watch, } from 'vue'; // Components import { BentoDropdownOptionsContainer } from './components/dropdown-options-container'; import { BentoTypography } from '@/components/typography'; import { DropdownInputDefault, DropdownInputSmall } from './components'; import { BentoListbox, useCachedSelectedValues, useMultiLevelItems } from '@/internal/listbox'; import { ErrorMessage } from '@/internal/error-message'; import { FieldLabel } from '@/internal/field-label'; import { BentoTeleport } from '@/internal/teleport'; // Utils import { deprecate } from '@/utils/ts/deprecate'; import { generateUid } from '@/core/utils/ts'; import { useAriaLabel } from '@/utils/ts/aria-label'; import { useI18n } from '@/utils/ts/i18n'; import { useFormFieldEmits } from '@/utils/ts/form-field/form-field'; // Composables import { useDisplayValue, useListboxKeyboardNavigation, useTextboxKeyboardNavigation } from './composables'; import { useVisibleDomOptions } from '@/internal/listbox/composables/use-visible-dom-options/use-visible-dom-options'; import { useFormLayoutFieldLoading } from '@/composables/use-form-layout-loading/use-form-layout-loading'; import { useSearchBarFilter } from '@/components/search-bar/useSearchBarDataFilter'; import { useHasSlot } from '@/composables'; import { getListboxOptionId } from '@/utils/ts/get-listbox-option-id'; // 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 BentoListboxOptionItem, type BentoListboxOptions, type BentoListboxSelectedValue, type BentoListboxSelectedValueLabelPair, } from '@/types/listbox'; import { DROPDOWN_SMALL_SIZE_INJECTION_KEY } from './dropdown.keys'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const attrs = useAttrs(); const slots = useSlots(); const hasSlot = useHasSlot(slots); const props = withDefaults(defineProps<BentoDropdownProps>(), { ariaLabel: null, condensed: false, emptyState: undefined, disabled: false, dynamicFiltering: false, error: false, errorMessage: null, description: null, hasMoreItems: false, isOptionDisabled: undefined, items: () => [], label: null, loading: false, componentLoading: false, lazyLoadType: BentoListbox.props.lazyLoadType.default, multiple: false, optional: false, placeholder: null, readonly: false, required: false, search: undefined, showSlotContentInMultiple: false, size: null, staticCategories: false, teleport: () => ({ disabled: true }), tooltipText: null, value: null, modelValue: null, virtualScroll: false, enableValueLabelPair: false, }); const emit = defineEmits<{ /** * Emitted when an option is clicked. Updates the selected option from the "value" property linked to the v-model. * @deprecated Since v2.0.0. Use `v-model` or `update:model-value` instead. */ (e: 'input', selectedValue: BentoListboxSelectedValue | any); // eslint-disable-line @typescript-eslint/no-explicit-any /** * Emitted when an option is clicked. Updates the selected option from the "modelValue" property linked to the v-model. */ (e: 'update:model-value', selectedValue: BentoListboxSelectedValue | any); // eslint-disable-line @typescript-eslint/no-explicit-any /** * Emitted when: * - `lazyLoad` is `automatic` and the scroll meets the end of the list * - `lazyload` is `button` and the button is clicked */ (e: 'show-more'); /** * Emitted when the dropdown is opened. */ (e: 'open'); }>(); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const { emitValue } = useFormFieldEmits<BentoListboxSelectedValue>(emit); const inputContainerRef = ref(null); const inputContainerRefWrapper = ref(null); const optionsContainerRef: Ref<InstanceType<typeof BentoDropdownOptionsContainer>> = ref(null); const isDropdownOpen = ref(false); const shouldShowActiveDescendant = ref(false); const dropdownOptionsContainerId = generateUid('bento-dropdown-options-container'); const labelId = generateUid('dropdown-label'); const descriptionId = generateUid('dropdown-description'); const errorId = generateUid('dropdown-error'); const { isReadOnly } = useFormLayoutFieldLoading(toRef(props, 'readonly')); const dropdownValue = computed(() => props.modelValue ?? props.value); const { cachedSelectedListboxOptions, cachedListboxSelectedValues, setCachedValues } = useCachedSelectedValues( toRef(props, 'items'), dropdownValue, toRef(props, 'multiple'), toRef(props, 'enableValueLabelPair') ); const ariaLabelAttribute = ref(attrs['aria-label']); const ariaDescribedByAttribute = ref(attrs['aria-describedby']); const { ariaLabel, label } = toRefs(props); // vue-i18n issue with t() used in script - direct usage in template works // requires either createI18n({ legacy: false }) or to be wrapped in computed() const computedAriaLabelFallbackMessage = computed(() => t('ariaLabelFallback')); const computedAriaLabel = useAriaLabel({ // TODO: take ariaLabel ref away when aria-label prop is removed ariaLabel: ariaLabel.value || (ariaLabelAttribute as Ref<string>), label, defaultFallback: computedAriaLabelFallbackMessage, }); // Inject input field component key to change the dropdown size const isInputComponent = props.size === BentoDropdownSize.SMALL || inject(DROPDOWN_SMALL_SIZE_INJECTION_KEY, false); const computedSize = computed(() => (isInputComponent ? BentoDropdownSize.SMALL : BentoDropdownSize.DEFAULT)); const conditionalClasses = computed(() => ({ 'b-dropdown--condensed': props.condensed, })); // Description styles const descriptionConditionalClasses = computed(() => ({ 'b-dropdown__description--error': !!props.errorMessage, })); // Filtering const searchTerm = ref(''); const items = toRef(props, 'items'); const staticCategories = toRef(props, 'staticCategories'); const filteredItems = useSearchBarFilter( searchTerm, items, props?.search?.searchEvent, staticCategories, props?.search?.debounceTime ); const isSearching = computed(() => props.dynamicFiltering && !!searchTerm.value); const displayValue = useDisplayValue({ items, placeholder: toRef(props, 'placeholder'), isMultiSelect: toRef(props, 'multiple'), selectedValues: cachedSelectedListboxOptions, }); const selectedValueItem = computed(() => { if (props?.multiple && Array.isArray(dropdownValue.value)) { return props.items.filter( ({ value }) => props?.value && (props?.value as BentoListboxMultiSelectValue).includes(value) ); } if (!props.multiple && props.staticCategories) { // Find the category that holds the selected value const category = props.items.find(categoryItem => categoryItem.items?.some(item => item.value === dropdownValue.value) ); // Find the specific item within the category const selectedCategoryItem = category ? category.items.find(item => item.value === dropdownValue.value) : props.items.find(({ value }) => value === dropdownValue.value); return selectedCategoryItem ?? null; } return props.items.find(({ value }) => value === dropdownValue.value); }); const selectedValueIndex = computed(() => { const searchList = isMultiLevelSelect.value ? flattenedItemsList.value : filteredItems.value; const index = searchList.findIndex(({ value }) => { return props.enableValueLabelPair ? (dropdownValue.value as BentoListboxSelectedValueLabelPair)?.value === value : dropdownValue.value === value; }); return index > -1 ? index : 0; }); const { isMultiLevelSelect, flattenedItemsList } = useMultiLevelItems(items); const isTreePattern = computed(() => isMultiLevelSelect.value && !props.staticCategories); const computedOptionRole = computed<BentoListboxItemRole>(() => (isTreePattern.value ? 'treeitem' : 'option')); const additionalItemsSelected = computed(() => { if (props.multiple) { const selectedItemsCount = Array.isArray(dropdownValue.value) ? dropdownValue.value.length : 0; const total = isMultiLevelSelect.value ? flattenedItemsList.value.length : items.value.length; if (selectedItemsCount === 0) { return null; } // Display count always - show "all" only when is not external filtering (hasMoreItems = true) if (!props.hasMoreItems && total > 0 && total === selectedItemsCount) { return t('all'); } return selectedItemsCount; } return null; }); const firstEnabledFilteredItem = computed(() => filteredItems.value.find((item: BentoListboxOptionItem) => !props.isOptionDisabled?.(item)) ); const autocompleteSuggestion = computed(() => { if (props.multiple || !isSearching.value || !shouldShowActiveDescendant.value) { return ''; } return firstEnabledFilteredItem.value?.label ?? ''; }); const autocompleteSuggestionSuffix = computed(() => { if (!autocompleteSuggestion.value.toLocaleLowerCase().startsWith(searchTerm.value.toLocaleLowerCase())) { return ''; } return autocompleteSuggestion.value.slice(searchTerm.value.length); }); const activeDescendantId = computed(() => !props.multiple && isDropdownOpen.value && isSearching.value && shouldShowActiveDescendant.value && firstEnabledFilteredItem.value ? getListboxOptionId(dropdownOptionsContainerId, firstEnabledFilteredItem.value.value) : null ); const activeDescendantIndex = computed(() => activeDescendantId.value ? filteredItems.value.findIndex(({ value }) => value === firstEnabledFilteredItem.value?.value) : -1 ); const onSearchTermInput = (newSearchTerm: string) => { searchTerm.value = newSearchTerm; shouldShowActiveDescendant.value = true; }; // ARIA const ariaDescribedBy = computed( () => [ props.description ? descriptionId : null, ariaDescribedByAttribute.value, props.errorMessage ? errorId : null, ] .filter(Boolean) .join(' ') || null ); const listboxRef = computed(() => { const dropdownListboxRef = optionsContainerRef.value?.dropdownOptionsListboxRef; // Vue 3 exposes the ref object, while Vue 2 exposes the unwrapped DOM element. return (dropdownListboxRef?.listboxRef?.value ?? dropdownListboxRef?.listboxRef) as HTMLDivElement; }); const { visibleDomOptions, updateVisibleDomOptions } = useVisibleDomOptions( listboxRef, filteredItems, computedOptionRole ); const openDropdown = () => { if (isReadOnly.value) { return; } isDropdownOpen.value = true; nextTick(() => { updateVisibleDomOptions(); }); }; const closeDropdown = () => { isDropdownOpen.value = false; }; const toggleDropdown = () => { if (isReadOnly.value) { return; } isDropdownOpen.value = !isDropdownOpen.value; nextTick(() => { updateVisibleDomOptions(); }); // Clear search if (isSearching.value) { searchTerm.value = ''; } }; const clickOutsideDropdown = () => { if (isSearching.value) { searchTerm.value = ''; } if (isDropdownOpen.value) { isDropdownOpen.value = false; // Reset the internal dropdown options state optionsContainerRef.value.onOutsideDropdownClick(); } }; const clickOutsideDropdownOptions = computed(() => [ clickOutsideDropdown, { ignore: [listboxRef], }, ]); const onOptionSelected = async (selectedValue: BentoListboxOptions) => { // Clear and close the dropdown on single selection if (!props.multiple) { searchTerm.value = ''; isDropdownOpen.value = false; } setCachedValues(selectedValue); /** * Emit the value/label pair objects if enableValueLabelPair is enabled, * otherwise just emit the list of selected values (number or string) */ emitValue(cachedListboxSelectedValues.value); // Focus the input after selection and dropdown has closed if (!props.multiple) { await nextTick(); focus(); } }; const selectFirstMatchedOption = async () => { if (!firstEnabledFilteredItem.value) { return false; } await onOptionSelected([firstEnabledFilteredItem.value]); return true; }; const emitShowMoreEvent = () => { emit(BentoDropdownEvent.SHOW_MORE); }; const textboxKeyboardNavigationListeners = useTextboxKeyboardNavigation( { inputContainerRef, optionsContainerRef, selectedValueIndex, isDropdownOpen, isDynamicFltering: toRef(props, 'dynamicFiltering'), items: visibleDomOptions, isMultiple: toRef(props, 'multiple'), isSearching, activeDescendantIndex, selectFirstMatchedOption, totalItemCount: computed(() => filteredItems.value.length), isVirtualScroll: !props?.multiple && props?.virtualScroll !== false && props?.lazyLoadType === 'none', removeActiveDescendant: () => { // TODO: this solution is temporary. when we target dynamicFiltering accessibility, the component should use aria-activedescendant to navigate the options instead of focus shouldShowActiveDescendant.value = false; }, }, toggleDropdown, openDropdown, closeDropdown ); const listboxKeyboardNavigationListeners = useListboxKeyboardNavigation( { inputContainerRef, isDropdownOpen, }, closeDropdown ); const listboxListeners = { [BentoListboxEvent.CLOSE_DROPDOWN]: closeDropdown, [BentoListboxEvent.SELECT]: onOptionSelected, [BentoListboxEvent.SHOW_MORE]: emitShowMoreEvent, ...listboxKeyboardNavigationListeners, }; if (props.error) { deprecate( 'BentoDropdown "error" property', `Use the BentoDropdown "errorMessage" property instead to indicate that there is a modifier error. Having the field as "undefined" will hide/remove the error. <bento-dropdown errorMessage="Error message text" />`, '2.0.0' ); } if (props.size) { deprecate('BentoDropdown "size" property', `Do not use. Only 'default' size should be used.`, '2.0.0'); } if (props.value) { deprecate( 'BentoDropdown "value" property', `The use of "value" prop in "BentoDropdown" is no longer supported. Use the "v-model" or "model-value" property instead.`, '2.0.0' ); } const focus = () => { inputContainerRef.value?.focus(); }; const scrollToSelectedOption = async () => { await nextTick(); if (!isDropdownOpen.value || props.multiple || !optionsContainerRef.value) { return; } const index = selectedValueIndex.value; if (index <= 0) { return; } const dropdownOptionsListboxRef = optionsContainerRef.value.dropdownOptionsListboxRef; const listbox = dropdownOptionsListboxRef?.listboxRef; const listboxSingleSelect = dropdownOptionsListboxRef?.listboxItemRef; listboxSingleSelect?.scrollToItem?.(index, listbox); }; watch( () => isDropdownOpen.value, (isOpen: boolean) => { if (isOpen) { emit('open'); scrollToSelectedOption(); } } ); defineExpose({ focus, }); </script> <script lang="ts"> /** * A Dropdown shows a selected option's. * Use a dropdown when you want users to select options * from a list of pre-defined options. * * @example * import { BentoDropdown } from '@adyen/bento-vue2'; * import type { BentoDropdownOptions } from '@adyen/bento-vue2' * * export default { * components: { BentoDropdown }, * template: ` * <bento-dropdown * v-model="selectedValue" * :disabled="false" * :multiple="true" * :isOptionsDisabled="option => option.value === 2" * :items="options" * /> * `, * setup() { * const selectedValue = ref(2) // Default value * const options: BentoDropdownOptions = [ * { label: 'Option 1', value: 1 }, * { label: 'Option 2', value: 2 }, * ]; * return { options, selectedValue }; * } * } */ export default defineComponent({ i18n: { messages }, name: 'b-dropdown', components: { DropdownInputDefault, DropdownInputSmall, }, model: { prop: 'modelValue' }, }); </script> <style lang="scss" scoped src="./dropdown.scss" />
|
|
@@ -1 +1 @@
|
|
|
1
|
-
<template> <div ref="allViewsRef" class="b-header-with-views-all-views" data-testid="header-with-views-all-views" @keydown.esc.stop.prevent="closeMenu" > <bento-toggle-button ref="allViewsMenuToggleRef" aria-haspopup="true" :aria-expanded="showMenu" :aria-controls="showMenu ? menuPopoverId : null" variant="tertiary" :toggled="showMenu" @click="toggleShowMenu" > <template #iconLeft> <menu-icon :svg-title="t('toggleAllViews')" /> </template> </bento-toggle-button> <bento-popover v-if="allViewsMenuToggleRef" :id="menuPopoverId" ref="allViewsMenuPopoverRef" class="b-header-with-views-all-views__popover" :open="showMenu" :target-element="allViewsMenuToggleRef" :fixed-positioning="true" :overflow-visible="true" position="bottom-end" without-space > <output v-if="draggedViewIndex !== null && draggingOverViewIndex !== null" class="b-header-with-views-all-views__popover-status" > {{ t('movingDraggedToPosition', { dragged: menuItems[draggedViewIndex].label, draggingOver: menuItems[draggingOverViewIndex].label, }) }} </output> <ul ref="menuRef" class="b-header-with-views-all-views__popover-menu"> <li v-for="(item, index) in menuItems" ref="menuItemRefs" :key="index" data-drag-item class="b-header-with-views-all-views__popover-menu-item" :class="conditionalDraggableItemClass(index)" @pointerdown="handleDragStart(index, $event)" > <div class="b-header-with-views-all-views__popover-menu-item-content"> <bento-button data-drag-trigger class="b-header-with-views-all-views__popover-menu-item-button b-header-with-views-all-views__popover-menu-item-button-reorder" variant="tertiary" :aria-pressed="isItemDragged(index)" @keydown.enter.native="handleDragKeyboardToggle(index, $event)" @keydown.space.native="handleDragKeyboardToggle(index, $event)" @keydown.down.native="handleDragKeyboardMove(UseDragAndDropViewsDirectionEnum.DOWN, $event)" @keydown.up.native="handleDragKeyboardMove(UseDragAndDropViewsDirectionEnum.UP, $event)" @keydown.esc.native="handleDragStop" > <template #iconLeft> <grab-icon :svg-title="t('reorderView', { label: item.label })" /> </template> </bento-button> <bento-button data-view-select class="b-header-with-views-all-views__popover-menu-item-button b-header-with-views-all-views__popover-menu-item-button-select" variant="tertiary" @click="emit('select:view', index)" > <template #iconLeft> <span class="b-header-with-views-all-views__popover-menu-item-button-icon" :class="`b-header-with-views-all-views__popover-menu-item-button-icon--${item.iconColor}`" aria-hidden="true" > <component :is="BentoHeaderWithViewsItemIconElement[item.icon]" /> </span> </template> <bento-typography el="span" variant="body" class="b-header-with-views-all-views__popover-menu-item-button-text" > {{ item.label }} </bento-typography> </bento-button> <bento-menu ref="editViewMenuRefs" data-view-menu class="b-header-with-views-all-views__popover-menu-item-button" :button="{ variant: 'tertiary', condensed: true }" :close-on-click-outside-options="menuCloseOnClickOutsideOptions" :data="menuItemsActions" menu-position="right-start" @open="emit('open:view-menu', index)" @close="emit('close:view-menu', index)" @keydown.tab.native.capture="closeViewMenuNatively(index)" > <template #iconLeft> <options-horizontal-icon :
|
|
1
|
+
<template> <div ref="allViewsRef" class="b-header-with-views-all-views" data-testid="header-with-views-all-views" @keydown.esc.stop.prevent="closeMenu" > <bento-toggle-button ref="allViewsMenuToggleRef" aria-haspopup="true" :aria-expanded="showMenu" :aria-controls="showMenu ? menuPopoverId : null" variant="tertiary" :toggled="showMenu" @click="toggleShowMenu" > <template #iconLeft> <menu-icon :svg-title="t('toggleAllViews')" /> </template> </bento-toggle-button> <bento-popover v-if="allViewsMenuToggleRef" :id="menuPopoverId" ref="allViewsMenuPopoverRef" class="b-header-with-views-all-views__popover" :open="showMenu" :target-element="allViewsMenuToggleRef" :fixed-positioning="true" :overflow-visible="true" position="bottom-end" without-space > <output v-if="draggedViewIndex !== null && draggingOverViewIndex !== null" class="b-header-with-views-all-views__popover-status" > {{ t('movingDraggedToPosition', { dragged: menuItems[draggedViewIndex].label, draggingOver: menuItems[draggingOverViewIndex].label, }) }} </output> <ul ref="menuRef" class="b-header-with-views-all-views__popover-menu"> <li v-for="(item, index) in menuItems" ref="menuItemRefs" :key="index" data-drag-item class="b-header-with-views-all-views__popover-menu-item" :class="conditionalDraggableItemClass(index)" @pointerdown="handleDragStart(index, $event)" > <div class="b-header-with-views-all-views__popover-menu-item-content"> <bento-button data-drag-trigger class="b-header-with-views-all-views__popover-menu-item-button b-header-with-views-all-views__popover-menu-item-button-reorder" variant="tertiary" :aria-pressed="isItemDragged(index)" @keydown.enter.native="handleDragKeyboardToggle(index, $event)" @keydown.space.native="handleDragKeyboardToggle(index, $event)" @keydown.down.native="handleDragKeyboardMove(UseDragAndDropViewsDirectionEnum.DOWN, $event)" @keydown.up.native="handleDragKeyboardMove(UseDragAndDropViewsDirectionEnum.UP, $event)" @keydown.esc.native="handleDragStop" > <template #iconLeft> <grab-icon :svg-title="t('reorderView', { label: item.label })" /> </template> </bento-button> <bento-button data-view-select class="b-header-with-views-all-views__popover-menu-item-button b-header-with-views-all-views__popover-menu-item-button-select" variant="tertiary" @click="emit('select:view', index)" > <template #iconLeft> <span class="b-header-with-views-all-views__popover-menu-item-button-icon" :class="`b-header-with-views-all-views__popover-menu-item-button-icon--${item.iconColor}`" aria-hidden="true" > <component :is="BentoHeaderWithViewsItemIconElement[item.icon]" /> </span> </template> <bento-typography el="span" variant="body" class="b-header-with-views-all-views__popover-menu-item-button-text" > {{ item.label }} </bento-typography> </bento-button> <bento-menu ref="editViewMenuRefs" data-view-menu class="b-header-with-views-all-views__popover-menu-item-button" :button="{ variant: 'tertiary', condensed: true, 'aria-label': t('openViewOptions', { label: item.label }), }" :close-on-click-outside-options="menuCloseOnClickOutsideOptions" :data="menuItemsActions" menu-position="right-start" @open="emit('open:view-menu', index)" @close="emit('close:view-menu', index)" @keydown.tab.native.capture="closeViewMenuNatively(index)" > <template #iconLeft> <options-horizontal-icon :aria-hidden="true" /> </template> </bento-menu> </div> </li> </ul> </bento-popover> </div> </template> <script setup lang="ts"> import { BentoButton, BentoToggleButton } from '@/components/button'; import { BentoHeaderWithViewsItemIconElement } from '@/components/header-with-views/header-with-views.types'; import { BentoMenu } from '@/components/menu'; import { BentoPopover } from '@/components/popover'; import { BentoTypography } from '@/components/typography'; import { useClickOutside } from '@/composables'; import { generateUid } from '@/core/utils/ts'; import { useI18n } from '@/utils/ts/i18n'; import GrabIcon from '@adyen/ui-assets-icons-16/vue/grab'; import MenuIcon from '@adyen/ui-assets-icons-16/vue/menu'; import OptionsHorizontalIcon from '@adyen/ui-assets-icons-16/vue/options-horizontal'; import { nextTick, ref, toRef } from 'vue'; import { useDragAndDropViews, UseDragAndDropViewsDirectionEnum } from '../../composables'; import { type HeaderWithViewsAllViewsProps } from './header-with-views-all-views.types'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const props = withDefaults(defineProps<HeaderWithViewsAllViewsProps>(), {}); const emit = defineEmits<{ /** * Emitted when the "all views" menu is requested to be closed. */ (e: 'close:all-views'): void; /** * Emitted when a specific view's action menu is requested to be closed. */ (e: 'close:view-menu', index: number): void; /** * Emitted when the "all views" menu is requested to be opened. */ (e: 'open:all-views'): void; /** * Emitted when a specific view's action menu is requested to be opened. */ (e: 'open:view-menu', index: number): void; /** * Emitted when a view item is selected from the "all views" menu. */ (e: 'select:view', index: number): void; /** * Emitted when the order of views has been changed due to drag and drop. */ (e: 'reorder:view', newViews: HeaderWithViewsAllViewsProps['menuItems']): void; }>(); const showMenu = ref(false); const allViewsMenuToggleRef = ref(null); const allViewsMenuPopoverRef = ref(null); const allViewsRef = ref(null); const menuRef = ref(null); const menuItemRefs = ref<Array<HTMLElement>>(null); const editViewMenuRefs = ref(null); const menuPopoverId = generateUid('menu-popover'); const { t } = useI18n<{ message: MessageSchema }>({ messages }); useClickOutside(allViewsMenuPopoverRef, () => closeMenu(), { ignore: [allViewsMenuToggleRef, ...(props.menuCloseOnClickOutsideOptions?.ignore ?? [])], }); const openMenu = () => { showMenu.value = true; emit('open:all-views'); }; const closeMenu = async () => { if (!showMenu.value) { return; } showMenu.value = false; emit('close:all-views'); await nextTick(); allViewsMenuToggleRef.value.$el.focus(); }; const toggleShowMenu = (e: MouseEvent) => { e.stopPropagation(); if (!showMenu.value) { openMenu(); } else { closeMenu(); } }; const { draggedViewIndex, draggingOverViewIndex, isItemDragged, handleDragKeyboardMove, handleDragKeyboardToggle, handleDragStart, handleDragStop, } = useDragAndDropViews({ items: toRef(props, 'menuItems'), itemRefs: menuItemRefs, onDrop: value => emit('reorder:view', value), }); const conditionalDraggableItemClass = (index: number) => ({ ['b-header-with-views-all-views__popover-menu-item--dragging']: isItemDragged(index), }); const closeViewMenuNatively = (index: number) => { // skip built-in refocus on the menu button when menu is closed // enables tab to flow as intended by HTML spec editViewMenuRefs.value[index].closeMenu(false); }; </script> <script lang="ts"> /** * Internal component to handle the logic for the list of all views available. */ export default { i18n: { messages }, }; </script> <style lang="scss" scoped src="./header-with-views-all-views.scss" />
|
|
@@ -1 +1 @@
|
|
|
1
|
-
<template> <div ref="hostRef" class="b-header-with-views"> <div class="b-header-with-views__container" :class="conditionalContainerClass"> <div class="b-header-with-views__title"> <bento-typography el="h2" variant="title">{{ title }}</bento-typography> <info-icon-with-popover v-if="infoIconTooltipText || hasSlot('infoIconTooltipContent')" :popover-text="infoIconTooltipText" class="b-header-with-views__title-icon" > <template v-if="hasSlot('infoIconTooltipContent')" #default> <slot name="infoIconTooltipContent" /> </template> </info-icon-with-popover> </div> <fieldset ref="viewsRef" class="b-header-with-views__bar" :aria-label="t('viewListLabel')"> <div v-for="(view, index) in computedViews" :key="index" ref="viewRefs" class="b-header-with-views__bar-tab" :class="isViewActive(index) ? 'b-header-with-views__bar-tab--active' : null" > <bento-button class="b-header-with-views__bar-tab-button" variant="tertiary-with-background" :aria-current="currentActiveView === index ? 'true' : 'false'" @click="handleViewSelect(index)" @keydown.enter.native="handleViewSelect(index)" @keydown.space.native="handleViewSelect(index)" > <template #iconLeft> <span class="b-header-with-views__bar-tab-button-icon" :class="`b-header-with-views__bar-tab-button-icon--${view.iconColor}`" aria-hidden="true" > <component :is="BentoHeaderWithViewsItemIconElement[view.icon]" /> </span> </template> <bento-typography el="span" variant="body" stronger class="b-header-with-views__bar-tab-button-text" > {{ view.label }} </bento-typography> <template v-if="isViewUnsaved(index)" #iconRight> <span class="b-header-with-views__bar-tab-button--unsaved"> <dot-icon :svg-title="t('withUnsavedChanges')" /> </span> </template> </bento-button> <bento-menu v-if="currentActiveView === index" ref="editViewMenuRefs" class="b-header-with-views__bar-tab-menu" :button="{ variant: 'tertiary' }" :data="getViewMenuData(index)" :close-on-click-outside-options="{ ignore: [`#${editViewPopoverId}`], }" :menu-fixed-positioning="true" @open="showViewMenuPopover(index)" @close="hideViewMenuPopover()" @keydown.tab.native.capture="handleTabEditViewPopover" > <template #iconLeft> <options-horizontal-icon :svg-title="t('openViewOptions', { label: view.label })" /> </template> </bento-menu> </div> <div ref="viewControlRef" class="b-header-with-views__bar-control"> <!-- All views --> <header-with-views-all-views v-if="computedViews.length" ref="viewControlAllViewsRef" :menu-items="computedViews" :menu-items-actions="getViewMenuData(activeViewMenuPopover)" :menu-close-on-click-outside-options="{ ignore: [`#${editViewPopoverId}`] }" @close:view-menu="hideViewMenuPopover()" @close:all-views="showAllViewsPopover = false" @open:all-views="showAllViewsPopover = true" @open:view-menu="index => showViewMenuPopover(index)" @reorder:view="views => handleReorder(views)" @select:view="index => handleViewSelect(index)" /> <bento-toggle-button ref="createViewButtonRef" :aria-expanded="showCreateViewPopover" :aria-controls="showCreateViewPopover ? createViewPopoverId : null" :toggled="showCreateViewPopover" variant="tertiary" @click="showCreateViewPopover = !showCreateViewPopover" > <template #iconLeft> <plus-icon :svg-title="t('createView')" :aria-hidden="computedViews.length === 0" /> </template> <template v-if="computedViews.length === 0" #default>{{ t('createView') }}</template> </bento-toggle-button> </div> </fieldset> <header-meta v-if="hasMetaSection" class="b-header-with-views__meta-container" :description="description" :last-updated="lastUpdated" :reload-button="reloadButton" > <template v-if="hasSlot('description')" #description><slot name="description" /></template> </header-meta> <button-actions-with-menu v-if="actions" :actions="actions" :actions-layout="isCompact ? 'buttons-start' : 'buttons-end'" :displayed-actions="2" :disable-responsive-behavior="true" :hide-displayed-actions-breakpoint="STACKED_LAYOUT_BREAKPOINT" class="b-header-with-views__actions" /> </div> <!-- Create view --> <bento-popover v-if="activeViewMenuPopover === null && createViewButtonRef" :id="createViewPopoverId" ref="createViewPopoverRef" :dismissible="true" :fixed-positioning="true" :target-element="createViewButtonRef" :title="t('createView')" :open="showCreateViewPopover" :overflow-visible="true" position="bottom-start" @dismiss="hideCreateViewPopover" @keydown.esc.native.capture.stop.prevent="hideCreateViewPopover" > <header-with-views-edit-form context="create" :errors="errors" :additional-form-item="additionalViewSetting" :disable-customization="disableCustomization" @blur:label="value => validateLabel(value, activeViewMenuPopover)" @input:label="value => validateLabel(value, activeViewMenuPopover)" @keydown:enter="handleKeydownEnterViewItem" @update="handleCreateViewItem" @cancel="hideCreateViewPopover" > </header-with-views-edit-form> </bento-popover> <!-- Edit view --> <bento-popover v-if="activeViewMenuPopover !== null && editViewTargetElementRef" :id="editViewPopoverId" ref="editViewPopoverRef" :disable-focus-trap="false" :dismissible="true" :fixed-positioning="true" :target-element="editViewTargetElementRef" :title="t('editDetails')" :open="activeEditViewPopover !== null" :overflow-visible="true" position="right-start" @dismiss="setActiveEditViewPopover()" @keydown.esc.native.capture.stop.prevent="setActiveEditViewPopover()" > <header-with-views-edit-form v-if="computedViews[activeEditViewPopover]" context="edit" :additional-form-item="computedViews[activeEditViewPopover].additionalViewSetting" :errors="errors" :icon="computedViews[activeEditViewPopover].icon" :icon-color="computedViews[activeEditViewPopover].iconColor" :label="computedViews[activeEditViewPopover].label" :disable-customization="disableCustomization" @blur:label="value => validateLabel(value, activeViewMenuPopover)" @input:label="value => validateLabel(value, activeViewMenuPopover)" @keydown:enter="handleKeydownEnterViewItem" @update="handleUpdateViewItem" @cancel="hideEditViewPopover" > </header-with-views-edit-form> </bento-popover> <!-- Unsaved views prompt --> <bento-modal v-if="showUnsavedModalWarning" :actions="computedUnsavedViewModalActions" :is-dismissible="false" :is-open="showUnsavedModalWarning" @close-modal="showUnsavedModalWarning = false" > <template #default>{{ t('unsavedChangesTitle', { label: computedViews[currentActiveView].label }) }}</template> <template #content> <div class="b-header-with-views__modal-description"> <bento-typography>{{ t('unsavedChangesDescription') }}</bento-typography> <bento-checkbox v-if="skipUnsavedChangesText" :model-value="skipUnsavedChangesReminder" @update:model-value="value => (skipUnsavedChangesReminder = value)" > {{ skipUnsavedChangesText }} </bento-checkbox> </div> </template> </bento-modal> <!-- Delete views prompt --> <bento-modal v-if="showDeleteModalWarning" :actions="computedDeleteViewModalActions" :is-open="showDeleteModalWarning" :is-dismissible="false" destructive-actions @click.native.capture="handleClickModal" @close-modal="showDeleteModalWarning = false" > <template #default> {{ t('deleteViewTitle', { label: computedViewToBeDeletedLabel }) }} </template> <template #content> <div class="b-header-with-views__modal-description"> <bento-typography>{{ t('deleteViewDescription', { label: computedViewToBeDeletedLabel }) }}</bento-typography> </div> </template> </bento-modal> </div> </template> <script setup lang="ts"> import { BentoButton, BentoToggleButton } from '@/components/button'; import { BentoCheckbox } from '@/components/checkbox'; import { BentoMenu, type BentoMenuItem } from '@/components/menu'; import BentoModal from '@/components/modal/modal.vue'; import { BentoPopover } from '@/components/popover'; import { BentoTypography } from '@/components/typography'; import { HeaderMeta } from '@/internal'; import { useBentoToastController, useClickOutside, useHasSlot } from '@/composables'; import { generateUid } from '@/core/utils/ts'; import ButtonActionsWithMenu from '@/internal/button-actions-with-menu/button-actions-with-menu.vue'; import { InfoIconWithPopover } from '@/internal/info-icon-with-popover'; import { useI18n } from '@/utils/ts/i18n'; import BinIcon from '@adyen/ui-assets-icons-16/vue/bin'; import DotIcon from '@adyen/ui-assets-icons-16/vue/dot'; import DownloadIcon from '@adyen/ui-assets-icons-16/vue/download'; import EditIcon from '@adyen/ui-assets-icons-16/vue/edit-2'; import HeartIcon from '@adyen/ui-assets-icons-16/vue/heart'; import OptionsHorizontalIcon from '@adyen/ui-assets-icons-16/vue/options-horizontal'; import PlusIcon from '@adyen/ui-assets-icons-16/vue/plus'; import PlusMultipleIcon from '@adyen/ui-assets-icons-16/vue/plus-multiple'; import RefreshIcon from '@adyen/ui-assets-icons-16/vue/refresh'; import { useElementSize } from '@vueuse/core'; import { computed, nextTick, onMounted, ref, useSlots, watch } from 'vue'; import { HeaderWithViewsAllViews, HeaderWithViewsEditForm } from './components'; import { useEditViews, useResponsiveViews } from './composables'; import { type BentoHeaderWithViewsEmits, type BentoHeaderWithViewsItem, BentoHeaderWithViewsItemIconElement, type BentoHeaderWithViewsProps, } from './header-with-views.types'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const props = withDefaults(defineProps<BentoHeaderWithViewsProps>(), { actions: () => [], additionalViewSetting: null, title: null, description: null, disableCustomization: false, lastUpdated: null, reloadButton: null, infoIconTooltipText: null, views: () => [], disableAutoSelect: false, activeViewIndex: undefined, }); const emit = defineEmits<BentoHeaderWithViewsEmits>(); const STACKED_LAYOUT_BREAKPOINT = 573; const { t } = useI18n<{ message: MessageSchema }>({ messages }); const slots = useSlots(); const hasSlot = useHasSlot(slots); // refs const createViewButtonRef = ref(null); const createViewPopoverRef = ref(null); const editViewPopoverRef = ref(null); const editViewMenuRefs = ref([]); const editViewTargetElementRef = ref(null); // used as a mount for the edit form popover const hostRef = ref(null); const viewsRef = ref<HTMLElement>(null); // container for all views const viewRefs = ref<Array<HTMLDivElement>>([]); // individual view const viewControlRef = ref(null); // all views menu and create view button container const viewControlAllViewsRef = ref(null); // all views menu // controls const showCreateViewPopover = ref<boolean>(false); // control the create view popover const showAllViewsPopover = ref<boolean>(false); // control when menu button is shown const showUnsavedModalWarning = ref<boolean>(false); const showDeleteModalWarning = ref<boolean>(false); const activeEditViewPopover = ref<number>(null); // control the edit view popover const activeViewMenuPopover = ref<number>(null); // control when menu items are shown const internalActiveView = ref<number>(null); // control which view the user is currently on const nextActiveView = ref<number>(null); // control which view is does the user want to navigate to, used for caching unsaved view navigation const viewToBeDeleted = ref<number>(null); // cache which view is marked for deletion // Computed property to handle two-way binding for activeViewIndex const currentActiveView = computed({ get: () => internalActiveView.value, set: (value: number | null) => { internalActiveView.value = value; emit('update:active-view-index', value); }, }); // models const skipUnsavedChangesReminder = ref<boolean>(false); const createViewPopoverId = generateUid('createViewPopover'); const editViewPopoverId = generateUid('editViewPopover'); useResponsiveViews({ container: viewsRef, children: viewRefs, offset: viewControlRef, activeViewIndex: currentActiveView, }); const { computedViews, clearError, duplicateView, errors, setViews, updateView, validateLabel } = useEditViews({ additionalViewSetting: props.additionalViewSetting, views: props.views, }); const hideCreateViewPopover = async (updateFocus = true) => { showCreateViewPopover.value = false; if (updateFocus) { await nextTick(); createViewButtonRef.value.$el.focus(); } clearError(); }; const hideEditViewPopover = () => { const currentMenuRef = showAllViewsPopover.value ? viewControlAllViewsRef.value.$refs.editViewMenuRefs[activeEditViewPopover.value] : editViewMenuRefs.value[0]; currentMenuRef?.closeMenu(); }; useClickOutside(createViewPopoverRef, () => hideCreateViewPopover(false), { ignore: [createViewButtonRef], }); useClickOutside(editViewPopoverRef, () => setActiveEditViewPopover(), { ignore: [editViewTargetElementRef], }); const { width: hostWidth } = useElementSize(hostRef); const { addToast } = useBentoToastController(); const computedUnsavedViewModalActions = computed(() => [ { title: t('saveChanges'), event: handleSaveChanges, }, { title: t('leave'), event: handleRestoreChanges, }, ]); const computedDeleteViewModalActions = computed(() => [ { title: t('deleteViewAction'), event: async () => { showDeleteModalWarning.value = false; const updatedViewObj = updateView(null, viewToBeDeleted.value); emit('delete:view', updatedViewObj); hideViewMenuPopover(); // if a view that is placed higher in the order is deleted, ignore if (currentActiveView.value < viewToBeDeleted.value) { return; } // if a view that is placed lower in the order is deleted, // we need to shift the index to maintain the current view if (currentActiveView.value > viewToBeDeleted.value) { currentActiveView.value -= 1; } else if (currentActiveView.value === viewToBeDeleted.value) { if (updatedViewObj.newViews.length === 0) { // there are no more views currentActiveView.value = null; } else if (currentActiveView.value >= updatedViewObj.newViews.length) { // shift it to the left currentActiveView.value -= 1; } // else, keep it at the same index } viewToBeDeleted.value = null; }, }, { title: t('cancel'), event: () => { showDeleteModalWarning.value = false; }, }, ]); const computedViewToBeDeletedLabel = computed(() => computedViews.value[viewToBeDeleted.value]?.label); const hasMetaSection = computed(() => props.description || hasSlot('description') || props.lastUpdated); const isCompact = computed(() => hostWidth.value <= STACKED_LAYOUT_BREAKPOINT); const conditionalContainerClass = computed(() => ({ 'b-header-with-views__container--is-compact': isCompact.value, 'b-header-with-views__container--has-meta': hasMetaSection.value, })); watch([currentActiveView, computedViews], ([newIndex, newViews], [oldIndex, oldViews]) => { // skip if the labels are the same or no active view const isViewUpdated = newIndex === oldIndex && newViews.length === oldViews.length; const isViewDeletedWithChangedIndex = newViews[newIndex]?.label === oldViews[oldIndex]?.label; if (isViewUpdated || isViewDeletedWithChangedIndex) { return; } // allow empty label // occurs when the user removes all views emit('select:view', newViews[newIndex]?.label ?? null); }); // whenever the view menu popover changes, we reset the edit popover because it is never opened by default watch(activeViewMenuPopover, () => { setActiveEditViewPopover(); }); watch(showAllViewsPopover, value => { if (value) { return; } // if show all view popover is closed, close children as well hideViewMenuPopover(); }); // set the popover to open next to the edit button watch(activeEditViewPopover, value => { const editButtonIndex = isViewUnsaved(value) ? 2 : 0; // EDGE CASE: // Since edit button can be clicked while in all views popover or while it is in the bar, // we will not support the case where edit popover is opened and the screen is resize until the view menu is hidden. // Edit popover will still be displayed but the position does not have to follow the edit button as it is hidden. if (showAllViewsPopover.value) { const menuListItemRef = viewControlAllViewsRef.value.$refs.editViewMenuRefs[activeViewMenuPopover.value]?.$refs ?.menuItemListRef; editViewTargetElementRef.value = viewControlAllViewsRef.value.$refs.editViewMenuRefs[activeViewMenuPopover.value]?.$refs?.menuItemListRef ?.$refs?.menuItemRef?.[editButtonIndex] ?? null; if (!value) { menuListItemRef?.focusItem(); clearError(); } return; } const menuListItemRef = editViewMenuRefs.value.find(ref => ref.$refs.menuItemListRef)?.$refs?.menuItemListRef; editViewTargetElementRef.value = menuListItemRef?.$refs?.menuItemRef?.[editButtonIndex] ?? null; if (!value) { menuListItemRef?.focusItem(); clearError(); } }); // Watch for external changes to the activeViewIndex prop watch( () => props.activeViewIndex, newValue => { if (newValue !== undefined && newValue >= 0 && newValue < computedViews.value.length) { currentActiveView.value = newValue; } } ); onMounted(() => { if ( props.activeViewIndex !== undefined && props.activeViewIndex >= 0 && props.activeViewIndex < computedViews.value.length ) { currentActiveView.value = props.activeViewIndex; } else if (!props.disableAutoSelect) { const defaultView = computedViews.value.findIndex(view => view.isDefault); currentActiveView.value = defaultView >= 0 ? defaultView : 0; } }); const getViewMenuData = (index: number): Array<BentoMenuItem> => [ ...(isViewUnsaved(index) ? [ { text: t('saveChanges'), handler: handleSaveChanges, icon: DownloadIcon, }, { addDivider: true, text: t('restoreChanges'), handler: handleRestoreChanges, icon: RefreshIcon, }, ] : []), ...(!computedViews.value[index]?.isFixed ? [ { text: t('edit'), handler: () => { setActiveEditViewPopover(activeViewMenuPopover.value); }, icon: EditIcon, skipClose: true, }, ] : []), { text: t('setAsDefault'), handler: () => { const updatedViewObj = updateView({ isDefault: true }, activeViewMenuPopover.value); emit('update:view', updatedViewObj); addToast({ text: t('setAsDefaultToast', { label: updatedViewObj.targetViewLabel }), }); hideViewMenuPopover(); }, icon: HeartIcon, }, { text: t('duplicate'), handler: () => { const updatedViewObj = duplicateView(activeViewMenuPopover.value); emit('duplicate:view', updatedViewObj); hideViewMenuPopover(); // switch to newly duplicated view handleViewSelect(updatedViewObj.newViews.length - 1); }, icon: PlusMultipleIcon, }, ...(!computedViews.value[index]?.isFixed ? [ { text: t('delete'), handler: () => { viewToBeDeleted.value = activeViewMenuPopover.value; showDeleteModalWarning.value = true; }, critical: true, icon: BinIcon, }, ] : []), ]; const showViewMenuPopover = (index: number) => { activeViewMenuPopover.value = index; }; const hideViewMenuPopover = () => { activeViewMenuPopover.value = null; setActiveEditViewPopover(); }; const setActiveEditViewPopover = (index?: number) => { if (index === activeEditViewPopover.value) { activeEditViewPopover.value = null; } else { activeEditViewPopover.value = index ?? null; } }; const handleCreateViewItem = (value: BentoHeaderWithViewsItem) => { const updatedViewObj = updateView(value); emit('add:view', updatedViewObj); hideCreateViewPopover(); // switch to new view on add handleViewSelect(updatedViewObj.newViews.findIndex(view => updatedViewObj.targetViewLabel === view.label)); addToast({ text: t('createViewToast', { label: updatedViewObj.targetViewLabel }), }); }; const handleUpdateViewItem = (value: BentoHeaderWithViewsItem) => { emit('update:view', updateView(value, activeEditViewPopover.value)); hideEditViewPopover(); }; const handleKeydownEnterViewItem = (value: BentoHeaderWithViewsItem) => { validateLabel(value.label, activeViewMenuPopover.value); if (Object.values(errors).some(error => error.length > 0)) { return; } if (activeEditViewPopover.value) { handleUpdateViewItem(value); return; } handleCreateViewItem(value); }; const handleViewSelect = (index: number) => { // If there's no currently active view, or if there are no unsaved changes, // just switch to the new view. if (currentActiveView.value === null || !props.hasChanges || props.skipUnsavedChangesText === null) { // If switching from an existing view, emit restore event. if (currentActiveView.value !== null) { emit('restore:view', computedViews.value[currentActiveView.value].label); } currentActiveView.value = index; return; } nextActiveView.value = index; showUnsavedModalWarning.value = true; }; const handleReorder = (reorderedViews: Array<BentoHeaderWithViewsItem>) => { const currentActiveLabel = currentActiveView.value !== null ? computedViews.value[currentActiveView.value]?.label : null; const result = setViews(reorderedViews); if (currentActiveLabel) { const newIndex = result.newViews.findIndex(view => view.label === currentActiveLabel); if (newIndex !== -1) { currentActiveView.value = newIndex; } } emit('reorder:view', result); }; const handleClickModal = (event: MouseEvent) => { if ((event.target as HTMLElement).closest('button')) { return; } event.stopPropagation(); event.preventDefault(); }; const handleTabEditViewPopover = () => { // skip built-in refocus on the menu button when menu is closed // enables tab to flow as intended by HTML spec editViewMenuRefs.value[0].closeMenu(false); }; const handleSaveChanges = () => { const label = computedViews.value[currentActiveView.value].label; emit('save:view', label); if (showUnsavedModalWarning.value) { emit('update:skip-unsaved-changes', skipUnsavedChangesReminder.value); // fired when user saves the view while navigating to a different tab currentActiveView.value = nextActiveView.value; nextActiveView.value = null; showUnsavedModalWarning.value = false; } else { // fired when user saves the view via the menu hideViewMenuPopover(); } addToast({ text: t('saveChangesToast', { label }), action: { handler: () => emit('restore:view', label), text: t('undo'), }, }); }; const handleRestoreChanges = () => { emit('restore:view', computedViews.value[currentActiveView.value].label); if (showUnsavedModalWarning.value) { emit('update:skip-unsaved-changes', skipUnsavedChangesReminder.value); // fired when user restore the view while navigating to a different tab currentActiveView.value = nextActiveView.value; nextActiveView.value = null; showUnsavedModalWarning.value = false; } else { // fired when user restore the view via the menu hideViewMenuPopover(); } }; const isViewActive = (index: number) => currentActiveView.value === index; const isViewUnsaved = (index: number) => isViewActive(index) && props.hasChanges; </script> <script lang="ts"> /** * Header with views allow users to save and quickly retrieve configurations tailored to specific tasks. * For example, managing daily transactions, auditing, or analyzing specific data sets. * It also allows the user to define the columns, filters, and search queries they need. * * @example * import { BentoHeaderWithViews } from '@adyen/bento-vue2'; * * export default { * components: { BentoHeaderWithViews }, * template: ` * <bento-header-with-views * :actions="[ * { title: 'Primary action', event: () => undefined }, * { title: 'Secondary action', event: () => undefined }, * { title: 'Tertiary action', event: () => undefined }, * { title: 'Another action', event: () => undefined } * ]" * description="Page Description" * title="Page Title" * :views="[ * { icon: 'stack', iconColor: 'blue', label: 'A very long value' }, * { icon: 'stack', iconColor: 'primary', label: 'Title 1' }, * { icon: 'stack', iconColor: 'primary', label: 'Title 2' } * ]" * :additionalViewSetting="{ * label: 'Include date filter (relative)', * value: false * }" * hasChanges * /> * ` * }; */ export default { i18n: { messages }, }; </script> <style lang="scss" scoped src="./header-with-views.scss" />
|
|
1
|
+
<template> <div ref="hostRef" class="b-header-with-views"> <div class="b-header-with-views__container" :class="conditionalContainerClass"> <div class="b-header-with-views__title"> <bento-typography el="h2" variant="title">{{ title }}</bento-typography> <info-icon-with-popover v-if="infoIconTooltipText || hasSlot('infoIconTooltipContent')" :popover-text="infoIconTooltipText" class="b-header-with-views__title-icon" > <template v-if="hasSlot('infoIconTooltipContent')" #default> <slot name="infoIconTooltipContent" /> </template> </info-icon-with-popover> </div> <fieldset ref="viewsRef" class="b-header-with-views__bar" :aria-label="t('viewListLabel')"> <div v-for="(view, index) in computedViews" :key="index" ref="viewRefs" class="b-header-with-views__bar-tab" :class="isViewActive(index) ? 'b-header-with-views__bar-tab--active' : null" > <bento-button class="b-header-with-views__bar-tab-button" variant="tertiary-with-background" :aria-current="currentActiveView === index ? 'true' : 'false'" @click="handleViewSelect(index)" @keydown.enter.native="handleViewSelect(index)" @keydown.space.native="handleViewSelect(index)" > <template #iconLeft> <span class="b-header-with-views__bar-tab-button-icon" :class="`b-header-with-views__bar-tab-button-icon--${view.iconColor}`" aria-hidden="true" > <component :is="BentoHeaderWithViewsItemIconElement[view.icon]" /> </span> </template> <bento-typography el="span" variant="body" stronger class="b-header-with-views__bar-tab-button-text" > {{ view.label }} </bento-typography> <template v-if="isViewUnsaved(index)" #iconRight> <span class="b-header-with-views__bar-tab-button--unsaved"> <dot-icon :svg-title="t('withUnsavedChanges')" /> </span> </template> </bento-button> <bento-menu v-if="currentActiveView === index" ref="editViewMenuRefs" class="b-header-with-views__bar-tab-menu" :button="{ variant: 'tertiary', 'aria-label': t('openViewOptions', { label: view.label }) }" :data="getViewMenuData(index)" :close-on-click-outside-options="{ ignore: [`#${editViewPopoverId}`], }" :menu-fixed-positioning="true" @open="showViewMenuPopover(index)" @close="hideViewMenuPopover()" @keydown.tab.native.capture="handleTabEditViewPopover" > <template #iconLeft> <options-horizontal-icon :aria-hidden="true" /> </template> </bento-menu> </div> <div ref="viewControlRef" class="b-header-with-views__bar-control"> <!-- All views --> <header-with-views-all-views v-if="computedViews.length" ref="viewControlAllViewsRef" :menu-items="computedViews" :menu-items-actions="getViewMenuData(activeViewMenuPopover)" :menu-close-on-click-outside-options="{ ignore: [`#${editViewPopoverId}`] }" @close:view-menu="hideViewMenuPopover()" @close:all-views="showAllViewsPopover = false" @open:all-views="showAllViewsPopover = true" @open:view-menu="index => showViewMenuPopover(index)" @reorder:view="views => handleReorder(views)" @select:view="index => handleViewSelect(index)" /> <bento-toggle-button ref="createViewButtonRef" :aria-expanded="showCreateViewPopover" :aria-controls="showCreateViewPopover ? createViewPopoverId : null" :toggled="showCreateViewPopover" variant="tertiary" @click="showCreateViewPopover = !showCreateViewPopover" > <template #iconLeft> <plus-icon :svg-title="t('createView')" :aria-hidden="computedViews.length === 0" /> </template> <template v-if="computedViews.length === 0" #default>{{ t('createView') }}</template> </bento-toggle-button> </div> </fieldset> <header-meta v-if="hasMetaSection" class="b-header-with-views__meta-container" :description="description" :last-updated="lastUpdated" :reload-button="reloadButton" > <template v-if="hasSlot('description')" #description><slot name="description" /></template> </header-meta> <button-actions-with-menu v-if="actions" :actions="actions" :actions-layout="isCompact ? 'buttons-start' : 'buttons-end'" :displayed-actions="2" :disable-responsive-behavior="true" :hide-displayed-actions-breakpoint="STACKED_LAYOUT_BREAKPOINT" class="b-header-with-views__actions" /> </div> <!-- Create view --> <bento-popover v-if="activeViewMenuPopover === null && createViewButtonRef" :id="createViewPopoverId" ref="createViewPopoverRef" :dismissible="true" :fixed-positioning="true" :target-element="createViewButtonRef" :title="t('createView')" :open="showCreateViewPopover" :overflow-visible="true" position="bottom-start" @dismiss="hideCreateViewPopover" @keydown.esc.native.capture.stop.prevent="hideCreateViewPopover" > <header-with-views-edit-form context="create" :errors="errors" :additional-form-item="additionalViewSetting" :disable-customization="disableCustomization" @blur:label="value => validateLabel(value, activeViewMenuPopover)" @input:label="value => validateLabel(value, activeViewMenuPopover)" @keydown:enter="handleKeydownEnterViewItem" @update="handleCreateViewItem" @cancel="hideCreateViewPopover" > </header-with-views-edit-form> </bento-popover> <!-- Edit view --> <bento-popover v-if="activeViewMenuPopover !== null && editViewTargetElementRef" :id="editViewPopoverId" ref="editViewPopoverRef" :disable-focus-trap="false" :dismissible="true" :fixed-positioning="true" :target-element="editViewTargetElementRef" :title="t('editDetails')" :open="activeEditViewPopover !== null" :overflow-visible="true" position="right-start" @dismiss="setActiveEditViewPopover()" @keydown.esc.native.capture.stop.prevent="setActiveEditViewPopover()" > <header-with-views-edit-form v-if="computedViews[activeEditViewPopover]" context="edit" :additional-form-item="computedViews[activeEditViewPopover].additionalViewSetting" :errors="errors" :icon="computedViews[activeEditViewPopover].icon" :icon-color="computedViews[activeEditViewPopover].iconColor" :label="computedViews[activeEditViewPopover].label" :disable-customization="disableCustomization" @blur:label="value => validateLabel(value, activeViewMenuPopover)" @input:label="value => validateLabel(value, activeViewMenuPopover)" @keydown:enter="handleKeydownEnterViewItem" @update="handleUpdateViewItem" @cancel="hideEditViewPopover" > </header-with-views-edit-form> </bento-popover> <!-- Unsaved views prompt --> <bento-modal v-if="showUnsavedModalWarning" :actions="computedUnsavedViewModalActions" :is-dismissible="false" :is-open="showUnsavedModalWarning" @close-modal="showUnsavedModalWarning = false" > <template #default>{{ t('unsavedChangesTitle', { label: computedViews[currentActiveView].label }) }}</template> <template #content> <div class="b-header-with-views__modal-description"> <bento-typography>{{ t('unsavedChangesDescription') }}</bento-typography> <bento-checkbox v-if="skipUnsavedChangesText" :model-value="skipUnsavedChangesReminder" @update:model-value="value => (skipUnsavedChangesReminder = value)" > {{ skipUnsavedChangesText }} </bento-checkbox> </div> </template> </bento-modal> <!-- Delete views prompt --> <bento-modal v-if="showDeleteModalWarning" :actions="computedDeleteViewModalActions" :is-open="showDeleteModalWarning" :is-dismissible="false" destructive-actions @click.native.capture="handleClickModal" @close-modal="showDeleteModalWarning = false" > <template #default> {{ t('deleteViewTitle', { label: computedViewToBeDeletedLabel }) }} </template> <template #content> <div class="b-header-with-views__modal-description"> <bento-typography>{{ t('deleteViewDescription', { label: computedViewToBeDeletedLabel }) }}</bento-typography> </div> </template> </bento-modal> </div> </template> <script setup lang="ts"> import { BentoButton, BentoToggleButton } from '@/components/button'; import { BentoCheckbox } from '@/components/checkbox'; import { BentoMenu, type BentoMenuItem } from '@/components/menu'; import BentoModal from '@/components/modal/modal.vue'; import { BentoPopover } from '@/components/popover'; import { BentoTypography } from '@/components/typography'; import { HeaderMeta } from '@/internal'; import { useBentoToastController, useClickOutside, useHasSlot } from '@/composables'; import { generateUid } from '@/core/utils/ts'; import ButtonActionsWithMenu from '@/internal/button-actions-with-menu/button-actions-with-menu.vue'; import { InfoIconWithPopover } from '@/internal/info-icon-with-popover'; import { useI18n } from '@/utils/ts/i18n'; import BinIcon from '@adyen/ui-assets-icons-16/vue/bin'; import DotIcon from '@adyen/ui-assets-icons-16/vue/dot'; import DownloadIcon from '@adyen/ui-assets-icons-16/vue/download'; import EditIcon from '@adyen/ui-assets-icons-16/vue/edit-2'; import HeartIcon from '@adyen/ui-assets-icons-16/vue/heart'; import OptionsHorizontalIcon from '@adyen/ui-assets-icons-16/vue/options-horizontal'; import PlusIcon from '@adyen/ui-assets-icons-16/vue/plus'; import PlusMultipleIcon from '@adyen/ui-assets-icons-16/vue/plus-multiple'; import RefreshIcon from '@adyen/ui-assets-icons-16/vue/refresh'; import { useElementSize } from '@vueuse/core'; import { computed, nextTick, onMounted, ref, useSlots, watch } from 'vue'; import { HeaderWithViewsAllViews, HeaderWithViewsEditForm } from './components'; import { useEditViews, useResponsiveViews } from './composables'; import { type BentoHeaderWithViewsEmits, type BentoHeaderWithViewsItem, BentoHeaderWithViewsItemIconElement, type BentoHeaderWithViewsProps, } from './header-with-views.types'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const props = withDefaults(defineProps<BentoHeaderWithViewsProps>(), { actions: () => [], additionalViewSetting: null, title: null, description: null, disableCustomization: false, lastUpdated: null, reloadButton: null, infoIconTooltipText: null, views: () => [], disableAutoSelect: false, activeViewIndex: undefined, }); const emit = defineEmits<BentoHeaderWithViewsEmits>(); const STACKED_LAYOUT_BREAKPOINT = 573; const { t } = useI18n<{ message: MessageSchema }>({ messages }); const slots = useSlots(); const hasSlot = useHasSlot(slots); // refs const createViewButtonRef = ref(null); const createViewPopoverRef = ref(null); const editViewPopoverRef = ref(null); const editViewMenuRefs = ref([]); const editViewTargetElementRef = ref(null); // used as a mount for the edit form popover const hostRef = ref(null); const viewsRef = ref<HTMLElement>(null); // container for all views const viewRefs = ref<Array<HTMLDivElement>>([]); // individual view const viewControlRef = ref(null); // all views menu and create view button container const viewControlAllViewsRef = ref(null); // all views menu // controls const showCreateViewPopover = ref<boolean>(false); // control the create view popover const showAllViewsPopover = ref<boolean>(false); // control when menu button is shown const showUnsavedModalWarning = ref<boolean>(false); const showDeleteModalWarning = ref<boolean>(false); const activeEditViewPopover = ref<number>(null); // control the edit view popover const activeViewMenuPopover = ref<number>(null); // control when menu items are shown const internalActiveView = ref<number>(null); // control which view the user is currently on const nextActiveView = ref<number>(null); // control which view is does the user want to navigate to, used for caching unsaved view navigation const viewToBeDeleted = ref<number>(null); // cache which view is marked for deletion // Computed property to handle two-way binding for activeViewIndex const currentActiveView = computed({ get: () => internalActiveView.value, set: (value: number | null) => { internalActiveView.value = value; emit('update:active-view-index', value); }, }); // models const skipUnsavedChangesReminder = ref<boolean>(false); const createViewPopoverId = generateUid('createViewPopover'); const editViewPopoverId = generateUid('editViewPopover'); useResponsiveViews({ container: viewsRef, children: viewRefs, offset: viewControlRef, activeViewIndex: currentActiveView, }); const { computedViews, clearError, duplicateView, errors, setViews, updateView, validateLabel } = useEditViews({ additionalViewSetting: props.additionalViewSetting, views: props.views, }); const hideCreateViewPopover = async (updateFocus = true) => { showCreateViewPopover.value = false; if (updateFocus) { await nextTick(); createViewButtonRef.value.$el.focus(); } clearError(); }; const hideEditViewPopover = () => { const currentMenuRef = showAllViewsPopover.value ? viewControlAllViewsRef.value.$refs.editViewMenuRefs[activeEditViewPopover.value] : editViewMenuRefs.value[0]; currentMenuRef?.closeMenu(); }; useClickOutside(createViewPopoverRef, () => hideCreateViewPopover(false), { ignore: [createViewButtonRef], }); useClickOutside(editViewPopoverRef, () => setActiveEditViewPopover(), { ignore: [editViewTargetElementRef], }); const { width: hostWidth } = useElementSize(hostRef); const { addToast } = useBentoToastController(); const computedUnsavedViewModalActions = computed(() => [ { title: t('saveChanges'), event: handleSaveChanges, }, { title: t('leave'), event: handleRestoreChanges, }, ]); const computedDeleteViewModalActions = computed(() => [ { title: t('deleteViewAction'), event: async () => { showDeleteModalWarning.value = false; const updatedViewObj = updateView(null, viewToBeDeleted.value); emit('delete:view', updatedViewObj); hideViewMenuPopover(); // if a view that is placed higher in the order is deleted, ignore if (currentActiveView.value < viewToBeDeleted.value) { return; } // if a view that is placed lower in the order is deleted, // we need to shift the index to maintain the current view if (currentActiveView.value > viewToBeDeleted.value) { currentActiveView.value -= 1; } else if (currentActiveView.value === viewToBeDeleted.value) { if (updatedViewObj.newViews.length === 0) { // there are no more views currentActiveView.value = null; } else if (currentActiveView.value >= updatedViewObj.newViews.length) { // shift it to the left currentActiveView.value -= 1; } // else, keep it at the same index } viewToBeDeleted.value = null; }, }, { title: t('cancel'), event: () => { showDeleteModalWarning.value = false; }, }, ]); const computedViewToBeDeletedLabel = computed(() => computedViews.value[viewToBeDeleted.value]?.label); const hasMetaSection = computed(() => props.description || hasSlot('description') || props.lastUpdated); const isCompact = computed(() => hostWidth.value <= STACKED_LAYOUT_BREAKPOINT); const conditionalContainerClass = computed(() => ({ 'b-header-with-views__container--is-compact': isCompact.value, 'b-header-with-views__container--has-meta': hasMetaSection.value, })); watch([currentActiveView, computedViews], ([newIndex, newViews], [oldIndex, oldViews]) => { // skip if the labels are the same or no active view const isViewUpdated = newIndex === oldIndex && newViews.length === oldViews.length; const isViewDeletedWithChangedIndex = newViews[newIndex]?.label === oldViews[oldIndex]?.label; if (isViewUpdated || isViewDeletedWithChangedIndex) { return; } // allow empty label // occurs when the user removes all views emit('select:view', newViews[newIndex]?.label ?? null); }); // whenever the view menu popover changes, we reset the edit popover because it is never opened by default watch(activeViewMenuPopover, () => { setActiveEditViewPopover(); }); watch(showAllViewsPopover, value => { if (value) { return; } // if show all view popover is closed, close children as well hideViewMenuPopover(); }); // set the popover to open next to the edit button watch(activeEditViewPopover, value => { const editButtonIndex = isViewUnsaved(value) ? 2 : 0; // EDGE CASE: // Since edit button can be clicked while in all views popover or while it is in the bar, // we will not support the case where edit popover is opened and the screen is resize until the view menu is hidden. // Edit popover will still be displayed but the position does not have to follow the edit button as it is hidden. if (showAllViewsPopover.value) { const menuListItemRef = viewControlAllViewsRef.value.$refs.editViewMenuRefs[activeViewMenuPopover.value]?.$refs ?.menuItemListRef; editViewTargetElementRef.value = viewControlAllViewsRef.value.$refs.editViewMenuRefs[activeViewMenuPopover.value]?.$refs?.menuItemListRef ?.$refs?.menuItemRef?.[editButtonIndex] ?? null; if (!value) { menuListItemRef?.focusItem(); clearError(); } return; } const menuListItemRef = editViewMenuRefs.value.find(ref => ref.$refs.menuItemListRef)?.$refs?.menuItemListRef; editViewTargetElementRef.value = menuListItemRef?.$refs?.menuItemRef?.[editButtonIndex] ?? null; if (!value) { menuListItemRef?.focusItem(); clearError(); } }); // Watch for external changes to the activeViewIndex prop watch( () => props.activeViewIndex, newValue => { if (newValue !== undefined && newValue >= 0 && newValue < computedViews.value.length) { currentActiveView.value = newValue; } } ); onMounted(() => { if ( props.activeViewIndex !== undefined && props.activeViewIndex >= 0 && props.activeViewIndex < computedViews.value.length ) { currentActiveView.value = props.activeViewIndex; } else if (!props.disableAutoSelect) { const defaultView = computedViews.value.findIndex(view => view.isDefault); currentActiveView.value = defaultView >= 0 ? defaultView : 0; } }); const getViewMenuData = (index: number): Array<BentoMenuItem> => [ ...(isViewUnsaved(index) ? [ { text: t('saveChanges'), handler: handleSaveChanges, icon: DownloadIcon, }, { addDivider: true, text: t('restoreChanges'), handler: handleRestoreChanges, icon: RefreshIcon, }, ] : []), ...(!computedViews.value[index]?.isFixed ? [ { text: t('edit'), handler: () => { setActiveEditViewPopover(activeViewMenuPopover.value); }, icon: EditIcon, skipClose: true, }, ] : []), { text: t('setAsDefault'), handler: () => { const updatedViewObj = updateView({ isDefault: true }, activeViewMenuPopover.value); emit('update:view', updatedViewObj); addToast({ text: t('setAsDefaultToast', { label: updatedViewObj.targetViewLabel }), }); hideViewMenuPopover(); }, icon: HeartIcon, }, { text: t('duplicate'), handler: () => { const updatedViewObj = duplicateView(activeViewMenuPopover.value); emit('duplicate:view', updatedViewObj); hideViewMenuPopover(); // switch to newly duplicated view handleViewSelect(updatedViewObj.newViews.length - 1); }, icon: PlusMultipleIcon, }, ...(!computedViews.value[index]?.isFixed ? [ { text: t('delete'), handler: () => { viewToBeDeleted.value = activeViewMenuPopover.value; showDeleteModalWarning.value = true; }, critical: true, icon: BinIcon, }, ] : []), ]; const showViewMenuPopover = (index: number) => { activeViewMenuPopover.value = index; }; const hideViewMenuPopover = () => { activeViewMenuPopover.value = null; setActiveEditViewPopover(); }; const setActiveEditViewPopover = (index?: number) => { if (index === activeEditViewPopover.value) { activeEditViewPopover.value = null; } else { activeEditViewPopover.value = index ?? null; } }; const handleCreateViewItem = (value: BentoHeaderWithViewsItem) => { const updatedViewObj = updateView(value); emit('add:view', updatedViewObj); hideCreateViewPopover(); // switch to new view on add handleViewSelect(updatedViewObj.newViews.findIndex(view => updatedViewObj.targetViewLabel === view.label)); addToast({ text: t('createViewToast', { label: updatedViewObj.targetViewLabel }), }); }; const handleUpdateViewItem = (value: BentoHeaderWithViewsItem) => { emit('update:view', updateView(value, activeEditViewPopover.value)); hideEditViewPopover(); }; const handleKeydownEnterViewItem = (value: BentoHeaderWithViewsItem) => { validateLabel(value.label, activeViewMenuPopover.value); if (Object.values(errors).some(error => error.length > 0)) { return; } if (activeEditViewPopover.value) { handleUpdateViewItem(value); return; } handleCreateViewItem(value); }; const handleViewSelect = (index: number) => { // If there's no currently active view, or if there are no unsaved changes, // just switch to the new view. if (currentActiveView.value === null || !props.hasChanges || props.skipUnsavedChangesText === null) { // If switching from an existing view, emit restore event. if (currentActiveView.value !== null) { emit('restore:view', computedViews.value[currentActiveView.value].label); } currentActiveView.value = index; return; } nextActiveView.value = index; showUnsavedModalWarning.value = true; }; const handleReorder = (reorderedViews: Array<BentoHeaderWithViewsItem>) => { const currentActiveLabel = currentActiveView.value !== null ? computedViews.value[currentActiveView.value]?.label : null; const result = setViews(reorderedViews); if (currentActiveLabel) { const newIndex = result.newViews.findIndex(view => view.label === currentActiveLabel); if (newIndex !== -1) { currentActiveView.value = newIndex; } } emit('reorder:view', result); }; const handleClickModal = (event: MouseEvent) => { if ((event.target as HTMLElement).closest('button')) { return; } event.stopPropagation(); event.preventDefault(); }; const handleTabEditViewPopover = () => { // skip built-in refocus on the menu button when menu is closed // enables tab to flow as intended by HTML spec editViewMenuRefs.value[0].closeMenu(false); }; const handleSaveChanges = () => { const label = computedViews.value[currentActiveView.value].label; emit('save:view', label); if (showUnsavedModalWarning.value) { emit('update:skip-unsaved-changes', skipUnsavedChangesReminder.value); // fired when user saves the view while navigating to a different tab currentActiveView.value = nextActiveView.value; nextActiveView.value = null; showUnsavedModalWarning.value = false; } else { // fired when user saves the view via the menu hideViewMenuPopover(); } addToast({ text: t('saveChangesToast', { label }), action: { handler: () => emit('restore:view', label), text: t('undo'), }, }); }; const handleRestoreChanges = () => { emit('restore:view', computedViews.value[currentActiveView.value].label); if (showUnsavedModalWarning.value) { emit('update:skip-unsaved-changes', skipUnsavedChangesReminder.value); // fired when user restore the view while navigating to a different tab currentActiveView.value = nextActiveView.value; nextActiveView.value = null; showUnsavedModalWarning.value = false; } else { // fired when user restore the view via the menu hideViewMenuPopover(); } }; const isViewActive = (index: number) => currentActiveView.value === index; const isViewUnsaved = (index: number) => isViewActive(index) && props.hasChanges; </script> <script lang="ts"> /** * Header with views allow users to save and quickly retrieve configurations tailored to specific tasks. * For example, managing daily transactions, auditing, or analyzing specific data sets. * It also allows the user to define the columns, filters, and search queries they need. * * @example * import { BentoHeaderWithViews } from '@adyen/bento-vue2'; * * export default { * components: { BentoHeaderWithViews }, * template: ` * <bento-header-with-views * :actions="[ * { title: 'Primary action', event: () => undefined }, * { title: 'Secondary action', event: () => undefined }, * { title: 'Tertiary action', event: () => undefined }, * { title: 'Another action', event: () => undefined } * ]" * description="Page Description" * title="Page Title" * :views="[ * { icon: 'stack', iconColor: 'blue', label: 'A very long value' }, * { icon: 'stack', iconColor: 'primary', label: 'Title 1' }, * { icon: 'stack', iconColor: 'primary', label: 'Title 2' } * ]" * :additionalViewSetting="{ * label: 'Include date filter (relative)', * value: false * }" * hasChanges * /> * ` * }; */ export default { i18n: { messages }, }; </script> <style lang="scss" scoped src="./header-with-views.scss" />
|
package/dist/assets/components/internal/calendar/composables/use-granularity-adjustments.types.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import { type Day } from 'date-fns'; export type
|
|
1
|
+
import { type Day } from 'date-fns'; import type { CalendarGranularityConfig } from '../calendar.types'; /** Identifies one endpoint of a date range. */ export type BentoRangeEndpoint = 'start' | 'end'; /** Options for applying every date-range constraint in a consistent order. */ export interface BentoNormalizeRangeOptions { /** Requested start of the range. */ startDate: Date; /** Requested end of the range. */ endDate: Date; /** Endpoint that must stay unchanged if the range needs to be shortened. */ fixedEndpoint: BentoRangeEndpoint; /** Active granularity, including an optional granularity-specific maximum range. */ granularity?: CalendarGranularityConfig; /** Whether to expand the range to complete granularity units. */ alignToGranularity?: boolean; /** Maximum range used when the active granularity does not define one. */ fallbackMaxRange?: number; /** Earliest permitted date. */ min?: Date; /** Latest permitted date. */ max?: Date; } /** Date operations needed to measure and shorten a range in one granularity unit. */ export interface BentoGranularityClampConfig { /** Counts how many units the range spans. */ getCount: (end: Date, start: Date) => number; /** Moves a date backwards by the specified number of units. */ sub: (date: Date, amount: number) => Date; /** Moves a date forwards by the specified number of units. */ add: (date: Date, amount: number) => Date; } /** Functions that normalize complete ranges. */ export type UseGranularityAdjustments = (firstDayOfWeek: Day) => { normalizeRange: (options: BentoNormalizeRangeOptions) => { startDate: Date; endDate: Date }; };
|
package/dist/assets/components/internal/listbox/components/listbox-option/listbox-option.vue
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
<template> <div class="b-listbox-option" role="option" :class="conditionalClasses" v-bind="$attrs" @click="onClick" @keydown.enter="onOptionSelected(BentoListboxEvent.ENTER, $event)" @keydown.space="onOptionSelected(BentoListboxEvent.SPACE, $event)" @keydown.tab="onOptionSelected(BentoListboxEvent.TAB, $event)" > <slot></slot> </div> </template> <script lang="ts"> import { computed, defineComponent } from 'vue'; import { BentoListboxEvent } from '@/types/listbox'; import { BentoKeyboardNavigationKeyDownEvent } from '@/types/keyboard-navigation'; /** * listbox option wrapper. * Contains only styles and allows the Multi-Select and Single-Select DRY the styles. * * Options and Attributes are inherited (`inheritAttrs: true`) * * @example * <bento-listbox-option * ...props * ...attrs * /> */ export default defineComponent({ name: 'bento-listbox-option', props: { /** * Setting this property to true will disable the option. */ disabled: { type: Boolean, default: false }, /** * Indicates if the value is selected. Only one element can be selected at a time. */ selected: { type: Boolean, default: false }, }, emits: [BentoListboxEvent.CLICK, BentoListboxEvent.ENTER, BentoListboxEvent.SPACE, BentoListboxEvent.TAB], setup(props, { emit }) { const conditionalClasses = computed(() => ({ 'b-listbox-option--disabled': props.disabled, 'b-listbox-option--selected': props.selected, })); const onClick = (e: Event) => { emit(BentoListboxEvent.CLICK, e); }; const onOptionSelected = (eventName, e: Event) => { emit(eventName, e); }; return { // Enums BentoKeyboardNavigationKeyDownEvent, BentoListboxEvent, // Values conditionalClasses, // Events onClick, onOptionSelected, }; }, }); </script> <style lang="scss" scoped src="./listbox-option.scss" />
|
|
1
|
+
<template> <div class="b-listbox-option" role="option" :class="conditionalClasses" v-bind="$attrs" @click="onClick" @keydown.enter="onOptionSelected(BentoListboxEvent.ENTER, $event)" @keydown.space="onOptionSelected(BentoListboxEvent.SPACE, $event)" @keydown.tab="onOptionSelected(BentoListboxEvent.TAB, $event)" > <slot></slot> </div> </template> <script lang="ts"> import { computed, defineComponent } from 'vue'; import { BentoListboxEvent } from '@/types/listbox'; import { BentoKeyboardNavigationKeyDownEvent } from '@/types/keyboard-navigation'; /** * listbox option wrapper. * Contains only styles and allows the Multi-Select and Single-Select DRY the styles. * * Options and Attributes are inherited (`inheritAttrs: true`) * * @example * <bento-listbox-option * ...props * ...attrs * /> */ export default defineComponent({ name: 'bento-listbox-option', props: { /** * Indicates if the option is active. */ active: { type: Boolean, default: false }, /** * Setting this property to true will disable the option. */ disabled: { type: Boolean, default: false }, /** * Indicates if the value is selected. Only one element can be selected at a time. */ selected: { type: Boolean, default: false }, }, emits: [BentoListboxEvent.CLICK, BentoListboxEvent.ENTER, BentoListboxEvent.SPACE, BentoListboxEvent.TAB], setup(props, { emit }) { const conditionalClasses = computed(() => ({ 'b-listbox-option--active': props.active, 'b-listbox-option--disabled': props.disabled, 'b-listbox-option--selected': props.selected, })); const onClick = (e: Event) => { emit(BentoListboxEvent.CLICK, e); }; const onOptionSelected = (eventName, e: Event) => { emit(eventName, e); }; return { // Enums BentoKeyboardNavigationKeyDownEvent, BentoListboxEvent, // Values conditionalClasses, // Events onClick, onOptionSelected, }; }, }); </script> <style lang="scss" scoped src="./listbox-option.scss" />
|
|
@@ -1 +1 @@
|
|
|
1
|
-
<template> <div ref="listboxRef" v-bento-keyboard-navigation-directive v-bind="ariaAttributes" role="listbox" v-on="listboxListeners" @focusin="handleVirtualScrollingFocus" > <template v-if="hasCategories && staticCategories"> <template v-for="item in items"> <bento-listbox-single-select-category v-if="item.items && item.items.length > 0" :key="`category-${item.value}`" :category-label="item.label" > <bento-listbox-single-select-option v-for="option in item.items" :key="option.value" ref="listboxItemRef" role="option" :option="option" :disabled="isOptionDisabled ? isOptionDisabled(option) : null" :selected="option.value === selectedItem.value" v-on="listboxOptionListeners" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </bento-listbox-single-select-option> </bento-listbox-single-select-category> <bento-listbox-single-select-option v-else :key="`option-${item.value}`" ref="listboxItemRef" role="option" :option="item" :disabled="isOptionDisabled ? isOptionDisabled(item) : null" :selected="item.value === selectedItem.value" v-on="listboxOptionListeners" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </bento-listbox-single-select-option> </template> </template> <template v-else> <bento-listbox-single-select-option v-for="option in items" :key="option.value" ref="listboxItemRef" role="option" :option="option" :disabled="isOptionDisabled ? isOptionDisabled(option) : null" :selected="option.value === selectedItem.value" v-on="listboxOptionListeners" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </bento-listbox-single-select-option> </template> </div> </template> <script lang="ts"> import { computed, defineComponent, type HTMLAttributes, nextTick, type PropType, ref, toRef } from 'vue'; import { BentoListboxSingleSelectCategory } from './components/listbox-single-select-category'; import { BentoListboxSingleSelectOption } from '../listbox-single-select-option'; import { useListboxKeyboardNavigation } from '../../composables/useListboxKeyboardNavigation'; import { useVisibleDomOptions } from '@/internal/listbox/composables/use-visible-dom-options/use-visible-dom-options'; import { BentoListboxEvent, type BentoListboxIsOptionDisabled, type BentoListboxOptions } from '@/types/listbox'; import { BentoKeyboardNavigationKeyDownEvent } from '@/types/keyboard-navigation'; import { type Booleanish } from '@/types/prop-types'; import { BentoKeyboardNavigationDirective } from '@/directives'; /** * Listbox single-select options list * * @example * import { BentoListboxMultiSelect } from '@adyen/bento-vue2'; * * export default { * components: { BentoListboxMultiSelect }, * template: ` * <bento-listbox-multi-select * :items="[{ label: 'Option 1', value: 'option-1' }]" * :selected-values="['option-1']" * /> * ` * } */ export default defineComponent({ name: 'bento-listbox-single-select', components: { BentoListboxSingleSelectCategory, BentoListboxSingleSelectOption, }, directives: { BentoKeyboardNavigationDirective }, inheritAttrs: false, props: { /** * Function that allows the options to be disabled * * @type {BentoListboxIsOptionDisabled} * @param {BentoListboxOptionItem} option - listbox option object * @param {string} option.label - Option's label text * @param {string|number} option.value - Option's value * @param {unknown} option.data - Option's extra data to be passed to the default slot */ isOptionDisabled: { type: Function as PropType<BentoListboxIsOptionDisabled>, default: () => false, }, /** * The option elements to populate the listbox with. * It must be an array of {@see BentoListboxOptionItem }. * * @property {string} value.label - Text to be displayed in the option * @property {string,number} value.value - Value of the option */ items: { type: Array as PropType<BentoListboxOptions>, required: true, }, /** * The `input` value. * Providing an empty string will select no options. * Set to an empty string `''` if you don't want any of the available options to be selected */ selectedValue: { type: Array as PropType<BentoListboxOptions>, default: undefined, }, /** * If enabled, dropdown will display non-selectable, static categories */ staticCategories: { type: Boolean, default: false }, /** * Total number of items in the full list (used for virtual scroll keyboard navigation). * When greater than 0, virtual scroll keyboard navigation is enabled. */ totalItemCount: { type: Number, default: 0 }, /** * Function to scroll the virtual list container to a given item index. */ scrollToIndex: { type: Function as PropType<(index: number) => void>, default: undefined }, /** * The start index of the currently rendered virtual scroll slice. */ virtualScrollStartIndex: { type: Number, default: 0 }, }, emits: [BentoListboxEvent.SELECT, BentoListboxEvent.ENTER, BentoListboxEvent.SPACE, BentoListboxEvent.TAB], setup(props, { emit, expose, slots, attrs }) { const listboxRef = ref<HTMLElement>(); const listboxItemRef = ref([]); const selectedItem = computed(() => !!props.selectedValue?.length && props.selectedValue.at(0)); const ariaAttributes = computed( () => ({ 'aria-label': attrs['aria-label']?.toString() as string, 'aria-busy': attrs['aria-busy']?.toString() as Booleanish, 'aria-atomic': attrs['aria-atomic']?.toString() as Booleanish, 'aria-live': attrs['aria-live']?.toString() as string, }) as HTMLAttributes ); /** * Finds out if items have sub items */ const hasCategories = computed(() => !!props.items.some(({ items }) => !!items)); const onOptionSelected = eventName => (selectedOption: BentoListboxOptions) => { emit(eventName, [selectedOption]); }; // Keyboard navigation const { visibleDomOptions, updateVisibleDomOptions } = useVisibleDomOptions( listboxRef, toRef(props, 'items'), ref('option'), false ); const isVirtualScrollEnabled = computed(() => props.totalItemCount > 0); // When virtual scroll re-renders items (e.g. on mouse wheel scroll), the // browser may auto-focus an option. Sync the keyboard navigation index to // match the focused element so subsequent arrow key presses navigate from // the correct position. const handleVirtualScrollingFocus = (event: FocusEvent) => { if (!isVirtualScrollEnabled.value) { return; } const target = event.target as HTMLElement; if (target === listboxRef.value || target?.getAttribute('role') !== 'option') { return; } const localIndex = visibleDomOptions.value.indexOf(target as HTMLDivElement); if (localIndex !== -1) { const globalIndex = localIndex + props.virtualScrollStartIndex; setListboxFocusedIndex(globalIndex); } }; const focusItem = async (globalIndex: number) => { if (isVirtualScrollEnabled.value && props.scrollToIndex) { props.scrollToIndex(globalIndex); await nextTick(); await updateVisibleDomOptions(); const newDomIndex = globalIndex - props.virtualScrollStartIndex; visibleDomOptions.value[newDomIndex]?.focus(); } else { visibleDomOptions.value[globalIndex]?.focus(); } }; const navigateToItem = async (globalIndex: number) => { setListboxFocusedIndex(globalIndex); await focusItem(globalIndex); }; const scrollIntoContainer = (listboxItemElement?: HTMLElement, listboxElement?: HTMLElement) => { if (!listboxItemElement || !listboxElement) { return; } const elementRect = listboxItemElement.getBoundingClientRect(); const scrollableContainer = listboxElement; const containerRect = scrollableContainer.getBoundingClientRect(); if (elementRect.top < containerRect.top) { scrollableContainer.scrollTop -= containerRect.top - elementRect.top; } else if (elementRect.bottom > containerRect.bottom) { scrollableContainer.scrollTop += elementRect.bottom - containerRect.bottom; } }; const scrollToItem = async (globalIndex: number, listboxElement?: HTMLElement) => { await updateVisibleDomOptions(); if (isVirtualScrollEnabled.value && props.scrollToIndex) { props.scrollToIndex(globalIndex); } else { scrollIntoContainer(visibleDomOptions.value[globalIndex], listboxElement); } }; const { moveToNextElement, moveToPreviousElement, moveToFirstElement, moveToLastElement, setListboxFocusedIndex, } = useListboxKeyboardNavigation( { items: visibleDomOptions, totalItemCount: toRef(props, 'totalItemCount'), }, focusItem ); const listboxListeners = { [BentoKeyboardNavigationKeyDownEvent.ARROW_UP]: moveToPreviousElement, [BentoKeyboardNavigationKeyDownEvent.ARROW_DOWN]: moveToNextElement, [BentoKeyboardNavigationKeyDownEvent.HOME]: moveToFirstElement, [BentoKeyboardNavigationKeyDownEvent.END]: moveToLastElement, }; const listboxOptionListeners = { [BentoListboxEvent.SELECT]: onOptionSelected(BentoListboxEvent.SELECT), [BentoListboxEvent.SPACE]: onOptionSelected(BentoListboxEvent.SPACE), [BentoListboxEvent.TAB]: onOptionSelected(BentoListboxEvent.TAB), [BentoListboxEvent.ENTER]: onOptionSelected(BentoListboxEvent.ENTER), }; expose({ /** * Reference for a single listbox item. * Required for keyboard navigation in dropdown as $children is removed in vue@3 */ listboxItemRef, /** * Method to set the index of the item to be focused on * Used for focus management to be kept in-sync with parent e.g. dropdowns */ setListboxFocusedIndex, /** * Navigates to an item by global index, handling virtual scroll if enabled. * Scrolls the virtual list and focuses the item. */ navigateToItem, /** * Scrolls to an item by global index without changing focus. * Used when the dropdown opens via click to bring the selected option into view. */ scrollToItem, }); return { // Enums BentoListboxEvent, // Values listboxRef, listboxItemRef, selectedItem, slots, ariaAttributes, // Computed hasCategories, // Methods onOptionSelected, // Keyboard navigation listboxListeners, listboxOptionListeners, // Virtual scroll focus management handleVirtualScrollingFocus, }; }, }); </script>
|
|
1
|
+
<template> <div ref="listboxRef" v-bento-keyboard-navigation-directive v-bind="ariaAttributes" role="listbox" v-on="listboxListeners" @focusin="handleVirtualScrollingFocus" > <template v-if="hasCategories && staticCategories"> <template v-for="item in items"> <bento-listbox-single-select-category v-if="item.items && item.items.length > 0" :key="`category-${item.value}`" :category-label="item.label" > <bento-listbox-single-select-option v-for="option in item.items" :id="getOptionId(option)" :key="option.value" ref="listboxItemRef" role="option" :active="activeDescendantId === getOptionId(option)" :option="option" :disabled="isOptionDisabled ? isOptionDisabled(option) : null" :selected="option.value === selectedItem.value" v-on="listboxOptionListeners" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </bento-listbox-single-select-option> </bento-listbox-single-select-category> <bento-listbox-single-select-option v-else :id="getOptionId(item)" :key="`option-${item.value}`" ref="listboxItemRef" role="option" :active="activeDescendantId === getOptionId(item)" :option="item" :disabled="isOptionDisabled ? isOptionDisabled(item) : null" :selected="item.value === selectedItem.value" v-on="listboxOptionListeners" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </bento-listbox-single-select-option> </template> </template> <template v-else> <bento-listbox-single-select-option v-for="option in items" :id="getOptionId(option)" :key="option.value" ref="listboxItemRef" role="option" :active="activeDescendantId === getOptionId(option)" :option="option" :disabled="isOptionDisabled ? isOptionDisabled(option) : null" :selected="option.value === selectedItem.value" v-on="listboxOptionListeners" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </bento-listbox-single-select-option> </template> </div> </template> <script lang="ts"> import { computed, defineComponent, type HTMLAttributes, nextTick, type PropType, ref, toRef } from 'vue'; import { BentoListboxSingleSelectCategory } from './components/listbox-single-select-category'; import { BentoListboxSingleSelectOption } from '../listbox-single-select-option'; import { useListboxKeyboardNavigation } from '../../composables/useListboxKeyboardNavigation'; import { useVisibleDomOptions } from '@/internal/listbox/composables/use-visible-dom-options/use-visible-dom-options'; import { BentoListboxEvent, type BentoListboxIsOptionDisabled, type BentoListboxOptionItem, type BentoListboxOptions, } from '@/types/listbox'; import { BentoKeyboardNavigationKeyDownEvent } from '@/types/keyboard-navigation'; import { type Booleanish } from '@/types/prop-types'; import { getListboxOptionId } from '@/utils/ts/get-listbox-option-id'; import { BentoKeyboardNavigationDirective } from '@/directives'; /** * Listbox single-select options list * * @example * import { BentoListboxMultiSelect } from '@adyen/bento-vue2'; * * export default { * components: { BentoListboxMultiSelect }, * template: ` * <bento-listbox-multi-select * :items="[{ label: 'Option 1', value: 'option-1' }]" * :selected-values="['option-1']" * /> * ` * } */ export default defineComponent({ name: 'bento-listbox-single-select', components: { BentoListboxSingleSelectCategory, BentoListboxSingleSelectOption, }, directives: { BentoKeyboardNavigationDirective }, inheritAttrs: false, props: { /** * Function that allows the options to be disabled * * @type {BentoListboxIsOptionDisabled} * @param {BentoListboxOptionItem} option - listbox option object * @param {string} option.label - Option's label text * @param {string|number} option.value - Option's value * @param {unknown} option.data - Option's extra data to be passed to the default slot */ isOptionDisabled: { type: Function as PropType<BentoListboxIsOptionDisabled>, default: () => false, }, /** * Identifies the currently active option in the listbox. */ activeDescendantId: { type: String, default: null }, /** * The option elements to populate the listbox with. * It must be an array of {@see BentoListboxOptionItem }. * * @property {string} value.label - Text to be displayed in the option * @property {string,number} value.value - Value of the option */ items: { type: Array as PropType<BentoListboxOptions>, required: true, }, /** * Prefix used to generate stable option IDs. */ optionIdBase: { type: String, required: true }, /** * The `input` value. * Providing an empty string will select no options. * Set to an empty string `''` if you don't want any of the available options to be selected */ selectedValue: { type: Array as PropType<BentoListboxOptions>, default: undefined, }, /** * If enabled, dropdown will display non-selectable, static categories */ staticCategories: { type: Boolean, default: false }, /** * Total number of items in the full list (used for virtual scroll keyboard navigation). * When greater than 0, virtual scroll keyboard navigation is enabled. */ totalItemCount: { type: Number, default: 0 }, /** * Function to scroll the virtual list container to a given item index. */ scrollToIndex: { type: Function as PropType<(index: number) => void>, default: undefined }, /** * The start index of the currently rendered virtual scroll slice. */ virtualScrollStartIndex: { type: Number, default: 0 }, }, emits: [BentoListboxEvent.SELECT, BentoListboxEvent.ENTER, BentoListboxEvent.SPACE, BentoListboxEvent.TAB], setup(props, { emit, expose, slots, attrs }) { const listboxRef = ref<HTMLElement>(); const listboxItemRef = ref([]); const selectedItem = computed(() => !!props.selectedValue?.length && props.selectedValue.at(0)); const ariaAttributes = computed( () => ({ 'aria-label': attrs['aria-label']?.toString() as string, 'aria-busy': attrs['aria-busy']?.toString() as Booleanish, 'aria-atomic': attrs['aria-atomic']?.toString() as Booleanish, 'aria-live': attrs['aria-live']?.toString() as string, }) as HTMLAttributes ); /** * Finds out if items have sub items */ const hasCategories = computed(() => !!props.items.some(({ items }) => !!items)); const onOptionSelected = eventName => (selectedOption: BentoListboxOptions) => { emit(eventName, [selectedOption]); }; const getOptionId = (option: BentoListboxOptionItem) => getListboxOptionId(props.optionIdBase, option.value); // Keyboard navigation const { visibleDomOptions, updateVisibleDomOptions } = useVisibleDomOptions( listboxRef, toRef(props, 'items'), ref('option'), false ); const isVirtualScrollEnabled = computed(() => props.totalItemCount > 0); // When virtual scroll re-renders items (e.g. on mouse wheel scroll), the // browser may auto-focus an option. Sync the keyboard navigation index to // match the focused element so subsequent arrow key presses navigate from // the correct position. const handleVirtualScrollingFocus = (event: FocusEvent) => { if (!isVirtualScrollEnabled.value) { return; } const target = event.target as HTMLElement; if (target === listboxRef.value || target?.getAttribute('role') !== 'option') { return; } const localIndex = visibleDomOptions.value.indexOf(target as HTMLDivElement); if (localIndex !== -1) { const globalIndex = localIndex + props.virtualScrollStartIndex; setListboxFocusedIndex(globalIndex); } }; const focusItem = async (globalIndex: number) => { if (isVirtualScrollEnabled.value && props.scrollToIndex) { props.scrollToIndex(globalIndex); await nextTick(); await updateVisibleDomOptions(); const newDomIndex = globalIndex - props.virtualScrollStartIndex; visibleDomOptions.value[newDomIndex]?.focus(); } else { visibleDomOptions.value[globalIndex]?.focus(); } }; const navigateToItem = async (globalIndex: number) => { setListboxFocusedIndex(globalIndex); await focusItem(globalIndex); }; const scrollIntoContainer = (listboxItemElement?: HTMLElement, listboxElement?: HTMLElement) => { if (!listboxItemElement || !listboxElement) { return; } const elementRect = listboxItemElement.getBoundingClientRect(); const scrollableContainer = listboxElement; const containerRect = scrollableContainer.getBoundingClientRect(); if (elementRect.top < containerRect.top) { scrollableContainer.scrollTop -= containerRect.top - elementRect.top; } else if (elementRect.bottom > containerRect.bottom) { scrollableContainer.scrollTop += elementRect.bottom - containerRect.bottom; } }; const scrollToItem = async (globalIndex: number, listboxElement?: HTMLElement) => { await updateVisibleDomOptions(); if (isVirtualScrollEnabled.value && props.scrollToIndex) { props.scrollToIndex(globalIndex); } else { scrollIntoContainer(visibleDomOptions.value[globalIndex], listboxElement); } }; const { moveToNextElement, moveToPreviousElement, moveToFirstElement, moveToLastElement, setListboxFocusedIndex, } = useListboxKeyboardNavigation( { items: visibleDomOptions, totalItemCount: toRef(props, 'totalItemCount'), }, focusItem ); const listboxListeners = { [BentoKeyboardNavigationKeyDownEvent.ARROW_UP]: moveToPreviousElement, [BentoKeyboardNavigationKeyDownEvent.ARROW_DOWN]: moveToNextElement, [BentoKeyboardNavigationKeyDownEvent.HOME]: moveToFirstElement, [BentoKeyboardNavigationKeyDownEvent.END]: moveToLastElement, }; const listboxOptionListeners = { [BentoListboxEvent.SELECT]: onOptionSelected(BentoListboxEvent.SELECT), [BentoListboxEvent.SPACE]: onOptionSelected(BentoListboxEvent.SPACE), [BentoListboxEvent.TAB]: onOptionSelected(BentoListboxEvent.TAB), [BentoListboxEvent.ENTER]: onOptionSelected(BentoListboxEvent.ENTER), }; expose({ /** * Reference for a single listbox item. * Required for keyboard navigation in dropdown as $children is removed in vue@3 */ listboxItemRef, /** * Method to set the index of the item to be focused on * Used for focus management to be kept in-sync with parent e.g. dropdowns */ setListboxFocusedIndex, /** * Navigates to an item by global index, handling virtual scroll if enabled. * Scrolls the virtual list and focuses the item. */ navigateToItem, /** * Scrolls to an item by global index without changing focus. * Used when the dropdown opens via click to bring the selected option into view. */ scrollToItem, }); return { // Enums BentoListboxEvent, // Values listboxRef, listboxItemRef, selectedItem, slots, ariaAttributes, getOptionId, // Computed hasCategories, // Methods onOptionSelected, // Keyboard navigation listboxListeners, listboxOptionListeners, // Virtual scroll focus management handleVirtualScrollingFocus, }; }, }); </script>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
<template> <bento-listbox-option class="b-listbox-single-select-option" :disabled="disabled" :selected="selected" :aria-selected="ariaSelected" :aria-disabled="ariaDisabled" :tabindex="tabIndex" @click="onOptionSelected(BentoListboxEvent.SELECT)" @enter-pressed="onOptionSelected(BentoListboxEvent.ENTER)" @space-pressed.prevent="onOptionSelected(BentoListboxEvent.SPACE)" @tab-pressed="onOptionSelected(BentoListboxEvent.TAB)" > <slot v-bind="option"> <span class="b-listbox-single-select-option__text"> <bento-typography el="span">{{ option.label }}</bento-typography> <bento-typography v-if="option.description" class="b-listbox-single-select-option__description" el="span" > {{ option.description }} </bento-typography> </span> </slot> <span class="b-listbox-single-select-option__check-icon"> <checkmark-icon v-show="selected" svg-title="selected" /> </span> </bento-listbox-option> </template> <script lang="ts"> import { computed, defineComponent, type PropType } from 'vue'; import { BentoTypography } from '@/components/typography'; import { BentoListboxOption } from '../listbox-option'; import { BentoListboxEvent, type BentoListboxOptionItem } from '@/types/listbox'; import CheckmarkIcon from '@adyen/ui-assets-icons-16/vue/checkmark'; /** * Listbox option element. * * @example * <bento-listbox-single-select-option * v-for="option in options" * :key="option.value" * :option="option" * :selected="option.selected" * @select="onOptionSelected" * /> */ export default defineComponent({ name: 'bento-listbox-single-select-option', components: { BentoListboxOption, BentoTypography, CheckmarkIcon, }, props: { /** * Setting this property to true will disable the option. */ disabled: { type: Boolean, default: false }, /** * The option item that contains all the option item's data. * @type {BentoListboxOptionItem} */ option: { type: Object as PropType<BentoListboxOptionItem>, required: true, }, /** * Indicates if the value is selected. Only one element can be selected at a time. */ selected: { type: Boolean, default: false }, }, emits: [BentoListboxEvent.ENTER, BentoListboxEvent.SELECT, BentoListboxEvent.SPACE, BentoListboxEvent.TAB], setup(props, { emit }) { const onOptionSelected = eventName => { if (props.disabled) { return; } /** * Trigerred when an option is clicked or selected via keyboard navigation. * Indicates which option was selected. * * @event {string} - The corresponding event * @property {number} selectedValue - Selected option's value */ emit(eventName, props.option); }; const ariaDisabled = computed(() => (props.disabled ? true : null)); const ariaSelected = computed(() => (props.selected ? true : null)); const tabIndex = computed(() => (props.disabled ? -1 : 0)); return { // Values ariaDisabled, ariaSelected, tabIndex, // Enums BentoListboxEvent, // Events onOptionSelected, }; }, }); </script> <style lang="scss" scoped src="./listbox-single-select-option.scss" />
|
|
1
|
+
<template> <bento-listbox-option class="b-listbox-single-select-option" :active="active" :disabled="disabled" :selected="selected" :aria-selected="ariaSelected" :aria-disabled="ariaDisabled" :tabindex="tabIndex" @click="onOptionSelected(BentoListboxEvent.SELECT)" @enter-pressed="onOptionSelected(BentoListboxEvent.ENTER)" @space-pressed.prevent="onOptionSelected(BentoListboxEvent.SPACE)" @tab-pressed="onOptionSelected(BentoListboxEvent.TAB)" > <slot v-bind="option"> <span class="b-listbox-single-select-option__text"> <bento-typography el="span">{{ option.label }}</bento-typography> <bento-typography v-if="option.description" class="b-listbox-single-select-option__description" el="span" > {{ option.description }} </bento-typography> </span> </slot> <span class="b-listbox-single-select-option__check-icon"> <checkmark-icon v-show="selected" svg-title="selected" /> </span> </bento-listbox-option> </template> <script lang="ts"> import { computed, defineComponent, type PropType } from 'vue'; import { BentoTypography } from '@/components/typography'; import { BentoListboxOption } from '../listbox-option'; import { BentoListboxEvent, type BentoListboxOptionItem } from '@/types/listbox'; import CheckmarkIcon from '@adyen/ui-assets-icons-16/vue/checkmark'; /** * Listbox option element. * * @example * <bento-listbox-single-select-option * v-for="option in options" * :key="option.value" * :option="option" * :selected="option.selected" * @select="onOptionSelected" * /> */ export default defineComponent({ name: 'bento-listbox-single-select-option', components: { BentoListboxOption, BentoTypography, CheckmarkIcon, }, props: { /** * Indicates if the option is active. */ active: { type: Boolean, default: false }, /** * Setting this property to true will disable the option. */ disabled: { type: Boolean, default: false }, /** * The option item that contains all the option item's data. * @type {BentoListboxOptionItem} */ option: { type: Object as PropType<BentoListboxOptionItem>, required: true, }, /** * Indicates if the value is selected. Only one element can be selected at a time. */ selected: { type: Boolean, default: false }, }, emits: [BentoListboxEvent.ENTER, BentoListboxEvent.SELECT, BentoListboxEvent.SPACE, BentoListboxEvent.TAB], setup(props, { emit }) { const onOptionSelected = eventName => { if (props.disabled) { return; } /** * Trigerred when an option is clicked or selected via keyboard navigation. * Indicates which option was selected. * * @event {string} - The corresponding event * @property {number} selectedValue - Selected option's value */ emit(eventName, props.option); }; const ariaDisabled = computed(() => (props.disabled ? true : null)); const ariaSelected = computed(() => (props.selected ? true : null)); const tabIndex = computed(() => (props.disabled ? -1 : 0)); return { // Values ariaDisabled, ariaSelected, tabIndex, // Enums BentoListboxEvent, // Events onOptionSelected, }; }, }); </script> <style lang="scss" scoped src="./listbox-single-select-option.scss" />
|