@adyen/bento-mcp 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +41 -9
  3. package/dist/assets/components/accordion/components/accordion-item.vue +1 -1
  4. package/dist/assets/components/card/card.vue +1 -1
  5. package/dist/assets/components/data-grid/components/data-grid-row/data-grid-row.vue +1 -1
  6. package/dist/assets/components/draggable/draggable.docs.mdx +76 -135
  7. package/dist/assets/components/draggable/draggable.stories.ts +1 -1
  8. package/dist/assets/components/draggable/draggable.types.ts +1 -1
  9. package/dist/assets/components/draggable/draggable.vue +1 -1
  10. package/dist/assets/components/drawer/drawer.docs.mdx +68 -0
  11. package/dist/assets/components/drawer/drawer.stories.ts +1 -0
  12. package/dist/assets/components/drawer/drawer.types.ts +1 -0
  13. package/dist/assets/components/drawer/drawer.vue +1 -0
  14. package/dist/assets/components/filter-bar/components/base-filter/base-filter.vue +1 -1
  15. package/dist/assets/components/input-field/input-field.types.ts +1 -1
  16. package/dist/assets/components/input-field/input-field.vue +1 -1
  17. package/dist/assets/components/internal/calendar/calendar.types.ts +1 -1
  18. package/dist/assets/components/internal/calendar/calendar.vue +1 -1
  19. package/dist/assets/components/internal/calendar/components/calendar-month/calendar-month.stories.ts +1 -1
  20. package/dist/assets/components/internal/calendar/components/calendar-month/calendar-month.types.ts +1 -1
  21. package/dist/assets/components/internal/calendar/components/calendar-month/calendar-month.vue +1 -1
  22. package/dist/assets/components/internal/calendar/components/calendar-month/composables/granular-min-max-date.types.ts +1 -1
  23. package/dist/assets/components/internal/calendar/components/calendar-year/calendar-year.types.ts +1 -1
  24. package/dist/assets/components/internal/calendar/components/calendar-year/calendar-year.vue +1 -1
  25. package/dist/assets/components/internal/teleport/teleport.vue +1 -1
  26. package/dist/assets/components/modal/modal.vue +1 -1
  27. package/dist/assets/components/navigation-menu/components/navigation-menu-group/navigation-menu-group.stories.ts +1 -0
  28. package/dist/assets/components/navigation-menu/components/navigation-menu-group/navigation-menu-group.types.ts +1 -0
  29. package/dist/assets/components/navigation-menu/components/navigation-menu-group/navigation-menu-group.vue +1 -0
  30. package/dist/assets/components/navigation-menu/components/navigation-menu-item/navigation-menu-item.stories.ts +1 -0
  31. package/dist/assets/components/navigation-menu/components/navigation-menu-item/navigation-menu-item.types.ts +1 -0
  32. package/dist/assets/components/navigation-menu/components/navigation-menu-item/navigation-menu-item.vue +1 -0
  33. package/dist/assets/components/navigation-menu/navigation-menu.docs.mdx +321 -0
  34. package/dist/assets/components/navigation-menu/navigation-menu.stories.ts +1 -0
  35. package/dist/assets/components/navigation-menu/navigation-menu.types.ts +1 -0
  36. package/dist/assets/components/navigation-menu/navigation-menu.vue +1 -0
  37. package/dist/assets/components/secondary-nav/components/secondary-nav-category/secondary-nav-category.vue +1 -1
  38. package/dist/assets/components/secondary-nav/components/secondary-nav-item/secondary-nav-item.vue +1 -1
  39. package/dist/assets/components/secondary-nav/secondary-nav.stories.ts +1 -1
  40. package/dist/assets/components/secondary-nav/secondary-nav.types.ts +1 -1
  41. package/dist/assets/components/secondary-nav/secondary-nav.vue +1 -1
  42. package/dist/assets/components/tabs/components/tab.types.ts +1 -1
  43. package/dist/assets/components/tabs/components/tab.vue +1 -1
  44. package/dist/assets/components/tabs/tabs.stories.ts +1 -1
  45. package/dist/assets/components/tabs/tabs.types.ts +1 -1
  46. package/dist/assets/components/tabs/tabs.vue +1 -1
  47. package/dist/assets/components.json +5 -0
  48. package/dist/assets/index.ts +1 -1
  49. package/dist/assets/usage.json +13 -8
  50. package/dist/assets/variables.css +3 -0
  51. package/dist/main.js +1 -1
  52. package/package.json +1 -1
@@ -1 +1 @@
1
- <!-- Taken from: https://github.com/Mechazawa/vue2-teleport/blob/master/src/Teleport.vue --> <template> <div v-if="isVue2" :class="classes"> <slot /> </div> <Teleport v-else :to="defaultTo" :where="where" :disabled="disabled" :class="classes"> <slot /> </Teleport> </template> <script setup lang="ts"> import { isVue2 } from 'vue-demi'; import { defineComponent, inject, type PropType } from 'vue'; import { MODAL_DIALOG_ID_INJECTION_KEY } from '@/components/modal/components/base-modal/base-modal.keys'; import { SIDEPANEL_DIALOG_ID_INJECTION_KEY } from '@/components/sidepanel/sidepanel.keys'; import { MODAL_FULLSCREEN_DIALOG_ID_INJECTION_KEY } from '@/components/modal-fullscreen/modal-fullscreen.keys'; import type { BentoTeleportProps } from './teleport.types'; </script> <script lang="ts"> export default defineComponent({ name: 'teleport', props: { to: { type: String as PropType<BentoTeleportProps['to']>, default: '', }, where: { type: String, default: 'after', }, disabled: Boolean as PropType<BentoTeleportProps['disabled']>, }, data: () => ({ childObserver: null, nodes: [], waiting: false, observer: null, parent: null, isVue2, }), computed: { classes() { if (this.disabled) { return ['b-teleport']; } return ['b-teleport', 'b-teleport--hidden']; }, defaultTo() { if (this.to) { return this.to; } const injectionKey = inject<string>(MODAL_DIALOG_ID_INJECTION_KEY, '') || inject<string>(MODAL_FULLSCREEN_DIALOG_ID_INJECTION_KEY, '') || inject<string>(SIDEPANEL_DIALOG_ID_INJECTION_KEY, ''); const teleportElement = injectionKey ? `#${injectionKey}` : 'body'; return teleportElement; }, }, watch: { to: 'maybeMove', where: 'maybeMove', disabled(value) { if (!isVue2) { return; } if (value) { this.disable(); // Ensure all event done. this.$nextTick(() => { this.teardownObserver(); }); } else { this.bootObserver(); this.move(); } }, }, mounted() { // In Vue 3, the built-in <Teleport> handles DOM movement. // Manual DOM manipulation and MutationObservers must be skipped // to avoid conflicting with Vue 3's virtual DOM tracking. if (!isVue2) { return; } // Store a reference to the nodes this.nodes = Array.from(this.$el.childNodes); if (!this.disabled) { this.bootObserver(); } // Move slot content to target this.maybeMove(); }, beforeDestroy() { // In Vue 3, cleanup is not needed as the built-in <Teleport> manages its own lifecycle. if (!isVue2) { return; } // Fix nodes reference this.nodes = this.getComponentChildrenNode(); // Move back this.disable(); // Stop observing this.teardownObserver(); }, methods: { // Using a fragment is faster because it'll trigger only a single reflow // See https://developer.mozilla.org/en-US/docs/Web/API/DocumentFragment getFragment(): DocumentFragment { const fragment = document.createDocumentFragment(); this.nodes.forEach(node => fragment.appendChild(node)); return fragment; }, disable() { this.$el.appendChild(this.getFragment()); this.parent = null; }, move() { this.waiting = false; this.parent = document.querySelector(this.defaultTo); if (!this.parent) { this.disable(); this.waiting = true; return; } if (this.where === 'before') { this.parent.prepend(this.getFragment()); } else { this.parent.appendChild(this.getFragment()); } }, maybeMove() { if (!isVue2 || this.disabled) { return; } this.move(); }, onMutations(mutations) { // Makes sure the move operation is only done once let shouldMove = false; for (let i = 0; i < mutations.length; i++) { const mutation = mutations[i]; const filteredAddedNodes = Array.from(mutation.addedNodes).filter( node => !this.nodes.includes(node) ); if (Array.from(mutation.removedNodes).includes(this.parent)) { this.disable(); this.waiting = !this.disabled; } else if (this.waiting && filteredAddedNodes.length > 0) { shouldMove = true; } } if (shouldMove) { this.move(); } }, bootObserver() { if (this.observer) { return; } this.observer = new MutationObserver(mutations => this.onMutations(mutations)); this.observer.observe(document.body, { childList: true, subtree: true, attributes: false, characterData: false, }); if (this.childObserver) { return; } // watch childNodes change this.childObserver = new MutationObserver(mutations => { const childChangeRecord = mutations.find(i => i.target === this.$el); if (childChangeRecord) { // Remove old nodes before update position. this.nodes.forEach(node => node.parentNode && node.parentNode.removeChild(node)); this.nodes = this.getComponentChildrenNode(); this.maybeMove(); } }); this.childObserver.observe(this.$el, { childList: true, subtree: false, attributes: false, characterData: false, }); }, teardownObserver() { if (this.observer) { this.observer.disconnect(); this.observer = null; } if (this.childObserver) { this.childObserver.disconnect(); this.childObserver = null; } }, getComponentChildrenNode() { // Vue3 if (!isVue2) { return []; } // Trick for Vue3 type checking return (this as any).$vnode.componentOptions.children.map(i => i.elm).filter(i => i); }, }, }); </script> <style lang="scss" scoped src="./teleport.scss" />
1
+ <!-- Taken from: https://github.com/Mechazawa/vue2-teleport/blob/master/src/Teleport.vue --> <template> <div v-if="isVue2" :class="classes"> <slot /> </div> <Teleport v-else :to="to || teleportTarget" :disabled="disabled"> <slot /> </Teleport> </template> <script setup lang="ts"> import { isVue2 } from 'vue-demi'; import { computed, defineComponent, inject, type PropType } from 'vue'; import { MODAL_DIALOG_ID_INJECTION_KEY } from '@/components/modal/components/base-modal/base-modal.keys'; import { SIDEPANEL_DIALOG_ID_INJECTION_KEY } from '@/components/sidepanel/sidepanel.keys'; import { MODAL_FULLSCREEN_DIALOG_ID_INJECTION_KEY } from '@/components/modal-fullscreen/modal-fullscreen.keys'; import type { BentoTeleportProps } from './teleport.types'; // inject() MUST be called during component setup (not inside an Options API // computed). In Vue 3.5+ calling inject outside of setup returns undefined, // which caused the "Invalid Teleport target: undefined" warning. const injectedModalKey = inject<string>(MODAL_DIALOG_ID_INJECTION_KEY, ''); const injectedModalFullscreenKey = inject<string>(MODAL_FULLSCREEN_DIALOG_ID_INJECTION_KEY, ''); const injectedSidepanelKey = inject<string>(SIDEPANEL_DIALOG_ID_INJECTION_KEY, ''); // Exposed to the template. Does not consider the `to` prop since that's // handled at the template call-site as `to || teleportTarget`. const teleportTarget = computed(() => { const key = injectedModalKey || injectedModalFullscreenKey || injectedSidepanelKey; return key ? `#${key}` : 'body'; }); </script> <script lang="ts"> export default defineComponent({ name: 'teleport', props: { to: { type: String as PropType<BentoTeleportProps['to']>, default: '', }, where: { type: String, default: 'after', }, disabled: Boolean as PropType<BentoTeleportProps['disabled']>, }, data: () => ({ childObserver: null, nodes: [], waiting: false, observer: null, parent: null, isVue2, }), computed: { classes() { if (this.disabled) { return ['b-teleport']; } return ['b-teleport', 'b-teleport--hidden']; }, // Only used by the Vue 2 polyfill path for internal DOM-move // target lookup (`document.querySelector(this.defaultTo)` in // `move()`). Vue 3 uses `teleportTarget` from <script setup> // directly in the template (`:to="to || teleportTarget"`). // // Vue 2 supports `inject()` during render-time computed // evaluation, so we resolve the modal/sidepanel injection keys // here. On Vue 3 this computed is never read because the // template branches on `isVue2` and the Vue 3 branch uses the // setup-bound `teleportTarget` instead. defaultTo() { if (this.to) { return this.to; } const injectionKey = inject<string>(MODAL_DIALOG_ID_INJECTION_KEY, '') || inject<string>(MODAL_FULLSCREEN_DIALOG_ID_INJECTION_KEY, '') || inject<string>(SIDEPANEL_DIALOG_ID_INJECTION_KEY, ''); return injectionKey ? `#${injectionKey}` : 'body'; }, }, watch: { to: 'maybeMove', where: 'maybeMove', disabled(value) { if (!isVue2) { return; } if (value) { this.disable(); // Ensure all event done. this.$nextTick(() => { this.teardownObserver(); }); } else { this.bootObserver(); this.move(); } }, }, mounted() { // In Vue 3, the built-in <Teleport> handles DOM movement. // Manual DOM manipulation and MutationObservers must be skipped // to avoid conflicting with Vue 3's virtual DOM tracking. if (!isVue2) { return; } // Store a reference to the nodes this.nodes = Array.from(this.$el.childNodes); if (!this.disabled) { this.bootObserver(); } // Move slot content to target this.maybeMove(); }, beforeDestroy() { // In Vue 3, cleanup is not needed as the built-in <Teleport> manages its own lifecycle. if (!isVue2) { return; } // Fix nodes reference this.nodes = this.getComponentChildrenNode(); // Move back this.disable(); // Stop observing this.teardownObserver(); }, methods: { // Using a fragment is faster because it'll trigger only a single reflow // See https://developer.mozilla.org/en-US/docs/Web/API/DocumentFragment getFragment(): DocumentFragment { const fragment = document.createDocumentFragment(); this.nodes.forEach(node => fragment.appendChild(node)); return fragment; }, disable() { this.$el.appendChild(this.getFragment()); this.parent = null; }, move() { this.waiting = false; this.parent = document.querySelector(this.defaultTo); if (!this.parent) { this.disable(); this.waiting = true; return; } if (this.where === 'before') { this.parent.prepend(this.getFragment()); } else { this.parent.appendChild(this.getFragment()); } }, maybeMove() { if (!isVue2 || this.disabled) { return; } this.move(); }, onMutations(mutations) { // Makes sure the move operation is only done once let shouldMove = false; for (let i = 0; i < mutations.length; i++) { const mutation = mutations[i]; const filteredAddedNodes = Array.from(mutation.addedNodes).filter( node => !this.nodes.includes(node) ); if (Array.from(mutation.removedNodes).includes(this.parent)) { this.disable(); this.waiting = !this.disabled; } else if (this.waiting && filteredAddedNodes.length > 0) { shouldMove = true; } } if (shouldMove) { this.move(); } }, bootObserver() { if (this.observer) { return; } this.observer = new MutationObserver(mutations => this.onMutations(mutations)); this.observer.observe(document.body, { childList: true, subtree: true, attributes: false, characterData: false, }); if (this.childObserver) { return; } // watch childNodes change this.childObserver = new MutationObserver(mutations => { const childChangeRecord = mutations.find(i => i.target === this.$el); if (childChangeRecord) { // Remove old nodes before update position. this.nodes.forEach(node => node.parentNode && node.parentNode.removeChild(node)); this.nodes = this.getComponentChildrenNode(); this.maybeMove(); } }); this.childObserver.observe(this.$el, { childList: true, subtree: false, attributes: false, characterData: false, }); }, teardownObserver() { if (this.observer) { this.observer.disconnect(); this.observer = null; } if (this.childObserver) { this.childObserver.disconnect(); this.childObserver = null; } }, getComponentChildrenNode() { // Vue3 if (!isVue2) { return []; } // Trick for Vue3 type checking return (this as any).$vnode.componentOptions.children.map(i => i.elm).filter(i => i); }, }, }); </script> <style lang="scss" scoped src="./teleport.scss" />
@@ -1 +1 @@
1
- <template> <component :is="computedVariant" class="b-modal" :is-open="isOpen" :actions="actions" :initially-open="initiallyOpen" v-bind="customProps" @close-modal="handleInternalClose" @update:active-page="emit('update:active-page', $event)" > <template v-if="hasSlot('default')" #default> <slot></slot> </template> <template v-if="hasSlot('content-col-1')" #content-col-1><slot name="content-col-1"></slot></template> <template v-if="hasSlot('content-col-2')" #content-col-2><slot name="content-col-2"></slot></template> <template v-if="hasSlot('content')" #content><slot name="content"></slot></template> </component> </template> <script setup lang="ts"> import { computed, defineComponent, type PropType, useAttrs, useSlots, watch } from 'vue'; import { BentoModalColumnLayoutVariant, BentoModalDialogType, BentoModalSize, BentoModalVariant, BentoModalVariantMapping, } from './modal.types'; import { BentoModalDefault, BentoModalDialog } from '@/components/modal/components'; import { useHasSlot } from '@/composables'; import { type BentoButtonActionsList } from '@/components/button'; import { deprecate } from '@/utils/ts/deprecate'; const LOADING_BTN_CLOSE_AFTER_SUCCESS_DELAY_TIME = 500; type BentoModalVariantProp = BentoModalVariant | `${BentoModalVariant}` | `${BentoModalVariantMapping}`; const slots = useSlots(); const hasSlot = useHasSlot(slots); const attrs = useAttrs(); const emit = defineEmits<{ /** * Emits update event when page is changed internally */ (e: 'update:active-page', page: string): void; /** * Emitted when the modal is closed */ (e: 'close-modal'); }>(); const props = defineProps({ /** * List of actions that will be used to render the buttons. * First element in the list will be rendered as "primary". * All elements after the first will be rendered as "secondary". * * Each object in the list should have a `title`, an `event` and * (optional) a Vue `icon` from the library `@adyen/ui-assets-icons-16`. * * `title`: Button's text. * * `event`: Event triggered when clicking the Button. * * `icon`: Vue Icon from the library `@adyen/ui-assets-icons-16`. * * ``` * [{ * title: 'Primary',2 * event: () => { ... }, * icon: FourPeopleIcon * }, { * title: 'Secondary', * event: () => { ... } * }] * ``` */ actions: { type: Array as PropType<BentoButtonActionsList>, default: null, }, /** * The id of the current active page * Should be the same id as the BentoModalPage to be displayed */ activePage: { type: String, default: null, }, /** * Text label that is displayed left to the action buttons */ actionsLabel: { type: String, default: null }, /** * Text that will be displayed in the content section of Modal */ content: { type: String, default: null }, /** * If a column layout is provided, it renders the respective left column width variant. This can be smaller than the second column, the same size as the second column or bigger than the second column. */ columnsSize: { type: String as PropType<BentoModalColumnLayoutVariant | `${BentoModalColumnLayoutVariant}`>, default: BentoModalColumnLayoutVariant.SMALLER, validator: (value: BentoModalColumnLayoutVariant) => Object.values(BentoModalColumnLayoutVariant).includes(value), }, /** * If provided, manages the padding of the whole modal container */ customPadding: { type: String, default: null }, /** * If passed - sets first button color to red */ destructiveActions: { type: Boolean, default: false }, /** * Changes the visual styling of action buttons */ dialogType: { type: String as PropType<BentoModalDialogType | `${BentoModalDialogType}`>, default: BentoModalDialogType.CONFIRMATION, validator: (value: BentoModalDialogType) => Object.values(BentoModalDialogType).includes(value), }, /** * Hides a cross icon on the right side of the title */ hideCloseButton: { type: Boolean, default: false }, /** * If set to true, it allows the modal to be closed without performing an action */ isDismissible: { type: Boolean, default: null }, /** * Controls the modal's state from the outside. Set it to true to open the modal */ isOpen: { type: Boolean, default: false, }, /** * Sets the open attribute on the dialog element, use isOpen instead to toggle modal visbility. */ initiallyOpen: { type: Boolean, default: false }, /** * Sets the width of the modal */ size: { type: String as PropType<BentoModalSize | `${BentoModalSize}`>, default: BentoModalSize.SMALL, validator: (value: BentoModalSize) => Object.values(BentoModalSize).includes(value), }, /** * Choose between default or dialog versions of modal */ variant: { type: String as PropType<BentoModalVariantProp>, default: 'default', validator: (value: BentoModalVariantProp) => Object.values<BentoModalVariantProp>(BentoModalVariant).includes(value) || Object.values<BentoModalVariantProp>(BentoModalVariantMapping).includes(value), }, /** * If provided, renders the modal's content in a two-column layout an wth dividers separating its content */ withColumnLayout: { type: Boolean, default: false }, }); const computedVariant = computed(() => { if (Object.values<BentoModalVariantProp>(BentoModalVariantMapping).includes(props.variant)) { return props.variant; } return BentoModalVariantMapping[props.variant.toUpperCase()]; }); const customProps = computed(() => computedVariant.value === BentoModalVariantMapping.DEFAULT ? { activePage: props.activePage, hideCloseButton: props.hideCloseButton, size: props.size, customPadding: props.customPadding, actionsLabel: props.actionsLabel, destructiveActions: props.destructiveActions, isDismissible: props.isDismissible ?? true, withColumnLayout: props.withColumnLayout, columnsSize: props.columnsSize, ...attrs, } : { activePage: props.activePage, dialogType: props.dialogType, content: props.content, isDismissible: props.isDismissible ?? false, ...attrs, } ); const onCloseModal = () => { emit('close-modal'); }; const handleInternalClose = () => { // Only emit if the modal is still open to prevent double emission from animationend if (props.isOpen) { onCloseModal(); } }; // If the first button is a loading button then close the modal have 0.5 seconds // if the state is changed to 'success'. watch( () => props.actions, () => { if (props.actions?.[0]?.state === 'success') { setTimeout(onCloseModal, LOADING_BTN_CLOSE_AFTER_SUCCESS_DELAY_TIME); } } ); if (Object.keys(attrs).includes('overflow-visible')) { deprecate( 'BentoModal "overflow-visible" property', `"overflow-visible" property was made redundant. BentoModal can work correctly without the need to pass the propery. Remove the "overflow-visible" prop from BentoModal to remove this error.`, '2.0.0' ); } </script> <script lang="ts"> /** * Modal is a window that appear over any other page content * and focus user attention exclusively on one task or piece of * information at the moment. * * @example * import { BentoModal } from '@adyen/bento-vue2'; * * export default { * components: { BentoModal }, * template: ` * <bento-modal :isOpen="true"> * Title of the Modal * <template #content> * Mandatory content slot * </template> * </bento-modal> * ` * }; */ export default defineComponent({ components: { BentoModalDefault, BentoModalDialog }, }); </script>
1
+ <template> <component :is="computedVariant" class="b-modal" :is-open="isOpen" :actions="actions" :initially-open="initiallyOpen" v-bind="customProps" @close-modal="onCloseModal" @update:active-page="emit('update:active-page', $event)" > <template v-if="hasSlot('default')" #default> <slot></slot> </template> <template v-if="hasSlot('content-col-1')" #content-col-1><slot name="content-col-1"></slot></template> <template v-if="hasSlot('content-col-2')" #content-col-2><slot name="content-col-2"></slot></template> <template v-if="hasSlot('content')" #content><slot name="content"></slot></template> </component> </template> <script setup lang="ts"> import { computed, defineComponent, type PropType, useAttrs, useSlots } from 'vue'; import { BentoModalColumnLayoutVariant, BentoModalDialogType, BentoModalSize, BentoModalVariant, BentoModalVariantMapping, } from './modal.types'; import { BentoModalDefault, BentoModalDialog } from '@/components/modal/components'; import { useHasSlot } from '@/composables'; import { type BentoButtonActionsList } from '@/components/button'; import { deprecate } from '@/utils/ts/deprecate'; type BentoModalVariantProp = BentoModalVariant | `${BentoModalVariant}` | `${BentoModalVariantMapping}`; const slots = useSlots(); const hasSlot = useHasSlot(slots); const attrs = useAttrs(); const emit = defineEmits<{ /** * Emits update event when page is changed internally */ (e: 'update:active-page', page: string): void; /** * Emitted when the modal is closed */ (e: 'close-modal'); }>(); const props = defineProps({ /** * List of actions that will be used to render the buttons. * First element in the list will be rendered as "primary". * All elements after the first will be rendered as "secondary". * * Each object in the list should have a `title`, an `event` and * (optional) a Vue `icon` from the library `@adyen/ui-assets-icons-16`. * * `title`: Button's text. * * `event`: Event triggered when clicking the Button. * * `icon`: Vue Icon from the library `@adyen/ui-assets-icons-16`. * * ``` * [{ * title: 'Primary',2 * event: () => { ... }, * icon: FourPeopleIcon * }, { * title: 'Secondary', * event: () => { ... } * }] * ``` */ actions: { type: Array as PropType<BentoButtonActionsList>, default: null, }, /** * The id of the current active page * Should be the same id as the BentoModalPage to be displayed */ activePage: { type: String, default: null, }, /** * Text label that is displayed left to the action buttons */ actionsLabel: { type: String, default: null }, /** * Text that will be displayed in the content section of Modal */ content: { type: String, default: null }, /** * If a column layout is provided, it renders the respective left column width variant. This can be smaller than the second column, the same size as the second column or bigger than the second column. */ columnsSize: { type: String as PropType<BentoModalColumnLayoutVariant | `${BentoModalColumnLayoutVariant}`>, default: BentoModalColumnLayoutVariant.SMALLER, validator: (value: BentoModalColumnLayoutVariant) => Object.values(BentoModalColumnLayoutVariant).includes(value), }, /** * If provided, manages the padding of the whole modal container */ customPadding: { type: String, default: null }, /** * If passed - sets first button color to red */ destructiveActions: { type: Boolean, default: false }, /** * Changes the visual styling of action buttons */ dialogType: { type: String as PropType<BentoModalDialogType | `${BentoModalDialogType}`>, default: BentoModalDialogType.CONFIRMATION, validator: (value: BentoModalDialogType) => Object.values(BentoModalDialogType).includes(value), }, /** * Hides a cross icon on the right side of the title */ hideCloseButton: { type: Boolean, default: false }, /** * If set to true, it allows the modal to be closed without performing an action */ isDismissible: { type: Boolean, default: null }, /** * Controls the modal's state from the outside. Set it to true to open the modal */ isOpen: { type: Boolean, default: false, }, /** * Sets the open attribute on the dialog element, use isOpen instead to toggle modal visbility. */ initiallyOpen: { type: Boolean, default: false }, /** * Sets the width of the modal */ size: { type: String as PropType<BentoModalSize | `${BentoModalSize}`>, default: BentoModalSize.SMALL, validator: (value: BentoModalSize) => Object.values(BentoModalSize).includes(value), }, /** * Choose between default or dialog versions of modal */ variant: { type: String as PropType<BentoModalVariantProp>, default: 'default', validator: (value: BentoModalVariantProp) => Object.values<BentoModalVariantProp>(BentoModalVariant).includes(value) || Object.values<BentoModalVariantProp>(BentoModalVariantMapping).includes(value), }, /** * If provided, renders the modal's content in a two-column layout an wth dividers separating its content */ withColumnLayout: { type: Boolean, default: false }, }); const computedVariant = computed(() => { if (Object.values<BentoModalVariantProp>(BentoModalVariantMapping).includes(props.variant)) { return props.variant; } return BentoModalVariantMapping[props.variant.toUpperCase()]; }); const customProps = computed(() => computedVariant.value === BentoModalVariantMapping.DEFAULT ? { activePage: props.activePage, hideCloseButton: props.hideCloseButton, size: props.size, customPadding: props.customPadding, actionsLabel: props.actionsLabel, destructiveActions: props.destructiveActions, isDismissible: props.isDismissible ?? true, withColumnLayout: props.withColumnLayout, columnsSize: props.columnsSize, ...attrs, } : { activePage: props.activePage, dialogType: props.dialogType, content: props.content, isDismissible: props.isDismissible ?? false, ...attrs, } ); const onCloseModal = () => { emit('close-modal'); }; if (Object.keys(attrs).includes('overflow-visible')) { deprecate( 'BentoModal "overflow-visible" property', `"overflow-visible" property was made redundant. BentoModal can work correctly without the need to pass the propery. Remove the "overflow-visible" prop from BentoModal to remove this error.`, '2.0.0' ); } </script> <script lang="ts"> /** * Modal is a window that appear over any other page content * and focus user attention exclusively on one task or piece of * information at the moment. * * @example * import { BentoModal } from '@adyen/bento-vue2'; * * export default { * components: { BentoModal }, * template: ` * <bento-modal :isOpen="true"> * Title of the Modal * <template #content> * Mandatory content slot * </template> * </bento-modal> * ` * }; */ export default defineComponent({ components: { BentoModalDefault, BentoModalDialog }, }); </script>
@@ -0,0 +1 @@
1
+ import BentoNavigationMenuGroup from './navigation-menu-group.vue'; import BentoNavigationMenu from '../../navigation-menu.vue'; import BentoNavigationMenuItem from '../navigation-menu-item/navigation-menu-item.vue'; import { BentoButton } from '@/components/button'; import type { Meta, StoryObj } from '@storybook/vue'; import NavPaymentsIcon from '@adyen/ui-assets-icons-16/vue/nav-payments'; import StarIcon from '@adyen/ui-assets-icons-16/vue/star'; import { isVue2 } from 'vue-demi'; const meta: Meta = { title: 'Navigation Menu/Navigation Menu Group', component: BentoNavigationMenuGroup, argTypes: { showIcon: { table: { category: 'Storybook controls', }, control: { type: 'boolean', }, }, icon: { table: { disable: true, }, }, default: { table: { disable: true, }, }, }, }; export default meta; type Story = StoryObj<typeof BentoNavigationMenuGroup>; export const Default: Story = { render: (_args, { argTypes }) => ({ components: { BentoButton, BentoNavigationMenu, BentoNavigationMenuGroup, BentoNavigationMenuItem, NavPaymentsIcon, StarIcon, }, props: Object.keys(argTypes), setup(props) { return { args: isVue2 ? props : _args, }; }, template: ` <div style="width: 248px;"> <bento-navigation-menu label="Pages"> <bento-navigation-menu-group :label="args.label" :is-expanded="args.isExpanded" > <template v-if="args.showIcon" #icon> <nav-payments-icon /> </template> <bento-navigation-menu-item nested value="payments" label="Payments" :link="{ to: '#payments', isNotRouting: true }"> <template v-if="args.showAction" #action> <bento-button variant="tertiary"> <template #iconLeft> <star-icon /> </template> </bento-button> </template> </bento-navigation-menu-item> <bento-navigation-menu-item nested value="preauth" label="Pre-authorized" :link="{ to: '#preauth', isNotRouting: true }"> <template v-if="args.showAction" #action> <bento-button variant="tertiary"> <template #iconLeft> <star-icon /> </template> </bento-button> </template> </bento-navigation-menu-item> </bento-navigation-menu-group> </bento-navigation-menu> </div> `, }), args: { label: 'Payments', isExpanded: false, showIcon: true, }, };
@@ -0,0 +1 @@
1
+ import type { Ref } from 'vue'; export interface BentoNavigationMenuGroupProps { /** * Whether the group is expanded */ isExpanded?: boolean; /** * Label text for the group */ label: string; } /** * @private */ export interface BentoNavigationMenuItemRegistration { id: string; isSelected: () => boolean; } /** * @private */ export interface BentoNavigationMenuGroupState { registerItem: (itemId: string, isSelected: () => boolean) => void; unregisterItem: (itemId: string) => void; isSemiCollapsed: Ref<boolean>; }
@@ -0,0 +1 @@
1
+ <template> <li class="b-navigation-menu-group"> <button :id="buttonId" type="button" class="b-navigation-menu-group__header" :class="{ ...headerClasses, 'b-navigation-menu-group__header--no-icon': !hasSlot('icon'), }" :aria-expanded="`${isOpen && !isSemiCollapsed}`" :aria-controls="contentId" :aria-description="headerAriaDescription" @click="toggleGroup" > <div v-if="hasSlot('icon')" class="b-navigation-menu-group__icon"> <slot name="icon" /> </div> <bento-typography ref="labelRef" v-bento-tooltip-directive="isTruncated ? label : undefined" el="span" variant="body" stronger class="b-navigation-menu-group__label" > {{ label }} </bento-typography> <div class="b-navigation-menu-group__icon b-navigation-menu-group__icon--chevron"> <chevron-up-icon v-if="isOpen && !isSemiCollapsed" aria-hidden="true" /> <chevron-down-icon v-else aria-hidden="true" /> </div> </button> <Transition name="b-navigation-menu-group__animation--content"> <div v-show="isOpen" :id="contentId" class="b-navigation-menu-group__content-wrapper" role="region" :aria-labelledby="buttonId" :style="contentMaxHeight" > <ul ref="contentDiv" class="b-navigation-menu-group__content"> <slot /> </ul> </div> </Transition> </li> </template> <script setup lang="ts"> import { computed, inject, onMounted, onUnmounted, provide, ref, useSlots, watch } from 'vue'; import { useResizeObserver } from '@vueuse/core'; import { BentoTypography } from '@/components/typography'; import { useExpandableContentHeight } from '@/composables/use-expandable-content-height/use-expandable-content-height'; import { useHasSlot } from '@/composables'; import { BentoTooltipDirective as vBentoTooltipDirective } from '@/directives/tooltip'; import { generateUid } from '@/core/utils/ts'; import { useI18n } from '@/utils/ts/i18n'; import ChevronDownIcon from '@adyen/ui-assets-icons-16/vue/chevron-down'; import ChevronUpIcon from '@adyen/ui-assets-icons-16/vue/chevron-up'; import type { BentoNavigationMenuGroupProps, BentoNavigationMenuGroupState, BentoNavigationMenuItemRegistration, } from './navigation-menu-group.types'; import { NAVIGATION_MENU_STATE_INJECTION_KEY } from '../../navigation-menu.keys'; import { NAVIGATION_MENU_GROUP_STATE_INJECTION_KEY } from './navigation-menu-group.keys'; import { type BentoNavigationMenuState } from '../../navigation-menu.types'; import messages from '../../messages.json'; type MessageSchema = (typeof messages)['en-US']; const props = withDefaults(defineProps<BentoNavigationMenuGroupProps>(), { isExpanded: false, }); const slots = useSlots(); const hasSlot = useHasSlot(slots); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const headerClasses = computed(() => ({ 'b-navigation-menu-group__header--expanded': isOpen.value && !isSemiCollapsed.value, })); const headerAriaDescription = computed(() => (isSemiCollapsed.value ? t('showingCurrentPage') : undefined)); const labelRef = ref(null); const isTruncated = ref(false); useResizeObserver(labelRef, () => { const el = labelRef.value?.$el ?? labelRef.value; if (el) { isTruncated.value = el.scrollWidth > el.clientWidth; } }); const groupId = generateUid('navigation-menu-group'); const buttonId = generateUid('navigation-menu-group-button'); const contentId = generateUid('navigation-menu-group-content'); const navigationMenuState = inject<BentoNavigationMenuState | null>(NAVIGATION_MENU_STATE_INJECTION_KEY, null); const nestedItems = ref<Array<BentoNavigationMenuItemRegistration>>([]); const registerItem = (itemId: string, isSelected: () => boolean) => { nestedItems.value.push({ id: itemId, isSelected }); }; const unregisterItem = (itemId: string) => { nestedItems.value = nestedItems.value.filter(item => item.id !== itemId); }; const isSemiCollapsed = ref(false); provide<BentoNavigationMenuGroupState>(NAVIGATION_MENU_GROUP_STATE_INJECTION_KEY, { registerItem, unregisterItem, isSemiCollapsed, }); const hasActiveItem = computed(() => nestedItems.value.some(item => item.isSelected())); const emit = defineEmits<{ /** * Emits update event when group is toggled */ (e: 'update:is-expanded', value: boolean): void; }>(); const isOpen = ref(props.isExpanded); const contentDiv = ref<HTMLUListElement>(null); const { contentHeight } = useExpandableContentHeight(contentDiv, isOpen); const contentMaxHeight = computed(() => (contentHeight.value ? { 'max-height': contentHeight.value } : {})); const toggleGroup = () => { if (isOpen.value && !isSemiCollapsed.value) { if (hasActiveItem.value) { isSemiCollapsed.value = true; } else { isOpen.value = false; } } else if (isSemiCollapsed.value) { isSemiCollapsed.value = false; } else { isOpen.value = true; } emit('update:is-expanded', isOpen.value && !isSemiCollapsed.value); }; const setOpen = (open: boolean) => { if (open) { isOpen.value = true; isSemiCollapsed.value = false; } else if (hasActiveItem.value) { isSemiCollapsed.value = true; } else { isSemiCollapsed.value = false; isOpen.value = false; } emit('update:is-expanded', open); }; watch( () => props.isExpanded, value => { if (!isSemiCollapsed.value) { isOpen.value = value; } } ); onMounted(() => { if (hasActiveItem.value && !isOpen.value) { isOpen.value = true; emit('update:is-expanded', true); } if (navigationMenuState) { navigationMenuState.registerGroup(groupId, setOpen, () => isOpen.value && !isSemiCollapsed.value); } }); onUnmounted(() => navigationMenuState?.unregisterGroup(groupId)); </script> <script lang="ts"> /** * Navigation menu group component for creating collapsible sections. * * @usage * import { BentoNavigationMenuGroup, BentoNavigationMenuItem } from '@adyen/bento-vue2'; * * export default { * components: { BentoNavigationMenuGroup, BentoNavigationMenuItem }, * template: ` * <bento-navigation-menu-group label="Section" :is-expanded="true"> * <template #icon><folder-icon /></template> * <bento-navigation-menu-item nested value="item-1" label="Item 1" /> * <bento-navigation-menu-item nested value="item-2" label="Item 2" /> * </bento-navigation-menu-group> * ` * } */ export default { name: 'bento-navigation-menu-group', i18n: { messages }, }; </script> <style lang="scss" scoped src="./navigation-menu-group.scss" />
@@ -0,0 +1 @@
1
+ import BentoNavigationMenuItem from './navigation-menu-item.vue'; import BentoNavigationMenu from '../../navigation-menu.vue'; import { BentoButton } from '@/components/button'; import type { Meta, StoryObj } from '@storybook/vue'; import NavHomeIcon from '@adyen/ui-assets-icons-16/vue/nav-home'; import StarIcon from '@adyen/ui-assets-icons-16/vue/star'; import { isVue2 } from 'vue-demi'; const meta: Meta = { title: 'Navigation Menu/Navigation Menu Item', component: BentoNavigationMenuItem, argTypes: { showIcon: { table: { category: 'Storybook controls', }, control: { type: 'boolean', }, }, showAction: { table: { category: 'Storybook controls', }, control: { type: 'boolean', }, }, icon: { table: { disable: true, }, }, action: { table: { disable: true, }, }, default: { table: { disable: true, }, }, }, }; export default meta; type Story = StoryObj<typeof BentoNavigationMenuItem>; export const Default: Story = { render: (_args, { argTypes }) => ({ components: { BentoButton, BentoNavigationMenu, BentoNavigationMenuItem, NavHomeIcon, StarIcon }, props: Object.keys(argTypes), setup(props) { return { args: isVue2 ? props : _args, }; }, template: ` <div style="width: 248px;"> <bento-navigation-menu label="Pages"> <bento-navigation-menu-item :value="args.value" :label="args.label" :link="args.link" :nested="args.nested" :always-show-actions="args.alwaysShowActions" > <template v-if="args.showIcon" #icon> <nav-home-icon /> </template> <template v-if="args.showAction" #action> <bento-button variant="tertiary"> <template #iconLeft> <star-icon /> </template> </bento-button> </template> </bento-navigation-menu-item> </bento-navigation-menu> </div> `, }), args: { value: 'home', label: 'Home', link: { to: '#home', isNotRouting: true }, nested: false, alwaysShowActions: false, showIcon: true, showAction: true, }, };
@@ -0,0 +1 @@
1
+ import type { BentoLinkProps } from '@/components/link/link.types'; export interface BentoNavigationMenuItemProps { /** * Whether the item is nested within a group (affects styling) */ nested?: boolean; /** * Label text for the item */ label: string; /** * Link properties to make the navigation item a navigable link */ link?: BentoLinkProps; /** * Value to identify this navigation item. Used with navigation-menu's activePage prop to determine if selected. */ value: string; /** * Whether the action slot should always be shown. When false, the action slot is only visible on hover. */ alwaysShowActions?: boolean; }
@@ -0,0 +1 @@
1
+ <template> <li> <component :is="wrapperComponent" v-bind="wrapperProps" class="b-navigation-menu-item" :class="{ ...conditionalClasses, 'b-navigation-menu-item--no-icon': !hasSlot('icon'), }" :aria-current="isSelected && link ? 'page' : undefined" :aria-hidden="!isVisible ? 'true' : undefined" :tabindex="!isVisible ? -1 : undefined" :inert="!isVisible ? '' : undefined" @click="handleClick" > <div v-if="!nested && hasSlot('icon')" class="b-navigation-menu-item__icon"> <slot name="icon" /> </div> <bento-typography ref="labelRef" v-bento-tooltip-directive="isTruncated ? label : undefined" el="span" variant="body" stronger class="b-navigation-menu-item__label" > {{ label }} </bento-typography> <div v-if="hasSlot('action')" class="b-navigation-menu-item__action"> <slot name="action" /> </div> </component> </li> </template> <script setup lang="ts"> import { computed, inject, onBeforeUnmount, onMounted, ref, useSlots } from 'vue'; import { useResizeObserver } from '@vueuse/core'; import { BentoLink } from '@/components/link'; import { BentoTypography } from '@/components/typography'; import { generateUid } from '@/core/utils/ts'; import { useHasSlot } from '@/composables'; import { BentoTooltipDirective as vBentoTooltipDirective } from '@/directives/tooltip'; import type { BentoNavigationMenuItemProps } from './navigation-menu-item.types'; import type { BentoNavigationMenuGroupState } from '../navigation-menu-group/navigation-menu-group.types'; import { NAVIGATION_MENU_GROUP_STATE_INJECTION_KEY } from '../navigation-menu-group/navigation-menu-group.keys'; import type { BentoNavigationMenuState } from '../../navigation-menu.types'; import { NAVIGATION_MENU_STATE_INJECTION_KEY } from '../../navigation-menu.keys'; const props = withDefaults(defineProps<BentoNavigationMenuItemProps>(), { nested: false, link: undefined, alwaysShowActions: false, }); const slots = useSlots(); const hasSlot = useHasSlot(slots); const wrapperComponent = computed(() => (props.link ? BentoLink : 'div')); const wrapperProps = computed(() => (props.link ? { ...props.link, disableTypography: true } : {})); const labelRef = ref(null); const isTruncated = ref(false); useResizeObserver(labelRef, () => { const el = labelRef.value?.$el ?? labelRef.value; if (el) { isTruncated.value = el.scrollWidth > el.clientWidth; } }); const itemId = generateUid('navigation-menu-item'); const groupState = inject<BentoNavigationMenuGroupState | null>(NAVIGATION_MENU_GROUP_STATE_INJECTION_KEY, null); const menuState = inject<BentoNavigationMenuState | null>(NAVIGATION_MENU_STATE_INJECTION_KEY, null); const isSelected = computed(() => menuState?.activePage?.value === props.value); const isVisible = computed(() => { if (!groupState) { return true; } if (!groupState.isSemiCollapsed.value) { return true; } return isSelected.value; }); const conditionalClasses = computed(() => ({ 'b-navigation-menu-item--selected': isSelected.value, 'b-navigation-menu-item--nested': props.nested, 'b-navigation-menu-item--hidden': !isVisible.value, 'b-navigation-menu-item--always-show-actions': props.alwaysShowActions, })); onMounted(() => { if (groupState && props.nested) { groupState.registerItem(itemId, () => isSelected.value); } }); const handleClick = () => { menuState?.updateActivePage(props.value); }; onBeforeUnmount(() => { if (groupState && props.nested) { groupState.unregisterItem(itemId); } }); </script> <script lang="ts"> /** * Navigation menu item component for rendering individual navigation links. * Can be used standalone or nested within a navigation-menu-group. * * @usage * import { BentoNavigationMenuItem } from '@adyen/bento-vue2'; * * export default { * components: { BentoNavigationMenuItem }, * template: ` * <bento-navigation-menu-item value="home" label="Home"> * <template #icon><home-icon /></template> * <template #action><star-icon /></template> * </bento-navigation-menu-item> * ` * } */ export default { name: 'bento-navigation-menu-item', }; </script> <style lang="scss" scoped src="./navigation-menu-item.scss" />
@@ -0,0 +1,321 @@
1
+ import { Canvas, Meta, Source } from '@storybook/blocks';
2
+ import * as NavigationMenuStories from './navigation-menu.stories';
3
+
4
+ <Meta of={NavigationMenuStories} />
5
+
6
+ # Navigation Menu
7
+
8
+ The Navigation Menu provides a structured way to organize and navigate through different sections of an application. It
9
+ supports simple lists, collapsible groups, and nested items, making it ideal for complex navigation hierarchies.
10
+
11
+ <Canvas of={NavigationMenuStories.EverythingBagel} />
12
+
13
+ ## Use Cases
14
+
15
+ Use a Navigation Menu when:
16
+
17
+ - You need to organize multiple navigation items in a vertical sidebar
18
+ - Your application has a hierarchical structure with sections and subsections
19
+ - You want to provide quick access to different pages or views within your application
20
+ - You need collapsible groups to reduce visual clutter while maintaining discoverability
21
+
22
+ ### Do not use
23
+
24
+ Do not use a Navigation Menu:
25
+
26
+ - For horizontal navigation - use `bento-tabs` or a horizontal navbar instead
27
+ - For secondary navigation within a content area - use `bento-secondary-nav` instead
28
+ - For switching between different views of the same content - use `bento-tabs` instead
29
+
30
+ **Navigation Menu vs Secondary Nav**: Navigation Menu is designed for primary app-level navigation in sidebars, while
31
+ Secondary Nav is better suited for content-level navigation within pages (e.g., settings sections).
32
+
33
+ **Navigation Menu vs Tabs**: Tabs are for switching between related content views on the same page, while Navigation
34
+ Menu is for navigating to different pages or major sections of the app.
35
+
36
+ ## Variations
37
+
38
+ ### Simple list
39
+
40
+ The most basic usage is a flat list of navigation items without any grouping.
41
+
42
+ <Canvas of={NavigationMenuStories.SimpleList} />
43
+
44
+ ### With groups
45
+
46
+ Use `bento-navigation-menu-group` to organize items into collapsible sections. This is useful when you have many
47
+ navigation items that can be logically grouped.
48
+
49
+ <Source
50
+ dark
51
+ language="html"
52
+ code={`<bento-navigation-menu>
53
+ <bento-navigation-menu-group label="Settings" :is-expanded="true">
54
+ <template #icon><folder-icon /></template>
55
+ <bento-navigation-menu-item nested value="general" label="General" />
56
+ <bento-navigation-menu-item nested value="security" label="Security" />
57
+ </bento-navigation-menu-group>
58
+ </bento-navigation-menu>`}
59
+ />
60
+
61
+ > **Note**: When using items inside a group, set the `nested` prop to `true` on each `bento-navigation-menu-item` to
62
+ > apply proper indentation and styling.
63
+
64
+ ### With header actions
65
+
66
+ Use the `headerActions` slot to add interactive elements like search or filters to the navigation header.
67
+
68
+ <Source
69
+ dark
70
+ language="html"
71
+ code={`<bento-navigation-menu label="Pages">
72
+ <template #headerActions>
73
+ <bento-button variant="tertiary-with-background">
74
+ <template #iconLeft><search-icon /></template>
75
+ </bento-button>
76
+ </template>
77
+ <!-- menu items -->
78
+ </bento-navigation-menu>`}
79
+ />
80
+
81
+ ### With expand/collapse all button
82
+
83
+ Set `showExpandAllButton` to `true` to display a button that expands or collapses all groups at once. This is helpful
84
+ when you have multiple collapsible groups.
85
+
86
+ > **Note**: The expand/collapse all button only appears when there is at least one `bento-navigation-menu-group` in the
87
+ > menu.
88
+
89
+ ### Action visibility
90
+
91
+ By default, content in the `action` slot of a menu item is only visible on hover. Set `alwaysShowActions` to `true` to
92
+ keep the action visible at all times.
93
+
94
+ ## Behavior
95
+
96
+ ### Navigation patterns
97
+
98
+ > **⚠️ Important**: Every `bento-navigation-menu-item` **must** be rendered as (or wrapped in) a real link. Without a
99
+ > link, the item is not keyboard-accessible, cannot be opened in a new tab, does not expose a URL to screen readers, and
100
+ > fails core navigation accessibility requirements. Use **one** of the two patterns below.
101
+
102
+ #### 1. Controlled navigation with the `link` prop
103
+
104
+ Pass a `link` object to `bento-navigation-menu-item` to integrate with routing. The `link` prop accepts the same props
105
+ as `bento-link`, allowing seamless integration with Vue Router or other routing solutions.
106
+
107
+ <Source
108
+ dark
109
+ language="html"
110
+ code={`<bento-navigation-menu-item
111
+ value="home"
112
+ label="Home"
113
+ :link="{ to: '/home' }"
114
+ >
115
+ <template #icon><home-icon /></template>
116
+ </bento-navigation-menu-item>`}
117
+ />
118
+
119
+ This is the recommended approach as it provides the best accessibility, SEO, and user experience (right-click to open in
120
+ new tab, copy link, etc.) out of the box.
121
+
122
+ #### 2. Wrapping with a custom link component
123
+
124
+ If you need full control over the link behavior, wrap the item with any link component (such as `router-link`,
125
+ `bento-link`, or a custom link component). The wrapping element **must** be a real, focusable link.
126
+
127
+ <Source
128
+ dark
129
+ language="html"
130
+ code={`<router-link to="/profile">
131
+ <bento-navigation-menu-item
132
+ value="profile"
133
+ label="Profile"
134
+ >
135
+ <template #icon><profile-icon /></template>
136
+ </bento-navigation-menu-item>
137
+ </router-link>`}
138
+ />
139
+
140
+ > **Note**: When wrapping with a custom link component, `aria-current` is **not** applied automatically by the menu item
141
+ > — you must set `aria-current="page"` on the wrapping element yourself (many routers, like Vue Router, handle this
142
+ > automatically via `router-link-active` / `router-link-exact-active`). `aria-current` is only applied automatically
143
+ > when you use the `link` prop.
144
+
145
+ #### Tracking navigation with the `update:active-page` event
146
+
147
+ The `bento-navigation-menu` emits `update:active-page` whenever an item is clicked. This is useful for side effects such
148
+ as analytics tracking, syncing state to a store, or triggering other UI updates — **in addition to** one of the two link
149
+ patterns above.
150
+
151
+ > **⚠️ Do not** use `update:active-page` as the **only** navigation mechanism. The event does not provide a focusable,
152
+ > keyboard-accessible link — items without a `link` prop or link wrapper are inaccessible to keyboard and screen reader
153
+ > users. Always pair it with one of the patterns above.
154
+
155
+ ### Group expansion state
156
+
157
+ Groups can be controlled or uncontrolled. To control a group's expansion state, bind to `is-expanded`:
158
+
159
+ <Source
160
+ dark
161
+ language="html"
162
+ code={`<bento-navigation-menu-group
163
+ :is-expanded="paymentsExpanded"
164
+ @update:is-expanded="paymentsExpanded = $event"
165
+ >
166
+ <!-- items -->
167
+ </bento-navigation-menu-group>`}
168
+ />
169
+
170
+ ### Semi-collapsed groups
171
+
172
+ When a group contains the currently active item, collapsing the group will enter a "semi-collapsed" state where only the
173
+ active item remains visible. This provides context about the current location while saving space. The group will close
174
+ fully as soon as a different page is selected.
175
+
176
+ ### Truncated labels
177
+
178
+ Item and group labels are truncated with an ellipsis when they overflow the available width. In that case, the full
179
+ label is automatically shown in a tooltip on hover and focus, so it remains discoverable without extra configuration.
180
+ When the label fits, no tooltip is rendered.
181
+
182
+ ## Accessibility
183
+
184
+ The Navigation Menu follows WCAG guidelines for accessible navigation patterns.
185
+
186
+ ### Nav landmark
187
+
188
+ The component uses the `<nav>` element, which creates a navigation landmark region. This helps screen reader users
189
+ quickly locate and navigate to the primary navigation area of your application.
190
+
191
+ #### Labeling the landmark
192
+
193
+ When a page has more than one navigation landmark (for example, a primary sidebar and a secondary nav, breadcrumbs, or a
194
+ footer nav), each landmark needs an accessible name so screen reader users can tell them apart.
195
+
196
+ - If a `label` prop is provided, the `<nav>` is automatically labeled via `aria-labelledby` pointing to the rendered
197
+ label — no extra work needed.
198
+ - If no `label` prop is provided, you **should** add an `aria-label` attribute to the component to give the landmark an
199
+ accessible name:
200
+
201
+ > **Note**: Omitting both `label` and `aria-label` is only acceptable when the page contains a single navigation
202
+ > landmark. When in doubt, provide one.
203
+
204
+ ### Keyboard interaction
205
+
206
+ | Keyboard interaction | Action |
207
+ | -------------------- | ------------------------------------------------------------------ |
208
+ | `Tab` | Moves focus to the next interactive element (item or group header) |
209
+ | `Shift + Tab` | Moves focus to the previous interactive element |
210
+ | `Space` | Activates the focused item or toggles the focused group |
211
+ | `Enter` | Activates the focused item or toggles the focused group |
212
+
213
+ ### Roles, states and properties
214
+
215
+ #### Navigation Menu (container)
216
+
217
+ | Role | Attribute | Element | Usage |
218
+ | ----- | -------------------------------- | ------- | --------------------------------------------------------------------------------------------- |
219
+ | `nav` | | `nav` | Identifies the element as a navigation landmark region |
220
+ | | `aria-labelledby="ID_REFERENCE"` | `nav` | Applied automatically when `label` is set; points to the rendered label |
221
+ | | `aria-label` | `nav` | Should be provided by the consumer when no `label` is set, to give the landmark a unique name |
222
+
223
+ #### Navigation Menu Item
224
+
225
+ | Role | Attribute | Element | Usage |
226
+ | ------ | --------------------- | ------- | ------------------------------------------------------------------------------- |
227
+ | | `aria-current="page"` | `a` | Applied automatically when the item is selected **and** the `link` prop is used |
228
+ | `link` | | `a` | Applied automatically when using the `link` prop (via `bento-link`) |
229
+
230
+ > When wrapping the item with a custom link component, set `aria-current="page"` on the wrapping element yourself.
231
+
232
+ #### Navigation Menu Group
233
+
234
+ | Role | Attribute | Element | Usage |
235
+ | -------- | -------------------------------- | -------- | ---------------------------------------------------------------------------- |
236
+ | `button` | | `button` | The group header is a button that controls expansion/collapse |
237
+ | | `aria-expanded="true"` | `button` | Indicates the group is expanded and its content is visible |
238
+ | | `aria-expanded="false"` | `button` | Indicates the group is collapsed and its content is hidden |
239
+ | | `aria-controls="ID_REFERENCE"` | `button` | Refers to the content region controlled by this button |
240
+ | `region` | | `div` | The expandable content area |
241
+ | | `aria-labelledby="ID_REFERENCE"` | `div` | Refers to the button that controls this region, providing an accessible name |
242
+
243
+ ## Component API
244
+
245
+ ### BentoNavigationMenu
246
+
247
+ The main container component.
248
+
249
+ #### Props
250
+
251
+ | Prop | Type | Default | Description |
252
+ | --------------------- | --------- | ----------- | ------------------------------------------------- |
253
+ | `label` | `string` | `undefined` | Label text for the navigation menu header |
254
+ | `showExpandAllButton` | `boolean` | `false` | Whether to show the expand/collapse all button |
255
+ | `activePage` | `string` | `undefined` | The value of the currently active navigation item |
256
+
257
+ #### Slots
258
+
259
+ | Slot | Description |
260
+ | --------------- | -------------------------------------- |
261
+ | `default` | Navigation items and groups |
262
+ | `headerActions` | Action buttons displayed in the header |
263
+
264
+ #### Events
265
+
266
+ | Event | Payload | Description |
267
+ | -------------------- | -------- | ------------------------------------ |
268
+ | `update:active-page` | `string` | Emitted when the active page changes |
269
+
270
+ ---
271
+
272
+ ### BentoNavigationMenuItem
273
+
274
+ Individual navigation item component.
275
+
276
+ #### Props
277
+
278
+ | Prop | Type | Default | Description |
279
+ | ------------------- | ---------------- | ----------- | ---------------------------------------------------------------------------- |
280
+ | `value` | `string` | (required) | Unique identifier for this navigation item |
281
+ | `label` | `string` | (required) | Label text for the item |
282
+ | `nested` | `boolean` | `false` | Whether the item is nested within a group (affects styling) |
283
+ | `link` | `BentoLinkProps` | `undefined` | Link properties to make the item a navigable link |
284
+ | `alwaysShowActions` | `boolean` | `false` | Whether the action slot should always be visible (default: visible on hover) |
285
+
286
+ #### Slots
287
+
288
+ | Slot | Description |
289
+ | -------- | ------------------------------------------------------------ |
290
+ | `icon` | Icon displayed before the label (not shown for nested items) |
291
+ | `action` | Action element displayed at the end of the item |
292
+
293
+ ---
294
+
295
+ ### BentoNavigationMenuGroup
296
+
297
+ Collapsible group container for navigation items.
298
+
299
+ #### Props
300
+
301
+ | Prop | Type | Default | Description |
302
+ | ------------ | --------- | ------- | ----------------------------- |
303
+ | `isExpanded` | `boolean` | `false` | Whether the group is expanded |
304
+ | `label` | `string` | — | Label text for the group |
305
+
306
+ #### Slots
307
+
308
+ | Slot | Description |
309
+ | --------- | ------------------------------- |
310
+ | `icon` | Icon displayed before the label |
311
+ | `default` | Nested navigation items |
312
+
313
+ #### Events
314
+
315
+ | Event | Payload | Description |
316
+ | -------------------- | --------- | --------------------------------- |
317
+ | `update:is-expanded` | `boolean` | Emitted when the group is toggled |
318
+
319
+ ## Resources
320
+
321
+ - [Figma link](https://www.figma.com/design/IXcB6elA29Qy6waH5afmO9/WIP-navigation?node-id=748-8290&m=dev)
@@ -0,0 +1 @@
1
+ import { ref } from 'vue'; import { storybookDocsParameter } from '@/utils/ts/storybook'; import BentoNavigationMenu from './navigation-menu.vue'; import BentoNavigationMenuItem from './components/navigation-menu-item/navigation-menu-item.vue'; import BentoNavigationMenuGroup from './components/navigation-menu-group/navigation-menu-group.vue'; import { BentoButton } from '@/components/button'; import { BentoLink } from '@/components/link'; import type { Meta, StoryObj } from '@storybook/vue'; import StarIcon from '@adyen/ui-assets-icons-16/vue/star'; import StarFilledIcon from '@adyen/ui-assets-icons-16/vue/star-filled'; import SearchIcon from '@adyen/ui-assets-icons-16/vue/search'; import NavHomeIcon from '@adyen/ui-assets-icons-16/vue/nav-home'; import NavPaymentsIcon from '@adyen/ui-assets-icons-16/vue/nav-payments'; import NavReportsIcon from '@adyen/ui-assets-icons-16/vue/nav-reports'; import NavSettingsIcon from '@adyen/ui-assets-icons-16/vue/nav-settings'; import NavAccountHoldersIcon from '@adyen/ui-assets-icons-16/vue/nav-account-holders'; import { isVue2 } from 'vue-demi'; import NavigationMenuEverythingBagelExample from './__tests__/navigation-menu-everything-bagel-example.vue?raw'; import NavigationMenuSimpleListExample from './__tests__/navigation-menu-simple-list-example.vue?raw'; export default { title: 'Navigation Menu', component: BentoNavigationMenu, argTypes: { default: { control: false, table: { type: { summary: 'Navigation items and groups' }, }, }, headerActions: { control: false, table: { type: { summary: 'Action buttons' }, }, }, }, } as Meta; type Story = StoryObj<typeof BentoNavigationMenu>; export const EverythingBagel: Story = { render: (_args, { argTypes }) => ({ components: { BentoNavigationMenu, BentoNavigationMenuItem, BentoNavigationMenuGroup, BentoButton, BentoLink, StarIcon, StarFilledIcon, SearchIcon, NavHomeIcon, NavPaymentsIcon, NavReportsIcon, NavSettingsIcon, NavAccountHoldersIcon, }, props: Object.keys(argTypes), setup(props) { const activePage = ref('home'); const paymentsExpanded = ref(true); const favorites = ref({}); const toggleFavorite = (value: string) => { favorites.value = { ...favorites.value, [value]: !favorites.value[value] }; }; return { activePage, paymentsExpanded, favorites, toggleFavorite, args: isVue2 ? props : _args, }; }, template: ` <div style="width: 248px;"> <bento-navigation-menu v-bind="args" :active-page="activePage" @update:active-page="activePage = $event"> <template #headerActions> <bento-button variant="tertiary-with-background"> <template #iconLeft> <search-icon /> </template> </bento-button> </template> <bento-navigation-menu-item value="home" label="Home" :link="{ to: '#home', isNotRouting: true }" > <template #icon> <nav-home-icon /> </template> </bento-navigation-menu-item> <bento-navigation-menu-group label="Payments" :is-expanded="paymentsExpanded" @update:is-expanded="paymentsExpanded = $event" > <template #icon> <nav-payments-icon /> </template> <bento-navigation-menu-item nested value="payments" label="Payments" :link="{ to: '#payments', isNotRouting: true }" :always-show-actions="!!favorites.payments || activePage === 'payments'" > <template #action> <bento-button variant="tertiary" @click.stop.prevent="toggleFavorite('payments')" > <template #iconLeft> <star-filled-icon style="color: var(--b-color-decorative-green);" v-if="favorites.payments" /> <star-icon v-else /> </template> </bento-button> </template> </bento-navigation-menu-item> <bento-navigation-menu-item nested value="preauth" label="Pre-authorized" :link="{ to: '#preauth', isNotRouting: true }" :always-show-actions="!!favorites.preauth || activePage === 'preauth'" > <template #action> <bento-button variant="tertiary" @click.stop.prevent="toggleFavorite('preauth')" > <template #iconLeft> <star-filled-icon style="color: var(--b-color-decorative-green);" v-if="favorites.preauth" /> <star-icon v-else /> </template> </bento-button> </template> </bento-navigation-menu-item> <bento-navigation-menu-item nested value="links" label="Payment links" :link="{ to: '#links', isNotRouting: true }" :always-show-actions="!!favorites.links || activePage === 'links'" > <template #action> <bento-button variant="tertiary" @click.stop.prevent="toggleFavorite('links')" > <template #iconLeft> <star-filled-icon style="color: var(--b-color-decorative-green);" v-if="favorites.links" /> <star-icon v-else /> </template> </bento-button> </template> </bento-navigation-menu-item> <bento-navigation-menu-item nested value="methods" label="Payment methods" :link="{ to: '#methods', isNotRouting: true }" :always-show-actions="!!favorites.methods || activePage === 'methods'" > <template #action> <bento-button variant="tertiary" @click.stop.prevent="toggleFavorite('methods')" > <template #iconLeft> <star-filled-icon style="color: var(--b-color-decorative-green);" v-if="favorites.methods" /> <star-icon v-else /> </template> </bento-button> </template> </bento-navigation-menu-item> </bento-navigation-menu-group> <bento-navigation-menu-group label="Reports" > <template #icon> <nav-reports-icon /> </template> <bento-navigation-menu-item nested value="analytics" label="Analytics" :link="{ to: '#analytics', isNotRouting: true }" :always-show-actions="!!favorites.analytics || activePage === 'analytics'" > <template #action> <bento-button variant="tertiary" @click.stop.prevent="toggleFavorite('analytics')" > <template #iconLeft> <star-filled-icon style="color: var(--b-color-decorative-green);" v-if="favorites.analytics" /> <star-icon v-else /> </template> </bento-button> </template> </bento-navigation-menu-item> <bento-navigation-menu-item nested value="transactions" label="Transactions" :link="{ to: '#transactions', isNotRouting: true }" :always-show-actions="!!favorites.transactions || activePage === 'transactions'" > <template #action> <bento-button variant="tertiary" @click.stop.prevent="toggleFavorite('transactions')" > <template #iconLeft> <star-filled-icon style="color: var(--b-color-decorative-green);" v-if="favorites.transactions" /> <star-icon v-else /> </template> </bento-button> </template> </bento-navigation-menu-item> </bento-navigation-menu-group> <bento-navigation-menu-item value="settings" label="Settings" :link="{ to: '#settings', isNotRouting: true }" :always-show-actions="!!favorites.settings || activePage === 'settings'" > <template #icon> <nav-settings-icon /> </template> <template #action> <bento-button variant="tertiary" @click.stop.prevent="toggleFavorite('settings')" > <template #iconLeft> <star-filled-icon style="color: var(--b-color-decorative-green);" v-if="favorites.settings" /> <star-icon v-else /> </template> </bento-button> </template> </bento-navigation-menu-item> <bento-link to="#profile" is-not-routing disable-typography> <bento-navigation-menu-item value="profile" label="Profile" :always-show-actions="!!favorites.profile || activePage === 'profile'" > <template #icon> <nav-account-holders-icon /> </template> <template #action> <bento-button variant="tertiary" @click.stop.prevent="toggleFavorite('profile')" > <template #iconLeft> <star-filled-icon style="color: var(--b-color-decorative-green);" v-if="favorites.profile" /> <star-icon v-else /> </template> </bento-button> </template> </bento-navigation-menu-item> </bento-link> </bento-navigation-menu> </div> `, }), args: { label: 'Pages', showExpandAllButton: true, }, parameters: storybookDocsParameter(NavigationMenuEverythingBagelExample), }; export const SimpleList: Story = { render: (_args, { argTypes }) => ({ components: { BentoNavigationMenu, BentoNavigationMenuItem, StarIcon, }, props: Object.keys(argTypes), setup(props) { const activePage = ref('home'); return { activePage, args: isVue2 ? props : _args }; }, template: ` <div style="width: 248px;"> <bento-navigation-menu v-bind="args" :active-page="activePage" @update:active-page="activePage = $event"> <bento-navigation-menu-item value="home" label="Home" /> <bento-navigation-menu-item value="analytics" label="Analytics" /> <bento-navigation-menu-item value="settings" label="Settings" /> <bento-navigation-menu-item value="profile" label="Profile" /> </bento-navigation-menu> </div> `, }), args: { label: 'Pages', }, parameters: storybookDocsParameter(NavigationMenuSimpleListExample), };
@@ -0,0 +1 @@
1
+ import { type Ref } from 'vue'; export interface BentoNavigationMenuProps { /** * Label text for the navigation menu header */ label?: string; /** * Whether to show the expand/collapse all button */ showExpandAllButton?: boolean; /** * The value of the currently active navigation item */ activePage?: string; } /** * @private */ export interface BentoNavigationMenuGroupRegistration { id: string; setOpen: (open: boolean) => void; getIsOpen: () => boolean; } /** * @private */ export interface BentoNavigationMenuState { registerGroup: (groupId: string, setOpen: (open: boolean) => void, getIsOpen: () => boolean) => void; unregisterGroup: (groupId: string) => void; activePage: Ref<string | undefined>; updateActivePage: (value: string) => void; }
@@ -0,0 +1 @@
1
+ <template> <nav class="b-navigation-menu" :aria-labelledby="label ? titleId : undefined"> <div v-if="showHeader" class="b-navigation-menu__header"> <bento-typography v-if="label" :id="titleId" el="p" variant="caption" stronger class="b-navigation-menu__title" > {{ label }} </bento-typography> <div class="b-navigation-menu__actions-container"> <div v-if="hasSlot('headerActions')" class="b-navigation-menu__actions"> <slot name="headerActions" /> </div> <bento-button v-if="computedShowExpandAll" v-bento-tooltip-directive="expandButtonLabel" variant="tertiary-with-background" :aria-label="expandButtonLabel" @click="toggleAllGroups" > <template #iconLeft> <menu-collapse-icon v-if="allExpanded" /> <menu-expand-icon v-else /> </template> </bento-button> </div> </div> <ul class="b-navigation-menu__content"> <slot /> </ul> </nav> </template> <script setup lang="ts"> import { computed, provide, ref, toRef, useSlots } from 'vue'; import { BentoTypography } from '@/components/typography'; import { BentoButton } from '@/components/button'; import MenuCollapseIcon from '@adyen/ui-assets-icons-16/vue/menu-collapse'; import MenuExpandIcon from '@adyen/ui-assets-icons-16/vue/menu-expand'; import { useHasSlot } from '@/composables'; import { BentoTooltipDirective as vBentoTooltipDirective } from '@/directives/tooltip'; import { generateUid } from '@/core/utils/ts'; import { useI18n } from '@/utils/ts/i18n'; import type { BentoNavigationMenuGroupRegistration, BentoNavigationMenuProps, BentoNavigationMenuState, } from './navigation-menu.types'; import { NAVIGATION_MENU_STATE_INJECTION_KEY } from './navigation-menu.keys'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const props = withDefaults(defineProps<BentoNavigationMenuProps>(), { label: undefined, showExpandAllButton: false, activePage: undefined, }); const emit = defineEmits<{ /** * Emits update event when active page is changed */ (e: 'update:active-page', value: string): void; }>(); const slots = useSlots(); const hasSlot = useHasSlot(slots); const titleId = generateUid('navigation-menu-title'); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const groups = ref<Array<BentoNavigationMenuGroupRegistration>>([]); const registerGroup = (groupId: string, setOpen: (open: boolean) => void, getIsOpen: () => boolean) => { groups.value.push({ id: groupId, setOpen, getIsOpen }); }; const unregisterGroup = (groupId: string) => { groups.value = groups.value.filter(g => g.id !== groupId); }; const allExpanded = computed(() => groups.value.length > 0 && groups.value.every(group => group.getIsOpen())); const computedShowExpandAll = computed(() => groups.value.length > 0 && props.showExpandAllButton); const showHeader = computed(() => props.label || hasSlot('headerActions') || computedShowExpandAll.value); const expandButtonLabel = computed(() => (allExpanded.value ? t('hideAllPages') : t('showAllPages'))); const toggleAllGroups = () => { const shouldExpand = !allExpanded.value; groups.value.forEach(({ setOpen }) => setOpen(shouldExpand)); }; const updateActivePage = (value: string) => { emit('update:active-page', value); }; provide<BentoNavigationMenuState>(NAVIGATION_MENU_STATE_INJECTION_KEY, { registerGroup, unregisterGroup, activePage: toRef(props, 'activePage'), updateActivePage, }); </script> <script lang="ts"> /** * Navigation menu container component with optional header and action buttons. * * @usage * import { BentoNavigationMenu, BentoNavigationMenuItem, BentoNavigationMenuGroup } from '@adyen/bento-vue2'; * * export default { * components: { BentoNavigationMenu, BentoNavigationMenuItem, BentoNavigationMenuGroup }, * template: ` * <bento-navigation-menu label="Pages" active-page="home"> * <template #headerActions> * <button>Search</button> * </template> * <bento-navigation-menu-item value="home" label="Home"> * <template #icon><home-icon /></template> * </bento-navigation-menu-item> * <bento-navigation-menu-group label="Settings"> * <template #icon><folder-icon /></template> * <bento-navigation-menu-item nested value="general" label="General" /> * </bento-navigation-menu-group> * </bento-navigation-menu> * ` * } */ export default { i18n: { messages }, }; </script> <style lang="scss" scoped src="./navigation-menu.scss" />
@@ -1 +1 @@
1
- <template> <section class="b-secondary-nav-category" :aria-label="label"> <bento-typography el="h1" class="b-secondary-nav-category__label" variant="caption" stronger>{{ label }}</bento-typography> <ul class="b-secondary-nav__list"> <li v-for="item in items" :key="item.value"> <secondary-nav-item :label="item.label" :value="item.value" :items="item.items" /> </li> </ul> </section> </template> <script lang="ts" setup> import { BentoTypography } from '@/components/typography'; import { SecondaryNavItem } from '../secondary-nav-item'; import { type BentoSecondaryNavItemsItem } from '../../secondary-nav.types'; defineProps<{ /** * The display name of the category */ label: string; /** * A list of {@see BentoSecondaryNavItemsItem} that belongs to this category */ items: Array<BentoSecondaryNavItemsItem>; }>(); </script> <script lang="ts"> /** * This component shouldn't be used directly. It is internally used by {@see BentoSecondaryNav} */ export default {}; </script> <style lang="scss" scoped src="./secondary-nav-category.scss" />
1
+ <template> <section class="b-secondary-nav-category" :aria-label="label"> <bento-typography el="h1" class="b-secondary-nav-category__label" variant="caption" stronger>{{ label }}</bento-typography> <ul class="b-secondary-nav__list"> <li v-for="item in items" :key="item.value"> <secondary-nav-item :label="item.label" :value="item.value" :items="item.items" :error-counter="item.errorCounter" /> </li> </ul> </section> </template> <script lang="ts" setup> import { BentoTypography } from '@/components/typography'; import { SecondaryNavItem } from '../secondary-nav-item'; import { type BentoSecondaryNavItemsItem } from '../../secondary-nav.types'; defineProps<{ /** * The display name of the category */ label: string; /** * A list of {@see BentoSecondaryNavItemsItem} that belongs to this category */ items: Array<BentoSecondaryNavItemsItem>; }>(); </script> <script lang="ts"> /** * This component shouldn't be used directly. It is internally used by {@see BentoSecondaryNav} */ export default {}; </script> <style lang="scss" scoped src="./secondary-nav-category.scss" />
@@ -1 +1 @@
1
- <template> <div class="b-secondary-nav-item"> <div class="b-secondary-nav-item__wrapper"> <button class="b-secondary-nav-item__option" :class="conditionalClasses" :aria-current="isItemSelected ? 'page' : undefined" :aria-expanded="expandleItemIsToggleOnly ? `${isOpen}` : undefined" :aria-controls="expandleItemIsToggleOnly ? subItemsId : undefined" @click="expandleItemIsToggleOnly ? onToggleClick() : onClick($event)" > <bento-typography class="b-secondary-nav-item__title" variant="body" stronger> {{ label }} </bento-typography> </button> <bento-button v-if="hasSubItems" :aria-hidden="expandleItemIsToggleOnly" :tabindex="expandleItemIsToggleOnly ? -1 : undefined" class="b-secondary-nav-item__toggle" variant="tertiary" :aria-label="isOpen ? `Collapse ${label}` : `Expand ${label}`" :aria-expanded="!expandleItemIsToggleOnly ? `${isOpen}` : undefined" :aria-controls="!expandleItemIsToggleOnly ? subItemsId : undefined" @click.stop="onToggleClick" > <template #iconLeft> <chevron-up-icon v-if="isOpen" class="b-secondary-nav-item__icon" aria-hidden="true" /> <chevron-down-icon v-else class="b-secondary-nav-item__icon" aria-hidden="true" /> </template> </bento-button> </div> <Transition :name="CONTENT_TRANSITION_NAME"> <div v-if="hasSubItems && isOpen" :id="subItemsId" :style="subItemsMaxHeight"> <ul ref="subItemsRef" class="b-secondary-nav-item__sub-items"> <li v-for="item in items" :key="item.value"> <secondary-nav-item :label="item.label" :value="item.value" :items="item.items" is-sub-item /> </li> </ul> </div> </Transition> </div> </template> <script lang="ts" setup> import { SECONDARY_NAV_STATE } from '../../secondary-nav.keys'; import { BentoButton } from '@/components/button'; import { BentoTypography } from '@/components/typography'; import ChevronDownIcon from '@adyen/ui-assets-icons-16/vue/chevron-down'; import ChevronUpIcon from '@adyen/ui-assets-icons-16/vue/chevron-up'; import { computed, inject, ref, watch } from 'vue'; import { generateUid } from '@/core/utils/ts'; import { type BentoSecondaryNavItemsItem } from '../../secondary-nav.types'; import { observeSizeOfElement } from '@/utils/ts/resize'; import { useExpandableContentHeight } from '@/composables/use-expandable-content-height/use-expandable-content-height'; const { activeItemValue, updateActiveItemValue, allowExpandableItemNavigation } = inject(SECONDARY_NAV_STATE); const CONTENT_TRANSITION_NAME = 'b-secondary-nav-item__animation--content'; const props = defineProps<{ /** * The display name of the navigation item */ label: string; /** * The value of this item. */ value: string; /** * Nested sub-items for this item. */ items?: Array<BentoSecondaryNavItemsItem>; /** * Defines if the item is a subitem */ isSubItem?: boolean; }>(); const isOpen = ref(false); const subItemsId = generateUid('secondary-nav-sub-items'); const subItemsRef = ref<HTMLUListElement>(null); const hasSubItems = computed(() => !!props.items?.length); const isItemSelected = computed(() => activeItemValue.value === props.value); const expandleItemIsToggleOnly = computed(() => hasSubItems.value && !allowExpandableItemNavigation.value); const { updateContentHeight, contentHeight } = useExpandableContentHeight(subItemsRef, isOpen); const subItemsMaxHeight = computed(() => (contentHeight.value ? { 'max-height': contentHeight.value } : {})); const conditionalClasses = computed(() => ({ 'b-secondary-nav-item__option--active': isItemSelected.value, 'b-secondary-nav-item__option--expandable': hasSubItems.value, 'b-secondary-nav-item__option--sub-item': props.isSubItem, })); const emit = defineEmits<{ /** * Event emitted when this element is clicked */ (e: 'click', clickEvent: MouseEvent): void; }>(); const onClick = (e: MouseEvent) => { emit('click', e); updateActiveItemValue(props.value); }; const onToggleClick = () => { isOpen.value = !isOpen.value; }; const hasActiveDescendant = (items: Array<BentoSecondaryNavItemsItem>, value: string): boolean => { return items.some( item => item.value === value || (item.items ? hasActiveDescendant(item.items, value) : false) ); }; watch( () => subItemsRef.value, () => { let observer: ResizeObserver | null = null; if (!observer && subItemsRef.value) { observer = observeSizeOfElement(subItemsRef.value, () => { if (subItemsRef.value) { updateContentHeight(); } }); } } ); watch( isOpen, () => { updateContentHeight(); }, { immediate: true } ); watch( activeItemValue, value => { if (hasSubItems.value && props.items && hasActiveDescendant(props.items, value)) { isOpen.value = true; } }, { immediate: true } ); </script> <script lang="ts"> /** * This component shouldn't be used directly. It is internally used by {@see BentoSecondaryNav} */ export default {}; </script> <style lang="scss" scoped src="./secondary-nav-item.scss" />
1
+ <template> <div class="b-secondary-nav-item"> <div class="b-secondary-nav-item__wrapper"> <button class="b-secondary-nav-item__option" :class="conditionalClasses" :aria-current="isItemSelected ? 'page' : undefined" :aria-expanded="expandleItemIsToggleOnly ? `${isOpen}` : undefined" :aria-controls="expandleItemIsToggleOnly ? subItemsId : undefined" @click="expandleItemIsToggleOnly ? onToggleClick() : onClick($event)" > <bento-typography class="b-secondary-nav-item__title" variant="body" stronger> {{ label }} </bento-typography> <bento-tag v-if="errorCounter" class="b-secondary-nav-item__error-counter" variant="red"> <template #icon> <cross-circle-fill-icon aria-hidden="true" /> </template> <span>{{ errorCounter }}</span> <span class="b-secondary-nav-item__visually-hidden">{{ tc('errorCount', errorCounter) }}</span> </bento-tag> </button> <bento-button v-if="hasSubItems" :aria-hidden="expandleItemIsToggleOnly" :tabindex="expandleItemIsToggleOnly ? -1 : undefined" class="b-secondary-nav-item__toggle" variant="tertiary" :aria-label="isOpen ? `Collapse ${label}` : `Expand ${label}`" :aria-expanded="!expandleItemIsToggleOnly ? `${isOpen}` : undefined" :aria-controls="!expandleItemIsToggleOnly ? subItemsId : undefined" @click.stop="onToggleClick" > <template #iconLeft> <chevron-up-icon v-if="isOpen" class="b-secondary-nav-item__icon" aria-hidden="true" /> <chevron-down-icon v-else class="b-secondary-nav-item__icon" aria-hidden="true" /> </template> </bento-button> </div> <Transition :name="CONTENT_TRANSITION_NAME"> <div v-if="hasSubItems && isOpen" :id="subItemsId" :style="subItemsMaxHeight"> <ul ref="subItemsRef" class="b-secondary-nav-item__sub-items"> <li v-for="item in items" :key="item.value"> <secondary-nav-item :label="item.label" :value="item.value" :items="item.items" :error-counter="item.errorCounter" is-sub-item /> </li> </ul> </div> </Transition> </div> </template> <script lang="ts" setup> import { SECONDARY_NAV_STATE } from '../../secondary-nav.keys'; import { BentoButton } from '@/components/button'; import { BentoTag } from '@/components/tag'; import { BentoTypography } from '@/components/typography'; import ChevronDownIcon from '@adyen/ui-assets-icons-16/vue/chevron-down'; import ChevronUpIcon from '@adyen/ui-assets-icons-16/vue/chevron-up'; import CrossCircleFillIcon from '@adyen/ui-assets-icons-16/vue/cross-circle-fill'; import { computed, inject, ref, watch } from 'vue'; import { useI18n } from '@/utils/ts/i18n'; import { generateUid } from '@/core/utils/ts'; import { type BentoSecondaryNavItemsItem } from '../../secondary-nav.types'; import { useExpandableContentHeight } from '@/composables/use-expandable-content-height/use-expandable-content-height'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const { tc } = useI18n<{ message: MessageSchema }>({ messages }); const { activeItemValue, updateActiveItemValue, allowExpandableItemNavigation } = inject(SECONDARY_NAV_STATE); const CONTENT_TRANSITION_NAME = 'b-secondary-nav-item__animation--content'; const props = defineProps<{ /** * The display name of the navigation item */ label: string; /** * The value of this item. */ value: string; /** * Nested sub-items for this item. */ items?: Array<BentoSecondaryNavItemsItem>; /** * Defines if the item is a subitem */ isSubItem?: boolean; /** * Defines the number of errors pertaining to this item */ errorCounter?: number; }>(); const isOpen = ref(false); const subItemsId = generateUid('secondary-nav-sub-items'); const subItemsRef = ref<HTMLUListElement>(null); const hasSubItems = computed(() => !!props.items?.length); const isItemSelected = computed(() => activeItemValue.value === props.value); const expandleItemIsToggleOnly = computed(() => hasSubItems.value && !allowExpandableItemNavigation.value); const { contentHeight } = useExpandableContentHeight(subItemsRef, isOpen); const subItemsMaxHeight = computed(() => (contentHeight.value ? { 'max-height': contentHeight.value } : {})); const conditionalClasses = computed(() => ({ 'b-secondary-nav-item__option--active': isItemSelected.value, 'b-secondary-nav-item__option--expandable': hasSubItems.value, 'b-secondary-nav-item__option--sub-item': props.isSubItem, })); const emit = defineEmits<{ /** * Event emitted when this element is clicked */ (e: 'click', clickEvent: MouseEvent): void; }>(); const onClick = (e: MouseEvent) => { emit('click', e); updateActiveItemValue(props.value); }; const onToggleClick = () => { isOpen.value = !isOpen.value; }; const hasActiveDescendant = (items: Array<BentoSecondaryNavItemsItem>, value: string): boolean => { return items.some( item => item.value === value || (item.items ? hasActiveDescendant(item.items, value) : false) ); }; const hasDescendantErrors = (items: Array<BentoSecondaryNavItemsItem>): boolean => items.some(item => !!item.errorCounter || (item.items ? hasDescendantErrors(item.items) : false)); watch( [activeItemValue, () => props.items], ([activeValue]) => { if (hasSubItems.value && props.items) { if (hasActiveDescendant(props.items, activeValue) || hasDescendantErrors(props.items)) { isOpen.value = true; } } }, { immediate: true, deep: true } ); </script> <script lang="ts"> /** * This component shouldn't be used directly. It is internally used by {@see BentoSecondaryNav} */ export default { i18n: { messages } }; </script> <style lang="scss" scoped src="./secondary-nav-item.scss" />
@@ -1 +1 @@
1
- import { action } from '@storybook/addon-actions'; import { isVue2 } from 'vue-demi'; import { storybookDocsParameter } from '@/utils/ts/storybook'; import BentoSecondaryNav from './secondary-nav.vue'; import WithSubItemsCodeSample from './__tests__/secondary-nav-with-sub-items.vue?raw'; import type { Meta, StoryObj } from '@storybook/vue'; import { type BentoSecondaryNavItems } from '@/components/secondary-nav/secondary-nav.types'; const meta: Meta = { title: 'Secondary Nav', component: BentoSecondaryNav, }; export default meta; type Story = StoryObj<typeof BentoSecondaryNav>; const DEFAULT_ITEMS: BentoSecondaryNavItems = [ { label: 'Option 1', value: 'option_1', }, { label: 'Option 2', value: 'option_2', }, { label: 'Option 3', value: 'option_3', }, ]; const ITEMS_WITH_CATEGORIES: BentoSecondaryNavItems = [ { label: 'Category 1', items: [ { label: 'Option 1', value: 'option_1', }, { label: 'Option 2', value: 'option_2', }, ], }, { label: 'Category 2', items: [ { label: 'Option 3', value: 'option_3', }, { label: 'Option 4', value: 'option_4', }, { label: 'Option 5', value: 'option_5', }, ], }, ]; const ITEMS_WITH_SUB_ITEMS: BentoSecondaryNavItems = ITEMS_WITH_CATEGORIES.map(category => ({ ...category, items: category.items.map(item => { if (item.value === 'option_2') { return { ...item, items: [ { label: 'Option 2.1', value: 'option_2_1', }, { label: 'Option 2.2', value: 'option_2_2', }, ], }; } if (item.value === 'option_4') { return { ...item, items: [ { label: 'Option 4.1', value: 'option_4_1', }, { label: 'Option 4.2', value: 'option_4_2', }, ], }; } return item; }), })); const DEFAULT_STORY_CODE_SAMPLE = ` <template> <bento-secondary-nav :items="items" :value="activeItem" @change="onChange" /> <template> <script lang="ts" setup> const items = [ { label: 'Option 1', value: 'option_1', }, { label: 'Option 2', value: 'option_2', }, { label: 'Option 3', value: 'option_3', }, ]; const activeItem = ref(items[0].value); const onChange = (newActiveItem: string) => { activeItem.value = newActiveItem; }; </script> `; export const Default: Story = { render: (_args, { argTypes }) => ({ components: { BentoSecondaryNav, }, props: Object.keys(argTypes), template: ` <bento-secondary-nav v-bind="args" @change="clickedAction" /> `, setup(props) { const clickedAction = value => action('change')(value); return { // Values args: isVue2 ? props : _args, // Events clickedAction, }; }, }), args: { items: DEFAULT_ITEMS, value: 'option_1', }, parameters: storybookDocsParameter(DEFAULT_STORY_CODE_SAMPLE), }; const WITH_CATEGORIES_STORY_CODE_SAMPLE = ` <template> <bento-secondary-nav :items="items" :value="activeItem" @change="onChange" /> <template> <script lang="ts" setup> const items = [ { label: 'Category 1', items: [ { label: 'Option 1', value: 'option_1', }, { label: 'Option 2', value: 'option_2', }, ], }, { label: 'Category 2', items: [ { label: 'Option 3', value: 'option_3', }, { label: 'Option 4', value: 'option_4', }, { label: 'Option 5', value: 'option_5', }, ], }, ]; const activeItem = ref(items[0].value); const onChange = (newActiveItem: string) => { activeItem.value = newActiveItem; }; </script> `; export const WithCategories: Story = { render: (_args, { argTypes }) => ({ components: { BentoSecondaryNav, }, props: Object.keys(argTypes), template: ` <bento-secondary-nav v-bind="args" @change="clickedAction" /> `, setup(props) { const clickedAction = value => action('change')(value); return { // Values args: isVue2 ? props : _args, // Events clickedAction, }; }, }), args: { items: ITEMS_WITH_CATEGORIES, }, parameters: storybookDocsParameter(WITH_CATEGORIES_STORY_CODE_SAMPLE), }; export const WithSubItems: Story = { render: (_args, { argTypes }) => ({ components: { BentoSecondaryNav, }, props: Object.keys(argTypes), template: ` <bento-secondary-nav v-bind="args" @change="clickedAction" /> `, setup(props) { const clickedAction = value => action('change')(value); return { args: isVue2 ? props : _args, clickedAction, }; }, }), args: { value: 'option_2_1', items: ITEMS_WITH_SUB_ITEMS, }, parameters: storybookDocsParameter(WithSubItemsCodeSample), };
1
+ import { action } from '@storybook/addon-actions'; import { isVue2 } from 'vue-demi'; import { storybookDocsParameter } from '@/utils/ts/storybook'; import BentoSecondaryNav from './secondary-nav.vue'; import DefaultCodeSample from './__tests__/secondary-nav-default-example.vue?raw'; import WithCategoriesCodeSample from './__tests__/secondary-nav-with-categories-example.vue?raw'; import WithSubItemsCodeSample from './__tests__/secondary-nav-with-sub-items-example.vue?raw'; import type { Meta, StoryObj } from '@storybook/vue'; import { type BentoSecondaryNavItems } from '@/components/secondary-nav/secondary-nav.types'; const meta: Meta = { title: 'Secondary Nav', component: BentoSecondaryNav, }; export default meta; type Story = StoryObj<typeof BentoSecondaryNav>; const DEFAULT_ITEMS: BentoSecondaryNavItems = [ { label: 'Option 1', value: 'option_1', }, { label: 'Option 2', value: 'option_2', errorCounter: 3, }, { label: 'Option 3', value: 'option_3', }, ]; const ITEMS_WITH_CATEGORIES: BentoSecondaryNavItems = [ { label: 'Category 1', items: [ { label: 'Option 1', value: 'option_1', errorCounter: 1, }, { label: 'Option 2', value: 'option_2', }, ], }, { label: 'Category 2', items: [ { label: 'Option 3', value: 'option_3', }, { label: 'Option 4', value: 'option_4', }, { label: 'Option 5', value: 'option_5', }, ], }, ]; const ITEMS_WITH_SUB_ITEMS: BentoSecondaryNavItems = ITEMS_WITH_CATEGORIES.map(category => ({ ...category, items: category.items.map(item => { if (item.value === 'option_2') { return { ...item, items: [ { label: 'Option 2.1', value: 'option_2_1', }, { label: 'Option 2.2', value: 'option_2_2', }, ], }; } if (item.value === 'option_4') { return { ...item, items: [ { label: 'Option 4.1', value: 'option_4_1', errorCounter: 2, }, { label: 'Option 4.2', value: 'option_4_2', }, ], }; } return item; }), })); export const Default: Story = { render: (_args, { argTypes }) => ({ components: { BentoSecondaryNav, }, props: Object.keys(argTypes), template: ` <bento-secondary-nav v-bind="args" @change="clickedAction" /> `, setup(props) { const clickedAction = value => action('change')(value); return { // Values args: isVue2 ? props : _args, // Events clickedAction, }; }, }), args: { items: DEFAULT_ITEMS, value: 'option_1', }, parameters: storybookDocsParameter(DefaultCodeSample), }; export const WithCategories: Story = { render: (_args, { argTypes }) => ({ components: { BentoSecondaryNav, }, props: Object.keys(argTypes), template: ` <bento-secondary-nav v-bind="args" @change="clickedAction" /> `, setup(props) { const clickedAction = value => action('change')(value); return { // Values args: isVue2 ? props : _args, // Events clickedAction, }; }, }), args: { items: ITEMS_WITH_CATEGORIES, }, parameters: storybookDocsParameter(WithCategoriesCodeSample), }; export const WithSubItems: Story = { render: (_args, { argTypes }) => ({ components: { BentoSecondaryNav, }, props: Object.keys(argTypes), template: ` <bento-secondary-nav v-bind="args" @change="clickedAction" /> `, setup(props) { const clickedAction = value => action('change')(value); return { args: isVue2 ? props : _args, clickedAction, }; }, }), args: { value: 'option_2_1', items: ITEMS_WITH_SUB_ITEMS, }, parameters: storybookDocsParameter(WithSubItemsCodeSample), };
@@ -1 +1 @@
1
- import type { Ref } from 'vue'; export interface BentoSecondaryNavState { activeItemValue: Ref<string>; updateActiveItemValue: (id: string) => void; allowExpandableItemNavigation: Ref<boolean>; } export interface BentoSecondaryNavItemsItem { label: string; value: string; items?: Array<BentoSecondaryNavItemsItem>; } export interface BentoSecondaryNavItemsCategory { label: string; items: Array<BentoSecondaryNavItemsItem>; } export type BentoSecondaryNavItems = Array<BentoSecondaryNavItemsItem | BentoSecondaryNavItemsCategory>;
1
+ import type { Ref } from 'vue'; export interface BentoSecondaryNavState { activeItemValue: Ref<string>; updateActiveItemValue: (id: string) => void; allowExpandableItemNavigation: Ref<boolean>; } export interface BentoSecondaryNavItemsItem { label: string; value: string; items?: Array<BentoSecondaryNavItemsItem>; errorCounter?: number; } export interface BentoSecondaryNavItemsCategory { label: string; items: Array<BentoSecondaryNavItemsItem>; } export type BentoSecondaryNavItems = Array<BentoSecondaryNavItemsItem | BentoSecondaryNavItemsCategory>;
@@ -1 +1 @@
1
- <template> <nav class="b-secondary-nav"> <bento-dropdown v-if="isMobile" :aria-label="t('mobileDropdownAriaLabel')" :items="mobileDropdownItems" :static-categories="hasCategories" :model-value="activeItemValue" :is-option-disabled="isMobileOptionDisabled" @update:model-value="updateActiveItemValue" > <template #display-value="{ label }"> <bento-typography el="span"> {{ label }} </bento-typography> </template> <template #default="{ label, data }"> <bento-typography el="span" :class="{ 'b-secondary-nav__nested-option': !!data?.level }"> {{ label }} </bento-typography> </template> </bento-dropdown> <ul v-else class="b-secondary-nav__list"> <li v-for="item in items" :key="item.label"> <secondary-nav-category v-if="'items' in item && !('value' in item)" :label="item.label" :items="item.items" /> <secondary-nav-item v-else :label="item.label" :value="item.value" :items="item.items" /> </li> </ul> </nav> </template> <script setup lang="ts"> import { computed, provide, readonly, ref, toRef, watch } from 'vue'; import { SecondaryNavItem } from './components/secondary-nav-item'; import { SecondaryNavCategory } from './components/secondary-nav-category'; import { SECONDARY_NAV_STATE } from './secondary-nav.keys'; import { type BentoSecondaryNavItems, type BentoSecondaryNavItemsCategory, type BentoSecondaryNavItemsItem, type BentoSecondaryNavState, } from './secondary-nav.types'; import { BentoDropdown } from '@/components/dropdown'; import { BentoTypography } from '@/components/typography'; import { useBreakpoints } from '@vueuse/core'; import { useI18n } from '@/utils/ts/i18n'; import { type BentoListboxOptionItem, type BentoListboxOptions } from '@/types/listbox'; import { BMediaQuerySMax } from '@adyen/bento-design-tokens/dist/js/bento/es6'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const { t } = useI18n<{ message: MessageSchema }>({ messages }); const breakpoints = useBreakpoints({ s: BMediaQuerySMax, }); const isMobile = breakpoints.smallerOrEqual('s'); const props = defineProps<{ /** * An array containing all the items on the nav. The array can have either {@see BentoSecondaryNavItemsCategory }, * which is used to group different items or {@see BentoSecondaryNavItemsItem } if you don't need to group them. */ items: BentoSecondaryNavItems; /** * The value of the selected item. * If nothing is passed, it defaults to the first item value in items. */ value?: string; /** * When true, items that contain subitems are also clickable and can be selected. * When false, those items only act as expand/collapse toggles. */ allowExpandableItemNavigation?: boolean; }>(); const emit = defineEmits<{ /** * Emmited when the current selected item changes */ (e: 'change', category: string): void; }>(); const isCategory = (item: BentoSecondaryNavItems[number]): item is BentoSecondaryNavItemsCategory => 'items' in item && !('value' in item); const mapNestedItems = (items: Array<BentoSecondaryNavItemsItem>, level = 0): BentoListboxOptions => items.flatMap((item): BentoListboxOptions => { const currentItem: BentoListboxOptionItem = { label: item.label, value: item.value, data: { level, hasSubItems: !!item.items?.length }, }; if (item.items?.length) { return [currentItem, ...mapNestedItems(item.items, level + 1)]; } return [currentItem]; }); const isMobileOptionDisabled = (option: BentoListboxOptionItem): boolean => !props.allowExpandableItemNavigation && (option.data as { hasSubItems?: boolean })?.hasSubItems; const hasCategories = computed(() => props.items.some(item => isCategory(item))); const mobileDropdownItems = computed<BentoListboxOptions>(() => { return props.items.flatMap(item => { if (isCategory(item)) { return [ { label: item.label, value: `category-${item.label}`, items: mapNestedItems(item.items), } satisfies BentoListboxOptionItem, ]; } if (item.items?.length) { return mapNestedItems([item]); } return [ { label: item.label, value: item.value, data: { level: 0 }, } satisfies BentoListboxOptionItem, ]; }); }); const flattenNestedItems = (items: Array<BentoSecondaryNavItemsItem>): Array<BentoSecondaryNavItemsItem> => items.flatMap(item => [item, ...(item.items?.length ? flattenNestedItems(item.items) : [])]); const findFirstSelectableNestedItemValue = (items: Array<BentoSecondaryNavItemsItem>): string => flattenNestedItems(items).find(item => props.allowExpandableItemNavigation || !item.items?.length)?.value ?? ''; const activeItemValueFallback = computed(() => { if (!props.items.length) { return ''; } return findFirstSelectableNestedItemValue( props.items.flatMap(item => isCategory(item) ? flattenNestedItems(item.items) : flattenNestedItems([item]) ) ); }); const activeItemValue = ref(props.value ?? activeItemValueFallback.value); const updateActiveItemValue = (value: string) => { activeItemValue.value = value; emit('change', value); }; // The component's state is inject on `secondary-nav-item` to determine the // current active item and to change its value const state: BentoSecondaryNavState = { activeItemValue: readonly(activeItemValue), updateActiveItemValue, allowExpandableItemNavigation: toRef(props, 'allowExpandableItemNavigation'), }; watch( () => props.value, value => { if (value !== undefined) { activeItemValue.value = value; } } ); provide(SECONDARY_NAV_STATE, state); </script> <script lang="ts"> /** * Secondary Nav offers users a structured way to access information. * It also provides a clear hierarchy for users to understand and navigate through various information. * * @example * import { BentoSecondaryNav } from '@adyen/bento-vue2'; * * export default { * components: { BentoSecondaryNav }, * template: ` * <bento-secondary-nav :value="value" :items="items" /> * `, * setup() { * const value = ref(2) // Active item value * const items: BentoSecondaryNavItemInfo[] = [ * { label: 'Option 1', value: 1 }, * { label: 'Option 2', value: 2 }, * ]; * return { items, value }; * } * } */ export default { i18n: { messages }, }; </script> <style lang="scss" scoped src="./secondary-nav.scss" />
1
+ <template> <nav class="b-secondary-nav"> <bento-dropdown v-if="isMobile" :aria-label="t('mobileDropdownAriaLabel')" :items="mobileDropdownItems" :static-categories="hasCategories" :model-value="activeItemValue" :is-option-disabled="isMobileOptionDisabled" @update:model-value="updateActiveItemValue" > <template #display-value="{ label }"> <bento-typography el="span"> {{ label }} </bento-typography> </template> <template #default="{ label, data }"> <bento-typography el="div" :class="{ 'b-secondary-nav__option': true, 'b-secondary-nav__nested-option': !!data?.level }" > {{ label }} <bento-tag v-if="data?.errorCounter" class="b-secondary-nav__error-counter" variant="red"> <template #icon> <cross-circle-fill-icon aria-hidden="true" /> </template> <span>{{ data.errorCounter }}</span> <span class="b-secondary-nav__visually-hidden">{{ tc('errorCount', data.errorCounter) }}</span> </bento-tag> </bento-typography> </template> </bento-dropdown> <ul v-else class="b-secondary-nav__list"> <li v-for="item in items" :key="item.label"> <secondary-nav-category v-if="'items' in item && !('value' in item)" :label="item.label" :items="item.items" /> <secondary-nav-item v-else :label="item.label" :value="item.value" :items="item.items" :error-counter="item.errorCounter" /> </li> </ul> </nav> </template> <script setup lang="ts"> import { computed, provide, readonly, ref, toRef, watch } from 'vue'; import { SecondaryNavItem } from './components/secondary-nav-item'; import { SecondaryNavCategory } from './components/secondary-nav-category'; import { SECONDARY_NAV_STATE } from './secondary-nav.keys'; import { type BentoSecondaryNavItems, type BentoSecondaryNavItemsCategory, type BentoSecondaryNavItemsItem, type BentoSecondaryNavState, } from './secondary-nav.types'; import { BentoDropdown } from '@/components/dropdown'; import { BentoTypography } from '@/components/typography'; import { BentoTag } from '@/components/tag'; import CrossCircleFillIcon from '@adyen/ui-assets-icons-16/vue/cross-circle-fill'; import { useBreakpoints } from '@vueuse/core'; import { useI18n } from '@/utils/ts/i18n'; import { type BentoListboxOptionItem, type BentoListboxOptions } from '@/types/listbox'; import { BMediaQuerySMax } from '@adyen/bento-design-tokens/dist/js/bento/es6'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const { t, tc } = useI18n<{ message: MessageSchema }>({ messages }); const breakpoints = useBreakpoints({ s: BMediaQuerySMax, }); const isMobile = breakpoints.smallerOrEqual('s'); const props = defineProps<{ /** * An array containing all the items on the nav. The array can have either {@see BentoSecondaryNavItemsCategory }, * which is used to group different items or {@see BentoSecondaryNavItemsItem } if you don't need to group them. */ items: BentoSecondaryNavItems; /** * The value of the selected item. * If nothing is passed, it defaults to the first item value in items. */ value?: string; /** * When true, items that contain subitems are also clickable and can be selected. * When false, those items only act as expand/collapse toggles. */ allowExpandableItemNavigation?: boolean; }>(); const emit = defineEmits<{ /** * Emmited when the current selected item changes */ (e: 'change', category: string): void; }>(); const isCategory = (item: BentoSecondaryNavItems[number]): item is BentoSecondaryNavItemsCategory => 'items' in item && !('value' in item); const mapNestedItems = (items: Array<BentoSecondaryNavItemsItem>, level = 0): BentoListboxOptions => items.flatMap((item): BentoListboxOptions => { const currentItem: BentoListboxOptionItem = { label: item.label, value: item.value, data: { level, hasSubItems: !!item.items?.length, errorCounter: item.errorCounter }, }; if (item.items?.length) { return [currentItem, ...mapNestedItems(item.items, level + 1)]; } return [currentItem]; }); const isMobileOptionDisabled = (option: BentoListboxOptionItem): boolean => !props.allowExpandableItemNavigation && (option.data as { hasSubItems?: boolean })?.hasSubItems; const hasCategories = computed(() => props.items.some(item => isCategory(item))); const mobileDropdownItems = computed<BentoListboxOptions>(() => { return props.items.flatMap(item => { if (isCategory(item)) { return [ { label: item.label, value: `category-${item.label}`, items: mapNestedItems(item.items), } satisfies BentoListboxOptionItem, ]; } if (item.items?.length) { return mapNestedItems([item]); } return [ { label: item.label, value: item.value, data: { level: 0, errorCounter: item.errorCounter }, } satisfies BentoListboxOptionItem, ]; }); }); const flattenNestedItems = (items: Array<BentoSecondaryNavItemsItem>): Array<BentoSecondaryNavItemsItem> => items.flatMap(item => [item, ...(item.items?.length ? flattenNestedItems(item.items) : [])]); const findFirstSelectableNestedItemValue = (items: Array<BentoSecondaryNavItemsItem>): string => flattenNestedItems(items).find(item => props.allowExpandableItemNavigation || !item.items?.length)?.value ?? ''; const activeItemValueFallback = computed(() => { if (!props.items.length) { return ''; } return findFirstSelectableNestedItemValue( props.items.flatMap(item => isCategory(item) ? flattenNestedItems(item.items) : flattenNestedItems([item]) ) ); }); const activeItemValue = ref(props.value ?? activeItemValueFallback.value); const updateActiveItemValue = (value: string) => { activeItemValue.value = value; emit('change', value); }; // The component's state is inject on `secondary-nav-item` to determine the // current active item and to change its value const state: BentoSecondaryNavState = { activeItemValue: readonly(activeItemValue), updateActiveItemValue, allowExpandableItemNavigation: toRef(props, 'allowExpandableItemNavigation'), }; watch( () => props.value, value => { if (value !== undefined) { activeItemValue.value = value; } } ); provide(SECONDARY_NAV_STATE, state); </script> <script lang="ts"> /** * Secondary Nav offers users a structured way to access information. * It also provides a clear hierarchy for users to understand and navigate through various information. * * @example * import { BentoSecondaryNav } from '@adyen/bento-vue2'; * * export default { * components: { BentoSecondaryNav }, * template: ` * <bento-secondary-nav :value="value" :items="items" /> * `, * setup() { * const value = ref(2) // Active item value * const items: BentoSecondaryNavItemInfo[] = [ * { label: 'Option 1', value: 1 }, * { label: 'Option 2', value: 2 }, * ]; * return { items, value }; * } * } */ export default { i18n: { messages }, }; </script> <style lang="scss" scoped src="./secondary-nav.scss" />
@@ -1 +1 @@
1
- import { type Ref } from 'vue'; import { type MaybeRef } from '@/types/maybe-ref'; /** * Public */ export interface BentoTabProps { /** * Displays a counter next to the tab title */ counter?: number; /** * Determines whether the tab is disabled */ disabled?: boolean; /** * Prevents a tab from being displayed. * Use `hidden` instead of `v-show`. */ hidden?: boolean; /** * Title of the tab */ title: string; /** * Displays the tab with a notification indicator */ withNotificationIndicator?: boolean; /** * Callback function to safely capture the underlying DOM element of the tab. * Useful for anchoring tooltips, popovers, or tutorials. * * @example * // 1. Define your ref and setter in setup() * const tabNode = ref<Element | null>(null); * const setTabNode = (el: Element | null) => { tabNode.value = el; }; * * // 2. Pass the setter to the tab * <bento-tab :set-element-ref="setTabNode"> */ setElementRef?: (el: Element | null) => void; } /** * Private */ export interface BentoTab { counter: Ref<number>; disabled: Ref<boolean>; hidden: Ref<boolean>; id: string; title: MaybeRef<string>; withNotificationIndicator: Ref<boolean>; setElementRef: BentoTabProps['setElementRef']; } export type BentoTabProvidedTitle = (value: string) => void;
1
+ import { type Ref } from 'vue'; import { type MaybeRef } from '@/types/maybe-ref'; /** * Public */ export interface BentoTabProps { /** * Displays a counter next to the tab title */ counter?: number; /** * Determines whether the tab is disabled */ disabled?: boolean; /** * Displays a counter with an error icon next to the tab title */ errorCounter?: number; /** * Prevents a tab from being displayed. * Use `hidden` instead of `v-show`. */ hidden?: boolean; /** * Title of the tab */ title: string; /** * Displays the tab with a notification indicator */ withNotificationIndicator?: boolean; /** * Callback function to safely capture the underlying DOM element of the tab. * Useful for anchoring tooltips, popovers, or tutorials. * * @example * // 1. Define your ref and setter in setup() * const tabNode = ref<Element | null>(null); * const setTabNode = (el: Element | null) => { tabNode.value = el; }; * * // 2. Pass the setter to the tab * <bento-tab :set-element-ref="setTabNode"> */ setElementRef?: (el: Element | null) => void; } /** * Private */ export interface BentoTab { counter: Ref<number>; disabled: Ref<boolean>; errorCounter: Ref<number>; hidden: Ref<boolean>; id: string; title: MaybeRef<string>; withNotificationIndicator: Ref<boolean>; setElementRef: BentoTabProps['setElementRef']; } export type BentoTabProvidedTitle = (value: string) => void;