@adyen/bento-mcp 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.4 (2026-03-25)
4
+
5
+ This was a version bump only for mcp to align it with other projects, there were no code changes.
6
+
7
+
3
8
  ## 0.1.3 (2026-03-18)
4
9
 
5
10
  This was a version bump only for mcp to align it with other projects, there were no code changes.
@@ -14,9 +14,9 @@ multiple items at the same time.
14
14
 
15
15
  Use when you want to enable user to perform single or bulk selection on data grid (or other components)
16
16
 
17
- - Selecting rows on data grid and performing single or bulk actions on selected rows
18
- - Selecting cards and and performing single or bulk actions on selected cards
19
- - Any other similar scenarios.
17
+ - Selecting rows on data grid and performing single or bulk actions on selected rows
18
+ - Selecting cards and and performing single or bulk actions on selected cards
19
+ - Any other similar scenarios.
20
20
 
21
21
  ## Placement
22
22
 
@@ -74,6 +74,42 @@ The default variant should be used for any generic case, accepts all props.
74
74
  To be used to confirm changes in a page. Only takes the `itemCounter` prop, the `selectionLabel` will always be 'unsaved
75
75
  changes' and the `actions` are always 'Save' and 'Cancel'. It also has no close button.
76
76
 
77
+ ## State
78
+
79
+ The Action Bar supports 4 states via the `state` prop:
80
+
81
+ - `start`: default selection/actions view.
82
+ - `loading`: shows a loading indicator and hides state action buttons.
83
+ - `success`: shows success feedback and can optionally show an Undo button.
84
+ - `fail`: shows failure feedback, always shows Cancel, and can optionally show Retry.
85
+
86
+ ### State label
87
+
88
+ For non-`start` states, the component renders a `stateLabel` slot.
89
+
90
+ - If the slot is provided, your custom content is rendered.
91
+ - If the slot is not provided, the component falls back to built-in labels:
92
+ - `loading` → `Applying...`
93
+ - `success` → `Success!`
94
+ - `fail` → `Something went wrong`
95
+
96
+ ### State action buttons
97
+
98
+ - Undo (`undo-action`) is shown in `success` only when `showUndoAction` is `true`.
99
+ - Retry (`retry-action`) is shown in `fail` only when `showRetryAction` is `true`.
100
+ - Cancel (`cancel-action`) is always shown in `fail`.
101
+
102
+ These buttons only emit events; state transitions are controlled by the user.
103
+
104
+ ### Success state
105
+
106
+ After showing a `success` message, use a `3s` timeout before deciding the next step.
107
+
108
+ After the timeout, one of two things should be configured:
109
+
110
+ - Close the action bar
111
+ - Return to the action bar (`start` state) — this option should be available only for the bulk-actions variant.
112
+
77
113
  ## Modifiers
78
114
 
79
115
  ### Extra bottom spacing
@@ -100,4 +136,4 @@ the bottom (like sticky pagination) to be visible.
100
136
 
101
137
  ## Resources
102
138
 
103
- - [Figma link](https://www.figma.com/design/uLabwF3243jdMDsNSP7U9I/Bento---Components?node-id=23466-201772&t=fUgRFhiNubQnT2RN-0)
139
+ - [Figma link](https://www.figma.com/design/uLabwF3243jdMDsNSP7U9I/Bento---Components?node-id=23466-201772&t=fUgRFhiNubQnT2RN-0)
@@ -1 +1 @@
1
- import { action } from '@storybook/addon-actions'; import { isVue2, ref } from 'vue-demi'; import { storybookDocsParameter } from '@/utils/ts/storybook'; import BentoActionBar from './action-bar.vue'; import { BentoButton } from '@/components/button'; import type { Meta, StoryObj } from '@storybook/vue'; import BinIcon from '@adyen/ui-assets-icons-16/vue/bin'; import EditIcon from '@adyen/ui-assets-icons-16/vue/edit-1'; const meta: Meta = { title: 'Action bar', component: BentoActionBar, argTypes: { variant: { options: ['default', 'confirmation'], control: { type: 'select', }, }, }, parameters: { layout: 'fullscreen', }, }; export default meta; type Story = StoryObj<typeof BentoActionBar>; const DEFAULT_ACTIONS = [ { title: 'Assign to...', disabled: true, event: () => true, }, { title: 'Snooze', event: () => true, }, { title: 'Edit', icon: EditIcon, event: () => true, }, { title: 'Delete', critical: true, icon: BinIcon, event: () => true, }, { title: 'Extra option', event: () => true, }, ]; const DEFAULT_PROPS = { actions: DEFAULT_ACTIONS, selectionLabel: 'selected', itemCounter: 3 }; const defaultCode = ` <template> <bento-action-bar :actions="[ { title: 'Assign to...', event: () => true, }, { title: 'Snooze', event: () => true, }, { title: 'Edit', icon: EditIcon, event: () => true, }, { title: 'Delete', critical: true, icon: BinIcon, event: () => true, }, { title: 'Extra option', event: () => true, } ]" @close="closeAction" :item-counter="3" /> <script setup> const closeAction = () => { // unselect selected items and reset itemCounter } </script> </template>`; export const Default: Story = { render: (_args, { argTypes }) => ({ components: { BentoActionBar, BentoButton }, props: Object.keys(argTypes), template: ` <div style="height:140px"> <bento-button @click='toggleActionBar = !toggleActionBar'>Toggle action bar</bento-button> <bento-action-bar v-if='toggleActionBar' v-bind="args" @close="closeAction" @cancel="cancelAction" @save="saveAction" /> </div> `, setup(props) { const closeAction = () => action('close')(); const cancelAction = () => action('cancel')(); const saveAction = () => action('save')(); const toggleActionBar = ref(true); return { // Values args: isVue2 ? props : _args, toggleActionBar, // Events closeAction, cancelAction, saveAction, }; }, }), args: { ...DEFAULT_PROPS, }, parameters: storybookDocsParameter(defaultCode), };
1
+ import { action } from '@storybook/addon-actions'; import { computed, ref, watch } from 'vue'; import { isVue2 } from 'vue-demi'; import { storybookDocsParameter } from '@/utils/ts/storybook'; import BentoActionBar from './action-bar.vue'; import { BentoButton } from '@/components/button'; import type { Meta, StoryObj } from '@storybook/vue'; import BinIcon from '@adyen/ui-assets-icons-16/vue/bin'; import EditIcon from '@adyen/ui-assets-icons-16/vue/edit-1'; import { BentoActionBarStateOptions, BentoActionBarVariantOptions } from './action-bar.types.ts'; import ActionBarDefault from './__tests__/action-bar-default.vue?raw'; const meta: Meta = { title: 'Action bar', component: BentoActionBar, argTypes: { variant: { options: BentoActionBarVariantOptions, control: { type: 'select', }, }, state: { options: BentoActionBarStateOptions, control: { type: 'select', }, }, // Slots stateLabel: { description: 'Custom text for the action bar state label.', table: { type: { summary: 'VNode[]' }, }, control: { type: 'text' }, }, }, parameters: { layout: 'fullscreen', }, }; export default meta; type Story = StoryObj<typeof BentoActionBar>; const DEFAULT_ACTIONS = [ { title: 'Assign to...', disabled: true, event: () => true, }, { title: 'Snooze', event: () => true, }, { title: 'Edit', icon: EditIcon, event: () => true, }, { title: 'Delete', critical: true, icon: BinIcon, event: () => true, }, { title: 'Extra option', event: () => true, }, ]; const DEFAULT_PROPS = { actions: DEFAULT_ACTIONS, selectionLabel: 'selected', itemCounter: 3, variant: 'default', state: 'start', extraBottomSpacing: false, visibleActions: 4, }; export const Default: Story = { render: (_args, { argTypes }) => ({ components: { BentoActionBar, BentoButton }, props: Object.keys(argTypes), template: ` <div style="height:140px"> <bento-button @click='toggleActionBar = !toggleActionBar'>Toggle action bar</bento-button> <bento-action-bar v-if='toggleActionBar' v-bind="args" :actions="actions" @close="closeAction" @cancel="cancelAction" @save="saveAction" @retry-action="retryAction" @undo-action="undoAction" @cancel-action="cancelStateAction" :state="state" :show-undo-action="showUndoAction" > <template #stateLabel>{{ slotStateLabel }}</template> </bento-action-bar> </div> `, setup(props) { const args = isVue2 ? props : _args; const state = ref<string>(args.state); const operation = ref<'delete' | 'restore'>('delete'); const timeoutRef = ref<ReturnType<typeof setTimeout> | null>(null); const cancelAction = () => action('cancel')(); const saveAction = () => action('save')(); const toggleActionBar = ref(true); const closeAction = () => { action('close')(); toggleActionBar.value = false; }; const clearTimer = () => { if (timeoutRef.value) { clearTimeout(timeoutRef.value); timeoutRef.value = null; } }; const showSuccessAndReset = () => { state.value = 'success'; timeoutRef.value = setTimeout(() => { state.value = 'start'; operation.value = 'delete'; timeoutRef.value = null; }, 3000); }; const setLoading = (nextOperation: 'delete' | 'restore', callback: () => void) => { clearTimer(); state.value = 'loading'; operation.value = nextOperation; timeoutRef.value = setTimeout(() => { timeoutRef.value = null; callback(); }, 1000); }; const deleteAction = () => { setLoading('delete', () => { state.value = 'fail'; }); }; const retryAction = () => { setLoading('delete', showSuccessAndReset); action('retry-action')(); }; const undoAction = () => { setLoading('restore', showSuccessAndReset); action('undo-action')(); }; const cancelStateAction = () => { state.value = 'start'; operation.value = 'delete'; action('cancel-action')(); }; const actions = DEFAULT_ACTIONS.map(a => (a.title === 'Delete' ? { ...a, event: deleteAction } : a)); const showUndoAction = computed(() => args.showUndoAction && operation.value === 'delete'); const stateLabel = computed(() => { switch (state.value) { case 'loading': return operation.value === 'restore' ? 'Restoring...' : 'Deleting...'; case 'fail': return operation.value === 'restore' ? 'Could not restore the item. Please try again.' : 'Could not delete the item. Please try again.'; case 'success': return operation.value === 'restore' ? 'Item was restored' : 'Item was deleted.'; default: return ''; } }); const slotStateLabel = computed(() => args.stateLabel || stateLabel.value); watch( () => args.state, value => { state.value = value; if (value === 'start') { operation.value = 'delete'; } } ); return { // Values args, state, toggleActionBar, slotStateLabel, actions, showUndoAction, // Events closeAction, cancelAction, saveAction, deleteAction, retryAction, undoAction, cancelStateAction, }; }, }), args: { ...DEFAULT_PROPS, showUndoAction: true, showRetryAction: true, }, parameters: storybookDocsParameter(ActionBarDefault), };
@@ -1 +1 @@
1
- <template> <Transition name="b-action-bar__animation" appear> <section class="b-action-bar" :class="conditionalClasses" :aria-label="t('actionBar')"> <div ref="actionBar" class="b-action-bar__container"> <div v-if="!isConfirmationVariant" class="b-action-bar__close-button"> <bento-button variant="tertiary" inverse :aria-label="t('close')" @click="emit('close')"> <template #iconLeft><cross-icon :aria-hidden="true" /></template> </bento-button> </div> <bento-typography el="span" stronger class="b-action-bar__selection"> <template v-if="isConfirmationVariant && noItemCounter"> {{ tc('unsavedChanges', 0) }} </template> <template v-else> {{ n(itemCounter) }} {{ isConfirmationVariant ? tc('unsavedChanges', itemCounter) : label }} </template> </bento-typography> <bento-button-actions v-if="isConfirmationVariant" :actions="confirmationActions" class="b-action-bar__actions" inverse /> <button-actions-with-menu v-else :actions="actionsInternal" class="b-action-bar__actions" :displayed-actions="internalDisplayedActions" menu-position="top-end" disable-responsive-behavior inverse /> </div> </section> </Transition> </template> <script setup lang="ts"> import { computed, nextTick, type PropType, provide, ref, toRefs, watch } from 'vue'; import { BentoButton, BentoButtonActions, type BentoButtonActionsList } from '@/components/button'; import { BentoTypography } from '@/components/typography'; import { type BentoActionBarVariant } from '@/components/action-bar/action-bar.types'; import ButtonActionsWithMenu from '@/components/internal/button-actions-with-menu/button-actions-with-menu.vue'; import CrossIcon from '@adyen/ui-assets-icons-16/vue/cross'; import { useI18n } from '@/utils/ts/i18n'; import { useWindowSize } from '@vueuse/core'; import messages from './messages.json'; import { POPOVER_OFFSET_INJECTION_KEY } from '@/components/popover/popover.keys'; type MessageSchema = (typeof messages)['en-US']; // Constants const AVERAGE_BUTTON_SIZE = 100; const MENU_OFFSET = 7; const RESPONSIVE_BREAKPOINT = 500; const SPACING = 32; const props = defineProps({ /** * List of actions that will be used to render the buttons. * Required and used for the `default` variant. * * Each object in the list should have a `title`, an `event` and, * optionally, an `icon`. * * The component will display 4 actions and the hide the rest inside a menu. * * @see BentoButton for a list of all the other props supported by each button action. */ actions: { type: Array as PropType<BentoButtonActionsList>, default: () => [], }, /** * How many actions should be visible outside the menu, cannot be more than 4 */ visibleActions: { type: Number, default: 4, validator: (value: number) => value > 0 && value <= 4, }, /** * Use when there are sticky elements at the bottom, like sticky pagination */ extraBottomSpacing: { type: Boolean, default: false, }, /** * Show how many items are selected. * When omitted in the `confirmation` variant, a generic "Unsaved changes" label is displayed. */ itemCounter: { type: Number, default: null, }, /** * Label for the selection, e.g. 'selected'/'changed' */ selectionLabel: { type: String, default: null, }, /** * Variant for the action bar * @values default, confirmation */ variant: { type: String as PropType<BentoActionBarVariant>, default: 'default', }, }); const emit = defineEmits<{ /** * Event emitted when the user click the close button */ (e: 'close'): void; (e: 'cancel'): void; (e: 'save'): void; }>(); const { t, tc, n } = useI18n<{ message: MessageSchema }>({ messages }); const label = computed(() => (props.selectionLabel ? props.selectionLabel : t('selected'))); // Refs const actionBar = ref<HTMLDivElement>(null); const { extraBottomSpacing, visibleActions } = toRefs(props); const { width } = useWindowSize(); const internalDisplayedActions = ref(null); const internalVisibleActions = ref(null); const isConfirmationVariant = computed(() => props.variant === 'confirmation'); watch( () => visibleActions.value, value => { internalVisibleActions.value = value <= 4 ? value : 4; internalDisplayedActions.value = internalVisibleActions.value; }, { immediate: true } ); // Styling const conditionalClasses = computed(() => ({ 'b-action-bar--extra-spacing': extraBottomSpacing.value, 'b-action-bar--confirmation': isConfirmationVariant.value, })); // Provide POPOVER_OFFSET_INJECTION_KEY to change the value of the popover offset provide(POPOVER_OFFSET_INJECTION_KEY, MENU_OFFSET); // Manipulate actions array to display as intended const actionsInternal = computed(() => { // All buttons should be of type secondary in the action bar const actions = props.actions.map(action => ({ ...action, variant: 'secondary', })) as BentoButtonActionsList; // Shown actions should display from left to right in action bar, instead of right to left which is the button actions default const shownActions = actions.slice(0, internalDisplayedActions.value).reverse(); const hiddenActions = actions.slice(internalDisplayedActions.value); return [...shownActions, ...hiddenActions]; }); const confirmationActions = computed<BentoButtonActionsList>(() => [ { title: t('save'), event: () => emit('save'), }, { title: t('cancel'), variant: 'secondary', event: () => emit('cancel'), }, ]); const noItemCounter = computed(() => { return props.itemCounter == null || props.itemCounter < 1; }); // Recursive functions to determine how many actions should be visible/hidden based on the screens width const showActions = async () => { await nextTick(); if ( width.value > actionBar.value.clientWidth + SPACING + AVERAGE_BUTTON_SIZE && internalDisplayedActions.value < internalVisibleActions.value ) { internalDisplayedActions.value += 1; showActions(); } }; const hideActions = async () => { await nextTick(); if (width.value < actionBar.value.clientWidth + SPACING && internalDisplayedActions.value > 0) { internalDisplayedActions.value -= 1; hideActions(); } }; // Screen width watcher to toggle hidden/visible actions watch( () => width.value, async (newValue, oldValue) => { await nextTick(); if (!actionBar.value) { return; } const isIncreasing = newValue > oldValue; if (width.value < RESPONSIVE_BREAKPOINT) { internalDisplayedActions.value = 0; } else if (isIncreasing) { showActions(); } else { hideActions(); } }, { immediate: true } ); </script> <script lang="ts"> /** * Action bar is a floating bar that provides users with quick access to key actions that can be applied to one or multiple items. * * @example * import { BentoActionBar } from '@adyen/bento-vue2'; * * export default { * components: { BentoActionBar }, * template: ` * <bento-action-bar * :actions='[ * { * title: 'Delete', * critical: true, * icon: BinIcon, * event: () => true, * }, * { * title: 'Edit', * icon: EditIcon, * event: () => true, * } * ]' * @close="closeAction" * :item-counter="5" * /> * ` * } */ export default { i18n: { messages }, }; </script> <style lang="scss" scoped src="./action-bar.scss" />
1
+ <template> <Transition name="b-action-bar__animation" appear> <section class="b-action-bar" :class="conditionalClasses" :aria-label="t('actionBar')" @animationend="onAnimationEnd" > <Transition :name="`b-action-bar__state-animation--${state}`" :mode="stateTransitionMode" @before-leave="onBeforeLeave" @enter="onEnter" @after-enter="onAfterEnter" > <div ref="actionBar" :key="state" class="b-action-bar__container" :class="containerClasses" :style="{ width: containerWidth, maxWidth: containerMaxWidth }" > <div v-if="state === 'start'" class="b-action-bar__container-inner"> <div v-if="!isConfirmationVariant" class="b-action-bar__close-button"> <bento-button variant="tertiary" inverse :aria-label="t('close')" @click="emit('close')"> <template #iconLeft><cross-icon :aria-hidden="true" /></template> </bento-button> </div> <bento-typography el="span" stronger class="b-action-bar__selection"> <template v-if="isConfirmationVariant && noItemCounter"> {{ tc('unsavedChanges', 0) }} </template> <template v-else> {{ n(itemCounter) }} {{ isConfirmationVariant ? tc('unsavedChanges', itemCounter) : label }} </template> </bento-typography> <bento-button-actions v-if="isConfirmationVariant" :actions="confirmationActions" class="b-action-bar__actions" inverse /> <button-actions-with-menu v-else :actions="actionsInternal" class="b-action-bar__actions" :displayed-actions="internalDisplayedActions" menu-position="top-end" disable-responsive-behavior inverse /> </div> <div v-else class="b-action-bar__container-inner"> <div class="b-action-bar__state-icon"> <bento-loading-indicator v-if="state === 'loading'" small inverse /> <checkmark-circle-fill v-if="state === 'success'" :aria-hidden="true" /> <cross-circle-fill v-if="state === 'fail'" :aria-hidden="true" /> </div> <bento-typography stronger> <slot name="stateLabel">{{ defaultStateLabel }}</slot> </bento-typography> <div v-if="!hideStateActions" class="b-action-bar__state-actions"> <bento-button v-if="state === 'success' && showUndoAction" variant="tertiary" inverse @click="emit('undo-action')" >{{ t('undo') }}</bento-button > <bento-button v-if="state === 'fail' && showRetryAction" variant="tertiary" inverse @click="emit('retry-action')" >{{ t('retry') }}</bento-button > <bento-button v-if="state === 'fail'" variant="tertiary" inverse @click="emit('cancel-action')" >{{ t('cancel') }}</bento-button > </div> </div> </div> </Transition> </section> </Transition> </template> <script setup lang="ts"> import { computed, nextTick, type PropType, provide, ref, toRefs, type TransitionProps, watch } from 'vue'; import { BentoButton, BentoButtonActions, type BentoButtonActionsList } from '@/components/button'; import { BentoTypography } from '@/components/typography'; import { BentoLoadingIndicator } from '@/components/loading-indicator'; import { type BentoActionBarState, type BentoActionBarVariant } from '@/components/action-bar/action-bar.types'; import ButtonActionsWithMenu from '@/components/internal/button-actions-with-menu/button-actions-with-menu.vue'; import CrossIcon from '@adyen/ui-assets-icons-16/vue/cross'; import CheckmarkCircleFill from '@adyen/ui-assets-icons-16/vue/checkmark-circle-fill'; import CrossCircleFill from '@adyen/ui-assets-icons-16/vue/cross-circle-fill'; import { useI18n } from '@/utils/ts/i18n'; import { useWindowSize } from '@vueuse/core'; import messages from './messages.json'; import { POPOVER_OFFSET_INJECTION_KEY } from '@/components/popover/popover.keys'; type MessageSchema = (typeof messages)['en-US']; // Constants const AVERAGE_BUTTON_SIZE = 100; const MENU_OFFSET = 7; const RESPONSIVE_BREAKPOINT = 500; const SPACING = 32; const props = defineProps({ /** * List of actions that will be used to render the buttons. * Required and used for the `default` variant. * * Each object in the list should have a `title`, an `event` and, * optionally, an `icon`. * * The component will display 4 actions and the hide the rest inside a menu. * * @see BentoButton for a list of all the other props supported by each button action. */ actions: { type: Array as PropType<BentoButtonActionsList>, default: () => [], }, /** * How many actions should be visible outside the menu, cannot be more than 4 */ visibleActions: { type: Number, default: 4, validator: (value: number) => value > 0 && value <= 4, }, /** * Use when there are sticky elements at the bottom, like sticky pagination */ extraBottomSpacing: { type: Boolean, default: false, }, /** * Show how many items are selected. * When omitted in the `confirmation` variant, a generic "Unsaved changes" label is displayed. */ itemCounter: { type: Number, default: null, }, /** * Label for the selection, e.g. 'selected'/'changed' */ selectionLabel: { type: String, default: null, }, /** * State of the action bar. * @values start, loading, success, fail */ state: { type: String as PropType<BentoActionBarState>, default: 'start', }, /** * Displays the undo action button in `success` state. */ showUndoAction: { type: Boolean, default: false, }, /** * Displays the retry action button in `fail` state. */ showRetryAction: { type: Boolean, default: false, }, /** * Variant for the action bar * @values default, confirmation */ variant: { type: String as PropType<BentoActionBarVariant>, default: 'default', }, }); const emit = defineEmits<{ /** * Event emitted when the user click the close button */ (e: 'close'): void; /** * Event emitted when the user clicks the cancel button in `confirmation` variant. */ (e: 'cancel'): void; /** * Event emitted when the user clicks the save button in `confirmation` variant. */ (e: 'save'): void; /** * Event emitted when the user clicks the undo button in `success` state. */ (e: 'undo-action'): void; /** * Event emitted when the user clicks the retry button in `fail` state. */ (e: 'retry-action'): void; /** * Event emitted when the user clicks the cancel button in `fail` state. */ (e: 'cancel-action'): void; }>(); const { t, tc, n } = useI18n<{ message: MessageSchema }>({ messages }); const label = computed(() => (props.selectionLabel ? props.selectionLabel : t('selected'))); const defaultStateLabel = computed(() => { switch (props.state) { case 'loading': return t('applying'); case 'success': return t('success'); case 'fail': return t('somethingWentWrong'); default: return ''; } }); // Refs const actionBar = ref<HTMLDivElement>(null); const containerMaxWidth = ref<string | null>(null); const containerWidth = ref<string | null>(null); const isShaking = ref(false); const stateTransitionMode = computed( () => (props.state === 'loading' ? 'out-in' : undefined) as TransitionProps['mode'] ); const hideStateActions = computed( () => props.state === 'loading' || (props.state === 'success' && !props.showUndoAction) ); const onBeforeLeave = (el: HTMLElement) => { if (props.state !== 'loading') { return; } containerMaxWidth.value = `${el.offsetWidth}px`; containerWidth.value = '100%'; }; const onEnter = (el: HTMLElement) => { if (props.state !== 'loading') { return; } // Reset styles to get natural width Object.assign(el.style, { width: 'auto', maxWidth: 'none' }); const newWidth = el.offsetWidth; // Reset it back to the original values Object.assign(el.style, { width: '100%', maxWidth: containerMaxWidth.value }); requestAnimationFrame(() => { containerMaxWidth.value = `${newWidth}px`; }); }; const onAfterEnter = () => { containerMaxWidth.value = null; containerWidth.value = null; if (props.state === 'fail') { isShaking.value = true; } }; const onAnimationEnd = (event: AnimationEvent) => { if (event.animationName.startsWith('shake-')) { isShaking.value = false; } }; const { extraBottomSpacing, visibleActions } = toRefs(props); const { width } = useWindowSize(); const internalDisplayedActions = ref(null); const internalVisibleActions = ref(null); const isConfirmationVariant = computed(() => props.variant === 'confirmation'); watch( () => visibleActions.value, value => { internalVisibleActions.value = value <= 4 ? value : 4; internalDisplayedActions.value = internalVisibleActions.value; }, { immediate: true } ); // Styling const conditionalClasses = computed(() => ({ 'b-action-bar--extra-spacing': extraBottomSpacing.value, 'b-action-bar--confirmation': isConfirmationVariant.value, 'b-action-bar__state-animation--shake': isShaking.value, })); const containerClasses = computed(() => ({ [`b-action-bar__container--${props.state}`]: props.state !== 'start', })); // Provide POPOVER_OFFSET_INJECTION_KEY to change the value of the popover offset provide(POPOVER_OFFSET_INJECTION_KEY, MENU_OFFSET); // Manipulate actions array to display as intended const actionsInternal = computed(() => { // All buttons should be of type secondary in the action bar const actions = props.actions.map(action => ({ ...action, variant: 'secondary', })) as BentoButtonActionsList; // Shown actions should display from left to right in action bar, instead of right to left which is the button actions default const shownActions = actions.slice(0, internalDisplayedActions.value).reverse(); const hiddenActions = actions.slice(internalDisplayedActions.value); return [...shownActions, ...hiddenActions]; }); const confirmationActions = computed<BentoButtonActionsList>(() => [ { title: t('save'), event: () => emit('save'), }, { title: t('cancel'), variant: 'secondary', event: () => emit('cancel'), }, ]); const noItemCounter = computed(() => { return props.itemCounter == null || props.itemCounter < 1; }); // Recursive functions to determine how many actions should be visible/hidden based on the screens width const showActions = async () => { await nextTick(); if ( width.value > actionBar.value.clientWidth + SPACING + AVERAGE_BUTTON_SIZE && internalDisplayedActions.value < internalVisibleActions.value ) { internalDisplayedActions.value += 1; showActions(); } }; const hideActions = async () => { await nextTick(); if (width.value < actionBar.value.clientWidth + SPACING && internalDisplayedActions.value > 0) { internalDisplayedActions.value -= 1; hideActions(); } }; // Screen width watcher to toggle hidden/visible actions watch( () => width.value, async (newValue, oldValue) => { await nextTick(); if (!actionBar.value) { return; } const isIncreasing = newValue > oldValue; if (width.value < RESPONSIVE_BREAKPOINT) { internalDisplayedActions.value = 0; } else if (isIncreasing) { showActions(); } else { hideActions(); } }, { immediate: true } ); </script> <script lang="ts"> /** * Action bar is a floating bar that provides users with quick access to key actions that can be applied to one or multiple items. * * @example * import { BentoActionBar } from '@adyen/bento-vue2'; * * export default { * components: { BentoActionBar }, * template: ` * <bento-action-bar * :actions='[ * { * title: 'Delete', * critical: true, * icon: BinIcon, * event: () => true, * }, * { * title: 'Edit', * icon: EditIcon, * event: () => true, * } * ]' * @close="closeAction" * :item-counter="5" * /> * ` * } */ export default { i18n: { messages }, }; </script> <style lang="scss" scoped src="./action-bar.scss" />
@@ -1 +1 @@
1
- <template> <div class="b-date-range-picker-calendar"> <!-- Form --> <div class="b-date-range-picker-calendar__form-container" :class="computedFormContainerClasses" data-testid="date-range-picker-calendar-form-container" > <div class="b-date-range-picker-calendar__form"> <template v-if="hasSlot('title')"> <slot name="title" /> </template> <bento-dropdown v-if="quickSelectRanges" :aria-label="t('customRange')" :items="quickSelectRangeDefaultItems" :model-value="selectedRangeSelectorValue" @update:model-value="onRangeSelectorInput" /> <bento-segmented-control v-if="granularities" :items="granularityItems" :model-value="internalRangeDate.granularity" full-width @update:model-value="onGranularityInput" > </bento-segmented-control> <div> <bento-typography stronger el="span" class="b-date-range-picker-calendar__label"> From </bento-typography> <div :class="formInputWrapperConditionalClasses"> <bento-input-field :aria-label="t('dateFrom')" :model-value="dateTextInput.startDate" :error="startDateError" class="b-date-range-picker-calendar__form-input" @update:model-value="onStartDateInput" @keydown="onDateInputKeyDown" > <template #description>{{ t(dateFormat) }}</template> </bento-input-field> <bento-input-field v-if="allowTimeInput" :aria-label="t('timeFrom')" :model-value="dateTextInput.startTime" :error="startTimeError" class="b-date-range-picker-calendar__form-input" @update:model-value="onStartTimeInput" @keydown="onTimeInputKeyDown" > <template #description>HH:MM:SS</template> </bento-input-field> </div> </div> <div> <bento-typography stronger el="span" class="b-date-range-picker-calendar__label"> To </bento-typography> <div :class="formInputWrapperConditionalClasses"> <bento-input-field class="b-date-range-picker-calendar__form-input" :aria-label="t('dateTo')" :model-value="dateTextInput.endDate" :error="endDateError" @update:model-value="onEndDateInput" @keydown="onDateInputKeyDown" > <template #description>{{ t(dateFormat) }}</template> </bento-input-field> <bento-input-field v-if="allowTimeInput" :aria-label="t('timeTo')" :model-value="dateTextInput.endTime" :error="endTimeError" class="b-date-range-picker-calendar__form-input" @update:model-value="onEndTimeInput" @keydown="onTimeInputKeyDown" > <template #description>HH:MM:SS</template> </bento-input-field> </div> </div> </div> <div v-if="hasSlot('actions')"> <slot name="actions" /> </div> </div> <!-- Calendar --> <div v-if="!hasNoCalendars" class="b-date-range-picker-calendar__calendars-container" data-testid="date-range-picker-calendar-calendar-container" > <calendar :value="internalRangeDate" :first-day-of-week="firstDayOfWeek" :is-date-disabled="isDateDisabled" :max-range="maxRange" :min="computedMinMax.min" :max="computedMinMax.max" :number-of-months="numberOfMonths" :show-end-date-on-open="showEndDateOnOpen" :granularity="selectedGranularity" :variant="variant" is-range @input="onDateInput" @start-date-selected="onStartDateSelected" @end-date-selected="onEndDateSelected" /> </div> </div> </template> <script setup lang="ts"> import { computed, onMounted, type PropType, reactive, ref, toRaw, useSlots } from 'vue'; import { Calendar, type CalendarGranularityType } from '@/internal'; import { BentoTypography } from '@/components/typography'; import { BentoInputField } from '@/components/input-field'; import BentoDropdown from '@/components/dropdown/dropdown.vue'; import { BentoSegmentedControl, type BentoSegmentedControlItem } from '@/components/segmented-control'; import { useI18n } from '@/utils/ts/i18n'; import { debounce } from '@/utils/ts/debounce'; import { useDateInputFormatter, useHasSlot } from '@/composables'; import { useGranularityAdjustments } from '@/components/internal/calendar/composables/use-granularity-adjustments'; import { useGranularMinMaxDate } from '@/components/internal/calendar/components/calendar-month/composables/granular-min-max-date'; import { useGranularityMemory } from '../../composables/use-granularity-memory'; import { DateRangePickerCalendarEvent, type DateRangePickerCalendarFormData, type DateRangePickerCalendarRangeSelectorItems, } from './date-range-picker-calendar.types'; import type { BentoDateRangePickerGranularityConfig, BentoDateRangePickerValue, } from '../../date-range-picker.types'; import { endOfDay } from 'date-fns/endOfDay'; import { setHours } from 'date-fns/setHours'; import { setMinutes } from 'date-fns/setMinutes'; import { setSeconds } from 'date-fns/setSeconds'; import { startOfDay } from 'date-fns/startOfDay'; import { isSameSecond } from 'date-fns/isSameSecond'; import { isSameDay } from 'date-fns/isSameDay'; import { isEqual } from 'date-fns/isEqual'; import { isToday } from 'date-fns/isToday'; import { setMilliseconds } from 'date-fns/setMilliseconds'; import { dateToTimeInputString } from '@/utils/ts/format-date/format-date'; import { formatTimeInputValue, validateTimeInput } from '@/utils/ts/time-input'; import messages from './messages.json'; const FORM_INPUT_DEBOUNCE_TIME = 300; const RANGE_SELECTOR_CUSTOM_RANGE_KEY = 'customRange'; type MessageSchema = (typeof messages)['en-US']; const props = defineProps({ /** * Allows user to enter time values in the form */ allowTimeInput: { type: Boolean, default: false }, /** * Ranges form persistance data */ dateFormData: { type: Object as PropType<DateRangePickerCalendarFormData>, default: () => ({ startDate: undefined, endDate: undefined, startTime: undefined, endTime: undefined }), }, /** * Allows you to set the first day of the week. 0 is sunday, 1 is monday, 6 is saturday */ firstDayOfWeek: Calendar.props.firstDayOfWeek, /** * A list of available granularities. * If present, the date picker will display a segmented control to change granularity. */ granularities: { type: Array as PropType<Array<BentoDateRangePickerGranularityConfig>>, default: null, }, /** * Indicate if a date should be disabled or not */ isDateDisabled: Calendar.props.isDateDisabled, /** * Set a maximum number of dates to be selectable by the range. */ maxRange: Calendar.props.maxRange, /** * Sets a minimum limit for selectable dates - dates before `min` will not be selectable */ min: Calendar.props.min, /** * Sets a maximum limit for selectable dates - dates after `max` will not be selectable */ max: Calendar.props.max, /** * Number of months rendered on pane */ numberOfMonths: { type: Calendar.props.numberOfMonths.type, default: Calendar.props.numberOfMonths.default, validator: (n: number) => n >= 0, }, /** * Displays the end date's month when the calendar is opened. Defaults to false i.e. the start date's month is displayed. */ showEndDateOnOpen: { type: Boolean, default: false }, /** * Enables the custom range selector. * If provided, must be an array that sets the custom range dropdown items. Items are of the structure: * `label` - label of custom range item. * `value` - a unique key of the custom range item. * `data` - an object `{ startDate: Date; endDate: Date }` to set the date picker range to upon selecting. */ quickSelectRanges: { type: Array as PropType<DateRangePickerCalendarRangeSelectorItems>, default: undefined, }, /** * Selected date */ value: { type: Object as PropType<BentoDateRangePickerValue>, default: undefined }, /** * The type of calendar to display. Defaults to showing days. */ variant: Calendar.props.variant, }); const emit = defineEmits([ DateRangePickerCalendarEvent.CUSTOM_RANGE, DateRangePickerCalendarEvent.INPUT, DateRangePickerCalendarEvent.ERROR, DateRangePickerCalendarEvent.FORM_DATE, DateRangePickerCalendarEvent.START_DATE_SELECTED, ]); const slots = useSlots(); const hasSlot = useHasSlot(slots); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const { dateFormat, isFormatValid, parseDate, onKeyDown, autoFormat, formatDate: formatDateUtil, } = useDateInputFormatter(); const calculatedIsRelativeToNowQuickSelectRanges = ref({}); const defaultGranularity = computed(() => (props.variant === 'month' ? 'monthly' : 'daily')); const hasNoCalendars = computed(() => props.numberOfMonths === 0); const internalRangeDate = reactive<BentoDateRangePickerValue>({ startDate: props.value?.startDate, endDate: props.value?.endDate, range: props.value?.range, ...(props.granularities ? { granularity: props.value?.granularity || defaultGranularity.value, } : {}), }); /** * Deserializes a date to transform it to a UTC standard so it can be compared regardless of timezone. * src: https://stackoverflow.com/a/38050824 * @param date Date to deserialize/normalize for comparisson */ function deserializeDate(date: Date) { if (!date) { return null; } return new Date( date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), date.getUTCHours(), date.getUTCMinutes(), date.getUTCSeconds() ); } /** * Currently range selector dropdown item that is selected. * The logic will try and find in each range item: * - If an endDate does not exist, then only check if the startDate day matches and if today's day matches. * - if time input has been enabled, then check if the seconds match. * - if an endDate does exist then just check if the startDate day and the endDate day match. */ const selectedRangeSelectorValue = computed(() => { return ( // eslint-disable-next-line consistent-return props?.quickSelectRanges?.find(({ value, data }) => { if (!data.endDate) { // Check if this quick select range is explicitly marked as "relative to now" if (data.isRelativeToNow) { // Retrieve previously calculated start/end dates for this relative range. // We use these if the user has already clicked on this time of quick range const newlyCalculatedQuickSelectDates = calculatedIsRelativeToNowQuickSelectRanges.value?.[value]; return newlyCalculatedQuickSelectDates ? isEqual( deserializeDate(newlyCalculatedQuickSelectDates.startDate), deserializeDate(internalRangeDate.startDate) ) && isEqual( deserializeDate(newlyCalculatedQuickSelectDates.endDate), deserializeDate(internalRangeDate.endDate) ) : // If not stored calculated dates, fall back to comparing the quick select item's value // with the internal range's stored range identifier. value === internalRangeDate.range; } // This block handles quick select ranges that do not have an endDate // AND are NOT explicitly marked as 'isRelativeToNow'. // This means the endDate will be today at the end of the day return ( isEqual(deserializeDate(data.startDate), deserializeDate(internalRangeDate.startDate)) && isEqual( deserializeDate(internalRangeDate.endDate), deserializeDate(endOfDay(internalRangeDate.endDate)) ) && isToday(deserializeDate(internalRangeDate.endDate)) && (props.granularities ? data.granularity === internalRangeDate.granularity : true) ); } if (props.allowTimeInput) { const res = isSameSecond(deserializeDate(data.startDate), deserializeDate(internalRangeDate.startDate)) && isSameSecond(deserializeDate(data.endDate), deserializeDate(internalRangeDate.endDate)) && (props.granularities ? data.granularity === internalRangeDate.granularity : true); return res; } if (data.endDate) { return ( isSameDay(data.startDate, internalRangeDate.startDate) && isSameDay(data.endDate, internalRangeDate.endDate) && (props.granularities ? data.granularity === internalRangeDate.granularity : true) ); } })?.value ?? RANGE_SELECTOR_CUSTOM_RANGE_KEY ); }); const formInputWrapperConditionalClasses = computed(() => ({ 'b-date-range-picker-calendar__form-input-wrapper': props.allowTimeInput, })); const quickSelectRangeDefaultItems = computed(() => [ { label: t('customRange'), value: RANGE_SELECTOR_CUSTOM_RANGE_KEY }, ...(props.quickSelectRanges ? props.quickSelectRanges : []), ]); const selectedGranularity = computed( () => props.granularities?.find(({ type }) => type === internalRangeDate.granularity) || null ); const granularityItems = computed<Array<BentoSegmentedControlItem>>( () => props.granularities?.map(({ type, disabled }) => ({ label: t(type), value: type, disabled, })) ?? [] ); const { getGranularCalendarLimits } = useGranularMinMaxDate(props); const computedMinMax = computed(() => props.granularities ? getGranularCalendarLimits(selectedGranularity.value) : { min: props.min ? startOfDay(props.min) : null, max: props.max ? endOfDay(props.max) : null } ); const { adjustDateForQuarterly, adjustDateForMonthly, adjustDateForWeekly, adjustStartDateInputForWeekly, adjustEndDateInputForWeekly, adjustStartDateInputForMonthly, adjustEndDateInputForMonthly, adjustStartDateInputForQuarterly, adjustEndDateInputForQuarterly, } = useGranularityAdjustments(props.firstDayOfWeek); const { savedRanges, saveRange, resetSavedRanges } = useGranularityMemory(); /** * Format date to a string value in current format * @param dateToFormat - Date to be formatted */ const formatDate = (dateToFormat?: Date) => { if (!dateToFormat) { return ''; } return formatDateUtil(dateToFormat); }; // Form error flags const startDateError = ref(false); const endDateError = ref(false); const startTimeError = ref(false); const endTimeError = ref(false); // Form input text used in range variant const dateTextInput = reactive({ startDate: props.dateFormData.startDate, endDate: props.dateFormData.endDate, startTime: props.dateFormData.startTime, endTime: props.dateFormData.endTime, }); /** * Sets the time of a date object from a time string. * @param dateToSetTimeIn The date object to set the time in. * @param time The time string in HH:MM:SS format. * @returns A new Date object with the time set. */ const setTimeInDateObject = (dateToSetTimeIn: Date, time: string) => { let dateWithTime = new Date(dateToSetTimeIn); const [hours, minutes, seconds] = time.split(':'); dateWithTime = setHours(dateWithTime, parseInt(hours, 10)); dateWithTime = setMinutes(dateWithTime, parseInt(minutes, 10)); dateWithTime = setSeconds(dateWithTime, parseInt(seconds, 10)); return dateWithTime; }; const isValidTimeString = (timeText: string) => /^(?:[01]\d|2[01234]):(?:[012345]\d):(?:[012345]\d)$/.test(timeText); /** * Checks if a time string is valid and within the min/max bounds if a date is provided. * @param timeText The time string to validate. * @param date The date string to check the time against. * @returns True if the time is valid, false otherwise. */ const isValidTime = (timeText: string, date?: string) => { const isTimeAllowed = () => { if (date && isValidDate(date)) { const dateToCheckWithTime = setTimeInDateObject(parseDate(date), timeText); // Check if date is between min/max bounds, if there are any if ( (props.min && dateToCheckWithTime < setMilliseconds(props.min, 0)) || (props.max && dateToCheckWithTime > setMilliseconds(props.max, 0)) ) { return false; } } return true; }; return isValidTimeString(timeText) && isTimeAllowed(); }; /** * Sets the time in a date object if the time is valid and the variant is not 'month'. * @param date The date object to modify. * @param time The time string to set. * @returns The modified date object or the original if time is not set. */ const setTimeInDateRef = (date: Date, time: string) => { // Do not set time if month variant if (!time || !isValidTimeString(time) || props.variant === 'month') { return date; } return setTimeInDateObject(new Date(date), time); }; const onDateInput = (selectedRange: BentoDateRangePickerValue) => { dateTextInput.startTime = dateToTimeInputString( props.min && props.allowTimeInput && isSameDay(props.min, selectedRange.startDate) ? props.min : startOfDay(selectedRange.startDate) ); dateTextInput.endTime = dateToTimeInputString( props.max && props.allowTimeInput && isSameDay(props.max, selectedRange.endDate) ? props.max : endOfDay(selectedRange.endDate) ); internalRangeDate.startDate = setTimeInDateRef(selectedRange.startDate, dateTextInput.startTime); internalRangeDate.endDate = setTimeInDateRef(selectedRange.endDate, dateTextInput.endTime); // Resetting internal range date when selecting a custom range through the calendar internalRangeDate.range = null; if (props.granularities) { // Reset saved ranges on input resetSavedRanges(); } emit(DateRangePickerCalendarEvent.INPUT, { ...internalRangeDate, range: selectedRangeSelectorValue.value, }); }; /** * Checks if a date string is in current format and if the date is enabled. * @param dateText The date string to validate. * @returns True if the date is valid and enabled, false otherwise. */ const isValidDate = (dateText: string) => { if (!dateText) { return false; } // Is date selectable const isDateEnabled = (date: Date) => { // Check if date is between or equal to min/max bounds // Set the time to the start of the day to disregard time if ( (props.min && startOfDay(date) < startOfDay(props.min)) || (props.max && startOfDay(date) > startOfDay(props.max)) ) { return false; } // Check if function is set, otherwise all dates are enabled if (props.isDateDisabled) { return !props.isDateDisabled(startOfDay(date)); } return true; }; // Date matches text regex and is not disabled return isFormatValid(dateText) && isDateEnabled(parseDate(dateText)); }; /** * When mounted, set the input error states to true if the values are not valid */ onMounted(() => { // Start date validation startDateError.value = !!(props.dateFormData.startDate && !isValidDate(props.dateFormData.startDate)); // Start time validation if (props.allowTimeInput) { startTimeError.value = !!( props.dateFormData.startTime && !isValidTime(props.dateFormData.startTime, props.dateFormData.startDate) ); } else { startTimeError.value = false; } // End date validation endDateError.value = !!(props.dateFormData.endDate && !isValidDate(props.dateFormData.endDate)); // End time validation if (props.allowTimeInput) { endTimeError.value = !!( props.dateFormData.endTime && !isValidTime(props.dateFormData.endTime, props.dateFormData.endDate) ); } else { endTimeError.value = false; } }); const onStartDateSelected = (newStartDate: Date) => { dateTextInput.startDate = formatDate(newStartDate); if (!dateTextInput.startTime) { // Set the time to the beginning of the day dateTextInput.startTime = dateToTimeInputString(newStartDate); } else if (startTimeError.value) { // Set the form time if it was previously set dateTextInput.startTime = props.dateFormData?.startTime; } // Reset errors startDateError.value = false; startTimeError.value = false; emit(DateRangePickerCalendarEvent.START_DATE_SELECTED, newStartDate); }; const onEndDateSelected = (newEndDate: Date) => { dateTextInput.endDate = formatDate(newEndDate); if (!dateTextInput.endTime) { // Set the time to the end of the day dateTextInput.endTime = dateToTimeInputString(endOfDay(newEndDate)); } else if (endTimeError.value) { // Set the form time if it was previously set dateTextInput.endTime = props.dateFormData?.endTime; } // Reset errors endDateError.value = false; endTimeError.value = false; }; const getDate = (keyName: keyof BentoDateRangePickerValue) => { const dateProp = (props.value as BentoDateRangePickerValue)[keyName]; if (dateProp) { return dateProp; } if (props.dateFormData[keyName] && isValidDate(props.dateFormData[keyName])) { return parseDate(props.dateFormData[keyName]); } return undefined; }; const validateStartDate = debounce((startDateText: string) => { if (isValidDate(startDateText)) { let dateText = startDateText; startDateError.value = false; switch (selectedGranularity.value?.type) { case 'weekly': dateText = adjustStartDateInputForWeekly( startDateText, formatDate, computedMinMax.value.min, computedMinMax.value.max ); dateTextInput.startDate = dateText; break; case 'monthly': dateText = adjustStartDateInputForMonthly( startDateText, formatDate, computedMinMax.value.min, computedMinMax.value.max ); dateTextInput.startDate = dateText; break; case 'quarterly': dateText = adjustStartDateInputForQuarterly( startDateText, formatDate, computedMinMax.value.min, computedMinMax.value.max ); dateTextInput.startDate = dateText; break; case 'daily': default: break; } const startDate = startOfDay(parseDate(dateText)); internalRangeDate.startDate = setTimeInDateRef(startDate, dateTextInput.startTime); internalRangeDate.endDate = setTimeInDateRef( startOfDay(parseDate(dateTextInput.endDate)), dateTextInput.endTime ); if (isValidDate(dateTextInput.endDate)) { internalRangeDate.endDate = setTimeInDateRef( startOfDay(parseDate(dateTextInput.endDate)), dateTextInput.endTime ); } const endDate = getDate('endDate'); // Resetting internal range date when selecting a custom range through the calendar internalRangeDate.range = null; emit(DateRangePickerCalendarEvent.FORM_DATE, { startDate, endDate, }); emit(DateRangePickerCalendarEvent.INPUT, { ...internalRangeDate, range: selectedRangeSelectorValue.value, }); } else { // Indicate form error startDateError.value = true; // Emit Date picker calendar error emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); } }, FORM_INPUT_DEBOUNCE_TIME); const onStartDateInput = (startDateText: string) => { // Set the date as string when input through the form dateTextInput.startDate = autoFormat(startDateText); validateStartDate(dateTextInput.startDate); }; const validateEndDate = debounce((endDateText: string) => { if (isValidDate(endDateText)) { let dateText = endDateText; endDateError.value = false; switch (selectedGranularity.value?.type) { case 'weekly': dateText = adjustEndDateInputForWeekly( endDateText, formatDate, computedMinMax.value.min, computedMinMax.value.max ); dateTextInput.endDate = dateText; break; case 'monthly': dateText = adjustEndDateInputForMonthly( endDateText, formatDate, computedMinMax.value.min, computedMinMax.value.max ); dateTextInput.endDate = dateText; break; case 'quarterly': dateText = adjustEndDateInputForQuarterly( endDateText, formatDate, computedMinMax.value.min, computedMinMax.value.max ); dateTextInput.endDate = dateText; break; case 'daily': default: break; } // Format date string to Date object const endDate = startOfDay(parseDate(dateText)); internalRangeDate.endDate = setTimeInDateRef(endDate, dateTextInput.endTime); if (isValidDate(dateTextInput.startDate)) { internalRangeDate.startDate = setTimeInDateRef( startOfDay(parseDate(dateTextInput.startDate)), dateTextInput.startTime ); } const startDate = getDate('startDate'); // Resetting internal range date when selecting a custom range through the calendar internalRangeDate.range = null; emit(DateRangePickerCalendarEvent.FORM_DATE, { startDate, endDate, }); emit(DateRangePickerCalendarEvent.INPUT, { ...internalRangeDate, range: selectedRangeSelectorValue.value, }); } else { // Indicate form error endDateError.value = true; // Emit Date picker calendar error emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); } }, FORM_INPUT_DEBOUNCE_TIME); const onEndDateInput = (endDateText: string) => { // Set the date as string when input through the form dateTextInput.endDate = autoFormat(endDateText); validateEndDate(dateTextInput.endDate); }; const onDateInputKeyDown = (event: KeyboardEvent) => { onKeyDown(event); }; const validateStartTime = debounce((startTimeText: string) => { if (isValidTime(startTimeText, props.dateFormData.startDate)) { startTimeError.value = false; internalRangeDate.startDate = setTimeInDateRef(internalRangeDate.startDate, startTimeText); const endDate = getDate('endDate'); emit(DateRangePickerCalendarEvent.FORM_DATE, { startDate: internalRangeDate.startDate, endDate, startTime: startTimeText, endTime: dateTextInput.endTime ?? props.dateFormData.endTime, }); emit(DateRangePickerCalendarEvent.INPUT, { ...internalRangeDate, range: selectedRangeSelectorValue.value, }); } else { // Indicate form error startTimeError.value = true; // Emit Date picker calendar error emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); } }); const validateEndTime = debounce((endTimeText: string) => { if (isValidTime(endTimeText, props.dateFormData.endDate)) { endTimeError.value = false; internalRangeDate.endDate = setTimeInDateRef(internalRangeDate.endDate, endTimeText); emit(DateRangePickerCalendarEvent.FORM_DATE, { startDate: internalRangeDate.startDate, endDate: internalRangeDate.endDate, startTime: dateTextInput.startTime ?? props.dateFormData.startTime, endTime: endTimeText, }); emit(DateRangePickerCalendarEvent.INPUT, { ...internalRangeDate, range: selectedRangeSelectorValue.value, }); } else { // Indicate form error endTimeError.value = true; // Emit Date picker calendar error emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); } }); const onStartTimeInput = (startTimeText: string) => { dateTextInput.startTime = formatTimeInputValue(startTimeText); validateStartTime(dateTextInput.startTime); }; const onEndTimeInput = (endDateText: string) => { dateTextInput.endTime = formatTimeInputValue(endDateText); validateEndTime(dateTextInput.endTime); }; const onTimeInputKeyDown = (event: KeyboardEvent) => { validateTimeInput(event); }; const setInternalRange = ( startDate: Date, endDate: Date, granularity?: CalendarGranularityType, range?: string ) => { internalRangeDate.startDate = startDate; internalRangeDate.endDate = endDate; if (granularity) { internalRangeDate.granularity = granularity; } if (range) { internalRangeDate.range = range; } }; const onRangeSelectorInput = (newCustomRangeValue: string) => { const foundResult = props.quickSelectRanges?.find(({ value }) => value === newCustomRangeValue); if (foundResult) { resetSavedRanges(); const date = foundResult.data; const newEndDate = date.endDate || (date.isRelativeToNow ? new Date(Date.now()) : endOfDay(new Date(Date.now()))); // If the time difference between start and end dates is available, // calculating start date dynamically const newStartDate = date.isRelativeToNow && date.timeDifference ? new Date(newEndDate.getTime() - date.timeDifference) : date.startDate; // Set Form input Dates dateTextInput.endDate = formatDate(newEndDate); dateTextInput.startDate = formatDate(newStartDate); setInternalRange(newStartDate, newEndDate, null, newCustomRangeValue); if (props.granularities) { internalRangeDate.granularity = date.granularity || defaultGranularity.value; } // Set Form input time to quick select range time dateTextInput.startTime = dateToTimeInputString(newStartDate); dateTextInput.endTime = dateToTimeInputString(newEndDate); // Set calendar selection internalRangeDate.startDate = newStartDate; internalRangeDate.endDate = newEndDate; if (date.isRelativeToNow) { // Store this calculated start and end dates to be used in the selectedRangeSelectorValue logic calculatedIsRelativeToNowQuickSelectRanges.value = { ...calculatedIsRelativeToNowQuickSelectRanges.value, [newCustomRangeValue]: { startDate: newStartDate, endDate: newEndDate, }, }; } // Emit everytime the date range changes emit(DateRangePickerCalendarEvent.CUSTOM_RANGE, { ...foundResult.data, startDate: newStartDate, endDate: newEndDate, range: foundResult.value, }); } }; const adjustDate = { weekly: adjustDateForWeekly, monthly: adjustDateForMonthly, quarterly: adjustDateForQuarterly, }; const onGranularityInput = (value: CalendarGranularityType) => { const previousValue = internalRangeDate.granularity; internalRangeDate.granularity = value; // Save previously selected range if (previousValue) { saveRange(previousValue, structuredClone(toRaw(internalRangeDate))); } if (internalRangeDate.startDate && internalRangeDate.endDate) { // Adjust date range based on new granularity switch (value) { case 'quarterly': case 'monthly': case 'weekly': { if (savedRanges[value]) { setInternalRange(savedRanges[value].startDate, savedRanges[value].endDate, value); break; } const { startDate, endDate } = adjustDate[value]( internalRangeDate.startDate, internalRangeDate.endDate, computedMinMax.value.min, computedMinMax.value.max ); setInternalRange(startDate, endDate, value); break; } case 'daily': if (savedRanges.daily) { setInternalRange(savedRanges.daily.startDate, savedRanges.daily.endDate, value); } break; default: break; } dateTextInput.startDate = formatDate(internalRangeDate.startDate); dateTextInput.endDate = formatDate(internalRangeDate.endDate); emit(DateRangePickerCalendarEvent.INPUT, { ...internalRangeDate, range: selectedRangeSelectorValue.value, }); } }; const computedFormContainerClasses = computed(() => ({ 'b-date-range-picker-calendar__form-container--no-calendars': hasNoCalendars.value, })); </script> <script lang="ts"> /** * Calendar for range dates. Contains the logic to handle the range forms. * * @example * import DatePickerCalendarRange from './date-range-picker-calendar.vue'; * * export default { * components: { DatePickerCalendarRange }, * template: ` * <date-range-picker-calendar * :value="{startDate: new Date(), endDate: new Date() }" * :first-day-of-week="BentoDatePickerFirstDayOfWeek.MONDAY" * :is-date-disabled="(date: Date) => boolean" * :number-of-months="2" * @input="({ startDate, endDate }) => void" * /> * ` * } */ export default { name: 'date-range-picker-calendar', i18n: { messages }, }; </script> <style lang="scss" scoped src="./date-range-picker-calendar.scss" />
1
+ <template> <div class="b-date-range-picker-calendar"> <!-- Form --> <div class="b-date-range-picker-calendar__form-container" :class="computedFormContainerClasses" data-testid="date-range-picker-calendar-form-container" > <div class="b-date-range-picker-calendar__form"> <template v-if="hasSlot('title')"> <slot name="title" /> </template> <bento-dropdown v-if="quickSelectRanges" :aria-label="t('customRange')" :items="quickSelectRangeDefaultItems" :model-value="selectedRangeSelectorValue" @update:model-value="onRangeSelectorInput" /> <bento-segmented-control v-if="granularities" :items="granularityItems" :model-value="internalRangeDate.granularity" full-width @update:model-value="onGranularityInput" > </bento-segmented-control> <div> <bento-typography stronger el="span" class="b-date-range-picker-calendar__label"> From </bento-typography> <div :class="formInputWrapperConditionalClasses"> <bento-input-field :aria-label="t('dateFrom')" :model-value="dateTextInput.startDate" :error="startDateError" class="b-date-range-picker-calendar__form-input" @update:model-value="onStartDateInput" @keydown="onDateInputKeyDown" > <template #description>{{ t(dateFormat) }}</template> </bento-input-field> <bento-input-field v-if="allowTimeInput" :aria-label="t('timeFrom')" :model-value="dateTextInput.startTime" :error="startTimeError" class="b-date-range-picker-calendar__form-input" @update:model-value="onStartTimeInput" @keydown="onTimeInputKeyDown" > <template #description>HH:MM:SS</template> </bento-input-field> </div> </div> <div> <bento-typography stronger el="span" class="b-date-range-picker-calendar__label"> To </bento-typography> <div :class="formInputWrapperConditionalClasses"> <bento-input-field class="b-date-range-picker-calendar__form-input" :aria-label="t('dateTo')" :model-value="dateTextInput.endDate" :error="endDateError" @update:model-value="onEndDateInput" @keydown="onDateInputKeyDown" > <template #description>{{ t(dateFormat) }}</template> </bento-input-field> <bento-input-field v-if="allowTimeInput" :aria-label="t('timeTo')" :model-value="dateTextInput.endTime" :error="endTimeError" class="b-date-range-picker-calendar__form-input" @update:model-value="onEndTimeInput" @keydown="onTimeInputKeyDown" > <template #description>HH:MM:SS</template> </bento-input-field> </div> </div> </div> <div v-if="hasSlot('actions')"> <slot name="actions" /> </div> </div> <!-- Calendar --> <div v-if="!hasNoCalendars" class="b-date-range-picker-calendar__calendars-container" data-testid="date-range-picker-calendar-calendar-container" > <calendar :value="internalRangeDate" :first-day-of-week="firstDayOfWeek" :is-date-disabled="isDateDisabled" :max-range="maxRange" :min="computedMinMax.min" :max="computedMinMax.max" :number-of-months="numberOfMonths" :show-end-date-on-open="showEndDateOnOpen" :granularity="selectedGranularity" :variant="variant" is-range @input="onDateInput" @start-date-selected="onStartDateSelected" @end-date-selected="onEndDateSelected" /> </div> </div> </template> <script setup lang="ts"> import { computed, onMounted, type PropType, reactive, ref, toRaw, useSlots } from 'vue'; import { Calendar, type CalendarGranularityType } from '@/internal'; import { BentoTypography } from '@/components/typography'; import { BentoInputField } from '@/components/input-field'; import BentoDropdown from '@/components/dropdown/dropdown.vue'; import { BentoSegmentedControl, type BentoSegmentedControlItem } from '@/components/segmented-control'; import { useI18n } from '@/utils/ts/i18n'; import { debounce } from '@/utils/ts/debounce'; import { useDateInputFormatter, useHasSlot } from '@/composables'; import { useGranularityAdjustments } from '@/components/internal/calendar/composables/use-granularity-adjustments'; import { useGranularMinMaxDate } from '@/components/internal/calendar/components/calendar-month/composables/granular-min-max-date'; import { useGranularityMemory } from '../../composables/use-granularity-memory'; import { DateRangePickerCalendarEvent, type DateRangePickerCalendarFormData, type DateRangePickerCalendarRangeSelectorItems, } from './date-range-picker-calendar.types'; import type { BentoDateRangePickerGranularityConfig, BentoDateRangePickerValue, } from '../../date-range-picker.types'; import { endOfDay } from 'date-fns/endOfDay'; import { setHours } from 'date-fns/setHours'; import { setMinutes } from 'date-fns/setMinutes'; import { setSeconds } from 'date-fns/setSeconds'; import { startOfDay } from 'date-fns/startOfDay'; import { isSameSecond } from 'date-fns/isSameSecond'; import { isSameDay } from 'date-fns/isSameDay'; import { isEqual } from 'date-fns/isEqual'; import { isToday } from 'date-fns/isToday'; import { setMilliseconds } from 'date-fns/setMilliseconds'; import { dateToTimeInputString } from '@/utils/ts/format-date/format-date'; import { formatTimeInputValue, validateTimeInput } from '@/utils/ts/time-input'; import messages from './messages.json'; const FORM_INPUT_DEBOUNCE_TIME = 300; const RANGE_SELECTOR_CUSTOM_RANGE_KEY = 'customRange'; type MessageSchema = (typeof messages)['en-US']; const props = defineProps({ /** * Allows user to enter time values in the form */ allowTimeInput: { type: Boolean, default: false }, /** * Ranges form persistance data */ dateFormData: { type: Object as PropType<DateRangePickerCalendarFormData>, default: () => ({ startDate: undefined, endDate: undefined, startTime: undefined, endTime: undefined }), }, /** * Allows you to set the first day of the week. 0 is sunday, 1 is monday, 6 is saturday */ firstDayOfWeek: Calendar.props.firstDayOfWeek, /** * A list of available granularities. * If present, the date picker will display a segmented control to change granularity. */ granularities: { type: Array as PropType<Array<BentoDateRangePickerGranularityConfig>>, default: null, }, /** * Indicate if a date should be disabled or not */ isDateDisabled: Calendar.props.isDateDisabled, /** * Set a maximum number of dates to be selectable by the range. */ maxRange: Calendar.props.maxRange, /** * Sets a minimum limit for selectable dates - dates before `min` will not be selectable */ min: Calendar.props.min, /** * Sets a maximum limit for selectable dates - dates after `max` will not be selectable */ max: Calendar.props.max, /** * Number of months rendered on pane */ numberOfMonths: { type: Calendar.props.numberOfMonths.type, default: Calendar.props.numberOfMonths.default, validator: (n: number) => n >= 0, }, /** * Displays the end date's month when the calendar is opened. Defaults to false i.e. the start date's month is displayed. */ showEndDateOnOpen: { type: Boolean, default: false }, /** * Enables the custom range selector. * If provided, must be an array that sets the custom range dropdown items. Items are of the structure: * `label` - label of custom range item. * `value` - a unique key of the custom range item. * `data` - an object `{ startDate: Date; endDate: Date }` to set the date picker range to upon selecting. */ quickSelectRanges: { type: Array as PropType<DateRangePickerCalendarRangeSelectorItems>, default: undefined, }, /** * Selected date */ value: { type: Object as PropType<BentoDateRangePickerValue>, default: undefined }, /** * The type of calendar to display. Defaults to showing days. */ variant: Calendar.props.variant, }); const emit = defineEmits([ DateRangePickerCalendarEvent.CUSTOM_RANGE, DateRangePickerCalendarEvent.INPUT, DateRangePickerCalendarEvent.ERROR, DateRangePickerCalendarEvent.FORM_DATE, DateRangePickerCalendarEvent.START_DATE_SELECTED, ]); const slots = useSlots(); const hasSlot = useHasSlot(slots); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const { dateFormat, isFormatValid, parseDate, onKeyDown, autoFormat, formatDate: formatDateUtil, } = useDateInputFormatter(); const calculatedIsRelativeToNowQuickSelectRanges = ref({}); const defaultGranularity = computed(() => (props.variant === 'month' ? 'monthly' : 'daily')); const hasNoCalendars = computed(() => props.numberOfMonths === 0); const internalRangeDate = reactive<BentoDateRangePickerValue>({ startDate: props.value?.startDate, endDate: props.value?.endDate, range: props.value?.range, ...(props.granularities ? { granularity: props.value?.granularity || defaultGranularity.value, } : {}), }); /** * Currently range selector dropdown item that is selected. * The logic will try and find in each range item: * - If an endDate does not exist, then only check if the startDate day matches and if today's day matches. * - if time input has been enabled, then check if the seconds match. * - if an endDate does exist then just check if the startDate day and the endDate day match. * * Note: All date comparisons use local time consistently. Both sides of each comparison * originate from the same timezone context, so no UTC normalization is needed. */ const selectedRangeSelectorValue = computed(() => { return ( // eslint-disable-next-line consistent-return props?.quickSelectRanges?.find(({ value, data }) => { if (!data.endDate) { // Check if this quick select range is explicitly marked as "relative to now" if (data.isRelativeToNow) { // Retrieve previously calculated start/end dates for this relative range. // We use these if the user has already clicked on this time of quick range const newlyCalculatedQuickSelectDates = calculatedIsRelativeToNowQuickSelectRanges.value?.[value]; return newlyCalculatedQuickSelectDates ? isEqual(newlyCalculatedQuickSelectDates.startDate, internalRangeDate.startDate) && isEqual(newlyCalculatedQuickSelectDates.endDate, internalRangeDate.endDate) : // If not stored calculated dates, fall back to comparing the quick select item's value // with the internal range's stored range identifier. value === internalRangeDate.range; } // This block handles quick select ranges that do not have an endDate // AND are NOT explicitly marked as 'isRelativeToNow'. // This means the endDate will be today at the end of the day. // Uses isEqual for startDate to prevent false matches (e.g. same-day selection // accidentally matching a "This week" range). When time input is enabled, // also verify the end time is at end of day so that manual time changes // correctly fall back to "Custom range". return ( isEqual(data.startDate, internalRangeDate.startDate) && isToday(internalRangeDate.endDate) && (!props.allowTimeInput || isSameSecond(internalRangeDate.endDate, endOfDay(internalRangeDate.endDate))) && (props.granularities ? data.granularity === internalRangeDate.granularity : true) ); } if (props.allowTimeInput) { const res = isSameSecond(data.startDate, internalRangeDate.startDate) && isSameSecond(data.endDate, internalRangeDate.endDate) && (props.granularities ? data.granularity === internalRangeDate.granularity : true); return res; } if (data.endDate) { return ( isSameDay(data.startDate, internalRangeDate.startDate) && isSameDay(data.endDate, internalRangeDate.endDate) && (props.granularities ? data.granularity === internalRangeDate.granularity : true) ); } })?.value ?? RANGE_SELECTOR_CUSTOM_RANGE_KEY ); }); const formInputWrapperConditionalClasses = computed(() => ({ 'b-date-range-picker-calendar__form-input-wrapper': props.allowTimeInput, })); const quickSelectRangeDefaultItems = computed(() => [ { label: t('customRange'), value: RANGE_SELECTOR_CUSTOM_RANGE_KEY }, ...(props.quickSelectRanges ? props.quickSelectRanges : []), ]); const selectedGranularity = computed( () => props.granularities?.find(({ type }) => type === internalRangeDate.granularity) || null ); const granularityItems = computed<Array<BentoSegmentedControlItem>>( () => props.granularities?.map(({ type, disabled }) => ({ label: t(type), value: type, disabled, })) ?? [] ); const { getGranularCalendarLimits } = useGranularMinMaxDate(props); const computedMinMax = computed(() => props.granularities ? getGranularCalendarLimits(selectedGranularity.value) : { min: props.min ? startOfDay(props.min) : null, max: props.max ? endOfDay(props.max) : null } ); const { adjustDateForQuarterly, adjustDateForMonthly, adjustDateForWeekly, adjustStartDateInputForWeekly, adjustEndDateInputForWeekly, adjustStartDateInputForMonthly, adjustEndDateInputForMonthly, adjustStartDateInputForQuarterly, adjustEndDateInputForQuarterly, } = useGranularityAdjustments(props.firstDayOfWeek); const { savedRanges, saveRange, resetSavedRanges } = useGranularityMemory(); /** * Format date to a string value in current format * @param dateToFormat - Date to be formatted */ const formatDate = (dateToFormat?: Date) => { if (!dateToFormat) { return ''; } return formatDateUtil(dateToFormat); }; // Form error flags const startDateError = ref(false); const endDateError = ref(false); const startTimeError = ref(false); const endTimeError = ref(false); // Form input text used in range variant const dateTextInput = reactive({ startDate: props.dateFormData.startDate, endDate: props.dateFormData.endDate, startTime: props.dateFormData.startTime, endTime: props.dateFormData.endTime, }); /** * Sets the time of a date object from a time string. * @param dateToSetTimeIn The date object to set the time in. * @param time The time string in HH:MM:SS format. * @returns A new Date object with the time set. */ const setTimeInDateObject = (dateToSetTimeIn: Date, time: string) => { let dateWithTime = new Date(dateToSetTimeIn); const [hours, minutes, seconds] = time.split(':'); dateWithTime = setHours(dateWithTime, parseInt(hours, 10)); dateWithTime = setMinutes(dateWithTime, parseInt(minutes, 10)); dateWithTime = setSeconds(dateWithTime, parseInt(seconds, 10)); return dateWithTime; }; const isValidTimeString = (timeText: string) => /^(?:[01]\d|2[01234]):(?:[012345]\d):(?:[012345]\d)$/.test(timeText); /** * Checks if a time string is valid and within the min/max bounds if a date is provided. * @param timeText The time string to validate. * @param date The date string to check the time against. * @returns True if the time is valid, false otherwise. */ const isValidTime = (timeText: string, date?: string) => { const isTimeAllowed = () => { if (date && isValidDate(date)) { const dateToCheckWithTime = setTimeInDateObject(parseDate(date), timeText); // Check if date is between min/max bounds, if there are any if ( (props.min && dateToCheckWithTime < setMilliseconds(props.min, 0)) || (props.max && dateToCheckWithTime > setMilliseconds(props.max, 0)) ) { return false; } } return true; }; return isValidTimeString(timeText) && isTimeAllowed(); }; /** * Sets the time in a date object if the time is valid and the variant is not 'month'. * @param date The date object to modify. * @param time The time string to set. * @returns The modified date object or the original if time is not set. */ const setTimeInDateRef = (date: Date, time: string) => { // Do not set time if month variant if (!time || !isValidTimeString(time) || props.variant === 'month') { return date; } return setTimeInDateObject(new Date(date), time); }; const onDateInput = (selectedRange: BentoDateRangePickerValue) => { dateTextInput.startTime = dateToTimeInputString( props.min && props.allowTimeInput && isSameDay(props.min, selectedRange.startDate) ? props.min : startOfDay(selectedRange.startDate) ); dateTextInput.endTime = dateToTimeInputString( props.max && props.allowTimeInput && isSameDay(props.max, selectedRange.endDate) ? props.max : endOfDay(selectedRange.endDate) ); internalRangeDate.startDate = setTimeInDateRef(selectedRange.startDate, dateTextInput.startTime); internalRangeDate.endDate = setTimeInDateRef(selectedRange.endDate, dateTextInput.endTime); // Resetting internal range date when selecting a custom range through the calendar internalRangeDate.range = null; if (props.granularities) { // Reset saved ranges on input resetSavedRanges(); } emit(DateRangePickerCalendarEvent.INPUT, { ...internalRangeDate, range: selectedRangeSelectorValue.value, }); }; /** * Checks if a date string is in current format and if the date is enabled. * @param dateText The date string to validate. * @returns True if the date is valid and enabled, false otherwise. */ const isValidDate = (dateText: string) => { if (!dateText) { return false; } // Is date selectable const isDateEnabled = (date: Date) => { // Check if date is between or equal to min/max bounds // Set the time to the start of the day to disregard time if ( (props.min && startOfDay(date) < startOfDay(props.min)) || (props.max && startOfDay(date) > startOfDay(props.max)) ) { return false; } // Check if function is set, otherwise all dates are enabled if (props.isDateDisabled) { return !props.isDateDisabled(startOfDay(date)); } return true; }; // Date matches text regex and is not disabled return isFormatValid(dateText) && isDateEnabled(parseDate(dateText)); }; /** * When mounted, set the input error states to true if the values are not valid */ onMounted(() => { // Start date validation startDateError.value = !!(props.dateFormData.startDate && !isValidDate(props.dateFormData.startDate)); // Start time validation if (props.allowTimeInput) { startTimeError.value = !!( props.dateFormData.startTime && !isValidTime(props.dateFormData.startTime, props.dateFormData.startDate) ); } else { startTimeError.value = false; } // End date validation endDateError.value = !!(props.dateFormData.endDate && !isValidDate(props.dateFormData.endDate)); // End time validation if (props.allowTimeInput) { endTimeError.value = !!( props.dateFormData.endTime && !isValidTime(props.dateFormData.endTime, props.dateFormData.endDate) ); } else { endTimeError.value = false; } }); const onStartDateSelected = (newStartDate: Date) => { dateTextInput.startDate = formatDate(newStartDate); if (!dateTextInput.startTime) { // Set the time to the beginning of the day dateTextInput.startTime = dateToTimeInputString(newStartDate); } else if (startTimeError.value) { // Set the form time if it was previously set dateTextInput.startTime = props.dateFormData?.startTime; } // Reset errors startDateError.value = false; startTimeError.value = false; emit(DateRangePickerCalendarEvent.START_DATE_SELECTED, newStartDate); }; const onEndDateSelected = (newEndDate: Date) => { dateTextInput.endDate = formatDate(newEndDate); if (!dateTextInput.endTime) { // Set the time to the end of the day dateTextInput.endTime = dateToTimeInputString(endOfDay(newEndDate)); } else if (endTimeError.value) { // Set the form time if it was previously set dateTextInput.endTime = props.dateFormData?.endTime; } // Reset errors endDateError.value = false; endTimeError.value = false; }; const getDate = (keyName: keyof BentoDateRangePickerValue) => { const dateProp = (props.value as BentoDateRangePickerValue)[keyName]; if (dateProp) { return dateProp; } if (props.dateFormData[keyName] && isValidDate(props.dateFormData[keyName])) { return parseDate(props.dateFormData[keyName]); } return undefined; }; const validateStartDate = debounce((startDateText: string) => { if (isValidDate(startDateText)) { let dateText = startDateText; startDateError.value = false; switch (selectedGranularity.value?.type) { case 'weekly': dateText = adjustStartDateInputForWeekly( startDateText, formatDate, computedMinMax.value.min, computedMinMax.value.max ); dateTextInput.startDate = dateText; break; case 'monthly': dateText = adjustStartDateInputForMonthly( startDateText, formatDate, computedMinMax.value.min, computedMinMax.value.max ); dateTextInput.startDate = dateText; break; case 'quarterly': dateText = adjustStartDateInputForQuarterly( startDateText, formatDate, computedMinMax.value.min, computedMinMax.value.max ); dateTextInput.startDate = dateText; break; case 'daily': default: break; } const startDate = startOfDay(parseDate(dateText)); internalRangeDate.startDate = setTimeInDateRef(startDate, dateTextInput.startTime); internalRangeDate.endDate = setTimeInDateRef( startOfDay(parseDate(dateTextInput.endDate)), dateTextInput.endTime ); if (isValidDate(dateTextInput.endDate)) { internalRangeDate.endDate = setTimeInDateRef( startOfDay(parseDate(dateTextInput.endDate)), dateTextInput.endTime ); } const endDate = getDate('endDate'); // Resetting internal range date when selecting a custom range through the calendar internalRangeDate.range = null; emit(DateRangePickerCalendarEvent.FORM_DATE, { startDate, endDate, }); emit(DateRangePickerCalendarEvent.INPUT, { ...internalRangeDate, range: selectedRangeSelectorValue.value, }); } else { // Indicate form error startDateError.value = true; // Emit Date picker calendar error emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); } }, FORM_INPUT_DEBOUNCE_TIME); const onStartDateInput = (startDateText: string) => { // Set the date as string when input through the form dateTextInput.startDate = autoFormat(startDateText); validateStartDate(dateTextInput.startDate); }; const validateEndDate = debounce((endDateText: string) => { if (isValidDate(endDateText)) { let dateText = endDateText; endDateError.value = false; switch (selectedGranularity.value?.type) { case 'weekly': dateText = adjustEndDateInputForWeekly( endDateText, formatDate, computedMinMax.value.min, computedMinMax.value.max ); dateTextInput.endDate = dateText; break; case 'monthly': dateText = adjustEndDateInputForMonthly( endDateText, formatDate, computedMinMax.value.min, computedMinMax.value.max ); dateTextInput.endDate = dateText; break; case 'quarterly': dateText = adjustEndDateInputForQuarterly( endDateText, formatDate, computedMinMax.value.min, computedMinMax.value.max ); dateTextInput.endDate = dateText; break; case 'daily': default: break; } // Format date string to Date object const endDate = startOfDay(parseDate(dateText)); internalRangeDate.endDate = setTimeInDateRef(endDate, dateTextInput.endTime); if (isValidDate(dateTextInput.startDate)) { internalRangeDate.startDate = setTimeInDateRef( startOfDay(parseDate(dateTextInput.startDate)), dateTextInput.startTime ); } const startDate = getDate('startDate'); // Resetting internal range date when selecting a custom range through the calendar internalRangeDate.range = null; emit(DateRangePickerCalendarEvent.FORM_DATE, { startDate, endDate, }); emit(DateRangePickerCalendarEvent.INPUT, { ...internalRangeDate, range: selectedRangeSelectorValue.value, }); } else { // Indicate form error endDateError.value = true; // Emit Date picker calendar error emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); } }, FORM_INPUT_DEBOUNCE_TIME); const onEndDateInput = (endDateText: string) => { // Set the date as string when input through the form dateTextInput.endDate = autoFormat(endDateText); validateEndDate(dateTextInput.endDate); }; const onDateInputKeyDown = (event: KeyboardEvent) => { onKeyDown(event); }; const validateStartTime = debounce((startTimeText: string) => { if (isValidTime(startTimeText, props.dateFormData.startDate)) { startTimeError.value = false; internalRangeDate.startDate = setTimeInDateRef(internalRangeDate.startDate, startTimeText); const endDate = getDate('endDate'); emit(DateRangePickerCalendarEvent.FORM_DATE, { startDate: internalRangeDate.startDate, endDate, startTime: startTimeText, endTime: dateTextInput.endTime ?? props.dateFormData.endTime, }); emit(DateRangePickerCalendarEvent.INPUT, { ...internalRangeDate, range: selectedRangeSelectorValue.value, }); } else { // Indicate form error startTimeError.value = true; // Emit Date picker calendar error emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); } }); const validateEndTime = debounce((endTimeText: string) => { if (isValidTime(endTimeText, props.dateFormData.endDate)) { endTimeError.value = false; internalRangeDate.endDate = setTimeInDateRef(internalRangeDate.endDate, endTimeText); emit(DateRangePickerCalendarEvent.FORM_DATE, { startDate: internalRangeDate.startDate, endDate: internalRangeDate.endDate, startTime: dateTextInput.startTime ?? props.dateFormData.startTime, endTime: endTimeText, }); emit(DateRangePickerCalendarEvent.INPUT, { ...internalRangeDate, range: selectedRangeSelectorValue.value, }); } else { // Indicate form error endTimeError.value = true; // Emit Date picker calendar error emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); } }); const onStartTimeInput = (startTimeText: string) => { dateTextInput.startTime = formatTimeInputValue(startTimeText); validateStartTime(dateTextInput.startTime); }; const onEndTimeInput = (endDateText: string) => { dateTextInput.endTime = formatTimeInputValue(endDateText); validateEndTime(dateTextInput.endTime); }; const onTimeInputKeyDown = (event: KeyboardEvent) => { validateTimeInput(event); }; const setInternalRange = ( startDate: Date, endDate: Date, granularity?: CalendarGranularityType, range?: string ) => { internalRangeDate.startDate = startDate; internalRangeDate.endDate = endDate; if (granularity) { internalRangeDate.granularity = granularity; } if (range) { internalRangeDate.range = range; } }; const onRangeSelectorInput = (newCustomRangeValue: string) => { const foundResult = props.quickSelectRanges?.find(({ value }) => value === newCustomRangeValue); if (foundResult) { resetSavedRanges(); const date = foundResult.data; const newEndDate = date.endDate || (date.isRelativeToNow ? new Date(Date.now()) : endOfDay(new Date(Date.now()))); // If the time difference between start and end dates is available, // calculating start date dynamically const newStartDate = date.isRelativeToNow && date.timeDifference ? new Date(newEndDate.getTime() - date.timeDifference) : date.startDate; // Set Form input Dates dateTextInput.endDate = formatDate(newEndDate); dateTextInput.startDate = formatDate(newStartDate); setInternalRange(newStartDate, newEndDate, null, newCustomRangeValue); if (props.granularities) { internalRangeDate.granularity = date.granularity || defaultGranularity.value; } // Set Form input time to quick select range time dateTextInput.startTime = dateToTimeInputString(newStartDate); dateTextInput.endTime = dateToTimeInputString(newEndDate); // Set calendar selection internalRangeDate.startDate = newStartDate; internalRangeDate.endDate = newEndDate; if (date.isRelativeToNow) { // Store this calculated start and end dates to be used in the selectedRangeSelectorValue logic calculatedIsRelativeToNowQuickSelectRanges.value = { ...calculatedIsRelativeToNowQuickSelectRanges.value, [newCustomRangeValue]: { startDate: newStartDate, endDate: newEndDate, }, }; } // Emit everytime the date range changes emit(DateRangePickerCalendarEvent.CUSTOM_RANGE, { ...foundResult.data, startDate: newStartDate, endDate: newEndDate, range: foundResult.value, }); } }; const adjustDate = { weekly: adjustDateForWeekly, monthly: adjustDateForMonthly, quarterly: adjustDateForQuarterly, }; const onGranularityInput = (value: CalendarGranularityType) => { const previousValue = internalRangeDate.granularity; internalRangeDate.granularity = value; // Save previously selected range if (previousValue) { saveRange(previousValue, structuredClone(toRaw(internalRangeDate))); } if (internalRangeDate.startDate && internalRangeDate.endDate) { // Adjust date range based on new granularity switch (value) { case 'quarterly': case 'monthly': case 'weekly': { if (savedRanges[value]) { setInternalRange(savedRanges[value].startDate, savedRanges[value].endDate, value); break; } const { startDate, endDate } = adjustDate[value]( internalRangeDate.startDate, internalRangeDate.endDate, computedMinMax.value.min, computedMinMax.value.max ); setInternalRange(startDate, endDate, value); break; } case 'daily': if (savedRanges.daily) { setInternalRange(savedRanges.daily.startDate, savedRanges.daily.endDate, value); } break; default: break; } dateTextInput.startDate = formatDate(internalRangeDate.startDate); dateTextInput.endDate = formatDate(internalRangeDate.endDate); emit(DateRangePickerCalendarEvent.INPUT, { ...internalRangeDate, range: selectedRangeSelectorValue.value, }); } }; const computedFormContainerClasses = computed(() => ({ 'b-date-range-picker-calendar__form-container--no-calendars': hasNoCalendars.value, })); </script> <script lang="ts"> /** * Calendar for range dates. Contains the logic to handle the range forms. * * @example * import DatePickerCalendarRange from './date-range-picker-calendar.vue'; * * export default { * components: { DatePickerCalendarRange }, * template: ` * <date-range-picker-calendar * :value="{startDate: new Date(), endDate: new Date() }" * :first-day-of-week="BentoDatePickerFirstDayOfWeek.MONDAY" * :is-date-disabled="(date: Date) => boolean" * :number-of-months="2" * @input="({ startDate, endDate }) => void" * /> * ` * } */ export default { name: 'date-range-picker-calendar', i18n: { messages }, }; </script> <style lang="scss" scoped src="./date-range-picker-calendar.scss" />
@@ -1 +1 @@
1
- <template> <component :is="popperContainerOrPopoverComponent" class="b-dropdown-options-container" :class="conditionalClasses" v-bind="popperContainerOrPopoverProps" > <div @keydown.esc="onEscapeKey"> <bento-listbox :id="id" ref="dropdownOptionsListboxRef" :aria-label="ariaLabel" class="b-dropdown-options-container__listbox" :class="conditionalListboxClasses" :component-loading="componentLoading" :empty-state="emptyState" :is-option-disabled="isOptionDisabled" :items="items" :selected-value="internalSelectedValue" :multiple="multiple" :static-categories="staticCategories" :no-results-message="noResultsMessage" :searching="searching" :loading="loading" :lazy-load-type="lazyLoadType" :has-more-items="hasMoreItems" :virtual-scroll="virtualScroll" v-on="listeners" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </bento-listbox> </div> </component> </template> <script setup lang="ts"> import { computed, defineComponent, type PropType, ref, toRefs, useSlots } from 'vue'; import { BentoListbox } from '@/internal'; import { type BentoListboxEvent, type BentoListboxIsOptionDisabled, type BentoListboxOptions, } from '@/types/listbox'; import { PopperContainer } from '@/components/popper-container'; import { type BentoDropdownEmptyStateProps, type BentoDropdownEvent, BentoDropdownLazyLoadType, type BentoDropdownVirtulisationOptions, } from '../../dropdown.types'; import { useI18n } from '@/utils/ts/i18n'; import { BentoPopover } from '@/components/popover'; import { useDropdownOptionsListbox } from './composables/use-dropdown-options-listbox'; import { generateUid } from '@/core/utils/ts'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const slots = useSlots(); const emit = defineEmits<{ /** * Emitted when the dropdown is closing. */ (e: BentoDropdownEvent.CLOSE_DROPDOWN); /** * Emitted when the `ENTER` key is pressed */ (e: BentoListboxEvent.ENTER, newSelectedValue: BentoListboxOptions); /** * Emitted when the `ESCAPE` key is pressed */ (e: BentoListboxEvent.ESCAPE); /** * Emitted when an item is selected */ (e: BentoListboxEvent.SELECT, newSelectedValue: BentoListboxOptions); /** * Emitted when: * - `lazyLoad` is `automatic` and the scroll meets the end of the list * - `lazyload` is `button` and the button is clicked */ (e: BentoDropdownEvent.SHOW_MORE); /** * Emitted when the `SPACE` key is pressed */ (e: BentoListboxEvent.SPACE, newSelectedValue: BentoListboxOptions); /** * Emitted when the `TAB` key is pressed */ (e: BentoListboxEvent.TAB, newSelectedValue: BentoListboxOptions); }>(); const props = defineProps({ /** * Defines a string value that labels an interactive element. */ ariaLabel: { type: String, default: null }, /** * Indicates that dropdown items are loading */ componentLoading: { type: Boolean, default: false }, /** * Empty state props used with the inner empty state component which is displayed when no search results are found. */ emptyState: { type: Object as PropType<BentoDropdownEmptyStateProps>, default: undefined, }, /** * Disables dropdown functionality */ disabled: { type: Boolean, default: false }, /** * Indicates whether there are more items to load */ hasMoreItems: { type: Boolean, default: false }, /** * Identifies the listbox whose contents are controlled by the the combobox on which the aria-controls attribute is set. */ id: { type: String, required: true }, /** * Function that allows the options to be disabled * * @type {BentoDropdownIsOptionDisabled} * @param {BentoDropdownOptionItem} option - Dropdown option object * @param {string} option.label - Option's label text * @param {string|number} option.value - Option's value * @param {unknown} option.data - Option's extra data to be passed to the default slot */ isOptionDisabled: { type: Function as PropType<BentoListboxIsOptionDisabled>, default: undefined, }, /** * The option elements to populate the dropdown with. * It must be an array of {@see BentoDropdownOptionItem } * * @property {string} value.label - Text to be displayed in the option * * @property {string,number} value.value - Value of the option */ items: { type: Array as PropType<BentoListboxOptions>, required: true, }, /** * The type of `Lazy Load`. * Type "automatic" will enable infinite scrolling * Type "button" will enable lazy loading with "Show more" button */ lazyLoadType: { type: String as PropType<BentoDropdownLazyLoadType | `${BentoDropdownLazyLoadType}`>, default: BentoDropdownLazyLoadType.AUTOMATIC, validator: (value: BentoDropdownLazyLoadType) => Object.values(BentoDropdownLazyLoadType).includes(value), }, /** * Indicates if new options are lazy loading. */ loading: { type: Boolean, default: false }, /** * If enabled, the user will be able to select multiple items. */ multiple: { type: Boolean, default: false }, /** * Indicates if the dropdown is open so that * the container can be displayed. */ open: { type: Boolean, required: true }, /** * The `input` value. * Providing an empty string or empty array will select no options. * Set to an empty string `''` if you don't want any of the available options to be selected for single select */ selectedValue: { type: Array as PropType<BentoListboxOptions>, default: null, }, /** * Flag that indicates that an external search it's being made * to filter out the items inside the the multi-select listbox * * If enabled, hides the "Select all" checkbox in "multiple" mode. */ searching: BentoListbox.props.searching, /** * If enabled, dropdown will display non-selectable, static categories */ staticCategories: { type: Boolean, default: false }, /** * Reference to the DOM element or Vue component for positioning * the Dropdown container. */ targetElement: { type: PopperContainer.props.targetElement.type, required: true }, /** * Enables virtual scrolling if set to true or by providing an object with itemHeight function. * The itemHeight function is used to calculate the height of rendered item given it's index. */ virtualScroll: { type: [Boolean, Object] as PropType<BentoDropdownVirtulisationOptions>, default: false, }, }); const { selectedValue, multiple } = toRefs(props); const dropdownOptionsListboxRef = ref(null); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const isDropdownOpen = computed(() => props.open && !props.disabled); const noResultsMessage = computed(() => t('noOptionsMatchThisSearch') as string); const conditionalClasses = computed(() => ({ 'b-dropdown-options-container--single': !props?.multiple, })); const conditionalListboxClasses = computed(() => ({ 'b-dropdown-options-container__listbox--single': !props?.multiple, })); const { actions, internalSelectedValue, listeners, onEscapeKey, onOutsideDropdownClick } = useDropdownOptionsListbox(selectedValue, multiple, isDropdownOpen, emit); // Use different components depending on if the dropdown is a single or multi-select const popperContainerOrPopoverComponent = computed(() => (props?.multiple ? BentoPopover : PopperContainer)); const popperContainerOrPopoverProps = computed(() => { const containerMinAndMaxWidth = { 'min-width': `${(props?.targetElement as HTMLElement)?.clientWidth}px`, 'max-width': 'min(500px, 95%)', }; return props?.multiple ? ({ actionsLayout: 'space-between', ariaLabel: props.ariaLabel, actions: actions.value, divider: true, fallbackPosition: ['top-start'], id: generateUid(`dropdown-options-${props.id}`), open: props.open, position: 'bottom-start', style: { width: 'auto', ...containerMinAndMaxWidth, }, targetElement: props.targetElement, } as InstanceType<typeof BentoPopover>['$props']) : ({ fallbackPosition: ['top-start'], offset: [0, 8], position: 'bottom-start', style: { display: isDropdownOpen.value ? 'block' : 'none', ...containerMinAndMaxWidth, }, targetElement: props.targetElement, } as InstanceType<typeof PopperContainer>['$props']); }); defineExpose({ onOutsideDropdownClick, }); </script> <script lang="ts"> /** * Dropdown options container. * It lists all the available options. * * @example * <bento-dropdown-options-container * v-if="inputContainerRef" <!-- Ref to the input to match the width --> * :id="dropdownOptionsContainerId" * :target-element="inputContainerRef" * :aria-label="ariaLabel" * :open="isDropdownOpen" * :disabled="disabled" * :multiple="multiple" * :selected="value" * :isOptionDisabled="option => option.value === 2" * :items="[{ * { label: 'Option 1', value: 1 }, * { label: 'Option 2', value: 2 }, * }]" * @select="onOptionSelected" * /> */ export default defineComponent({ i18n: { messages }, name: 'bento-dropdown-options-container', }); </script> <style lang="scss" scoped src="./dropdown-options-container.scss" />
1
+ <template> <component :is="popperContainerOrPopoverComponent" v-if="isDropdownOpen" class="b-dropdown-options-container" :class="conditionalClasses" v-bind="popperContainerOrPopoverProps" > <div @keydown.esc="onEscapeKey"> <bento-listbox :id="id" ref="dropdownOptionsListboxRef" :aria-label="ariaLabel" class="b-dropdown-options-container__listbox" :class="conditionalListboxClasses" :component-loading="componentLoading" :empty-state="emptyState" :is-option-disabled="isOptionDisabled" :items="items" :selected-value="internalSelectedValue" :multiple="multiple" :static-categories="staticCategories" :no-results-message="noResultsMessage" :searching="searching" :loading="loading" :lazy-load-type="lazyLoadType" :has-more-items="hasMoreItems" :virtual-scroll="virtualScroll" v-on="listeners" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </bento-listbox> </div> </component> </template> <script setup lang="ts"> import { computed, defineComponent, type PropType, ref, toRefs, useSlots } from 'vue'; import { BentoListbox } from '@/internal'; import { type BentoListboxEvent, type BentoListboxIsOptionDisabled, type BentoListboxOptions, } from '@/types/listbox'; import { PopperContainer } from '@/components/popper-container'; import { type BentoDropdownEmptyStateProps, type BentoDropdownEvent, BentoDropdownLazyLoadType, type BentoDropdownVirtulisationOptions, } from '../../dropdown.types'; import { useI18n } from '@/utils/ts/i18n'; import { BentoPopover } from '@/components/popover'; import { useDropdownOptionsListbox } from './composables/use-dropdown-options-listbox'; import { generateUid } from '@/core/utils/ts'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const slots = useSlots(); const emit = defineEmits<{ /** * Emitted when the dropdown is closing. */ (e: BentoDropdownEvent.CLOSE_DROPDOWN); /** * Emitted when the `ENTER` key is pressed */ (e: BentoListboxEvent.ENTER, newSelectedValue: BentoListboxOptions); /** * Emitted when the `ESCAPE` key is pressed */ (e: BentoListboxEvent.ESCAPE); /** * Emitted when an item is selected */ (e: BentoListboxEvent.SELECT, newSelectedValue: BentoListboxOptions); /** * Emitted when: * - `lazyLoad` is `automatic` and the scroll meets the end of the list * - `lazyload` is `button` and the button is clicked */ (e: BentoDropdownEvent.SHOW_MORE); /** * Emitted when the `SPACE` key is pressed */ (e: BentoListboxEvent.SPACE, newSelectedValue: BentoListboxOptions); /** * Emitted when the `TAB` key is pressed */ (e: BentoListboxEvent.TAB, newSelectedValue: BentoListboxOptions); }>(); const props = defineProps({ /** * Defines a string value that labels an interactive element. */ ariaLabel: { type: String, default: null }, /** * Indicates that dropdown items are loading */ componentLoading: { type: Boolean, default: false }, /** * Empty state props used with the inner empty state component which is displayed when no search results are found. */ emptyState: { type: Object as PropType<BentoDropdownEmptyStateProps>, default: undefined, }, /** * Disables dropdown functionality */ disabled: { type: Boolean, default: false }, /** * Indicates whether there are more items to load */ hasMoreItems: { type: Boolean, default: false }, /** * Identifies the listbox whose contents are controlled by the the combobox on which the aria-controls attribute is set. */ id: { type: String, required: true }, /** * Function that allows the options to be disabled * * @type {BentoDropdownIsOptionDisabled} * @param {BentoDropdownOptionItem} option - Dropdown option object * @param {string} option.label - Option's label text * @param {string|number} option.value - Option's value * @param {unknown} option.data - Option's extra data to be passed to the default slot */ isOptionDisabled: { type: Function as PropType<BentoListboxIsOptionDisabled>, default: undefined, }, /** * The option elements to populate the dropdown with. * It must be an array of {@see BentoDropdownOptionItem } * * @property {string} value.label - Text to be displayed in the option * * @property {string,number} value.value - Value of the option */ items: { type: Array as PropType<BentoListboxOptions>, required: true, }, /** * The type of `Lazy Load`. * Type "automatic" will enable infinite scrolling * Type "button" will enable lazy loading with "Show more" button */ lazyLoadType: { type: String as PropType<BentoDropdownLazyLoadType | `${BentoDropdownLazyLoadType}`>, default: BentoDropdownLazyLoadType.AUTOMATIC, validator: (value: BentoDropdownLazyLoadType) => Object.values(BentoDropdownLazyLoadType).includes(value), }, /** * Indicates if new options are lazy loading. */ loading: { type: Boolean, default: false }, /** * If enabled, the user will be able to select multiple items. */ multiple: { type: Boolean, default: false }, /** * Indicates if the dropdown is open so that * the container can be displayed. */ open: { type: Boolean, required: true }, /** * The `input` value. * Providing an empty string or empty array will select no options. * Set to an empty string `''` if you don't want any of the available options to be selected for single select */ selectedValue: { type: Array as PropType<BentoListboxOptions>, default: null, }, /** * Flag that indicates that an external search it's being made * to filter out the items inside the the multi-select listbox * * If enabled, hides the "Select all" checkbox in "multiple" mode. */ searching: BentoListbox.props.searching, /** * If enabled, dropdown will display non-selectable, static categories */ staticCategories: { type: Boolean, default: false }, /** * Reference to the DOM element or Vue component for positioning * the Dropdown container. */ targetElement: { type: PopperContainer.props.targetElement.type, required: true }, /** * Enables virtual scrolling if set to true or by providing an object with itemHeight function. * The itemHeight function is used to calculate the height of rendered item given it's index. */ virtualScroll: { type: [Boolean, Object] as PropType<BentoDropdownVirtulisationOptions>, default: false, }, }); const { selectedValue, multiple } = toRefs(props); const dropdownOptionsListboxRef = ref(null); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const isDropdownOpen = computed(() => props.open && !props.disabled); const noResultsMessage = computed(() => t('noOptionsMatchThisSearch') as string); const conditionalClasses = computed(() => ({ 'b-dropdown-options-container--single': !props?.multiple, })); const conditionalListboxClasses = computed(() => ({ 'b-dropdown-options-container__listbox--single': !props?.multiple, })); const { actions, internalSelectedValue, listeners, onEscapeKey, onOutsideDropdownClick } = useDropdownOptionsListbox(selectedValue, multiple, isDropdownOpen, emit); // Use different components depending on if the dropdown is a single or multi-select const popperContainerOrPopoverComponent = computed(() => (props?.multiple ? BentoPopover : PopperContainer)); const popperContainerOrPopoverProps = computed(() => { const containerMinAndMaxWidth = { 'min-width': `${(props?.targetElement as HTMLElement)?.clientWidth}px`, 'max-width': 'min(500px, 95%)', }; return props?.multiple ? ({ actionsLayout: 'space-between', ariaLabel: props.ariaLabel, actions: actions.value, divider: true, fallbackPosition: ['top-start'], id: generateUid(`dropdown-options-${props.id}`), open: props.open, position: 'bottom-start', style: { width: 'auto', ...containerMinAndMaxWidth, }, targetElement: props.targetElement, } as InstanceType<typeof BentoPopover>['$props']) : ({ fallbackPosition: ['top-start'], offset: [0, 8], position: 'bottom-start', style: { display: isDropdownOpen.value ? 'block' : 'none', ...containerMinAndMaxWidth, }, targetElement: props.targetElement, } as InstanceType<typeof PopperContainer>['$props']); }); defineExpose({ onOutsideDropdownClick, }); </script> <script lang="ts"> /** * Dropdown options container. * It lists all the available options. * * @example * <bento-dropdown-options-container * v-if="inputContainerRef" <!-- Ref to the input to match the width --> * :id="dropdownOptionsContainerId" * :target-element="inputContainerRef" * :aria-label="ariaLabel" * :open="isDropdownOpen" * :disabled="disabled" * :multiple="multiple" * :selected="value" * :isOptionDisabled="option => option.value === 2" * :items="[{ * { label: 'Option 1', value: 1 }, * { label: 'Option 2', value: 2 }, * }]" * @select="onOptionSelected" * /> */ export default defineComponent({ i18n: { messages }, name: 'bento-dropdown-options-container', }); </script> <style lang="scss" scoped src="./dropdown-options-container.scss" />
@@ -1 +1 @@
1
- import { action } from '@storybook/addon-actions'; import { computed, onMounted, ref, watch } from 'vue'; import { isVue2 } from 'vue-demi'; import { type BentoListboxOptionItem, type BentoListboxOptions, type BentoListboxSelectedValue } from '@/types/listbox'; import { BentoDropdownEvent, BentoDropdownLazyLoadType, type BentoDropdownSearchOptions, } from '@/components/dropdown/dropdown.types'; import { BentoTag, BentoTagVariant } from '@/components/tag'; import { useCountryName, useCountryPhoneCode } from '@/components/country'; import { storybookDocsParameter } from '@/utils/ts/storybook'; import type { Meta, StoryObj } from '@storybook/vue'; import BentoDropdown from './dropdown.vue'; import DropdownWithDescriptionExample from './__tests__/dropdown-with-description-example.vue?raw'; import DropdownWithCustomSlotsExample from './__tests__/dropdown-with-custom-slots-example.vue?raw'; // Generate array from 1 to # // { label: 'Option #', value: # }, const DEFAULT_OPTIONS: BentoListboxOptions = new Array(10) .fill(0) .map((_, index) => ({ label: `Option ${index + 1}`, value: index + 1 })); const DEFAULT_DESCRIPTION = 'Choose an option'; const DEFAULT_LABEL = 'Label'; const DEFAULT_PLACEHOLDER = 'Select an option'; const DEFAULT_PROPS = { label: DEFAULT_LABEL, description: DEFAULT_DESCRIPTION, placeholder: DEFAULT_PLACEHOLDER, lazyLoadType: 'none', enableValueLabelPair: false, }; const COUNTRY_OPTIONS = ['NL', 'BE', 'DE'].map(code => ({ label: `${useCountryName(code, 'en-US').value} ${useCountryPhoneCode(code).value}`, value: code, displayValue: `${code} (${useCountryPhoneCode(code).value})`, })); const showMoreOptions = () => action(BentoDropdownEvent.SHOW_MORE)(); const meta: Meta = { title: 'Dropdown', component: BentoDropdown, argTypes: { 'show-more': { name: 'show-more', table: { type: { summary: '() => void' }, }, control: { disable: true }, }, size: { table: { disable: true } }, // Slots default: { description: 'Custom slots for the items and display value', table: { category: 'Slots', type: { summary: 'VNode[]' }, }, control: { type: 'text' }, }, description: { description: 'Custom slot for the description', table: { category: 'Slots', type: { summary: 'VNode[]' }, }, control: { type: 'text' }, }, 'display-value': { description: 'Custom slot for the display value when the display value is different than the items', table: { category: 'Slots', type: { summary: 'VNode[]' }, }, control: { type: 'text' }, }, innerSlot: { // Hide non-existent slot inaccurately created by storybook table: { disable: true }, }, // Values lazyLoadType: { options: Object.values(BentoDropdownLazyLoadType), mapping: BentoDropdownLazyLoadType, control: { type: 'select', }, }, error: { control: { type: 'boolean' }, table: { category: 'Deprecated' }, }, input: { description: 'Deprecated since version 2.0. Use `v-model` or `update:model-value` instead', table: { category: 'Deprecated' }, control: { disable: true }, }, value: { control: { type: 'text' }, table: { category: 'Deprecated' }, description: 'Deprecated for usage with properties since version 2.0. Use `v-model` or `model-value` property instead', }, }, }; export default meta; type Story = StoryObj<typeof BentoDropdown>; const defaultDocs = ` <template> <bento-dropdown v-model="modelValue" aria-label="Descriptive definition of what the dropdown is for" description="Description of the field" :condensed="false" :disabled="false" :empty-state="emptyState" dynamic-filtering error-message="This is an error message" :multiple="false" :items="items" :is-option-disabled="isOptionDisabled" label="Dropdown input label" :optional="!isRequired" tooltip-text="Label tooltip text" placeholder="Dropdown placeholder text" :required="isRequired" enable-value-label-pair /> </template> <script setup lang="ts"> import { BentoDropdown, BentoDropdownEmptyStateProps, BentoListboxOptionItem, BentoListboxOptions, BentoListboxSelectedValue, } from '@adyen/bento-vue2'; import { computed, ref } from 'vue'; const modelValue = ref<BentoListboxSelectedValue>({ label: 'Option 1', value: 1 }); const items: BentoListboxOptions = new Array(10) .fill(0) .map((_, index) => ({ label: \`Option \${index + 1}\`, value: index + 1 })); // "required" and "optional" are mutually exclusive const isRequired = ref(false); const emptyState = computed<BentoDropdownEmptyStateProps>(() => ({ title: 'Unable to retrieve items', action: { title: 'Refresh the page to try again', event: () => { // Refresh page logic }, }, })); // Disable individual options const isOptionDisabled = (option: BentoListboxOptionItem) => option.value === 3; </script> `; export const Default: Story = { render: (_args, { argTypes }) => ({ components: { BentoDropdown }, props: Object.keys(argTypes), setup(props) { const args = isVue2 ? props : _args; const selectedValue = ref(args.modelValue); const isOptionDisabled = (option: BentoListboxOptionItem) => option.value === 3; const onUpdateModelValue = (newSelectedValue: BentoListboxSelectedValue) => { selectedValue.value = newSelectedValue; action('update:model-value')(newSelectedValue); }; return { // Values args, selectedValue, // Methods isOptionDisabled, onUpdateModelValue, showMoreOptions, }; }, template: ` <div style="max-width: 300px"> <bento-dropdown v-bind="args" :model-value="selectedValue" @update:model-value="onUpdateModelValue" :isOptionDisabled="isOptionDisabled" @show-more="showMoreOptions" /> </div> `, }), args: { ...DEFAULT_PROPS, modelValue: DEFAULT_OPTIONS[0].value, items: DEFAULT_OPTIONS, }, parameters: storybookDocsParameter(defaultDocs), }; export const MultiSelect: Story = { render: (_args, { argTypes }) => ({ components: { BentoDropdown }, props: Object.keys(argTypes), setup(props) { const args = isVue2 ? props : _args; const selectedValue = ref(args.modelValue); const isOptionDisabled = (option: BentoListboxOptionItem) => [3, 5, 7, 9].includes(option.value as number); const onUpdateModelValue = (newSelectedValue: BentoListboxSelectedValue) => { selectedValue.value = newSelectedValue; action('update:model-value')(newSelectedValue); }; return { // Values selectedValue, args, // Methods isOptionDisabled, onUpdateModelValue, showMoreOptions, }; }, template: ` <div style="max-width: 300px"> <bento-dropdown v-bind="args" :model-value="selectedValue" @update:model-value="onUpdateModelValue" :isOptionDisabled="isOptionDisabled" @show-more="showMoreOptions" /> </div> `, }), args: { ...DEFAULT_PROPS, description: 'Choose one or more options', modelValue: [1, 3, 100], multiple: true, items: DEFAULT_OPTIONS, }, parameters: storybookDocsParameter(` <template> <bento-dropdown v-model="modelValue" aria-label="Descriptive definition of what the dropdown is for" description="Description of the field" :condensed="false" :disabled="false" :empty-state="emptyState" dynamic-filtering error-message="This is an error message" :multiple="true" :items="items" :is-option-disabled="isOptionDisabled" label="Dropdown input label" :optional="!isRequired" tooltip-text="Label tooltip text" placeholder="Dropdown placeholder text" :required="isRequired" enable-value-label-pair @update:model-value="onUpdateModelValue" /> </template> <script setup lang="ts"> import { BentoDropdown, BentoDropdownEmptyStateProps, BentoListboxOptionItem, BentoListboxOptions, BentoListboxSelectedValue, } from '@adyen/bento-vue2'; import { computed, ref } from 'vue'; const modelValue = ref<BentoListboxSelectedValue>([ { label: 'Option 1', value: 1 } ]); const items: BentoListboxOptions = new Array(10) .fill(0) .map((_, index) => ({ label: \`Option \${index + 1}\`, value: index + 1 })); // "required" and "optional" are mutually exclusive const isRequired = ref(false); const emptyState = computed<BentoDropdownEmptyStateProps>(() => ({ title: 'Unable to retrieve items', action: { title: 'Refresh the page to try again', event: () => { // Refresh page logic }, }, })); // Disable individual options const isOptionDisabled = (option: BentoListboxOptionItem) => option.value === 3; const onUpdateModelValue = (newSelectedValue: BentoListboxSelectedValue) => { modelValue.value = newSelectedValue; }; </script> `), }; const TAG_LIST = Object.values(BentoTagVariant); const TAG_OPTIONS: BentoListboxOptions = new Array(TAG_LIST.length) .fill(0) .map((_, index) => ({ label: `Option ${index + 1}`, value: TAG_LIST[index] })); export const Slots: Story = { render: (_args, { argTypes }) => ({ components: { BentoDropdown, BentoTag }, props: Object.keys(argTypes), setup(props) { const selectedValue = ref(props.value); const onUpdateModelValue = (newSelectedValue: BentoListboxSelectedValue) => { selectedValue.value = newSelectedValue; action('update:model-value')(newSelectedValue); }; return { // Values args: isVue2 ? props : _args, selectedValue, // Methods onUpdateModelValue, showMoreOptions, }; }, template: ` <div style="max-width: 300px"> <bento-dropdown v-bind="args" :model-value="selectedValue" @update:model-value="onUpdateModelValue" v-slot="{label, value}" @show-more="showMoreOptions" > <bento-tag :variant="value" :label="label" /> </bento-dropdown> </div> `, }), args: { ...DEFAULT_PROPS, modelValue: TAG_OPTIONS[1].value, items: TAG_OPTIONS, }, parameters: storybookDocsParameter(DropdownWithCustomSlotsExample), }; export const WithDescription: Story = { ...Default, args: { ...DEFAULT_PROPS, modelValue: [1, 2, 3], multiple: true, items: DEFAULT_OPTIONS.map(item => ({ ...item, description: `Description ${item.value}`, })), }, parameters: storybookDocsParameter(DropdownWithDescriptionExample), }; export const NestedMultiSelect: Story = { render: (_args, { argTypes }) => ({ components: { BentoDropdown }, props: Object.keys(argTypes), setup(props) { const args = isVue2 ? props : _args; const selectedValue = ref(args.modelValue); const isOptionDisabled = (option: BentoListboxOptionItem) => [4, 5].includes(option.value as number); const onUpdateModelValue = (newSelectedValue: BentoListboxSelectedValue) => { selectedValue.value = newSelectedValue; action('update:model-value')(newSelectedValue); }; return { // Values args, selectedValue, // Methods isOptionDisabled, onUpdateModelValue, }; }, template: ` <div style="max-width: 300px"> <bento-dropdown v-bind="args" :model-value="selectedValue" @update:model-value="onUpdateModelValue" :isOptionDisabled="isOptionDisabled" /> </div> `, }), args: { ...DEFAULT_PROPS, description: 'Choose one or more options', modelValue: [2, 3, 100], multiple: true, items: [ { label: 'Option 1', description: 'Uncategorized', value: 1, }, { label: 'Category 1', description: 'C1', value: 'c1', items: [ { label: 'Option 2', description: 'C1-O2', value: 2, }, { label: 'Option 3', description: 'C1-O3', value: 3, }, ], }, { label: 'Category 2', description: 'C2', value: 'c2', items: [ { label: 'Option 4', description: 'C2-O4', value: 4, }, { label: 'Option 5', description: 'C2-O5', value: 5, }, ], }, ] as BentoListboxOptions, }, }; const externalFilteringTemplate = storybookDocsParameter(` <template> <bento-dropdown v-model="selectedValue" :items="internalList" :search="search" :component-loading="isSearching" dynamic-filtering multiple has-more-items lazy-load-type="automatic" label="Dropdown with external filtering" enable-value-label-pair /> </template> <script setup lang="ts"> import { ref, computed } from 'vue'; import { BentoDropdown, BentoListboxOptions, BentoListboxSelectedValueLabelPair, BentoDropdownSearchOptions, } from '@adyen/bento-vue2'; const initialItems: BentoListboxOptions = [ { label: 'Option 1', value: 1 }, { label: 'Option 2', value: 2 }, { label: 'Option 3', value: 3 }, { label: 'Option 4', value: 4 }, { label: 'Option 5', value: 5 }, ]; const selectedValue = ref<Array<BentoListboxSelectedValueLabelPair>>([]); const internalList = ref<BentoListboxOptions>(initialItems); const isSearching = ref(false); const searchForValue = (searchString: string) => { // This is where you would typically make an API call // to fetch data based on the searchString. // For this example, we'll simulate it. if (!searchString) { internalList.value = initialItems; return; } const newItems = new Array(5).fill(0).map((_, index) => ({ label: \`\${searchString} \${index + 1}\`, value: \`\${searchString}-\${index + 1}\`, })); internalList.value = newItems; }; const search = computed<BentoDropdownSearchOptions>(() => ({ debounceTime: 500, searchEvent: async (value: string) => { isSearching.value = true; // Simulate API call delay await new Promise(resolve => setTimeout(resolve, 1000)); searchForValue(value); isSearching.value = false; }, })); </script> `); export const externalFiltering: Story = { render: (_args, { argTypes }) => ({ components: { BentoDropdown }, props: Object.keys(argTypes), setup(props) { const args = isVue2 ? props : _args; const selectedValue = ref(args.modelValue); const internalList = ref<BentoListboxOptions>(args.items); const isSearching = ref(false); const searchForValue = (searchString: string) => { // This is where you would typically make an API call // to fetch data based on the searchString. // For this example, we'll simulate it. if (!searchString) { internalList.value = args.items; return; } const newItems = new Array(5).fill(0).map((_, index) => ({ label: `${searchString} ${index + 1}`, value: `${searchString}-${index + 1}`, })); internalList.value = newItems; }; const search = computed<BentoDropdownSearchOptions>(() => ({ debounceTime: args.search.debounceTime, searchEvent: async value => { isSearching.value = true; await new Promise(resolve => setTimeout(resolve, 1000)); searchForValue(value); action('searchEvent')(value); isSearching.value = false; }, })); const onUpdateModelValue = (newSelectedValue: BentoListboxSelectedValue) => { selectedValue.value = newSelectedValue; action('update:model-value')(newSelectedValue); }; return { args, selectedValue, internalList, isSearching, search, onUpdateModelValue, }; }, template: ` <div style="max-width: 300px"> <bento-dropdown v-bind="args" :model-value="selectedValue" :items="internalList" :search="search" :component-loading="isSearching" @update:model-value="onUpdateModelValue" /> </div> `, }), args: { ...DEFAULT_PROPS, items: DEFAULT_OPTIONS, label: 'Dropdown with external filtering', description: 'Type to simulate fetching new items.', dynamicFiltering: true, multiple: true, modelValue: [], search: { debounceTime: 500, }, }, parameters: externalFilteringTemplate, }; const lazyLoadingTemplate = storybookDocsParameter(` <template> <bento-dropdown v-model="selectedValue" :items="listboxItems" :loading="isLoading" :has-more-items="hasMoreItems" :lazy-load-type="lazyLoadType" label="Lazy Loading Dropdown" multiple enable-value-label-pair @show-more="onLazyLoad" /> </template> <script setup lang="ts"> import { ref, onMounted } from 'vue'; import { BentoDropdown, BentoListboxOptions, BentoListboxSelectedValueLabelPair, BentoDropdownLazyLoadType, } from '@adyen/bento-vue2'; // --- Mock API for demonstration --- const ALL_ITEMS = Array.from({ length: 100 }, (_, i) => ({ label: \`Item \${i + 1}\`, value: i + 1, })); const PAGE_SIZE = 20; const fetchItems = (page: number) => { console.log(\`Fetching page: \${page}\`); return new Promise<{ items: BentoListboxOptions; hasMore: boolean }>(resolve => { setTimeout(() => { const start = (page - 1) * PAGE_SIZE; const end = start + PAGE_SIZE; const pageItems = ALL_ITEMS.slice(start, end); resolve({ items: pageItems, hasMore: end < ALL_ITEMS.length, }); }, 500); }); }; // --- End Mock API --- const selectedValue = ref<Array<BentoListboxSelectedValueLabelPair>>([]); const listboxItems = ref<BentoListboxOptions>([]); const isLoading = ref(false); const hasMoreItems = ref(true); const currentPage = ref(1); const lazyLoadType = ref<BentoDropdownLazyLoadType>('automatic'); // or 'button' const onLazyLoad = async () => { if (isLoading.value || !hasMoreItems.value) { return; } currentPage.value++; isLoading.value = true; const { items, hasMore } = await fetchItems(currentPage.value); // Append new items to the existing list listboxItems.value = [...listboxItems.value, ...items]; hasMoreItems.value = hasMore; isLoading.value = false; }; // Load initial data when the component mounts onMounted(async () => { isLoading.value = true; const { items, hasMore } = await fetchItems(currentPage.value); listboxItems.value = items; hasMoreItems.value = hasMore; isLoading.value = false; }); </script> `); export const LazyLoading: Story = { render: (_args, { argTypes }) => ({ components: { BentoDropdown }, props: Object.keys(argTypes), setup(props) { const args = isVue2 ? props : _args; const selectedValue = ref<BentoListboxSelectedValue>([]); const listboxItems = ref<BentoListboxOptions>([]); const isLoading = ref(false); const hasMoreItems = ref(true); const currentPage = ref(1); const allItems = Array.from({ length: 100 }, (_, i) => ({ label: `Item ${i + 1}`, value: i + 1, })); const pageSize = 20; const fetchItems = (page: number) => { return new Promise<{ items: BentoListboxOptions; hasMore: boolean }>(resolve => { setTimeout(() => { const start = (page - 1) * pageSize; const end = start + pageSize; const pageItems = allItems.slice(start, end); resolve({ items: pageItems, hasMore: end < allItems.length, }); }, 500); }); }; const onLazyLoad = async () => { if (isLoading.value || !hasMoreItems.value) { return; } currentPage.value++; isLoading.value = true; const { items, hasMore } = await fetchItems(currentPage.value); listboxItems.value = [...listboxItems.value, ...items]; hasMoreItems.value = hasMore; isLoading.value = false; action('show-more')(); }; const onUpdateModelValue = (newSelectedValue: BentoListboxSelectedValue) => { selectedValue.value = newSelectedValue; action('update:model-value')(newSelectedValue); }; const loadInitial = async () => { currentPage.value = 1; isLoading.value = true; const { items, hasMore } = await fetchItems(currentPage.value); listboxItems.value = items; hasMoreItems.value = hasMore; isLoading.value = false; }; watch(() => args.lazyLoadType, loadInitial, { immediate: true }); return { args, selectedValue, listboxItems, isLoading, hasMoreItems, onLazyLoad, onUpdateModelValue, }; }, template: ` <div style="max-width: 300px"> <bento-dropdown v-bind="args" :items="listboxItems" :model-value="selectedValue" :loading="isLoading" :has-more-items="hasMoreItems" @update:model-value="onUpdateModelValue" @show-more="onLazyLoad" /> </div> `, }), args: { ...DEFAULT_PROPS, label: 'Lazy Loading', description: 'Scroll down or click the button to load more items.', multiple: true, lazyLoadType: 'automatic', }, parameters: lazyLoadingTemplate, }; const externalFilteringAndLazyLoadingTemplate = storybookDocsParameter(` <template> <bento-dropdown v-model="selectedValue" :items="listboxItems" :component-loading="isComponentLoading" :loading="isLazyLoading" :has-more-items="hasMoreItems" :search="search" :lazy-load-type="lazyLoadType" label="External Filtering & Lazy Loading" dynamic-filtering multiple enable-value-label-pair @show-more="onLazyLoad" /> </template> <script setup lang="ts"> import { ref, computed, onMounted } from 'vue'; import { BentoDropdown, BentoListboxOptions, BentoListboxSelectedValueLabelPair, BentoDropdownSearchOptions, BentoDropdownLazyLoadType, } from '@adyen/bento-vue2'; // --- Mock API for demonstration --- const ALL_ITEMS = Array.from({ length: 1000 }, (_, i) => ({ label: \`Item \${i + 1}\`, value: i + 1, })); const PAGE_SIZE = 20; const fetchItems = (searchTerm: string, page: number) => { console.log(\`Fetching items with searchTerm: "\${searchTerm}", page: \${page}\`); return new Promise<{ items: BentoListboxOptions; hasMore: boolean }>(resolve => { setTimeout(() => { const filtered = ALL_ITEMS.filter(item => item.label.toLowerCase().includes(searchTerm.toLowerCase()) ); const start = (page - 1) * PAGE_SIZE; const end = start + PAGE_SIZE; const pageItems = filtered.slice(start, end); resolve({ items: pageItems, hasMore: end < filtered.length, }); }, 500); }); }; // --- End Mock API --- const selectedValue = ref<Array<BentoListboxSelectedValueLabelPair>>([]); const listboxItems = ref<BentoListboxOptions>([]); const isComponentLoading = ref(false); const isLazyLoading = ref(false); const hasMoreItems = ref(true); const currentPage = ref(1); const currentSearchTerm = ref(''); const lazyLoadType = ref<BentoDropdownLazyLoadType>('automatic'); const onSearch = async (searchTerm: string) => { currentSearchTerm.value = searchTerm; currentPage.value = 1; isComponentLoading.value = true; const { items, hasMore } = await fetchItems(searchTerm, currentPage.value); listboxItems.value = items; hasMoreItems.value = hasMore; isComponentLoading.value = false; }; const onLazyLoad = async () => { if (isLazyLoading.value || !hasMoreItems.value) { return; } currentPage.value++; isLazyLoading.value = true; const { items, hasMore } = await fetchItems(currentSearchTerm.value, currentPage.value); listboxItems.value = [...listboxItems.value, ...items]; hasMoreItems.value = hasMore; isLazyLoading.value = false; }; const search = computed<BentoDropdownSearchOptions>(() => ({ searchEvent: onSearch, })); onMounted(() => { onSearch(''); }); </script> `); export const ExternalFilteringAndLazyLoading: Story = { render: (_args, { argTypes }) => ({ components: { BentoDropdown }, props: Object.keys(argTypes), setup(props) { const args = isVue2 ? props : _args; const selectedValue = ref<BentoListboxSelectedValue>([{ label: 'Item 2000', value: 2000 }]); const listboxItems = ref<BentoListboxOptions>([]); const isComponentLoading = ref(false); const isLazyLoading = ref(false); const hasMoreItems = ref(true); const currentPage = ref(1); const currentSearchTerm = ref(''); const allItems = Array.from({ length: 1000 }, (_, i) => ({ label: `Item ${i + 1}`, value: i + 1, })); const pageSize = 20; const fetchItems = (searchTerm: string, page: number) => { return new Promise<{ items: BentoListboxOptions; hasMore: boolean }>(resolve => { setTimeout(() => { const filtered = allItems.filter(item => item.label.toLowerCase().includes(searchTerm.toLowerCase()) ); const start = (page - 1) * pageSize; const end = start + pageSize; const pageItems = filtered.slice(start, end); resolve({ items: pageItems, hasMore: end < filtered.length, }); }, 500); }); }; const onSearch = async (searchTerm: string) => { action('searchEvent')(searchTerm); currentSearchTerm.value = searchTerm; currentPage.value = 1; isComponentLoading.value = true; const { items, hasMore } = await fetchItems(searchTerm, currentPage.value); listboxItems.value = items; hasMoreItems.value = hasMore; isComponentLoading.value = false; }; const onLazyLoad = async () => { if (isLazyLoading.value || !hasMoreItems.value) { return; } currentPage.value++; isLazyLoading.value = true; const { items, hasMore } = await fetchItems(currentSearchTerm.value, currentPage.value); listboxItems.value = [...listboxItems.value, ...items]; hasMoreItems.value = hasMore; isLazyLoading.value = false; action('show-more')(); }; const onUpdateModelValue = (newSelectedValue: BentoListboxSelectedValue) => { selectedValue.value = newSelectedValue; action('update:model-value')(newSelectedValue); }; const search = computed<BentoDropdownSearchOptions>(() => ({ searchEvent: onSearch, debounceTime: args.search?.debounceTime, })); onMounted(() => { onSearch(''); }); return { args, selectedValue, listboxItems, isComponentLoading, isLazyLoading, hasMoreItems, search, onLazyLoad, onUpdateModelValue, }; }, template: ` <div style="max-width: 300px"> <bento-dropdown v-bind="args" :items="listboxItems" :model-value="selectedValue" :component-loading="isComponentLoading" :loading="isLazyLoading" :has-more-items="hasMoreItems" :search="search" @update:model-value="onUpdateModelValue" @show-more="onLazyLoad" /> </div> `, }), args: { ...DEFAULT_PROPS, label: 'External Filtering & Lazy Loading', description: 'Search for items and scroll to load more results.', dynamicFiltering: true, multiple: true, lazyLoadType: 'automatic', search: { debounceTime: 500, }, }, parameters: externalFilteringAndLazyLoadingTemplate, }; export const StaticCategories: Story = { render: (_args, { argTypes }) => ({ components: { BentoDropdown }, props: Object.keys(argTypes), setup(props) { const selectedValue = ref(props.value); const onUpdateModelValue = (newSelectedValue: BentoListboxSelectedValue) => { selectedValue.value = newSelectedValue; action('update:model-value')(newSelectedValue); }; return { // Values selectedValue, args: isVue2 ? props : _args, // Methods onUpdateModelValue, }; }, template: ` <div style="max-width: 300px"> <bento-dropdown v-bind="args" :model-value="selectedValue" @update:model-value="onUpdateModelValue" /> </div> `, }), args: { ...DEFAULT_PROPS, description: 'Select an option from a category', modelValue: '', multiple: false, staticCategories: true, items: [ { label: 'Carnivores', value: 'carnivores', items: [ { label: 'T-Rex', value: 'trex', }, { label: 'Velociraptor', value: 'velociraptor', }, ], }, { label: 'Herbivores', value: 'herbivores', items: [ { label: 'Iguanodon', value: 'iguanodon', }, { label: 'Barney', value: 'barney', }, ], }, { label: 'Aquatic', value: 'aquatic', items: [ { label: 'Mosasaurus', value: 'mosasaurus', }, { label: 'Nessie', value: 'nessie', }, ], }, ] as BentoListboxOptions, }, }; export const VirtualScrolling: Story = { ...Default, args: { ...Default.args, items: new Array(1000).fill(0).map((_, index) => ({ label: `Option ${index + 1}`, value: index + 1 })), virtualScroll: true, }, }; export const CustomDisplayValue: Story = { render: (_args, { argTypes }) => ({ components: { BentoDropdown }, props: Object.keys(argTypes), setup(props) { const args = isVue2 ? props : _args; const selectedValue = ref(args.modelValue); const onUpdateModelValue = (newSelectedValue: BentoListboxSelectedValue) => { selectedValue.value = newSelectedValue; action('update:model-value')(newSelectedValue); }; return { // Values args, selectedValue, // Methods onUpdateModelValue, }; }, template: ` <div style="max-width: 300px"> <bento-dropdown v-bind="args" :model-value="selectedValue" @update:model-value="onUpdateModelValue" /> </div> `, }), args: { ...DEFAULT_PROPS, modelValue: COUNTRY_OPTIONS[0].value, items: COUNTRY_OPTIONS, }, parameters: storybookDocsParameter(` <bento-dropdown :items="[ {label: "Netherlands +31", displayValue: "NL +31", value: "NL"}, {label: "Belgium +32", displayValue: "BE +32", value: "BE"}, {label: "Germany +49", displayValue: "DE +49", value: "DE"}, ]" :model-value="selectedValue" @update:model-value="onUpdateModelValue" /> `), };
1
+ import { action } from '@storybook/addon-actions'; import { computed, onMounted, ref, watch } from 'vue'; import { isVue2 } from 'vue-demi'; import { type BentoListboxOptionItem, type BentoListboxOptions, type BentoListboxSelectedValue } from '@/types/listbox'; import { BentoDropdownEvent, BentoDropdownLazyLoadType, type BentoDropdownSearchOptions, } from '@/components/dropdown/dropdown.types'; import { BentoTag, BentoTagVariant } from '@/components/tag'; import { useCountryName, useCountryPhoneCode } from '@/components/country'; import { storybookDocsParameter } from '@/utils/ts/storybook'; import type { Meta, StoryObj } from '@storybook/vue'; import BentoDropdown from './dropdown.vue'; import DropdownWithDescriptionExample from './__tests__/dropdown-with-description-example.vue?raw'; import DropdownWithCustomSlotsExample from './__tests__/dropdown-with-custom-slots-example.vue?raw'; // Generate array from 1 to # // { label: 'Option #', value: # }, const DEFAULT_OPTIONS: BentoListboxOptions = new Array(10) .fill(0) .map((_, index) => ({ label: `Option ${index + 1}`, value: index + 1 })); const DEFAULT_DESCRIPTION = 'Choose an option'; const DEFAULT_LABEL = 'Label'; const DEFAULT_PLACEHOLDER = 'Select an option'; const DEFAULT_PROPS = { label: DEFAULT_LABEL, description: DEFAULT_DESCRIPTION, placeholder: DEFAULT_PLACEHOLDER, lazyLoadType: 'none', enableValueLabelPair: false, }; const COUNTRY_OPTIONS = ['NL', 'BE', 'DE'].map(code => ({ label: `${useCountryName(code, 'en-US').value} ${useCountryPhoneCode(code).value}`, value: code, displayValue: `${code} (${useCountryPhoneCode(code).value})`, })); const showMoreOptions = () => action(BentoDropdownEvent.SHOW_MORE)(); const meta: Meta = { title: 'Dropdown', component: BentoDropdown, argTypes: { 'show-more': { name: 'show-more', table: { type: { summary: '() => void' }, }, control: { disable: true }, }, size: { table: { disable: true } }, // Slots default: { description: 'Custom slots for the items and display value', table: { category: 'Slots', type: { summary: 'VNode[]' }, }, control: { type: 'text' }, }, description: { name: 'description', description: 'If set, displays a description text for the input.', table: { category: 'props', type: { summary: 'string' }, }, control: { type: 'text' }, }, 'slot:description': { name: 'description', defaultValue: '', description: 'If set, displays a description text for the input. Overrides the `description` prop when provided.', table: { category: 'Slots', type: { summary: 'VNode[]' }, }, control: { type: 'text' }, }, 'display-value': { description: 'Custom slot for the display value when the display value is different than the items', table: { category: 'Slots', type: { summary: 'VNode[]' }, }, control: { type: 'text' }, }, innerSlot: { // Hide non-existent slot inaccurately created by storybook table: { disable: true }, }, // Values lazyLoadType: { options: Object.values(BentoDropdownLazyLoadType), mapping: BentoDropdownLazyLoadType, control: { type: 'select', }, }, error: { control: { type: 'boolean' }, table: { category: 'Deprecated' }, }, input: { description: 'Deprecated since version 2.0. Use `v-model` or `update:model-value` instead', table: { category: 'Deprecated' }, control: { disable: true }, }, value: { control: { type: 'text' }, table: { category: 'Deprecated' }, description: 'Deprecated for usage with properties since version 2.0. Use `v-model` or `model-value` property instead', }, }, }; export default meta; type Story = StoryObj<typeof BentoDropdown>; const defaultDocs = ` <template> <bento-dropdown v-model="modelValue" aria-label="Descriptive definition of what the dropdown is for" description="Description of the field" :condensed="false" :disabled="false" :empty-state="emptyState" dynamic-filtering error-message="This is an error message" :multiple="false" :items="items" :is-option-disabled="isOptionDisabled" label="Dropdown input label" :optional="!isRequired" tooltip-text="Label tooltip text" placeholder="Dropdown placeholder text" :required="isRequired" enable-value-label-pair /> </template> <script setup lang="ts"> import { BentoDropdown, BentoDropdownEmptyStateProps, BentoListboxOptionItem, BentoListboxOptions, BentoListboxSelectedValue, } from '@adyen/bento-vue2'; import { computed, ref } from 'vue'; const modelValue = ref<BentoListboxSelectedValue>({ label: 'Option 1', value: 1 }); const items: BentoListboxOptions = new Array(10) .fill(0) .map((_, index) => ({ label: \`Option \${index + 1}\`, value: index + 1 })); // "required" and "optional" are mutually exclusive const isRequired = ref(false); const emptyState = computed<BentoDropdownEmptyStateProps>(() => ({ title: 'Unable to retrieve items', action: { title: 'Refresh the page to try again', event: () => { // Refresh page logic }, }, })); // Disable individual options const isOptionDisabled = (option: BentoListboxOptionItem) => option.value === 3; </script> `; export const Default: Story = { render: (_args, { argTypes }) => ({ components: { BentoDropdown }, props: Object.keys(argTypes), setup(props) { const args = isVue2 ? props : _args; const selectedValue = ref(args.modelValue); const isOptionDisabled = (option: BentoListboxOptionItem) => option.value === 3; const onUpdateModelValue = (newSelectedValue: BentoListboxSelectedValue) => { selectedValue.value = newSelectedValue; action('update:model-value')(newSelectedValue); }; return { // Values args, selectedValue, // Methods isOptionDisabled, onUpdateModelValue, showMoreOptions, }; }, template: ` <div style="max-width: 300px"> <bento-dropdown v-bind="args" :model-value="selectedValue" @update:model-value="onUpdateModelValue" :isOptionDisabled="isOptionDisabled" @show-more="showMoreOptions" > <template #description v-if="args['slot:description']">{{ args['slot:description'] }}</template> </bento-dropdown> </div> `, }), args: { ...DEFAULT_PROPS, modelValue: DEFAULT_OPTIONS[0].value, items: DEFAULT_OPTIONS, }, parameters: storybookDocsParameter(defaultDocs), }; export const MultiSelect: Story = { render: (_args, { argTypes }) => ({ components: { BentoDropdown }, props: Object.keys(argTypes), setup(props) { const args = isVue2 ? props : _args; const selectedValue = ref(args.modelValue); const isOptionDisabled = (option: BentoListboxOptionItem) => [3, 5, 7, 9].includes(option.value as number); const onUpdateModelValue = (newSelectedValue: BentoListboxSelectedValue) => { selectedValue.value = newSelectedValue; action('update:model-value')(newSelectedValue); }; return { // Values selectedValue, args, // Methods isOptionDisabled, onUpdateModelValue, showMoreOptions, }; }, template: ` <div style="max-width: 300px"> <bento-dropdown v-bind="args" :model-value="selectedValue" @update:model-value="onUpdateModelValue" :isOptionDisabled="isOptionDisabled" @show-more="showMoreOptions" > <template #description v-if="args['slot:description']">{{ args['slot:description'] }}</template> </bento-dropdown> </div> `, }), args: { ...DEFAULT_PROPS, description: 'Choose one or more options', modelValue: [1, 3, 100], multiple: true, items: DEFAULT_OPTIONS, }, parameters: storybookDocsParameter(` <template> <bento-dropdown v-model="modelValue" aria-label="Descriptive definition of what the dropdown is for" description="Description of the field" :condensed="false" :disabled="false" :empty-state="emptyState" dynamic-filtering error-message="This is an error message" :multiple="true" :items="items" :is-option-disabled="isOptionDisabled" label="Dropdown input label" :optional="!isRequired" tooltip-text="Label tooltip text" placeholder="Dropdown placeholder text" :required="isRequired" enable-value-label-pair @update:model-value="onUpdateModelValue" /> </template> <script setup lang="ts"> import { BentoDropdown, BentoDropdownEmptyStateProps, BentoListboxOptionItem, BentoListboxOptions, BentoListboxSelectedValue, } from '@adyen/bento-vue2'; import { computed, ref } from 'vue'; const modelValue = ref<BentoListboxSelectedValue>([ { label: 'Option 1', value: 1 } ]); const items: BentoListboxOptions = new Array(10) .fill(0) .map((_, index) => ({ label: \`Option \${index + 1}\`, value: index + 1 })); // "required" and "optional" are mutually exclusive const isRequired = ref(false); const emptyState = computed<BentoDropdownEmptyStateProps>(() => ({ title: 'Unable to retrieve items', action: { title: 'Refresh the page to try again', event: () => { // Refresh page logic }, }, })); // Disable individual options const isOptionDisabled = (option: BentoListboxOptionItem) => option.value === 3; const onUpdateModelValue = (newSelectedValue: BentoListboxSelectedValue) => { modelValue.value = newSelectedValue; }; </script> `), }; const TAG_LIST = Object.values(BentoTagVariant); const TAG_OPTIONS: BentoListboxOptions = new Array(TAG_LIST.length) .fill(0) .map((_, index) => ({ label: `Option ${index + 1}`, value: TAG_LIST[index] })); export const Slots: Story = { render: (_args, { argTypes }) => ({ components: { BentoDropdown, BentoTag }, props: Object.keys(argTypes), setup(props) { const selectedValue = ref(props.value); const onUpdateModelValue = (newSelectedValue: BentoListboxSelectedValue) => { selectedValue.value = newSelectedValue; action('update:model-value')(newSelectedValue); }; return { // Values args: isVue2 ? props : _args, selectedValue, // Methods onUpdateModelValue, showMoreOptions, }; }, template: ` <div style="max-width: 300px"> <bento-dropdown v-bind="args" :model-value="selectedValue" @update:model-value="onUpdateModelValue" @show-more="showMoreOptions" > <template #default="{ label, value }"> <bento-tag :variant="value" :label="label" /> </template> <template #description v-if="args['slot:description']">{{ args['slot:description'] }}</template> </bento-dropdown> </div> `, }), args: { ...DEFAULT_PROPS, modelValue: TAG_OPTIONS[1].value, items: TAG_OPTIONS, }, parameters: storybookDocsParameter(DropdownWithCustomSlotsExample), }; export const WithDescription: Story = { ...Default, args: { ...DEFAULT_PROPS, modelValue: [1, 2, 3], multiple: true, items: DEFAULT_OPTIONS.map(item => ({ ...item, description: `Description ${item.value}`, })), }, parameters: storybookDocsParameter(DropdownWithDescriptionExample), }; export const NestedMultiSelect: Story = { render: (_args, { argTypes }) => ({ components: { BentoDropdown }, props: Object.keys(argTypes), setup(props) { const args = isVue2 ? props : _args; const selectedValue = ref(args.modelValue); const isOptionDisabled = (option: BentoListboxOptionItem) => [4, 5].includes(option.value as number); const onUpdateModelValue = (newSelectedValue: BentoListboxSelectedValue) => { selectedValue.value = newSelectedValue; action('update:model-value')(newSelectedValue); }; return { // Values args, selectedValue, // Methods isOptionDisabled, onUpdateModelValue, }; }, template: ` <div style="max-width: 300px"> <bento-dropdown v-bind="args" :model-value="selectedValue" @update:model-value="onUpdateModelValue" :isOptionDisabled="isOptionDisabled" > <template #description v-if="args['slot:description']">{{ args['slot:description'] }}</template> </bento-dropdown> </div> `, }), args: { ...DEFAULT_PROPS, description: 'Choose one or more options', modelValue: [2, 3, 100], multiple: true, items: [ { label: 'Option 1', description: 'Uncategorized', value: 1, }, { label: 'Category 1', description: 'C1', value: 'c1', items: [ { label: 'Option 2', description: 'C1-O2', value: 2, }, { label: 'Option 3', description: 'C1-O3', value: 3, }, ], }, { label: 'Category 2', description: 'C2', value: 'c2', items: [ { label: 'Option 4', description: 'C2-O4', value: 4, }, { label: 'Option 5', description: 'C2-O5', value: 5, }, ], }, ] as BentoListboxOptions, }, }; const externalFilteringTemplate = storybookDocsParameter(` <template> <bento-dropdown v-model="selectedValue" :items="internalList" :search="search" :component-loading="isSearching" dynamic-filtering multiple has-more-items lazy-load-type="automatic" label="Dropdown with external filtering" enable-value-label-pair /> </template> <script setup lang="ts"> import { ref, computed } from 'vue'; import { BentoDropdown, BentoListboxOptions, BentoListboxSelectedValueLabelPair, BentoDropdownSearchOptions, } from '@adyen/bento-vue2'; const initialItems: BentoListboxOptions = [ { label: 'Option 1', value: 1 }, { label: 'Option 2', value: 2 }, { label: 'Option 3', value: 3 }, { label: 'Option 4', value: 4 }, { label: 'Option 5', value: 5 }, ]; const selectedValue = ref<Array<BentoListboxSelectedValueLabelPair>>([]); const internalList = ref<BentoListboxOptions>(initialItems); const isSearching = ref(false); const searchForValue = (searchString: string) => { // This is where you would typically make an API call // to fetch data based on the searchString. // For this example, we'll simulate it. if (!searchString) { internalList.value = initialItems; return; } const newItems = new Array(5).fill(0).map((_, index) => ({ label: \`\${searchString} \${index + 1}\`, value: \`\${searchString}-\${index + 1}\`, })); internalList.value = newItems; }; const search = computed<BentoDropdownSearchOptions>(() => ({ debounceTime: 500, searchEvent: async (value: string) => { isSearching.value = true; // Simulate API call delay await new Promise(resolve => setTimeout(resolve, 1000)); searchForValue(value); isSearching.value = false; }, })); </script> `); export const externalFiltering: Story = { render: (_args, { argTypes }) => ({ components: { BentoDropdown }, props: Object.keys(argTypes), setup(props) { const args = isVue2 ? props : _args; const selectedValue = ref(args.modelValue); const internalList = ref<BentoListboxOptions>(args.items); const isSearching = ref(false); const searchForValue = (searchString: string) => { // This is where you would typically make an API call // to fetch data based on the searchString. // For this example, we'll simulate it. if (!searchString) { internalList.value = args.items; return; } const newItems = new Array(5).fill(0).map((_, index) => ({ label: `${searchString} ${index + 1}`, value: `${searchString}-${index + 1}`, })); internalList.value = newItems; }; const search = computed<BentoDropdownSearchOptions>(() => ({ debounceTime: args.search.debounceTime, searchEvent: async value => { isSearching.value = true; await new Promise(resolve => setTimeout(resolve, 1000)); searchForValue(value); action('searchEvent')(value); isSearching.value = false; }, })); const onUpdateModelValue = (newSelectedValue: BentoListboxSelectedValue) => { selectedValue.value = newSelectedValue; action('update:model-value')(newSelectedValue); }; return { args, selectedValue, internalList, isSearching, search, onUpdateModelValue, }; }, template: ` <div style="max-width: 300px"> <bento-dropdown v-bind="args" :model-value="selectedValue" :items="internalList" :search="search" :component-loading="isSearching" @update:model-value="onUpdateModelValue" > <template #description v-if="args['slot:description']">{{ args['slot:description'] }}</template> </bento-dropdown> </div> `, }), args: { ...DEFAULT_PROPS, items: DEFAULT_OPTIONS, label: 'Dropdown with external filtering', description: 'Type to simulate fetching new items.', dynamicFiltering: true, multiple: true, modelValue: [], search: { debounceTime: 500, }, }, parameters: externalFilteringTemplate, }; const lazyLoadingTemplate = storybookDocsParameter(` <template> <bento-dropdown v-model="selectedValue" :items="listboxItems" :loading="isLoading" :has-more-items="hasMoreItems" :lazy-load-type="lazyLoadType" label="Lazy Loading Dropdown" multiple enable-value-label-pair @show-more="onLazyLoad" /> </template> <script setup lang="ts"> import { ref, onMounted } from 'vue'; import { BentoDropdown, BentoListboxOptions, BentoListboxSelectedValueLabelPair, BentoDropdownLazyLoadType, } from '@adyen/bento-vue2'; // --- Mock API for demonstration --- const ALL_ITEMS = Array.from({ length: 100 }, (_, i) => ({ label: \`Item \${i + 1}\`, value: i + 1, })); const PAGE_SIZE = 20; const fetchItems = (page: number) => { console.log(\`Fetching page: \${page}\`); return new Promise<{ items: BentoListboxOptions; hasMore: boolean }>(resolve => { setTimeout(() => { const start = (page - 1) * PAGE_SIZE; const end = start + PAGE_SIZE; const pageItems = ALL_ITEMS.slice(start, end); resolve({ items: pageItems, hasMore: end < ALL_ITEMS.length, }); }, 500); }); }; // --- End Mock API --- const selectedValue = ref<Array<BentoListboxSelectedValueLabelPair>>([]); const listboxItems = ref<BentoListboxOptions>([]); const isLoading = ref(false); const hasMoreItems = ref(true); const currentPage = ref(1); const lazyLoadType = ref<BentoDropdownLazyLoadType>('automatic'); // or 'button' const onLazyLoad = async () => { if (isLoading.value || !hasMoreItems.value) { return; } currentPage.value++; isLoading.value = true; const { items, hasMore } = await fetchItems(currentPage.value); // Append new items to the existing list listboxItems.value = [...listboxItems.value, ...items]; hasMoreItems.value = hasMore; isLoading.value = false; }; // Load initial data when the component mounts onMounted(async () => { isLoading.value = true; const { items, hasMore } = await fetchItems(currentPage.value); listboxItems.value = items; hasMoreItems.value = hasMore; isLoading.value = false; }); </script> `); export const LazyLoading: Story = { render: (_args, { argTypes }) => ({ components: { BentoDropdown }, props: Object.keys(argTypes), setup(props) { const args = isVue2 ? props : _args; const selectedValue = ref<BentoListboxSelectedValue>([]); const listboxItems = ref<BentoListboxOptions>([]); const isLoading = ref(false); const hasMoreItems = ref(true); const currentPage = ref(1); const allItems = Array.from({ length: 100 }, (_, i) => ({ label: `Item ${i + 1}`, value: i + 1, })); const pageSize = 20; const fetchItems = (page: number) => { return new Promise<{ items: BentoListboxOptions; hasMore: boolean }>(resolve => { setTimeout(() => { const start = (page - 1) * pageSize; const end = start + pageSize; const pageItems = allItems.slice(start, end); resolve({ items: pageItems, hasMore: end < allItems.length, }); }, 500); }); }; const onLazyLoad = async () => { if (isLoading.value || !hasMoreItems.value) { return; } currentPage.value++; isLoading.value = true; const { items, hasMore } = await fetchItems(currentPage.value); listboxItems.value = [...listboxItems.value, ...items]; hasMoreItems.value = hasMore; isLoading.value = false; action('show-more')(); }; const onUpdateModelValue = (newSelectedValue: BentoListboxSelectedValue) => { selectedValue.value = newSelectedValue; action('update:model-value')(newSelectedValue); }; const loadInitial = async () => { currentPage.value = 1; isLoading.value = true; const { items, hasMore } = await fetchItems(currentPage.value); listboxItems.value = items; hasMoreItems.value = hasMore; isLoading.value = false; }; watch(() => args.lazyLoadType, loadInitial, { immediate: true }); return { args, selectedValue, listboxItems, isLoading, hasMoreItems, onLazyLoad, onUpdateModelValue, }; }, template: ` <div style="max-width: 300px"> <bento-dropdown v-bind="args" :items="listboxItems" :model-value="selectedValue" :loading="isLoading" :has-more-items="hasMoreItems" @update:model-value="onUpdateModelValue" @show-more="onLazyLoad" > <template #description v-if="args['slot:description']">{{ args['slot:description'] }}</template> </bento-dropdown> </div> `, }), args: { ...DEFAULT_PROPS, label: 'Lazy Loading', description: 'Scroll down or click the button to load more items.', multiple: true, lazyLoadType: 'automatic', }, parameters: lazyLoadingTemplate, }; const externalFilteringAndLazyLoadingTemplate = storybookDocsParameter(` <template> <bento-dropdown v-model="selectedValue" :items="listboxItems" :component-loading="isComponentLoading" :loading="isLazyLoading" :has-more-items="hasMoreItems" :search="search" :lazy-load-type="lazyLoadType" label="External Filtering & Lazy Loading" dynamic-filtering multiple enable-value-label-pair @show-more="onLazyLoad" /> </template> <script setup lang="ts"> import { ref, computed, onMounted } from 'vue'; import { BentoDropdown, BentoListboxOptions, BentoListboxSelectedValueLabelPair, BentoDropdownSearchOptions, BentoDropdownLazyLoadType, } from '@adyen/bento-vue2'; // --- Mock API for demonstration --- const ALL_ITEMS = Array.from({ length: 1000 }, (_, i) => ({ label: \`Item \${i + 1}\`, value: i + 1, })); const PAGE_SIZE = 20; const fetchItems = (searchTerm: string, page: number) => { console.log(\`Fetching items with searchTerm: "\${searchTerm}", page: \${page}\`); return new Promise<{ items: BentoListboxOptions; hasMore: boolean }>(resolve => { setTimeout(() => { const filtered = ALL_ITEMS.filter(item => item.label.toLowerCase().includes(searchTerm.toLowerCase()) ); const start = (page - 1) * PAGE_SIZE; const end = start + PAGE_SIZE; const pageItems = filtered.slice(start, end); resolve({ items: pageItems, hasMore: end < filtered.length, }); }, 500); }); }; // --- End Mock API --- const selectedValue = ref<Array<BentoListboxSelectedValueLabelPair>>([]); const listboxItems = ref<BentoListboxOptions>([]); const isComponentLoading = ref(false); const isLazyLoading = ref(false); const hasMoreItems = ref(true); const currentPage = ref(1); const currentSearchTerm = ref(''); const lazyLoadType = ref<BentoDropdownLazyLoadType>('automatic'); const onSearch = async (searchTerm: string) => { currentSearchTerm.value = searchTerm; currentPage.value = 1; isComponentLoading.value = true; const { items, hasMore } = await fetchItems(searchTerm, currentPage.value); listboxItems.value = items; hasMoreItems.value = hasMore; isComponentLoading.value = false; }; const onLazyLoad = async () => { if (isLazyLoading.value || !hasMoreItems.value) { return; } currentPage.value++; isLazyLoading.value = true; const { items, hasMore } = await fetchItems(currentSearchTerm.value, currentPage.value); listboxItems.value = [...listboxItems.value, ...items]; hasMoreItems.value = hasMore; isLazyLoading.value = false; }; const search = computed<BentoDropdownSearchOptions>(() => ({ searchEvent: onSearch, })); onMounted(() => { onSearch(''); }); </script> `); export const ExternalFilteringAndLazyLoading: Story = { render: (_args, { argTypes }) => ({ components: { BentoDropdown }, props: Object.keys(argTypes), setup(props) { const args = isVue2 ? props : _args; const selectedValue = ref<BentoListboxSelectedValue>([{ label: 'Item 2000', value: 2000 }]); const listboxItems = ref<BentoListboxOptions>([]); const isComponentLoading = ref(false); const isLazyLoading = ref(false); const hasMoreItems = ref(true); const currentPage = ref(1); const currentSearchTerm = ref(''); const allItems = Array.from({ length: 1000 }, (_, i) => ({ label: `Item ${i + 1}`, value: i + 1, })); const pageSize = 20; const fetchItems = (searchTerm: string, page: number) => { return new Promise<{ items: BentoListboxOptions; hasMore: boolean }>(resolve => { setTimeout(() => { const filtered = allItems.filter(item => item.label.toLowerCase().includes(searchTerm.toLowerCase()) ); const start = (page - 1) * pageSize; const end = start + pageSize; const pageItems = filtered.slice(start, end); resolve({ items: pageItems, hasMore: end < filtered.length, }); }, 500); }); }; const onSearch = async (searchTerm: string) => { action('searchEvent')(searchTerm); currentSearchTerm.value = searchTerm; currentPage.value = 1; isComponentLoading.value = true; const { items, hasMore } = await fetchItems(searchTerm, currentPage.value); listboxItems.value = items; hasMoreItems.value = hasMore; isComponentLoading.value = false; }; const onLazyLoad = async () => { if (isLazyLoading.value || !hasMoreItems.value) { return; } currentPage.value++; isLazyLoading.value = true; const { items, hasMore } = await fetchItems(currentSearchTerm.value, currentPage.value); listboxItems.value = [...listboxItems.value, ...items]; hasMoreItems.value = hasMore; isLazyLoading.value = false; action('show-more')(); }; const onUpdateModelValue = (newSelectedValue: BentoListboxSelectedValue) => { selectedValue.value = newSelectedValue; action('update:model-value')(newSelectedValue); }; const search = computed<BentoDropdownSearchOptions>(() => ({ searchEvent: onSearch, debounceTime: args.search?.debounceTime, })); onMounted(() => { onSearch(''); }); return { args, selectedValue, listboxItems, isComponentLoading, isLazyLoading, hasMoreItems, search, onLazyLoad, onUpdateModelValue, }; }, template: ` <div style="max-width: 300px"> <bento-dropdown v-bind="args" :items="listboxItems" :model-value="selectedValue" :component-loading="isComponentLoading" :loading="isLazyLoading" :has-more-items="hasMoreItems" :search="search" @update:model-value="onUpdateModelValue" @show-more="onLazyLoad" > <template #description v-if="args['slot:description']">{{ args['slot:description'] }}</template> </bento-dropdown> </div> `, }), args: { ...DEFAULT_PROPS, label: 'External Filtering & Lazy Loading', description: 'Search for items and scroll to load more results.', dynamicFiltering: true, multiple: true, lazyLoadType: 'automatic', search: { debounceTime: 500, }, }, parameters: externalFilteringAndLazyLoadingTemplate, }; export const StaticCategories: Story = { render: (_args, { argTypes }) => ({ components: { BentoDropdown }, props: Object.keys(argTypes), setup(props) { const selectedValue = ref(props.value); const onUpdateModelValue = (newSelectedValue: BentoListboxSelectedValue) => { selectedValue.value = newSelectedValue; action('update:model-value')(newSelectedValue); }; return { // Values selectedValue, args: isVue2 ? props : _args, // Methods onUpdateModelValue, }; }, template: ` <div style="max-width: 300px"> <bento-dropdown v-bind="args" :model-value="selectedValue" @update:model-value="onUpdateModelValue" > <template #description v-if="args['slot:description']">{{ args['slot:description'] }}</template> </bento-dropdown> </div> `, }), args: { ...DEFAULT_PROPS, description: 'Select an option from a category', modelValue: '', multiple: false, staticCategories: true, items: [ { label: 'Carnivores', value: 'carnivores', items: [ { label: 'T-Rex', value: 'trex', }, { label: 'Velociraptor', value: 'velociraptor', }, ], }, { label: 'Herbivores', value: 'herbivores', items: [ { label: 'Iguanodon', value: 'iguanodon', }, { label: 'Barney', value: 'barney', }, ], }, { label: 'Aquatic', value: 'aquatic', items: [ { label: 'Mosasaurus', value: 'mosasaurus', }, { label: 'Nessie', value: 'nessie', }, ], }, ] as BentoListboxOptions, }, }; export const VirtualScrolling: Story = { ...Default, args: { ...Default.args, items: new Array(1000).fill(0).map((_, index) => ({ label: `Option ${index + 1}`, value: index + 1 })), virtualScroll: true, }, }; export const CustomDisplayValue: Story = { render: (_args, { argTypes }) => ({ components: { BentoDropdown }, props: Object.keys(argTypes), setup(props) { const args = isVue2 ? props : _args; const selectedValue = ref(args.modelValue); const onUpdateModelValue = (newSelectedValue: BentoListboxSelectedValue) => { selectedValue.value = newSelectedValue; action('update:model-value')(newSelectedValue); }; return { // Values args, selectedValue, // Methods onUpdateModelValue, }; }, template: ` <div style="max-width: 300px"> <bento-dropdown v-bind="args" :model-value="selectedValue" @update:model-value="onUpdateModelValue" > <template #description v-if="args['slot:description']">{{ args['slot:description'] }}</template> </bento-dropdown> </div> `, }), args: { ...DEFAULT_PROPS, modelValue: COUNTRY_OPTIONS[0].value, items: COUNTRY_OPTIONS, }, parameters: storybookDocsParameter(` <bento-dropdown :items="[ {label: "Netherlands +31", displayValue: "NL +31", value: "NL"}, {label: "Belgium +32", displayValue: "BE +32", value: "BE"}, {label: "Germany +49", displayValue: "DE +49", value: "DE"}, ]" :model-value="selectedValue" @update:model-value="onUpdateModelValue" /> `), };
@@ -1 +1 @@
1
- <!-- TODO: Remove aria props as these are part of JSX elements /vue/types/jsx.d.ts --> <!-- eslint-disable vue/attribute-hyphenation --> <template> <div v-bento-click-outside-directive="clickOutsideDropdown" class="b-dropdown" :class="conditionalClasses"> <field-label v-if="label" :id="labelId" :condensed="condensed" :label="label" :tooltip-text="tooltipText" :optional="optional" :required="required" /> <div ref="inputContainerRefWrapper" v-bento-keyboard-navigation-directive v-on="textboxKeyboardNavigationListeners" > <component :is="computedSize" ref="inputContainerRef" :condensed="condensed" :value="searchTerm" :additional-items-selected="additionalItemsSelected" :aria-controls="dropdownOptionsContainerId" :ariaExpanded="isDropdownOpen" :aria-labelledby="label ? labelId : null" :aria-describedby="ariaDescribedBy" :aria-label="computedAriaLabel" :aria-readonly="isReadOnly" :aria-required="required" :readonly="isReadOnly" :disabled="disabled" :display-value="displayValue" :dynamic-filtering="dynamicFiltering" :is-invalid="!!errorMessage || error" :multiple="multiple" :open="isDropdownOpen" :selected-value-item="!isInputComponent ? selectedValueItem : undefined" :show-slot-content-in-multiple="showSlotContentInMultiple" @input="searchTerm = $event" @open="toggleDropdown" @close="toggleDropdown" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </component> </div> <bento-dropdown-options-container v-if="inputContainerRef" :id="dropdownOptionsContainerId" ref="optionsContainerRef" :aria-label="computedAriaLabel" :component-loading="componentLoading" :empty-state="emptyState" :disabled="disabled" :is-option-disabled="isOptionDisabled" :items="filteredItems" :multiple="multiple" :static-categories="staticCategories" :open="isDropdownOpen" :selected-value="cachedSelectedListboxOptions" :searching="isSearching" :target-element="inputContainerRefWrapper" :loading="loading" :has-more-items="hasMoreItems" :lazy-load-type="lazyLoadType" :virtual-scroll="virtualScroll" v-on="listboxListeners" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </bento-dropdown-options-container> <error-message v-if="!!errorMessage" :id="errorId" :error-message="errorMessage" class="b-dropdown__error-message" /> <bento-typography v-if="description || hasSlot('description')" :id="descriptionId" class="b-dropdown__description" :class="descriptionConditionalClasses" el="span" > <slot name="description"> {{ description }} </slot> </bento-typography> </div> </template> <script setup lang="ts"> import { computed, defineComponent, inject, nextTick, type Ref, ref, toRef, toRefs, useAttrs, useSlots, watch, } from 'vue'; // Components import { BentoDropdownOptionsContainer } from './components/dropdown-options-container'; import { BentoTypography } from '@/components/typography'; import { DropdownInputDefault, DropdownInputSmall } from './components'; import { BentoListbox, ErrorMessage, FieldLabel, useCachedSelectedValues, useMultiLevelItems } from '@/internal'; // Utils import { deprecate } from '@/utils/ts/deprecate'; import { generateUid } from '@/core/utils/ts'; import { useAriaLabel } from '@/utils/ts/aria-label'; import { useI18n } from '@/utils/ts/i18n'; import { useFormFieldEmits } from '@/utils/ts/form-field/form-field'; // Composables import { useDisplayValue, useListboxKeyboardNavigation, useTextboxKeyboardNavigation } from './composables'; import { useVisibleDomOptions } from '@/internal/listbox/composables/use-visible-dom-options/use-visible-dom-options'; import { useFormLayoutFieldLoading } from '@/composables/use-form-layout-loading/use-form-layout-loading'; import { useSearchBarFilter } from '@/components/search-bar/useSearchBarDataFilter'; // Directives import { BentoClickOutsideDirective as vBentoClickOutsideDirective } from '@/directives/click-outside'; import { BentoKeyboardNavigationDirective as vBentoKeyboardNavigationDirective } from '@/directives'; // Types import { BentoDropdownEvent, type BentoDropdownProps, BentoDropdownSize } from './dropdown.types'; import { BentoListboxEvent, type BentoListboxItemRole, type BentoListboxMultiSelectValue, type BentoListboxOptions, type BentoListboxSelectedValue, type BentoListboxSelectedValueLabelPair, } from '@/types/listbox'; import messages from './messages.json'; import { useHasSlot } from '@/composables'; import { INPUT_FIELD_COMPONENT_INJECTION_KEY } from '@/components/input-field/input-field.keys'; type MessageSchema = (typeof messages)['en-US']; const attrs = useAttrs(); const slots = useSlots(); const hasSlot = useHasSlot(slots); const props = withDefaults(defineProps<BentoDropdownProps>(), { ariaLabel: null, condensed: false, emptyState: undefined, disabled: false, dynamicFiltering: false, error: false, errorMessage: null, description: null, hasMoreItems: false, isOptionDisabled: undefined, items: () => [], label: null, loading: false, componentLoading: false, lazyLoadType: BentoListbox.props.lazyLoadType.default, multiple: false, optional: false, placeholder: null, readonly: false, required: false, search: undefined, showSlotContentInMultiple: false, size: null, staticCategories: false, tooltipText: null, value: null, modelValue: null, virtualScroll: false, enableValueLabelPair: false, }); const emit = defineEmits<{ /** * Emitted when an option is clicked. Updates the selected option from the "value" property linked to the v-model. * @deprecated since version 2.0. Use `v-model` or `update:model-value instead. */ (e: 'input', selectedValue: BentoListboxSelectedValue | any); // eslint-disable-line @typescript-eslint/no-explicit-any /** * Emitted when an option is clicked. Updates the selected option from the "modelValue" property linked to the v-model. */ (e: 'update:model-value', selectedValue: BentoListboxSelectedValue | any); // eslint-disable-line @typescript-eslint/no-explicit-any /** * Emitted when: * - `lazyLoad` is `automatic` and the scroll meets the end of the list * - `lazyload` is `button` and the button is clicked */ (e: 'show-more'); /** * Emitted when the dropdown is opened. */ (e: 'open'); }>(); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const { emitValue } = useFormFieldEmits<BentoListboxSelectedValue>(emit); const inputContainerRef = ref(null); const inputContainerRefWrapper = ref(null); const optionsContainerRef: Ref<InstanceType<typeof BentoDropdownOptionsContainer>> = ref(null); const isDropdownOpen = ref(false); const dropdownOptionsContainerId = generateUid('bento-dropdown-options-container'); const labelId = generateUid('dropdown-label'); const descriptionId = generateUid('dropdown-description'); const errorId = generateUid('dropdown-error'); const { isReadOnly } = useFormLayoutFieldLoading(toRef(props, 'readonly')); const dropdownValue = computed(() => props.modelValue ?? props.value); const { cachedSelectedListboxOptions, cachedListboxSelectedValues, setCachedValues } = useCachedSelectedValues( toRef(props, 'items'), dropdownValue, toRef(props, 'multiple'), toRef(props, 'enableValueLabelPair') ); const ariaLabelAttribute = ref(attrs['aria-label']); const ariaDescribedByAttribute = ref(attrs['aria-describedby']); const { ariaLabel, label } = toRefs(props); // vue-i18n issue with t() used in script - direct usage in template works // requires either createI18n({ legacy: false }) or to be wrapped in computed() const computedAriaLabelFallbackMessage = computed(() => t('ariaLabelFallback')); const computedAriaLabel = useAriaLabel({ // TODO: take ariaLabel ref away when aria-label prop is removed ariaLabel: ariaLabel.value || (ariaLabelAttribute as Ref<string>), label, defaultFallback: computedAriaLabelFallbackMessage, }); // Inject input field component key to change the dropdown size const isInputComponent = props.size === BentoDropdownSize.SMALL || inject(INPUT_FIELD_COMPONENT_INJECTION_KEY, false); const computedSize = computed(() => (isInputComponent ? BentoDropdownSize.SMALL : BentoDropdownSize.DEFAULT)); const conditionalClasses = computed(() => ({ 'b-dropdown--condensed': props.condensed, })); // Description styles const descriptionConditionalClasses = computed(() => ({ 'b-dropdown__description--error': !!props.errorMessage, })); // Filtering const searchTerm = ref(''); const items = toRef(props, 'items'); const staticCategories = toRef(props, 'staticCategories'); const filteredItems = useSearchBarFilter( searchTerm, items, props?.search?.searchEvent, staticCategories, props?.search?.debounceTime ); const isSearching = computed(() => props.dynamicFiltering && !!searchTerm.value); const displayValue = useDisplayValue({ items, placeholder: toRef(props, 'placeholder'), isMultiSelect: toRef(props, 'multiple'), selectedValues: cachedSelectedListboxOptions, }); const selectedValueItem = computed(() => { if (props?.multiple && Array.isArray(dropdownValue.value)) { return props.items.filter( ({ value }) => props?.value && (props?.value as BentoListboxMultiSelectValue).includes(value) ); } if (!props.multiple && props.staticCategories) { // Find the category that holds the selected value const category = props.items.find(categoryItem => categoryItem.items.some(item => item.value === dropdownValue.value) ); // Find the specific item within the category const selectedCategoryItem = category ? category.items.find(item => item.value === dropdownValue.value) : null; return selectedCategoryItem; } return props.items.find(({ value }) => value === dropdownValue.value); }); const selectedValueIndex = computed(() => { const index = filteredItems.value.findIndex(({ value }) => { return props.enableValueLabelPair ? (dropdownValue.value as BentoListboxSelectedValueLabelPair).value === value : dropdownValue.value === value; }); return index > -1 ? index : 0; }); const { isMultiLevelSelect, flattenedItemsList } = useMultiLevelItems(items); const isTreePattern = computed(() => isMultiLevelSelect.value && !props.staticCategories); const computedOptionRole = computed<BentoListboxItemRole>(() => (isTreePattern.value ? 'treeitem' : 'option')); const additionalItemsSelected = computed(() => { if (props.multiple) { const selectedItemsCount = Array.isArray(dropdownValue.value) ? dropdownValue.value.length : 0; const total = isMultiLevelSelect.value ? flattenedItemsList.value.length : items.value.length; if (selectedItemsCount === 0) { return null; } // Display count always - show "all" only when is not external filtering (hasMoreItems = true) if (!props.hasMoreItems && total > 0 && total === selectedItemsCount) { return t('all'); } return selectedItemsCount; } return null; }); // ARIA const ariaDescribedBy = computed( () => [ props.description ? descriptionId : null, ariaDescribedByAttribute.value, props.errorMessage ? errorId : null, ] .filter(Boolean) .join(' ') || null ); const listboxRef = computed(() => optionsContainerRef.value?.$el as HTMLDivElement); const { visibleDomOptions, updateVisibleDomOptions } = useVisibleDomOptions( listboxRef, filteredItems, computedOptionRole ); const openDropdown = () => { if (isReadOnly.value) { return; } isDropdownOpen.value = true; nextTick(() => { updateVisibleDomOptions(); }); }; const closeDropdown = () => { isDropdownOpen.value = false; }; const toggleDropdown = () => { if (isReadOnly.value) { return; } isDropdownOpen.value = !isDropdownOpen.value; nextTick(() => { updateVisibleDomOptions(); }); // Clear search if (isSearching.value) { searchTerm.value = ''; } }; const clickOutsideDropdown = () => { if (isSearching.value) { searchTerm.value = ''; } if (isDropdownOpen.value) { isDropdownOpen.value = false; // Reset the internal dropdown options state optionsContainerRef.value.onOutsideDropdownClick(); } }; const onOptionSelected = (selectedValue: BentoListboxOptions) => { // Clear and close the dropdown on single selection if (!props.multiple) { searchTerm.value = ''; isDropdownOpen.value = false; focus(); } setCachedValues(selectedValue); /** * Emit the value/label pair objects if enableValueLabelPair is enabled, * otherwise just emit the list of selected values (number or string) */ emitValue(cachedListboxSelectedValues.value); }; const emitShowMoreEvent = () => { emit(BentoDropdownEvent.SHOW_MORE); }; const textboxKeyboardNavigationListeners = useTextboxKeyboardNavigation( { inputContainerRef, optionsContainerRef, selectedValueIndex, isDropdownOpen, isDynamicFltering: toRef(props, 'dynamicFiltering'), items: visibleDomOptions, isMultiple: props.multiple, }, toggleDropdown, openDropdown, closeDropdown ); const listboxKeyboardNavigationListeners = useListboxKeyboardNavigation( { inputContainerRef, isDropdownOpen, }, closeDropdown ); const listboxListeners = { [BentoListboxEvent.CLOSE_DROPDOWN]: closeDropdown, [BentoListboxEvent.SELECT]: onOptionSelected, [BentoListboxEvent.SHOW_MORE]: emitShowMoreEvent, ...listboxKeyboardNavigationListeners, }; if (props.error) { deprecate( 'BentoDropdown "error" property', `Use the BentoDropdown "errorMessage" property instead to indicate that there is a modifier error. Having the field as "undefined" will hide/remove the error. <bento-dropdown errorMessage="Error message text" />`, '2.0.0' ); } if (props.size) { deprecate('BentoDropdown "size" property', `Do not use. Only 'default' size should be used.`, '2.0.0'); } if (props.value) { deprecate( 'BentoDropdown "value" property', `The use of "value" prop in "BentoDropdown" is no longer supported. Use the "v-model" or "model-value" property instead.`, '2.0.0' ); } const focus = () => { inputContainerRef.value.focus(); }; watch( () => isDropdownOpen.value, (isOpen: boolean) => { if (isOpen) { emit('open'); } } ); defineExpose({ focus, }); </script> <script lang="ts"> /** * A Dropdown shows a selected option's. * Use a dropdown when you want users to select options * from a list of pre-defined options. * * @example * import { BentoDropdown } from '@adyen/bento-vue2'; * import type { BentoDropdownOptions } from '@adyen/bento-vue2' * * export default { * components: { BentoDropdown }, * template: ` * <bento-dropdown * v-model="selectedValue" * :disabled="false" * :multiple="true" * :isOptionsDisabled="option => option.value === 2" * :items="options" * /> * `, * setup() { * const selectedValue = ref(2) // Default value * const options: BentoDropdownOptions = [ * { label: 'Option 1', value: 1 }, * { label: 'Option 2', value: 2 }, * ]; * return { options, selectedValue }; * } * } */ export default defineComponent({ i18n: { messages }, name: 'b-dropdown', components: { DropdownInputDefault, DropdownInputSmall, }, model: { prop: 'modelValue' }, }); </script> <style lang="scss" scoped src="./dropdown.scss" />
1
+ <!-- TODO: Remove aria props as these are part of JSX elements /vue/types/jsx.d.ts --> <!-- eslint-disable vue/attribute-hyphenation --> <template> <div v-bento-click-outside-directive="clickOutsideDropdown" class="b-dropdown" :class="conditionalClasses"> <field-label v-if="label" :id="labelId" :condensed="condensed" :label="label" :tooltip-text="tooltipText" :optional="optional" :required="required" /> <div ref="inputContainerRefWrapper" v-bento-keyboard-navigation-directive v-on="textboxKeyboardNavigationListeners" > <component :is="computedSize" ref="inputContainerRef" :condensed="condensed" :value="searchTerm" :additional-items-selected="additionalItemsSelected" :aria-controls="dropdownOptionsContainerId" :ariaExpanded="isDropdownOpen" :aria-labelledby="label ? labelId : null" :aria-describedby="ariaDescribedBy" :aria-label="computedAriaLabel" :aria-readonly="isReadOnly" :aria-required="required" :readonly="isReadOnly" :disabled="disabled" :display-value="displayValue" :dynamic-filtering="dynamicFiltering" :is-invalid="!!errorMessage || error" :multiple="multiple" :open="isDropdownOpen" :selected-value-item="!isInputComponent ? selectedValueItem : undefined" :show-slot-content-in-multiple="showSlotContentInMultiple" @input="searchTerm = $event" @open="toggleDropdown" @close="toggleDropdown" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </component> </div> <bento-dropdown-options-container v-if="inputContainerRef" :id="dropdownOptionsContainerId" ref="optionsContainerRef" :aria-label="computedAriaLabel" :component-loading="componentLoading" :empty-state="emptyState" :disabled="disabled" :is-option-disabled="isOptionDisabled" :items="filteredItems" :multiple="multiple" :static-categories="staticCategories" :open="isDropdownOpen" :selected-value="cachedSelectedListboxOptions" :searching="isSearching" :target-element="inputContainerRefWrapper" :loading="loading" :has-more-items="hasMoreItems" :lazy-load-type="lazyLoadType" :virtual-scroll="virtualScroll" v-on="listboxListeners" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </bento-dropdown-options-container> <error-message v-if="!!errorMessage" :id="errorId" :error-message="errorMessage" class="b-dropdown__error-message" /> <bento-typography v-if="description || hasSlot('description')" :id="descriptionId" class="b-dropdown__description" :class="descriptionConditionalClasses" el="span" > <slot name="description"> {{ description }} </slot> </bento-typography> </div> </template> <script setup lang="ts"> import { computed, defineComponent, inject, nextTick, type Ref, ref, toRef, toRefs, useAttrs, useSlots, watch, } from 'vue'; // Components import { BentoDropdownOptionsContainer } from './components/dropdown-options-container'; import { BentoTypography } from '@/components/typography'; import { DropdownInputDefault, DropdownInputSmall } from './components'; import { BentoListbox, ErrorMessage, FieldLabel, useCachedSelectedValues, useMultiLevelItems } from '@/internal'; // Utils import { deprecate } from '@/utils/ts/deprecate'; import { generateUid } from '@/core/utils/ts'; import { useAriaLabel } from '@/utils/ts/aria-label'; import { useI18n } from '@/utils/ts/i18n'; import { useFormFieldEmits } from '@/utils/ts/form-field/form-field'; // Composables import { useDisplayValue, useListboxKeyboardNavigation, useTextboxKeyboardNavigation } from './composables'; import { useVisibleDomOptions } from '@/internal/listbox/composables/use-visible-dom-options/use-visible-dom-options'; import { useFormLayoutFieldLoading } from '@/composables/use-form-layout-loading/use-form-layout-loading'; import { useSearchBarFilter } from '@/components/search-bar/useSearchBarDataFilter'; // Directives import { BentoClickOutsideDirective as vBentoClickOutsideDirective } from '@/directives/click-outside'; import { BentoKeyboardNavigationDirective as vBentoKeyboardNavigationDirective } from '@/directives'; // Types import { BentoDropdownEvent, type BentoDropdownProps, BentoDropdownSize } from './dropdown.types'; import { BentoListboxEvent, type BentoListboxItemRole, type BentoListboxMultiSelectValue, type BentoListboxOptions, type BentoListboxSelectedValue, type BentoListboxSelectedValueLabelPair, } from '@/types/listbox'; import messages from './messages.json'; import { useHasSlot } from '@/composables'; import { INPUT_FIELD_COMPONENT_INJECTION_KEY } from '@/components/input-field/input-field.keys'; type MessageSchema = (typeof messages)['en-US']; const attrs = useAttrs(); const slots = useSlots(); const hasSlot = useHasSlot(slots); const props = withDefaults(defineProps<BentoDropdownProps>(), { ariaLabel: null, condensed: false, emptyState: undefined, disabled: false, dynamicFiltering: false, error: false, errorMessage: null, description: null, hasMoreItems: false, isOptionDisabled: undefined, items: () => [], label: null, loading: false, componentLoading: false, lazyLoadType: BentoListbox.props.lazyLoadType.default, multiple: false, optional: false, placeholder: null, readonly: false, required: false, search: undefined, showSlotContentInMultiple: false, size: null, staticCategories: false, tooltipText: null, value: null, modelValue: null, virtualScroll: false, enableValueLabelPair: false, }); const emit = defineEmits<{ /** * Emitted when an option is clicked. Updates the selected option from the "value" property linked to the v-model. * @deprecated since version 2.0. Use `v-model` or `update:model-value instead. */ (e: 'input', selectedValue: BentoListboxSelectedValue | any); // eslint-disable-line @typescript-eslint/no-explicit-any /** * Emitted when an option is clicked. Updates the selected option from the "modelValue" property linked to the v-model. */ (e: 'update:model-value', selectedValue: BentoListboxSelectedValue | any); // eslint-disable-line @typescript-eslint/no-explicit-any /** * Emitted when: * - `lazyLoad` is `automatic` and the scroll meets the end of the list * - `lazyload` is `button` and the button is clicked */ (e: 'show-more'); /** * Emitted when the dropdown is opened. */ (e: 'open'); }>(); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const { emitValue } = useFormFieldEmits<BentoListboxSelectedValue>(emit); const inputContainerRef = ref(null); const inputContainerRefWrapper = ref(null); const optionsContainerRef: Ref<InstanceType<typeof BentoDropdownOptionsContainer>> = ref(null); const isDropdownOpen = ref(false); const dropdownOptionsContainerId = generateUid('bento-dropdown-options-container'); const labelId = generateUid('dropdown-label'); const descriptionId = generateUid('dropdown-description'); const errorId = generateUid('dropdown-error'); const { isReadOnly } = useFormLayoutFieldLoading(toRef(props, 'readonly')); const dropdownValue = computed(() => props.modelValue ?? props.value); const { cachedSelectedListboxOptions, cachedListboxSelectedValues, setCachedValues } = useCachedSelectedValues( toRef(props, 'items'), dropdownValue, toRef(props, 'multiple'), toRef(props, 'enableValueLabelPair') ); const ariaLabelAttribute = ref(attrs['aria-label']); const ariaDescribedByAttribute = ref(attrs['aria-describedby']); const { ariaLabel, label } = toRefs(props); // vue-i18n issue with t() used in script - direct usage in template works // requires either createI18n({ legacy: false }) or to be wrapped in computed() const computedAriaLabelFallbackMessage = computed(() => t('ariaLabelFallback')); const computedAriaLabel = useAriaLabel({ // TODO: take ariaLabel ref away when aria-label prop is removed ariaLabel: ariaLabel.value || (ariaLabelAttribute as Ref<string>), label, defaultFallback: computedAriaLabelFallbackMessage, }); // Inject input field component key to change the dropdown size const isInputComponent = props.size === BentoDropdownSize.SMALL || inject(INPUT_FIELD_COMPONENT_INJECTION_KEY, false); const computedSize = computed(() => (isInputComponent ? BentoDropdownSize.SMALL : BentoDropdownSize.DEFAULT)); const conditionalClasses = computed(() => ({ 'b-dropdown--condensed': props.condensed, })); // Description styles const descriptionConditionalClasses = computed(() => ({ 'b-dropdown__description--error': !!props.errorMessage, })); // Filtering const searchTerm = ref(''); const items = toRef(props, 'items'); const staticCategories = toRef(props, 'staticCategories'); const filteredItems = useSearchBarFilter( searchTerm, items, props?.search?.searchEvent, staticCategories, props?.search?.debounceTime ); const isSearching = computed(() => props.dynamicFiltering && !!searchTerm.value); const displayValue = useDisplayValue({ items, placeholder: toRef(props, 'placeholder'), isMultiSelect: toRef(props, 'multiple'), selectedValues: cachedSelectedListboxOptions, }); const selectedValueItem = computed(() => { if (props?.multiple && Array.isArray(dropdownValue.value)) { return props.items.filter( ({ value }) => props?.value && (props?.value as BentoListboxMultiSelectValue).includes(value) ); } if (!props.multiple && props.staticCategories) { // Find the category that holds the selected value const category = props.items.find(categoryItem => categoryItem.items.some(item => item.value === dropdownValue.value) ); // Find the specific item within the category const selectedCategoryItem = category ? category.items.find(item => item.value === dropdownValue.value) : null; return selectedCategoryItem; } return props.items.find(({ value }) => value === dropdownValue.value); }); const selectedValueIndex = computed(() => { const index = filteredItems.value.findIndex(({ value }) => { return props.enableValueLabelPair ? (dropdownValue.value as BentoListboxSelectedValueLabelPair).value === value : dropdownValue.value === value; }); return index > -1 ? index : 0; }); const { isMultiLevelSelect, flattenedItemsList } = useMultiLevelItems(items); const isTreePattern = computed(() => isMultiLevelSelect.value && !props.staticCategories); const computedOptionRole = computed<BentoListboxItemRole>(() => (isTreePattern.value ? 'treeitem' : 'option')); const additionalItemsSelected = computed(() => { if (props.multiple) { const selectedItemsCount = Array.isArray(dropdownValue.value) ? dropdownValue.value.length : 0; const total = isMultiLevelSelect.value ? flattenedItemsList.value.length : items.value.length; if (selectedItemsCount === 0) { return null; } // Display count always - show "all" only when is not external filtering (hasMoreItems = true) if (!props.hasMoreItems && total > 0 && total === selectedItemsCount) { return t('all'); } return selectedItemsCount; } return null; }); // ARIA const ariaDescribedBy = computed( () => [ props.description ? descriptionId : null, ariaDescribedByAttribute.value, props.errorMessage ? errorId : null, ] .filter(Boolean) .join(' ') || null ); const listboxRef = computed(() => optionsContainerRef.value?.$el as HTMLDivElement); const { visibleDomOptions, updateVisibleDomOptions } = useVisibleDomOptions( listboxRef, filteredItems, computedOptionRole ); const openDropdown = () => { if (isReadOnly.value) { return; } isDropdownOpen.value = true; nextTick(() => { updateVisibleDomOptions(); }); }; const closeDropdown = () => { isDropdownOpen.value = false; }; const toggleDropdown = () => { if (isReadOnly.value) { return; } isDropdownOpen.value = !isDropdownOpen.value; nextTick(() => { updateVisibleDomOptions(); }); // Clear search if (isSearching.value) { searchTerm.value = ''; } }; const clickOutsideDropdown = () => { if (isSearching.value) { searchTerm.value = ''; } if (isDropdownOpen.value) { isDropdownOpen.value = false; // Reset the internal dropdown options state optionsContainerRef.value.onOutsideDropdownClick(); } }; const onOptionSelected = async (selectedValue: BentoListboxOptions) => { // Clear and close the dropdown on single selection if (!props.multiple) { searchTerm.value = ''; isDropdownOpen.value = false; } setCachedValues(selectedValue); /** * Emit the value/label pair objects if enableValueLabelPair is enabled, * otherwise just emit the list of selected values (number or string) */ emitValue(cachedListboxSelectedValues.value); // Focus the input after selection and dropdown has closed if (!props.multiple) { await nextTick(); focus(); } }; const emitShowMoreEvent = () => { emit(BentoDropdownEvent.SHOW_MORE); }; const textboxKeyboardNavigationListeners = useTextboxKeyboardNavigation( { inputContainerRef, optionsContainerRef, selectedValueIndex, isDropdownOpen, isDynamicFltering: toRef(props, 'dynamicFiltering'), items: visibleDomOptions, isMultiple: props.multiple, }, toggleDropdown, openDropdown, closeDropdown ); const listboxKeyboardNavigationListeners = useListboxKeyboardNavigation( { inputContainerRef, isDropdownOpen, }, closeDropdown ); const listboxListeners = { [BentoListboxEvent.CLOSE_DROPDOWN]: closeDropdown, [BentoListboxEvent.SELECT]: onOptionSelected, [BentoListboxEvent.SHOW_MORE]: emitShowMoreEvent, ...listboxKeyboardNavigationListeners, }; if (props.error) { deprecate( 'BentoDropdown "error" property', `Use the BentoDropdown "errorMessage" property instead to indicate that there is a modifier error. Having the field as "undefined" will hide/remove the error. <bento-dropdown errorMessage="Error message text" />`, '2.0.0' ); } if (props.size) { deprecate('BentoDropdown "size" property', `Do not use. Only 'default' size should be used.`, '2.0.0'); } if (props.value) { deprecate( 'BentoDropdown "value" property', `The use of "value" prop in "BentoDropdown" is no longer supported. Use the "v-model" or "model-value" property instead.`, '2.0.0' ); } const focus = () => { inputContainerRef.value.focus(); }; watch( () => isDropdownOpen.value, (isOpen: boolean) => { if (isOpen) { emit('open'); } } ); defineExpose({ focus, }); </script> <script lang="ts"> /** * A Dropdown shows a selected option's. * Use a dropdown when you want users to select options * from a list of pre-defined options. * * @example * import { BentoDropdown } from '@adyen/bento-vue2'; * import type { BentoDropdownOptions } from '@adyen/bento-vue2' * * export default { * components: { BentoDropdown }, * template: ` * <bento-dropdown * v-model="selectedValue" * :disabled="false" * :multiple="true" * :isOptionsDisabled="option => option.value === 2" * :items="options" * /> * `, * setup() { * const selectedValue = ref(2) // Default value * const options: BentoDropdownOptions = [ * { label: 'Option 1', value: 1 }, * { label: 'Option 2', value: 2 }, * ]; * return { options, selectedValue }; * } * } */ export default defineComponent({ i18n: { messages }, name: 'b-dropdown', components: { DropdownInputDefault, DropdownInputSmall, }, model: { prop: 'modelValue' }, }); </script> <style lang="scss" scoped src="./dropdown.scss" />
@@ -1 +1 @@
1
- import { action } from '@storybook/addon-actions'; import { storybookDocsParameter } from '@/utils/ts/storybook'; import { type BentoInputDropdownProps, BentoInputFieldElementPosition, BentoInputFieldType, BentoInputFieldVariant, } from './input-field.types'; import { type BentoListboxSelectedValue } from '@/types/listbox'; import { ref, watch } from 'vue'; import type { Meta, StoryObj } from '@storybook/vue'; import { isVue2 } from 'vue-demi'; import BentoInputField from './input-field.vue'; import CheckmarkCircleFill from '@adyen/ui-assets-icons-16/vue/checkmark-circle-fill'; import PaymentMethodTypes from '@adyen/ui-assets-icons-16/vue/payment-method-types'; import './input-field.stories.scss?module'; const meta: Meta = { title: 'Input field', component: BentoInputField, argTypes: { variant: { options: Object.values(BentoInputFieldVariant), mapping: BentoInputFieldVariant, defaultValue: BentoInputFieldVariant.DEFAULT, control: { type: 'select', }, }, type: { options: Object.values(BentoInputFieldType), mapping: BentoInputFieldType, defaultValue: BentoInputFieldType.TEXT, control: { type: 'select', }, }, default: { name: 'default', defaultValue: '', description: 'Label for the input field', table: { type: { summary: 'VNode[]' }, }, control: { type: 'text', }, }, description: { name: 'description', defaultValue: '', description: 'Description below input', table: { type: { summary: 'VNode[]' }, }, control: { type: 'text', }, }, dropdownPosition: { options: Object.values(BentoInputFieldElementPosition), mapping: BentoInputFieldElementPosition, defaultValue: BentoInputFieldElementPosition.START, control: { type: 'select', }, }, staticValue: { name: 'staticValue', defaultValue: '', description: 'Static text that displayed before or after input value', table: { type: { summary: 'VNode[]' }, }, control: { type: 'text', }, }, staticValuePosition: { options: Object.values(BentoInputFieldElementPosition), mapping: BentoInputFieldElementPosition, defaultValue: BentoInputFieldElementPosition.START, control: { type: 'select', }, }, defaultIconBefore: { name: 'defaultIconBefore', defaultValue: '', description: 'Optional `icon` at the left side', table: { type: { summary: 'VNode[]' }, category: 'Deprecated', }, control: { disable: true, }, }, iconBefore: { name: 'iconBefore', defaultValue: '', description: 'Optional `icon` at the left side', table: { type: { summary: 'VNode[]' }, }, control: { disable: true, }, }, iconAfter: { name: 'iconAfter', defaultValue: '', description: 'Optional `icon` at the right side', table: { type: { summary: 'VNode[]' }, }, control: { disable: true, }, }, error: { control: { type: 'boolean' }, table: { category: 'Deprecated' }, }, value: { control: { type: 'text' }, table: { category: 'Deprecated' }, description: 'Deprecated for usage with properties since version 2.0. Use `v-model` or `model-value` property instead', }, // Events input: { table: { type: { summary: '(value: BentoInputFieldValue) => void' }, category: 'Deprecated', }, control: { disable: true }, description: 'Deprecated since version 2.0. Use `v-model` or `update:model-value` instead', }, 'update:model-value': { table: { type: { summary: '(value: BentoInputFieldValue) => void' }, }, control: { disable: true }, }, }, }; export default meta; type Story = StoryObj<typeof BentoInputField>; const INPUT_FIELD_DEFAULT_LABEL = 'Label above'; const INPUT_FIELD_DEFAULT_VALUE = 'Entered Text'; const INPUT_FIELD_DEFAULT_DESCRIPTION = 'Description below'; const DEFAULT_INPUT_FIELD_PROPS = { disabled: false, variant: 'default', description: INPUT_FIELD_DEFAULT_DESCRIPTION, modelValue: INPUT_FIELD_DEFAULT_VALUE, staticValue: 'EUR', }; export const Default: Story = { render: (_args, { argTypes }) => ({ components: { BentoInputField }, props: Object.keys(argTypes), template: ` <bento-input-field class="b-input-field-story" v-bind="args" :model-value="inputValue" @update:model-value="inputAction" @change="changeAction" @focus="focusAction" @blur="blurAction" > <template #default v-if="args.default">{{ args.default }}</template> <template #description>{{ args.description }}</template> </bento-input-field> `, setup(props) { const args = isVue2 ? props : _args; const inputValue = ref(args.modelValue); const inputAction = (value: string) => { inputValue.value = value; action('input')(value); }; const changeAction = (value: string) => action('change')(value); const focusAction = (value: string) => action('focus')(value); const blurAction = (value: string) => action('blur')(value); watch( () => args.modelValue, () => { inputValue.value = args.modelValue; } ); return { args, inputValue, inputAction, changeAction, focusAction, blurAction, }; }, }), args: { ...DEFAULT_INPUT_FIELD_PROPS, default: INPUT_FIELD_DEFAULT_LABEL, }, parameters: storybookDocsParameter(` <bento-input-field variant="default" :model-value="inputValue" @update:model-value="inputAction" @change="changeAction" @focus="focusAction" @blur="blurAction" > ${INPUT_FIELD_DEFAULT_LABEL} <template #description> ${INPUT_FIELD_DEFAULT_DESCRIPTION} </template> </bento-input-field> `), }; export const DefaultWithIcons = { render: (_args, { argTypes }) => ({ components: { BentoInputField, CheckmarkCircleFill }, props: Object.keys(argTypes), template: ` <bento-input-field class="b-input-field-story" v-bind="args" @update:model-value="inputAction" @change="changeAction" @focus="focusAction" @blur="blurAction" > <template #default v-if="args.default">{{ args.default }}</template> <template #iconBefore> <checkmark-circle-fill svg-title="checkmark-circle-fill" /> </template> <template #iconAfter> <checkmark-circle-fill svg-title="checkmark-circle-fill" /> </template> <template #description>{{ args.description }}</template> </bento-input-field> `, setup(props) { const inputAction = (value: string) => action('input')(value); const changeAction = (value: string) => action('change')(value); const focusAction = (value: string) => action('focus')(value); const blurAction = (value: string) => action('blur')(value); return { args: isVue2 ? props : _args, inputAction, changeAction, focusAction, blurAction, }; }, }), args: { ...DEFAULT_INPUT_FIELD_PROPS, default: 'With Icons', }, parameters: storybookDocsParameter(` <bento-input-field variant="default" :model-value="inputValue" @update:model-value="inputAction" @change="changeAction" @focus="focusAction" @blur="blurAction" > ${INPUT_FIELD_DEFAULT_LABEL} <template #description> ${INPUT_FIELD_DEFAULT_DESCRIPTION} </template> <template #iconBefore> <checkmark-circle-fill svg-title="checkmark-circle-fill" /> </template> <template #iconAfter> <checkmark-circle-fill svg-title="checkmark-circle-fill" /> </template> </bento-input-field> `), }; export const PaymentsMethod = { render: (_args, { argTypes }) => ({ components: { BentoInputField, CheckmarkCircleFill, PaymentMethodTypes }, props: Object.keys(argTypes), template: ` <bento-input-field class="b-input-field-story" v-bind="args" @update:model-value="inputAction" @change="changeAction" @focus="focusAction" @blur="blurAction" > <template #default v-if="args.default">{{ args.default }}</template> <template #paymentMethod> <payment-method-types svg-title="payment-method-types" /> </template> <template #iconAfter> <checkmark-circle-fill svg-title="checkmark-circle-fill" /> </template> <template #description>{{ args.description }}</template> <template #staticValue> EUR </template> </bento-input-field> `, setup(props) { const inputAction = (value: string) => action('input')(value); const changeAction = (value: string) => action('change')(value); const focusAction = (value: string) => action('focus')(value); const blurAction = (value: string) => action('blur')(value); return { args: isVue2 ? props : _args, inputAction, changeAction, focusAction, blurAction, }; }, }), args: { ...DEFAULT_INPUT_FIELD_PROPS, default: 'Payment Method Variant', variant: 'payment-method', }, parameters: storybookDocsParameter(` <bento-input-field variant="payment-method" :model-value="inputValue" @update:model-value="inputAction" @change="changeAction" @focus="focusAction" @blur="blurAction" > ${INPUT_FIELD_DEFAULT_LABEL} <template #description> ${INPUT_FIELD_DEFAULT_DESCRIPTION} </template> <template #paymentMethod> <bento-payment-method svg-title="payment-method" /> </template> <template #iconAfter> <checkmark-circle-fill svg-title="checkmark-circle-fill" /> </template> </bento-input-field> `), }; export const StaticValue = { ...PaymentsMethod, args: { ...DEFAULT_INPUT_FIELD_PROPS, default: 'Static Value Variant', variant: 'static-value', }, parameters: storybookDocsParameter(` <bento-input-field variant="static-value" :model-value="inputValue" @update:model-value="inputAction" @change="changeAction" @focus="focusAction" @blur="blurAction" > ${INPUT_FIELD_DEFAULT_LABEL} <template #description> ${INPUT_FIELD_DEFAULT_DESCRIPTION} </template> <template #staticValue> EUR </template> <template #iconAfter> <checkmark-circle-fill svg-title="checkmark-circle-fill" /> </template> </bento-input-field> `), }; const DROPDOWN_DEFAULT_OPTIONS = [ { label: 'EUR', value: 'EUR' }, { label: 'USD', value: 'USD' }, ]; export const Dropdown: Story = { render: (_args, { argTypes }) => ({ components: { BentoInputField, CheckmarkCircleFill, PaymentMethodTypes }, props: Object.keys(argTypes), template: ` <bento-input-field class="b-input-field-story" v-bind="args" :dropdown.sync="dropdownProps" @update:model-value="inputAction" @change="changeAction" @focus="focusAction" @blur="blurAction" > <template #default v-if="args.default">{{ args.default }}</template> <template #paymentMethod> <payment-method-types svg-title="payment-method-types" /> </template> <template #iconAfter> <checkmark-circle-fill svg-title="checkmark-circle-fill" /> </template> <template #description>{{ args.description }}</template> </bento-input-field> `, setup(props) { const args = isVue2 ? props : _args; const dropdownProps = ref({ ...args?.dropdown }); const inputAction = (value: string) => action('input')(value); const changeAction = (value: string) => action('change')(value); const focusAction = (value: string) => action('focus')(value); const blurAction = (value: string) => action('blur')(value); const dropdownInputAction = (value: BentoListboxSelectedValue) => { action('dropdown-input')(value); }; watch( () => args.dropdown, () => { Object.assign(dropdownProps.value, args.dropdown); } ); return { args, inputAction, changeAction, focusAction, blurAction, dropdownInputAction, dropdownProps, }; }, }), args: { default: 'Dropdown Variant', description: INPUT_FIELD_DEFAULT_DESCRIPTION, modelValue: INPUT_FIELD_DEFAULT_VALUE, variant: 'dropdown', dropdown: { items: DROPDOWN_DEFAULT_OPTIONS, modelValue: DROPDOWN_DEFAULT_OPTIONS[0].value, readonly: false, 'aria-label': 'Dropdown aria label', dynamicFiltering: false, } satisfies BentoInputDropdownProps, }, };
1
+ import { action } from '@storybook/addon-actions'; import { storybookDocsParameter } from '@/utils/ts/storybook'; import { type BentoInputDropdownProps, BentoInputFieldElementPosition, BentoInputFieldType, BentoInputFieldVariant, } from './input-field.types'; import { type BentoListboxSelectedValue } from '@/types/listbox'; import { ref, watch } from 'vue'; import type { Meta, StoryObj } from '@storybook/vue'; import { isVue2 } from 'vue-demi'; import BentoInputField from './input-field.vue'; import CheckmarkCircleFill from '@adyen/ui-assets-icons-16/vue/checkmark-circle-fill'; import PaymentMethodTypes from '@adyen/ui-assets-icons-16/vue/payment-method-types'; import './input-field.stories.scss?module'; const meta: Meta = { title: 'Input field', component: BentoInputField, argTypes: { variant: { options: Object.values(BentoInputFieldVariant), mapping: BentoInputFieldVariant, defaultValue: BentoInputFieldVariant.DEFAULT, control: { type: 'select', }, }, type: { options: Object.values(BentoInputFieldType), mapping: BentoInputFieldType, defaultValue: BentoInputFieldType.TEXT, control: { type: 'select', }, }, default: { name: 'default', defaultValue: '', description: 'Label for the input field', table: { type: { summary: 'VNode[]' }, }, control: { type: 'text', }, }, description: { name: 'description', description: 'If set, displays a description text for the input.', table: { category: 'props', type: { summary: 'string' }, }, control: { type: 'text', }, }, 'slot:description': { name: 'description', defaultValue: '', description: 'If set, displays a description text for the input. Overrides the `description` prop when provided.', table: { type: { summary: 'VNode[]' }, category: 'slots', }, control: { type: 'text', }, }, dropdownPosition: { options: Object.values(BentoInputFieldElementPosition), mapping: BentoInputFieldElementPosition, defaultValue: BentoInputFieldElementPosition.START, control: { type: 'select', }, }, staticValue: { name: 'staticValue', defaultValue: '', description: 'Static text that displayed before or after input value', table: { type: { summary: 'VNode[]' }, }, control: { type: 'text', }, }, staticValuePosition: { options: Object.values(BentoInputFieldElementPosition), mapping: BentoInputFieldElementPosition, defaultValue: BentoInputFieldElementPosition.START, control: { type: 'select', }, }, defaultIconBefore: { name: 'defaultIconBefore', defaultValue: '', description: 'Optional `icon` at the left side', table: { type: { summary: 'VNode[]' }, category: 'Deprecated', }, control: { disable: true, }, }, iconBefore: { name: 'iconBefore', defaultValue: '', description: 'Optional `icon` at the left side', table: { type: { summary: 'VNode[]' }, }, control: { disable: true, }, }, iconAfter: { name: 'iconAfter', defaultValue: '', description: 'Optional `icon` at the right side', table: { type: { summary: 'VNode[]' }, }, control: { disable: true, }, }, error: { control: { type: 'boolean' }, table: { category: 'Deprecated' }, }, value: { control: { type: 'text' }, table: { category: 'Deprecated' }, description: 'Deprecated for usage with properties since version 2.0. Use `v-model` or `model-value` property instead', }, // Events input: { table: { type: { summary: '(value: BentoInputFieldValue) => void' }, category: 'Deprecated', }, control: { disable: true }, description: 'Deprecated since version 2.0. Use `v-model` or `update:model-value` instead', }, 'update:model-value': { table: { type: { summary: '(value: BentoInputFieldValue) => void' }, }, control: { disable: true }, }, }, }; export default meta; type Story = StoryObj<typeof BentoInputField>; const INPUT_FIELD_DEFAULT_LABEL = 'Label above'; const INPUT_FIELD_DEFAULT_VALUE = 'Entered Text'; const INPUT_FIELD_DEFAULT_DESCRIPTION = 'Description below'; const DEFAULT_INPUT_FIELD_PROPS = { disabled: false, variant: 'default', description: INPUT_FIELD_DEFAULT_DESCRIPTION, modelValue: INPUT_FIELD_DEFAULT_VALUE, staticValue: 'EUR', }; export const Default: Story = { render: (_args, { argTypes }) => ({ components: { BentoInputField }, props: Object.keys(argTypes), template: ` <bento-input-field class="b-input-field-story" v-bind="args" :model-value="inputValue" @update:model-value="inputAction" @change="changeAction" @focus="focusAction" @blur="blurAction" > <template #default v-if="args.default">{{ args.default }}</template> <template #description v-if="args['slot:description']">{{ args['slot:description'] }}</template> </bento-input-field> `, setup(props) { const args = isVue2 ? props : _args; const inputValue = ref(args.modelValue); const inputAction = (value: string) => { inputValue.value = value; action('input')(value); }; const changeAction = (value: string) => action('change')(value); const focusAction = (value: string) => action('focus')(value); const blurAction = (value: string) => action('blur')(value); watch( () => args.modelValue, () => { inputValue.value = args.modelValue; } ); return { args, inputValue, inputAction, changeAction, focusAction, blurAction, }; }, }), args: { ...DEFAULT_INPUT_FIELD_PROPS, default: INPUT_FIELD_DEFAULT_LABEL, }, parameters: storybookDocsParameter(` <bento-input-field variant="default" :model-value="inputValue" @update:model-value="inputAction" @change="changeAction" @focus="focusAction" @blur="blurAction" > ${INPUT_FIELD_DEFAULT_LABEL} <template #description> ${INPUT_FIELD_DEFAULT_DESCRIPTION} </template> </bento-input-field> `), }; export const DefaultWithIcons = { render: (_args, { argTypes }) => ({ components: { BentoInputField, CheckmarkCircleFill }, props: Object.keys(argTypes), template: ` <bento-input-field class="b-input-field-story" v-bind="args" @update:model-value="inputAction" @change="changeAction" @focus="focusAction" @blur="blurAction" > <template #default v-if="args.default">{{ args.default }}</template> <template #iconBefore> <checkmark-circle-fill svg-title="checkmark-circle-fill" /> </template> <template #iconAfter> <checkmark-circle-fill svg-title="checkmark-circle-fill" /> </template> <template #description v-if="args['slot:description']">{{ args['slot:description'] }}</template> </bento-input-field> `, setup(props) { const inputAction = (value: string) => action('input')(value); const changeAction = (value: string) => action('change')(value); const focusAction = (value: string) => action('focus')(value); const blurAction = (value: string) => action('blur')(value); return { args: isVue2 ? props : _args, inputAction, changeAction, focusAction, blurAction, }; }, }), args: { ...DEFAULT_INPUT_FIELD_PROPS, default: 'With Icons', }, parameters: storybookDocsParameter(` <bento-input-field variant="default" :model-value="inputValue" @update:model-value="inputAction" @change="changeAction" @focus="focusAction" @blur="blurAction" > ${INPUT_FIELD_DEFAULT_LABEL} <template #description> ${INPUT_FIELD_DEFAULT_DESCRIPTION} </template> <template #iconBefore> <checkmark-circle-fill svg-title="checkmark-circle-fill" /> </template> <template #iconAfter> <checkmark-circle-fill svg-title="checkmark-circle-fill" /> </template> </bento-input-field> `), }; export const PaymentsMethod = { render: (_args, { argTypes }) => ({ components: { BentoInputField, CheckmarkCircleFill, PaymentMethodTypes }, props: Object.keys(argTypes), template: ` <bento-input-field class="b-input-field-story" v-bind="args" @update:model-value="inputAction" @change="changeAction" @focus="focusAction" @blur="blurAction" > <template #default v-if="args.default">{{ args.default }}</template> <template #paymentMethod> <payment-method-types svg-title="payment-method-types" /> </template> <template #iconAfter> <checkmark-circle-fill svg-title="checkmark-circle-fill" /> </template> <template #description v-if="args['slot:description']">{{ args['slot:description'] }}</template> <template #staticValue> EUR </template> </bento-input-field> `, setup(props) { const inputAction = (value: string) => action('input')(value); const changeAction = (value: string) => action('change')(value); const focusAction = (value: string) => action('focus')(value); const blurAction = (value: string) => action('blur')(value); return { args: isVue2 ? props : _args, inputAction, changeAction, focusAction, blurAction, }; }, }), args: { ...DEFAULT_INPUT_FIELD_PROPS, default: 'Payment Method Variant', variant: 'payment-method', }, parameters: storybookDocsParameter(` <bento-input-field variant="payment-method" :model-value="inputValue" @update:model-value="inputAction" @change="changeAction" @focus="focusAction" @blur="blurAction" > ${INPUT_FIELD_DEFAULT_LABEL} <template #description> ${INPUT_FIELD_DEFAULT_DESCRIPTION} </template> <template #paymentMethod> <bento-payment-method svg-title="payment-method" /> </template> <template #iconAfter> <checkmark-circle-fill svg-title="checkmark-circle-fill" /> </template> </bento-input-field> `), }; export const StaticValue = { ...PaymentsMethod, args: { ...DEFAULT_INPUT_FIELD_PROPS, default: 'Static Value Variant', variant: 'static-value', }, parameters: storybookDocsParameter(` <bento-input-field variant="static-value" :model-value="inputValue" @update:model-value="inputAction" @change="changeAction" @focus="focusAction" @blur="blurAction" > ${INPUT_FIELD_DEFAULT_LABEL} <template #description> ${INPUT_FIELD_DEFAULT_DESCRIPTION} </template> <template #staticValue> EUR </template> <template #iconAfter> <checkmark-circle-fill svg-title="checkmark-circle-fill" /> </template> </bento-input-field> `), }; const DROPDOWN_DEFAULT_OPTIONS = [ { label: 'EUR', value: 'EUR' }, { label: 'USD', value: 'USD' }, ]; export const Dropdown: Story = { render: (_args, { argTypes }) => ({ components: { BentoInputField, CheckmarkCircleFill, PaymentMethodTypes }, props: Object.keys(argTypes), template: ` <bento-input-field class="b-input-field-story" v-bind="args" :dropdown.sync="dropdownProps" @update:model-value="inputAction" @change="changeAction" @focus="focusAction" @blur="blurAction" > <template #default v-if="args.default">{{ args.default }}</template> <template #paymentMethod> <payment-method-types svg-title="payment-method-types" /> </template> <template #iconAfter> <checkmark-circle-fill svg-title="checkmark-circle-fill" /> </template> <template #description v-if="args['slot:description']">{{ args['slot:description'] }}</template> </bento-input-field> `, setup(props) { const args = isVue2 ? props : _args; const dropdownProps = ref({ ...args?.dropdown }); const inputAction = (value: string) => action('input')(value); const changeAction = (value: string) => action('change')(value); const focusAction = (value: string) => action('focus')(value); const blurAction = (value: string) => action('blur')(value); const dropdownInputAction = (value: BentoListboxSelectedValue) => { action('dropdown-input')(value); }; watch( () => args.dropdown, () => { Object.assign(dropdownProps.value, args.dropdown); } ); return { args, inputAction, changeAction, focusAction, blurAction, dropdownInputAction, dropdownProps, }; }, }), args: { default: 'Dropdown Variant', description: INPUT_FIELD_DEFAULT_DESCRIPTION, modelValue: INPUT_FIELD_DEFAULT_VALUE, variant: 'dropdown', dropdown: { items: DROPDOWN_DEFAULT_OPTIONS, modelValue: DROPDOWN_DEFAULT_OPTIONS[0].value, readonly: false, 'aria-label': 'Dropdown aria label', dynamicFiltering: false, } satisfies BentoInputDropdownProps, }, };
@@ -1 +1 @@
1
- import { action } from '@storybook/addon-actions'; import { isVue2 } from 'vue-demi'; import { storybookDocsParameter } from '@/utils/ts/storybook'; import BentoInputFieldPassword from './input-field-password.vue'; import type { Meta, StoryObj } from '@storybook/vue'; import { minLength } from '@/components/input-field-password/utilities/input-field-password.validation'; import { type BentoInputFieldPasswordValidation } from './input-field-password.types'; const meta: Meta = { title: 'Input Field Password', component: BentoInputFieldPassword, argTypes: { condensed: { table: { type: { summary: 'boolean' }, }, }, disabled: { table: { type: { summary: 'boolean' }, }, }, errorMessage: { table: { type: { summary: 'string' }, }, }, placeholder: { table: { type: { summary: 'string' }, }, }, readonly: { table: { type: { summary: 'boolean' }, }, }, modelValue: { control: { type: 'text' }, table: { type: { summary: 'string | number' }, }, }, label: { table: { type: { summary: 'string' }, }, }, // Events 'input:valid': { table: { type: { summary: '() => void' }, }, control: { disable: true }, }, // Deprecated input: { description: 'Deprecated since version 2.0. Use `v-model` or `update:model-value instead', table: { category: 'Deprecated' }, control: { disable: true }, }, value: { control: { type: 'text' }, table: { category: 'Deprecated' }, description: 'Deprecated for usage with properties since version 2.0. Use `v-model` or `model-value` property instead', }, }, }; export default meta; type Story = StoryObj<typeof BentoInputFieldPassword>; const DEFAULT_PROPS = { label: 'Password', withHint: true, condensed: false, disabled: false, readonly: false, description: 'Enter your password', }; const CUSTOM_VALIDATION: BentoInputFieldPasswordValidation = { extendDefaultValidation: true, list: [ { label: 'Always true', key: 'alwaysTrue', validate: () => true, }, { label: '18 characters', key: 'minLength', validate: minLength, additionalArgs: [18], }, { key: 'requireSpecialCharacter', disabled: true, }, ], }; const defaultCode = ` <template> <bento-input-field-password label="Label" :error-message="errors.password" @update:model-value="updateModelValue" @input:valid="inputValidAction" @input:error="inputErrorAction" /> </template> <script setup lang="ts"> import { BentoInputFieldPassword } from '@adyen/bento-vue2' import { reactive } from 'vue'; const errors = reactive({ password: '', }); const updateModelValue = () => { // do something when the input value changes } const inputValidAction = () => { // do something when password is valid } const inputErrorAction = () => { errors.password = 'Password field has an error'; } </script> `; export const Default: Story = { render: (_args, { argTypes }) => ({ components: { BentoInputFieldPassword }, props: Object.keys(argTypes), template: ` <bento-input-field-password v-bind="args" @update:model-value="updateModelValue" @input:valid="inputValidAction" @input:error="inputErrorAction" /> `, setup(props) { return { // Values args: isVue2 ? props : _args, // Events updateModelValue: (value: string) => action('update:model-value')(value), inputErrorAction: (value: string) => action('input:error')(value), inputValidAction: action('input:valid'), }; }, }), args: DEFAULT_PROPS, parameters: storybookDocsParameter(defaultCode), }; const customValidationCode = ` <template> <bento-input-field-password label="Label" :error-message="errors.password" :validation="{ extendDefaultValidation: true, list: [ { // custom rule label: 'Always true', key: 'alwaysTrue', validate: () => true, }, { // override existing rule label: '18 characters', key: 'minLength', validate: minLength, additionalArgs: [18], }, { // disable existing rule key: 'requireSpecialCharacter', disabled: true, }, ], }" @update:model-value="updateModelValue" @input:valid="inputValidAction" @input:error="inputErrorAction" /> </template> <script setup lang="ts"> import { BentoInputFieldPassword, minLength } from '@adyen/bento-vue2' import { reactive } from 'vue'; const errors = reactive({ password: '', }); const updateModelValue = () => { // do something when the input value changes } const inputValidAction = () => { // do something when password is valid } const inputErrorAction = () => { errors.password = 'Password field has an error'; } </script> `; export const CustomValidation: Story = { render: (_args, { argTypes }) => ({ components: { BentoInputFieldPassword }, props: Object.keys(argTypes), template: ` <bento-input-field-password v-bind="args" @update:model-value="updateModelValue" @input:valid="inputValidAction" @input:error="inputErrorAction" /> `, setup(props) { return { // Values args: isVue2 ? props : _args, // Events updateModelValue: (value: string) => action('update:model-value')(value), inputErrorAction: (value: string) => action('input:error')(value), inputValidAction: action('input:valid'), }; }, }), args: { ...DEFAULT_PROPS, validation: CUSTOM_VALIDATION, }, parameters: storybookDocsParameter(customValidationCode), };
1
+ import { action } from '@storybook/addon-actions'; import { isVue2 } from 'vue-demi'; import { storybookDocsParameter } from '@/utils/ts/storybook'; import BentoInputFieldPassword from './input-field-password.vue'; import type { Meta, StoryObj } from '@storybook/vue'; import { minLength } from '@/components/input-field-password/utilities/input-field-password.validation'; import { type BentoInputFieldPasswordValidation } from './input-field-password.types'; const meta: Meta = { title: 'Input Field Password', component: BentoInputFieldPassword, argTypes: { condensed: { table: { type: { summary: 'boolean' }, }, }, disabled: { table: { type: { summary: 'boolean' }, }, }, errorMessage: { table: { type: { summary: 'string' }, }, }, placeholder: { table: { type: { summary: 'string' }, }, }, readonly: { table: { type: { summary: 'boolean' }, }, }, description: { name: 'description', description: 'If set, displays a description text for the input.', table: { category: 'props', type: { summary: 'string' }, }, control: { type: 'text', }, }, 'slot:description': { name: 'description', defaultValue: '', description: 'If set, displays a description text for the input. Overrides the `description` prop when provided.', table: { category: 'slots', type: { summary: 'VNode[]' }, }, control: { type: 'text', }, }, modelValue: { control: { type: 'text' }, table: { type: { summary: 'string | number' }, }, }, label: { table: { type: { summary: 'string' }, }, }, // Events 'input:valid': { table: { type: { summary: '() => void' }, }, control: { disable: true }, }, // Deprecated input: { description: 'Deprecated since version 2.0. Use `v-model` or `update:model-value instead', table: { category: 'Deprecated' }, control: { disable: true }, }, value: { control: { type: 'text' }, table: { category: 'Deprecated' }, description: 'Deprecated for usage with properties since version 2.0. Use `v-model` or `model-value` property instead', }, }, }; export default meta; type Story = StoryObj<typeof BentoInputFieldPassword>; const DEFAULT_PROPS = { label: 'Password', withHint: true, condensed: false, disabled: false, readonly: false, description: 'Enter your password', }; const CUSTOM_VALIDATION: BentoInputFieldPasswordValidation = { extendDefaultValidation: true, list: [ { label: 'Always true', key: 'alwaysTrue', validate: () => true, }, { label: '18 characters', key: 'minLength', validate: minLength, additionalArgs: [18], }, { key: 'requireSpecialCharacter', disabled: true, }, ], }; const defaultCode = ` <template> <bento-input-field-password label="Label" :error-message="errors.password" @update:model-value="updateModelValue" @input:valid="inputValidAction" @input:error="inputErrorAction" /> </template> <script setup lang="ts"> import { BentoInputFieldPassword } from '@adyen/bento-vue2' import { reactive } from 'vue'; const errors = reactive({ password: '', }); const updateModelValue = () => { // do something when the input value changes } const inputValidAction = () => { // do something when password is valid } const inputErrorAction = () => { errors.password = 'Password field has an error'; } </script> `; export const Default: Story = { render: (_args, { argTypes }) => ({ components: { BentoInputFieldPassword }, props: Object.keys(argTypes), template: ` <bento-input-field-password v-bind="args" @update:model-value="updateModelValue" @input:valid="inputValidAction" @input:error="inputErrorAction" > <template #description v-if="args['slot:description']">{{ args['slot:description'] }}</template> </bento-input-field-password> `, setup(props) { return { // Values args: isVue2 ? props : _args, // Events updateModelValue: (value: string) => action('update:model-value')(value), inputErrorAction: (value: string) => action('input:error')(value), inputValidAction: action('input:valid'), }; }, }), args: DEFAULT_PROPS, parameters: storybookDocsParameter(defaultCode), }; const customValidationCode = ` <template> <bento-input-field-password label="Label" :error-message="errors.password" :validation="{ extendDefaultValidation: true, list: [ { // custom rule label: 'Always true', key: 'alwaysTrue', validate: () => true, }, { // override existing rule label: '18 characters', key: 'minLength', validate: minLength, additionalArgs: [18], }, { // disable existing rule key: 'requireSpecialCharacter', disabled: true, }, ], }" @update:model-value="updateModelValue" @input:valid="inputValidAction" @input:error="inputErrorAction" /> </template> <script setup lang="ts"> import { BentoInputFieldPassword, minLength } from '@adyen/bento-vue2' import { reactive } from 'vue'; const errors = reactive({ password: '', }); const updateModelValue = () => { // do something when the input value changes } const inputValidAction = () => { // do something when password is valid } const inputErrorAction = () => { errors.password = 'Password field has an error'; } </script> `; export const CustomValidation: Story = { render: (_args, { argTypes }) => ({ components: { BentoInputFieldPassword }, props: Object.keys(argTypes), template: ` <bento-input-field-password v-bind="args" @update:model-value="updateModelValue" @input:valid="inputValidAction" @input:error="inputErrorAction" > <template #description v-if="args['slot:description']">{{ args['slot:description'] }}</template> </bento-input-field-password> `, setup(props) { return { // Values args: isVue2 ? props : _args, // Events updateModelValue: (value: string) => action('update:model-value')(value), inputErrorAction: (value: string) => action('input:error')(value), inputValidAction: action('input:valid'), }; }, }), args: { ...DEFAULT_PROPS, validation: CUSTOM_VALIDATION, }, parameters: storybookDocsParameter(customValidationCode), };
@@ -1 +1 @@
1
- import { isVue2 } from 'vue-demi'; import { action } from '@storybook/addon-actions'; import { nextTick, onMounted, ref } from 'vue'; import { storybookDocsParameter } from '@/utils/ts/storybook'; import BentoInputFieldPhoneNumber from './input-field-phone-number.vue'; import type { Meta, StoryObj } from '@storybook/vue'; import type { BentoInputFieldPhoneNumberDropdownProps } from './input-field-phone-number.types'; import InputFieldPhoneNumberDefaultExample from './__tests__/input-field-phone-number-default-example.vue?raw'; const meta: Meta = { title: 'Input field phone number', component: BentoInputFieldPhoneNumber, argTypes: { input: { description: 'Deprecated since version 2.0. Use `v-model` or `update:model-value instead', table: { category: 'Deprecated' }, control: { disable: true }, }, value: { control: { type: 'text' }, table: { category: 'Deprecated' }, description: 'Deprecated for usage with properties since version 2.0. Use `v-model` or `model-value` property instead', }, }, }; export default meta; type Story = StoryObj<typeof BentoInputFieldPhoneNumber>; export const Default: Story = { render: (_args, { argTypes }) => ({ components: { BentoInputFieldPhoneNumber }, props: Object.keys(argTypes), template: ` <bento-input-field-phone-number v-bind="args" :model-value="value" :selected-country="country" :error-message="errorMessage" @update:model-value="updateModelValue" @update:selected-country="onSelectedCountry" @blur="onBlur" @input:valid="validate" > <template #default v-if='args.default'> {{ args.default }} </template> <template #description v-if='args.description'> {{ args.description }} </template> </bento-input-field-phone-number> `, setup(props) { const value = ref('+12025550123'); const country = ref('US'); const errorMessage = ref(''); const isValid = ref(false); const updateErrorMessage = () => { if (!isValid.value && value.value) { errorMessage.value = 'This number is not valid.'; } else { errorMessage.value = ''; } }; const onSelectedCountry = async updatedCountry => { country.value = updatedCountry; action('update:selected-country')(updatedCountry); await nextTick(); updateErrorMessage(); }; const updateModelValue = input => { value.value = input; action('update:model-value')(input); }; const validate = (validity: boolean) => { isValid.value = validity; if (validity) { updateErrorMessage(); } action('input:valid')(validity); }; const onBlur = () => { updateErrorMessage(); action('blur')(); }; onMounted(() => { updateErrorMessage(); }); return { // Values args: isVue2 ? props : _args, value, country, errorMessage, // Methods onSelectedCountry, updateModelValue, onBlur, validate, }; }, }), args: { label: 'Phone number', description: 'Select your country and input the phone number', dropdown: { readonly: false, } satisfies BentoInputFieldPhoneNumberDropdownProps, }, parameters: storybookDocsParameter(InputFieldPhoneNumberDefaultExample), };
1
+ import { isVue2 } from 'vue-demi'; import { action } from '@storybook/addon-actions'; import { nextTick, onMounted, ref } from 'vue'; import { storybookDocsParameter } from '@/utils/ts/storybook'; import BentoInputFieldPhoneNumber from './input-field-phone-number.vue'; import type { Meta, StoryObj } from '@storybook/vue'; import type { BentoInputFieldPhoneNumberDropdownProps } from './input-field-phone-number.types'; import InputFieldPhoneNumberDefaultExample from './__tests__/input-field-phone-number-default-example.vue?raw'; const meta: Meta = { title: 'Input field phone number', component: BentoInputFieldPhoneNumber, argTypes: { description: { name: 'description', description: 'If set, displays a description text for the input.', table: { category: 'props', type: { summary: 'string' }, }, control: { type: 'text', }, }, 'slot:description': { name: 'description', defaultValue: '', description: 'If set, displays a description text for the input. Overrides the `description` prop when provided.', table: { category: 'slots', type: { summary: 'VNode[]' }, }, control: { type: 'text', }, }, input: { description: 'Deprecated since version 2.0. Use `v-model` or `update:model-value instead', table: { category: 'Deprecated' }, control: { disable: true }, }, value: { control: { type: 'text' }, table: { category: 'Deprecated' }, description: 'Deprecated for usage with properties since version 2.0. Use `v-model` or `model-value` property instead', }, }, }; export default meta; type Story = StoryObj<typeof BentoInputFieldPhoneNumber>; export const Default: Story = { render: (_args, { argTypes }) => ({ components: { BentoInputFieldPhoneNumber }, props: Object.keys(argTypes), template: ` <bento-input-field-phone-number v-bind="args" :model-value="value" :selected-country="country" :error-message="errorMessage" @update:model-value="updateModelValue" @update:selected-country="onSelectedCountry" @blur="onBlur" @input:valid="validate" > <template #default v-if='args.default'> {{ args.default }} </template> <template #description v-if="args['slot:description']"> {{ args['slot:description'] }} </template> </bento-input-field-phone-number> `, setup(props) { const value = ref('+12025550123'); const country = ref('US'); const errorMessage = ref(''); const isValid = ref(false); const updateErrorMessage = () => { if (!isValid.value && value.value) { errorMessage.value = 'This number is not valid.'; } else { errorMessage.value = ''; } }; const onSelectedCountry = async updatedCountry => { country.value = updatedCountry; action('update:selected-country')(updatedCountry); await nextTick(); updateErrorMessage(); }; const updateModelValue = input => { value.value = input; action('update:model-value')(input); }; const validate = (validity: boolean) => { isValid.value = validity; if (validity) { updateErrorMessage(); } action('input:valid')(validity); }; const onBlur = () => { updateErrorMessage(); action('blur')(); }; onMounted(() => { updateErrorMessage(); }); return { // Values args: isVue2 ? props : _args, value, country, errorMessage, // Methods onSelectedCountry, updateModelValue, onBlur, validate, }; }, }), args: { label: 'Phone number', description: 'Select your country and input the phone number', dropdown: { readonly: false, } satisfies BentoInputFieldPhoneNumberDropdownProps, }, parameters: storybookDocsParameter(InputFieldPhoneNumberDefaultExample), };
@@ -1 +1 @@
1
- <template> <input :id="id" ref="inputRef" :aria-checked="isChecked" :aria-label="ariaLabel" :aria-invalid="ariaInvalid" :aria-describedby="ariaDescribedby" :checked="isChecked" :disabled="disabled" :value="value" :name="name" type="radio" class="b-radio-input" :class="conditionalClasses" role="radio" @click="onClick" @change="onChange" /> </template> <script setup lang="ts"> import { computed, type ComputedRef, type PropType, ref, toRefs } from 'vue'; import { RadioInputStateClass, type RadioInputValue } from './radio-input.types'; import type { HTMLAttributes } from 'vue/types/jsx.d.ts'; const props = defineProps({ /** * The aria label associated to the input checkbox. */ ariaLabel: { type: String, default: '' }, /** * An additional string that serves as description for the input checkbox (useful if the component shows a description). */ ariaDescribedby: { type: String, default: '' }, /** * Disable the radio button (optional) */ disabled: { type: Boolean, default: false }, /** * Shows bento-radio-button in error state (optional) */ hasError: { type: Boolean, default: false }, /** * Sets the id of the radio input. Required for accessibility purposes */ id: { type: String, default: null }, /** * This property should not be used directly. * It will be used by v-model directive on radio component. */ modelValue: { type: [String, Number] as PropType<RadioInputValue>, default: null, }, /** * Sets the radio button to readonly (optional) */ readonly: { type: Boolean, default: false }, /** * Indicates the value of the input that will be returned when it's checked */ value: { type: [String, Number] as PropType<RadioInputValue>, required: true, }, /** * Sets the name of the radio input. * Used for differentiating radio groups or accessing input via form submission. */ name: { type: String, default: null }, }); const emit = defineEmits<{ /** * Emit that emits the input value */ (e: 'update:model-value', value: RadioInputValue): void; }>(); const { value } = toRefs(props); const inputRef = ref(null); const conditionalClasses = computed(() => ({ [`b-radio-input--${RadioInputStateClass.DISABLED}`]: props.disabled, [`b-radio-input--${RadioInputStateClass.ERROR}`]: props.hasError, [`b-radio-input--${RadioInputStateClass.READONLY}`]: props.readonly, })); const ariaInvalid = computed<HTMLAttributes['aria-invalid']>(() => props.hasError); const isChecked: ComputedRef<boolean> = computed(() => props.value === props.modelValue); const onChange = () => { if (!props.disabled && !props.readonly) { emit('update:model-value', props.value); } }; // readonly attr doesn't work with radio button, so we need to manually ignore the click instead const onClick = event => { if (props.readonly) { event.preventDefault(); } }; const focus = () => { inputRef.value.focus(); }; defineExpose({ focus, }); </script> <script lang="ts"> /** * Internal component that renders a radio input (without label) * * @example * import { RadioInput } from '@/internal/radio-input'; * * export default { * components: { BentoCheckboxInput }, * template: ` * <label for="checkbox-id">Your label</label> * <span id="description-id">Description</span> * <radio-input id="checkbox-id" aria=label="Your label" aria-describedby="description-id" /> * ` * } */ export default { name: 'radio-input', inheritAttrs: false, }; </script> <style lang="scss" scoped src="./radio-input.scss" />
1
+ <template> <div class="b-radio-input"> <input :id="id" ref="inputRef" :aria-checked="isChecked" :aria-label="ariaLabel" :aria-invalid="ariaInvalid" :aria-describedby="ariaDescribedby" :checked="isChecked" :disabled="disabled" :value="value" :name="name" type="radio" class="b-radio-input__input" :class="conditionalClasses" role="radio" @click="onClick" @change="onChange" /> </div> </template> <script setup lang="ts"> import { computed, type ComputedRef, type PropType, ref, toRefs } from 'vue'; import { RadioInputStateClass, type RadioInputValue } from './radio-input.types'; import type { HTMLAttributes } from 'vue/types/jsx.d.ts'; const props = defineProps({ /** * The aria label associated to the input checkbox. */ ariaLabel: { type: String, default: '' }, /** * An additional string that serves as description for the input checkbox (useful if the component shows a description). */ ariaDescribedby: { type: String, default: '' }, /** * Disable the radio button (optional) */ disabled: { type: Boolean, default: false }, /** * Shows bento-radio-button in error state (optional) */ hasError: { type: Boolean, default: false }, /** * Sets the id of the radio input. Required for accessibility purposes */ id: { type: String, default: null }, /** * This property should not be used directly. * It will be used by v-model directive on radio component. */ modelValue: { type: [String, Number] as PropType<RadioInputValue>, default: null, }, /** * Sets the radio button to readonly (optional) */ readonly: { type: Boolean, default: false }, /** * Indicates the value of the input that will be returned when it's checked */ value: { type: [String, Number] as PropType<RadioInputValue>, required: true, }, /** * Sets the name of the radio input. * Used for differentiating radio groups or accessing input via form submission. */ name: { type: String, default: null }, }); const emit = defineEmits<{ /** * Emit that emits the input value */ (e: 'update:model-value', value: RadioInputValue): void; }>(); const { value } = toRefs(props); const inputRef = ref(null); const conditionalClasses = computed(() => ({ [`b-radio-input__input--${RadioInputStateClass.DISABLED}`]: props.disabled, [`b-radio-input__input--${RadioInputStateClass.ERROR}`]: props.hasError, [`b-radio-input__input--${RadioInputStateClass.READONLY}`]: props.readonly, })); const ariaInvalid = computed<HTMLAttributes['aria-invalid']>(() => props.hasError); const isChecked: ComputedRef<boolean> = computed(() => props.value === props.modelValue); const onChange = () => { if (!props.disabled && !props.readonly) { emit('update:model-value', props.value); } }; // readonly attr doesn't work with radio button, so we need to manually ignore the click instead const onClick = event => { if (props.readonly) { event.preventDefault(); } }; const focus = () => { inputRef.value.focus(); }; defineExpose({ focus, }); </script> <script lang="ts"> /** * Internal component that renders a radio input (without label) * * @example * import { RadioInput } from '@/internal/radio-input'; * * export default { * components: { BentoCheckboxInput }, * template: ` * <label for="checkbox-id">Your label</label> * <span id="description-id">Description</span> * <radio-input id="checkbox-id" aria=label="Your label" aria-describedby="description-id" /> * ` * } */ export default { name: 'radio-input', inheritAttrs: false, }; </script> <style lang="scss" scoped src="./radio-input.scss" />
package/dist/main.js CHANGED
@@ -30048,7 +30048,7 @@ function addGetComponent(server) {
30048
30048
  var package_default = {
30049
30049
  name: "@adyen/bento-mcp",
30050
30050
  mcpName: "io.github.adyen/bento-mcp",
30051
- version: "0.1.3",
30051
+ version: "0.1.4",
30052
30052
  type: "module",
30053
30053
  description: "A Model Context Protocol server implementation for Bento",
30054
30054
  license: "MIT",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@adyen/bento-mcp",
3
3
  "mcpName": "io.github.adyen/bento-mcp",
4
- "version": "0.1.3",
4
+ "version": "0.1.4",
5
5
  "type": "module",
6
6
  "description": "A Model Context Protocol server implementation for Bento",
7
7
  "license": "MIT",