@adyen/bento-mcp 0.5.1 → 0.5.3

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 (62) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/assets/components/accordion/components/accordion-item.vue +1 -1
  3. package/dist/assets/components/card/card.vue +1 -1
  4. package/dist/assets/components/dashed-underline/dashed-underline.vue +1 -1
  5. package/dist/assets/components/data-grid/components/data-grid-row/data-grid-row.types.ts +1 -1
  6. package/dist/assets/components/data-grid/components/data-grid-row/data-grid-row.vue +1 -1
  7. package/dist/assets/components/date-picker/composables/use-date-picker-single-calendar-text.types.ts +1 -1
  8. package/dist/assets/components/date-picker/date-picker.vue +1 -1
  9. package/dist/assets/components/date-range-picker/components/date-range-picker-calendar/date-range-picker-calendar.vue +1 -1
  10. package/dist/assets/components/date-time-picker/date-time-picker.docs.mdx +129 -0
  11. package/dist/assets/components/date-time-picker/date-time-picker.stories.ts +1 -0
  12. package/dist/assets/components/date-time-picker/date-time-picker.types.ts +1 -0
  13. package/dist/assets/components/date-time-picker/date-time-picker.vue +1 -0
  14. package/dist/assets/components/draggable/draggable.docs.mdx +76 -135
  15. package/dist/assets/components/draggable/draggable.stories.ts +1 -1
  16. package/dist/assets/components/draggable/draggable.types.ts +1 -1
  17. package/dist/assets/components/draggable/draggable.vue +1 -1
  18. package/dist/assets/components/dropdown/components/dropdown-base-textbox/dropdown-base-textbox.vue +1 -1
  19. package/dist/assets/components/dropdown/components/dropdown-small-textbox/dropdown-small-textbox.vue +1 -1
  20. package/dist/assets/components/dropdown/dropdown.vue +1 -1
  21. package/dist/assets/components/file-uploader/file-uploader.vue +1 -1
  22. package/dist/assets/components/filter-bar/components/base-filter/base-filter.vue +1 -1
  23. package/dist/assets/components/input-field/input-field.vue +1 -1
  24. package/dist/assets/components/internal/calendar/calendar.types.ts +1 -1
  25. package/dist/assets/components/internal/calendar/calendar.vue +1 -1
  26. package/dist/assets/components/internal/calendar/components/calendar-month/calendar-month.stories.ts +1 -1
  27. package/dist/assets/components/internal/calendar/components/calendar-month/calendar-month.types.ts +1 -1
  28. package/dist/assets/components/internal/calendar/components/calendar-month/calendar-month.vue +1 -1
  29. package/dist/assets/components/internal/calendar/components/calendar-month/composables/granular-min-max-date.types.ts +1 -1
  30. package/dist/assets/components/internal/calendar/components/calendar-year/calendar-year.types.ts +1 -1
  31. package/dist/assets/components/internal/calendar/components/calendar-year/calendar-year.vue +1 -1
  32. package/dist/assets/components/internal/info-icon-with-popover/info-icon-with-popover.vue +1 -1
  33. package/dist/assets/components/internal/teleport/teleport.vue +1 -1
  34. package/dist/assets/components/link/link.vue +1 -1
  35. package/dist/assets/components/navigation-menu/components/navigation-menu-group/navigation-menu-group.stories.ts +1 -1
  36. package/dist/assets/components/navigation-menu/components/navigation-menu-group/navigation-menu-group.vue +1 -1
  37. package/dist/assets/components/navigation-menu/components/navigation-menu-item/navigation-menu-item.stories.ts +1 -1
  38. package/dist/assets/components/navigation-menu/components/navigation-menu-item/navigation-menu-item.vue +1 -1
  39. package/dist/assets/components/navigation-menu/navigation-menu.docs.mdx +7 -43
  40. package/dist/assets/components/navigation-menu/navigation-menu.stories.ts +1 -1
  41. package/dist/assets/components/navigation-menu/navigation-menu.types.ts +1 -1
  42. package/dist/assets/components/navigation-menu/navigation-menu.vue +1 -1
  43. package/dist/assets/components/popover/popover.docs.mdx +74 -15
  44. package/dist/assets/components/popover/popover.stories.ts +1 -1
  45. package/dist/assets/components/secondary-nav/components/secondary-nav-category/secondary-nav-category.vue +1 -1
  46. package/dist/assets/components/secondary-nav/components/secondary-nav-item/secondary-nav-item.vue +1 -1
  47. package/dist/assets/components/secondary-nav/secondary-nav.stories.ts +1 -1
  48. package/dist/assets/components/secondary-nav/secondary-nav.types.ts +1 -1
  49. package/dist/assets/components/secondary-nav/secondary-nav.vue +1 -1
  50. package/dist/assets/components/selection-card/components/selection-card-group/selection-card-group.types.ts +1 -1
  51. package/dist/assets/components/tabs/components/tab.types.ts +1 -1
  52. package/dist/assets/components/tabs/components/tab.vue +1 -1
  53. package/dist/assets/components/tabs/tabs.stories.ts +1 -1
  54. package/dist/assets/components/tabs/tabs.types.ts +1 -1
  55. package/dist/assets/components/tabs/tabs.vue +1 -1
  56. package/dist/assets/components.json +2 -0
  57. package/dist/assets/composables/use-bento-delayed-hover/use-bento-delayed-hover.docs.mdx +73 -0
  58. package/dist/assets/composables/use-bento-delayed-hover/use-bento-delayed-hover.stories.ts +1 -0
  59. package/dist/assets/index.ts +1 -1
  60. package/dist/assets/usage.json +12 -10
  61. package/dist/main.js +1 -1
  62. package/package.json +1 -1
@@ -1 +1 @@
1
- <template> <a v-if="external || isNotRouting" :class="conditionalClasses" v-bind="$attrs" :aria-describedby="externalLinkId" :href="$props.to.toString()" :title="getAttributeTitle($attrs.title)" :target="computedTarget" @click="onClick" > <bento-typography v-if="!disableTypography" class="b-link__text" el="span" variant="body"> <slot /> </bento-typography> <slot v-else /> <span v-if="showExternalLinkIcon" aria-hidden="true" class="b-link__external-link-icon"> <external-link-icon /> </span> <span v-if="external" :id="externalLinkId" class="b-link--visually-hidden">{{ t('opensInNewTab') }}</span> </a> <router-link v-else v-bind="{ ...$attrs, ...$props, ...nonForwardedProps }" :class="conditionalClasses" :target="target" :to="props.to" custom @click.native="onClick" > <bento-typography v-if="!disableTypography" class="b-link__text" el="span" variant="body"> <slot /> </bento-typography> <slot v-else /> </router-link> </template> <script setup lang="ts"> import { RouterLink } from 'vue-router'; import { computed } from 'vue'; import { generateUid } from '@/core/utils/ts'; import { BentoTypography } from '@/components/typography'; import { type BentoLinkProps, BentoLinkVariant } from './link.types'; import { useI18n } from '@/utils/ts/i18n'; import type { RouterLinkSlotArgument } from 'vue-router/types/router.d.ts'; import ExternalLinkIcon from '@adyen/ui-assets-icons-16/vue/external-link'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const { t } = useI18n<{ message: MessageSchema }>({ messages }); const props = withDefaults(defineProps<BentoLinkProps>(), { target: null, variant: BentoLinkVariant.DEFAULT, }); // Props that should not be forwarded to the underlying anchor element const nonForwardedProps = { variant: undefined, isButton: undefined, truncate: undefined, disableTypography: undefined, external: undefined, isNotRouting: undefined, }; const externalLinkId = generateUid('external-link'); const getAttributeTitle = (attributesTitle: unknown) => attributesTitle as string; const conditionalClasses = computed(() => ({ 'b-link': !props.isButton, [`b-link--${BentoLinkVariant.QUIET}`]: props.variant === BentoLinkVariant.QUIET, [`b-link--truncate`]: props.truncate, })); const computedTarget = computed(() => (props.external ? (props.target ?? '_blank') : props.target)); const showExternalLinkIcon = computed(() => !(props.isButton || props.isNotRouting)); const emit = defineEmits<{ (e: 'click', clickEvent: MouseEvent): void; }>(); const onClick = (e: MouseEvent, navigateCallback?: RouterLinkSlotArgument['navigate']) => { emit('click', e); if (navigateCallback) { navigateCallback(); } }; </script> <script lang="ts"> /** * Extends the `router-link` from the `vue-router` NPM package. * * @example * import { BentoLink } from '@adyen/bento-vue2'; * * export default { * components: { BentoLink }, * template: ` * <bento-link to="/"> * link * </bento-link> * ` * } */ export default { name: 'bento-link', inheritAttrs: false, i18n: { messages }, }; </script> <style lang="scss" scoped src="@/core/components/link/link.scss" />
1
+ <template> <a v-if="external || isNotRouting" :class="conditionalClasses" v-bind="$attrs" :aria-describedby="external ? externalLinkId : null" :href="$props.to.toString()" :title="getAttributeTitle($attrs.title)" :target="computedTarget" @click="onClick" > <bento-typography v-if="!disableTypography" class="b-link__text" el="span" variant="body"> <slot /> </bento-typography> <slot v-else /> <span v-if="showExternalLinkIcon" aria-hidden="true" class="b-link__external-link-icon"> <external-link-icon /> </span> <span v-if="external" :id="externalLinkId" class="b-link--visually-hidden">{{ t('opensInNewTab') }}</span> </a> <router-link v-else v-bind="{ ...$attrs, ...$props, ...nonForwardedProps }" :class="conditionalClasses" :target="target" :to="props.to" custom @click.native="onClick" > <bento-typography v-if="!disableTypography" class="b-link__text" el="span" variant="body"> <slot /> </bento-typography> <slot v-else /> </router-link> </template> <script setup lang="ts"> import { RouterLink } from 'vue-router'; import { computed } from 'vue'; import { generateUid } from '@/core/utils/ts'; import { BentoTypography } from '@/components/typography'; import { type BentoLinkProps, BentoLinkVariant } from './link.types'; import { useI18n } from '@/utils/ts/i18n'; import type { RouterLinkSlotArgument } from 'vue-router/types/router.d.ts'; import ExternalLinkIcon from '@adyen/ui-assets-icons-16/vue/external-link'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const { t } = useI18n<{ message: MessageSchema }>({ messages }); const props = withDefaults(defineProps<BentoLinkProps>(), { target: null, variant: BentoLinkVariant.DEFAULT, }); // Props that should not be forwarded to the underlying anchor element const nonForwardedProps = { variant: undefined, isButton: undefined, truncate: undefined, disableTypography: undefined, external: undefined, isNotRouting: undefined, }; const externalLinkId = generateUid('external-link'); const getAttributeTitle = (attributesTitle: unknown) => attributesTitle as string; const conditionalClasses = computed(() => ({ 'b-link': !props.isButton, [`b-link--${BentoLinkVariant.QUIET}`]: props.variant === BentoLinkVariant.QUIET, [`b-link--truncate`]: props.truncate, })); const computedTarget = computed(() => (props.external ? (props.target ?? '_blank') : props.target)); const showExternalLinkIcon = computed(() => !(props.isButton || props.isNotRouting)); const emit = defineEmits<{ (e: 'click', clickEvent: MouseEvent): void; }>(); const onClick = (e: MouseEvent, navigateCallback?: RouterLinkSlotArgument['navigate']) => { emit('click', e); if (navigateCallback) { navigateCallback(); } }; </script> <script lang="ts"> /** * Extends the `router-link` from the `vue-router` NPM package. * * @example * import { BentoLink } from '@adyen/bento-vue2'; * * export default { * components: { BentoLink }, * template: ` * <bento-link to="/"> * link * </bento-link> * ` * } */ export default { name: 'bento-link', inheritAttrs: false, i18n: { messages }, }; </script> <style lang="scss" scoped src="@/core/components/link/link.scss" />
@@ -1 +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, }, };
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 svg-title="Add to favorites" /> </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 svg-title="Add to favorites" /> </template> </bento-button> </template> </bento-navigation-menu-item> </bento-navigation-menu-group> </bento-navigation-menu> </div> `, }), args: { label: 'Payments', isExpanded: false, showIcon: true, }, };
@@ -1 +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 { observeSizeOfElement } from '@/utils/ts/resize'; 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 { updateContentHeight, 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; } } ); watch( () => contentDiv.value, () => { let observer: ResizeObserver | null = null; if (!observer && contentDiv.value) { observer = observeSizeOfElement(contentDiv.value, () => { if (contentDiv.value) { updateContentHeight(); } }); } } ); watch( isOpen, () => { updateContentHeight(); }, { immediate: true } ); 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" />
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" aria-hidden="true"> <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" aria-hidden="true"> <chevron-up-icon v-if="isOpen && !isSemiCollapsed" /> <chevron-down-icon v-else /> </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" :link="{ to: '/section/item-1' }" /> * <bento-navigation-menu-item nested value="item-2" label="Item 2" :link="{ to: '/section/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" />
@@ -1 +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, }, };
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 svg-title="Add to favorites" /> </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, }, };
@@ -1 +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" />
1
+ <template> <li class="b-navigation-menu-item" :class="{ ...conditionalClasses, 'b-navigation-menu-item--no-icon': !hasSlot('icon'), }" :aria-hidden="!isVisible ? 'true' : undefined" > <component :is="componentType" v-bind="componentProps" class="b-navigation-menu-item__link" :aria-current="isSelected && link ? 'page' : undefined" :tabindex="!isVisible ? -1 : undefined" :inert="!isVisible ? '' : undefined" @click="handleClick" > <div v-if="!nested && hasSlot('icon')" class="b-navigation-menu-item__icon" aria-hidden="true"> <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> </component> <div v-if="hasSlot('action')" class="b-navigation-menu-item__action"> <slot name="action" /> </div> </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, alwaysShowActions: false, }); const slots = useSlots(); const hasSlot = useHasSlot(slots); const componentType = computed(() => (props.link ? BentoLink : 'div')); const componentProps = 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" :link="{ to: '/home' }"> * <template #icon><home-icon /></template> * <template #action><star-icon svg-title="Add to favorites" /></template> * </bento-navigation-menu-item> * ` * } */ export default { name: 'bento-navigation-menu-item', }; </script> <style lang="scss" scoped src="./navigation-menu-item.scss" />
@@ -52,8 +52,8 @@ navigation items that can be logically grouped.
52
52
  code={`<bento-navigation-menu>
53
53
  <bento-navigation-menu-group label="Settings" :is-expanded="true">
54
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" />
55
+ <bento-navigation-menu-item nested value="general" label="General" :link="{ to: '/settings/general' }" />
56
+ <bento-navigation-menu-item nested value="security" label="Security" :link="{ to: '/settings/security' }" />
57
57
  </bento-navigation-menu-group>
58
58
  </bento-navigation-menu>`}
59
59
  />
@@ -71,7 +71,7 @@ Use the `headerActions` slot to add interactive elements like search or filters
71
71
  code={`<bento-navigation-menu label="Pages">
72
72
  <template #headerActions>
73
73
  <bento-button variant="tertiary-with-background">
74
- <template #iconLeft><search-icon /></template>
74
+ <template #iconLeft><search-icon svg-title="Search" /></template>
75
75
  </bento-button>
76
76
  </template>
77
77
  <!-- menu items -->
@@ -93,13 +93,7 @@ keep the action visible at all times.
93
93
 
94
94
  ## Behavior
95
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
96
+ ### Navigation
103
97
 
104
98
  Pass a `link` object to `bento-navigation-menu-item` to integrate with routing. The `link` prop accepts the same props
105
99
  as `bento-link`, allowing seamless integration with Vue Router or other routing solutions.
@@ -116,41 +110,13 @@ as `bento-link`, allowing seamless integration with Vue Router or other routing
116
110
  </bento-navigation-menu-item>`}
117
111
  />
118
112
 
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
113
  #### Tracking navigation with the `update:active-page` event
146
114
 
147
115
  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.
116
+ as analytics tracking, syncing state to a store, or triggering other UI updates.
150
117
 
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.
118
+ > **Do not** use `update:active-page` as the only navigation mechanism. Always provide the `link` prop for accessible
119
+ > navigation.
154
120
 
155
121
  ### Group expansion state
156
122
 
@@ -227,8 +193,6 @@ footer nav), each landmark needs an accessible name so screen reader users can t
227
193
  | | `aria-current="page"` | `a` | Applied automatically when the item is selected **and** the `link` prop is used |
228
194
  | `link` | | `a` | Applied automatically when using the `link` prop (via `bento-link`) |
229
195
 
230
- > When wrapping the item with a custom link component, set `aria-current="page"` on the wrapping element yourself.
231
-
232
196
  #### Navigation Menu Group
233
197
 
234
198
  | Role | Attribute | Element | Usage |
@@ -1 +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), };
1
+ 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 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 { ref } from 'vue'; import { action } from '@storybook/addon-actions'; import { isVue2 } from 'vue-demi'; import { storybookDocsParameter } from '@/utils/ts/storybook'; 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, 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] }; }; const onExpandAll = () => action('expand-all')(); const onCollapseAll = () => action('collapse-all')(); return { activePage, paymentsExpanded, favorites, toggleFavorite, onExpandAll, onCollapseAll, args: isVue2 ? props : _args, }; }, template: ` <div style="width: 248px;"> <bento-navigation-menu v-bind="args" :active-page="activePage" @update:active-page="activePage = $event" @expand-all="onExpandAll" @collapse-all="onCollapseAll"> <template #headerActions> <bento-button variant="tertiary-with-background"> <template #iconLeft> <search-icon svg-title="Search" /> </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" svg-title="Remove from favorites" /> <star-icon v-else svg-title="Add to favorites" /> </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" svg-title="Remove from favorites" /> <star-icon v-else svg-title="Add to favorites" /> </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" svg-title="Remove from favorites" /> <star-icon v-else svg-title="Add to favorites" /> </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" svg-title="Remove from favorites" /> <star-icon v-else svg-title="Add to favorites" /> </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" svg-title="Remove from favorites" /> <star-icon v-else svg-title="Add to favorites" /> </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" svg-title="Remove from favorites" /> <star-icon v-else svg-title="Add to favorites" /> </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" svg-title="Remove from favorites" /> <star-icon v-else svg-title="Add to favorites" /> </template> </bento-button> </template> </bento-navigation-menu-item> <bento-navigation-menu-item value="profile" label="Profile" :link="{ to: '#profile', isNotRouting: true }" :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" svg-title="Remove from favorites" /> <star-icon v-else svg-title="Add to favorites" /> </template> </bento-button> </template> </bento-navigation-menu-item> </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" :link="{ to: '#home', isNotRouting: true }" /> <bento-navigation-menu-item value="analytics" label="Analytics" :link="{ to: '#analytics', isNotRouting: true }" /> <bento-navigation-menu-item value="settings" label="Settings" :link="{ to: '#settings', isNotRouting: true }" /> <bento-navigation-menu-item value="profile" label="Profile" :link="{ to: '#profile', isNotRouting: true }" /> </bento-navigation-menu> </div> `, }), args: { label: 'Pages', }, parameters: storybookDocsParameter(NavigationMenuSimpleListExample), };
@@ -1 +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; }
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; } export interface BentoNavigationMenuEmits { /** * Emits update event when active page is changed */ (e: 'update:active-page', value: string): void; /** * Emits event when expand all button is clicked */ (e: 'expand-all'): void; /** * Emits event when collapse all button is clicked */ (e: 'collapse-all'): void; } /** * @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; }
@@ -1 +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
+ <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" aria-hidden="true" /> <menu-expand-icon v-else aria-hidden="true" /> </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 { BentoNavigationMenuEmits, 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<BentoNavigationMenuEmits>(); 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)); if (shouldExpand) { emit('expand-all'); } else { emit('collapse-all'); } }; 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" :link="{ to: '/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" :link="{ to: '/settings/general' }" /> * </bento-navigation-menu-group> * </bento-navigation-menu> * ` * } */ export default { i18n: { messages }, }; </script> <style lang="scss" scoped src="./navigation-menu.scss" />
@@ -1,4 +1,4 @@
1
- import { Canvas, Story, Meta } from '@storybook/blocks';
1
+ import { Canvas, Source, Story, Meta } from '@storybook/blocks';
2
2
  import * as ComponentStories from './popover.stories';
3
3
 
4
4
  <Meta of={ComponentStories} />
@@ -14,8 +14,8 @@ control or interactive area.
14
14
 
15
15
  Use popover when you need:
16
16
 
17
- - Provide a piece of non-critical extra information (informative, educative).
18
- - Provide contextual to trigger functionality.
17
+ - Provide a piece of non-critical extra information (informative, educative).
18
+ - Provide contextual to trigger functionality.
19
19
 
20
20
  ### Do not use
21
21
 
@@ -36,11 +36,11 @@ Using the `title` prop and `headerIcon` slot we can create a header for the Popo
36
36
  Using the `actions` prop we can provide an array of action objects (see the Button Actions story). The `actionsLayout`
37
37
  prop can be used to define the layout of buttons and can take up one of the following values:
38
38
 
39
- - `'buttons-end'`
40
- - `'fill-container'`
41
- - `'space-between'`
42
- - `'vertical-stack'`
43
- - `'buttons-start'`
39
+ - `'buttons-end'`
40
+ - `'fill-container'`
41
+ - `'space-between'`
42
+ - `'vertical-stack'`
43
+ - `'buttons-start'`
44
44
 
45
45
  ### Image
46
46
 
@@ -48,6 +48,65 @@ prop can be used to define the layout of buttons and can take up one of the foll
48
48
 
49
49
  Using the `image` slot you can set a header image.
50
50
 
51
+ ### Triggering on hover
52
+
53
+ <Canvas of={ComponentStories.OnHover} />
54
+
55
+ The popover can be opened on hover by using the `useBentoDelayedHover` composable. It returns the `isOpen` ref, the
56
+ individual handlers (`open`, `close`), and a `hoverEvents` object that bundles them as `mouseenter`/`mouseleave`/
57
+ `focusin`/`focusout` listeners. Mouse and focus events are handled, so the popover also opens when the trigger receives
58
+ keyboard focus, and a small closing delay is applied to prevent flickering when the user moves the cursor or focus from
59
+ the trigger to the popover content.
60
+
61
+ Bind the handlers on the trigger via `v-on="hoverEvents"`:
62
+
63
+ <Source
64
+ format={'html'}
65
+ dark
66
+ code={`
67
+ <span ref="triggerRef" v-on="hoverEvents">Hover me</span>
68
+ `}
69
+ />
70
+
71
+ When the popover is **NOT** a descendant of the trigger element (for example, rendered as a sibling or teleported
72
+ elsewhere in the DOM), also bind `hoverEvents` on the popover so that hovering or focusing its content keeps it open:
73
+
74
+ <Source
75
+ format={'html'}
76
+ dark
77
+ code={`
78
+ <span ref="triggerRef" v-on="hoverEvents">Hover me</span>
79
+
80
+ <bento-popover v-on="hoverEvents" :open="isOpen" :target-element="triggerRef">
81
+ Popover content
82
+ </bento-popover>
83
+ `}
84
+ />
85
+
86
+ When the popover is rendered as a descendant of the trigger element, this is not necessary — the trigger's own
87
+ `mouseleave` will not fire while the cursor is over its descendants.
88
+
89
+ > **Vue 2 only:** when binding on a Vue component (e.g. `bento-button` or `bento-popover`) rather than a native HTML
90
+ > element, `v-on` with object syntax does not attach the listeners to the underlying DOM element — the `.native`
91
+ > modifier is required for that, and it cannot be combined with the object form of `v-on`. To work around this, either
92
+ > wrap the component in a native element and use `v-on="hoverEvents"` on the wrapper, or break the object up and bind
93
+ > the handlers individually with `.native`. Neither workaround is needed in Vue 3, where listeners on a component
94
+ > automatically fall through to the root element.
95
+
96
+ <Source
97
+ format={'html'}
98
+ dark
99
+ code={`
100
+ <!-- Option A: wrap in a native element -->
101
+ <span v-on="hoverEvents" tabindex="0">
102
+ <bento-tag ref="triggerRef" label="Hover me" />
103
+ </span>
104
+
105
+ <!-- Option B: bind events individually with .native -->
106
+
107
+ <bento-tag ref="triggerRef" label="Hover me" tabindex="0" @mouseenter.native="open" @mouseleave.native="close"
108
+ @focusin.native="open" @focusout.native="close" /> `} />
109
+
51
110
  ## Accessibility 🚧
52
111
 
53
112
  NOTE: The focus trap within the popover does not function as it should right now, this issue is currently being worked
@@ -71,12 +130,12 @@ documentation provided.
71
130
 
72
131
  #### Please note that:
73
132
 
74
- - The element that shows and hides the content has role button, e.g. use the `BentoButton` component.
75
- - When the Popover is visible, the element with role button has `aria-expanded` set to `true`. When the content area
76
- is hidden, it is set to `false`.
77
- - When the Popover is visible, the popover needs to be identified by an `id` and the trigger element with role button
78
- needs to have `aria-controls` set to the popover `id`. When the popover is hidden, the `aria-controls` attribute
79
- needs to be set to `null`.
133
+ - The element that shows and hides the content has role button, e.g. use the `BentoButton` component.
134
+ - When the Popover is visible, the element with role button has `aria-expanded` set to `true`. When the content area is
135
+ hidden, it is set to `false`.
136
+ - When the Popover is visible, the popover needs to be identified by an `id` and the trigger element with role button
137
+ needs to have `aria-controls` set to the popover `id`. When the popover is hidden, the `aria-controls` attribute needs
138
+ to be set to `null`.
80
139
 
81
140
  | ARIA attribute | is automatic? | fallback used | manual override |
82
141
  | --------------- | ------------------------------------------------------------------------ | ------------- | --------------- |
@@ -86,4 +145,4 @@ documentation provided.
86
145
 
87
146
  ## Resources
88
147
 
89
- - [Figma link](https://www.figma.com/file/uLabwF3243jdMDsNSP7U9I/branch/xxUamzpDM5NlukATVD9JFT/Bento---Components?type=design&node-id=1913%3A27764&mode=design&t=cY18II3hTxXGJeoI-1)
148
+ - [Figma link](https://www.figma.com/file/uLabwF3243jdMDsNSP7U9I/branch/xxUamzpDM5NlukATVD9JFT/Bento---Components?type=design&node-id=1913%3A27764&mode=design&t=cY18II3hTxXGJeoI-1)
@@ -1 +1 @@
1
- import { onMounted, ref, watch } from 'vue'; import { isVue2 } from 'vue-demi'; import { type Meta, type StoryObj } from '@storybook/vue'; import { storybookDocsParameter } from '../../utils/ts/storybook'; import { action } from '@storybook/addon-actions'; import { BentoButton, BentoButtonActionsLayoutBasic } from '@/components/button'; import { BentoTypography } from '@/components/typography'; import { BentoPopoverImage } from './components/popover-image'; import BentoPopover from './popover.vue'; import InfoFilledIcon from '@adyen/ui-assets-icons-16/vue/info-filled'; import { BentoPopoverPositions } from './popover.types'; import { popoverImageSrcStub } from '../../__tests__/img-stub.json'; import './popover.stories.scss?module'; const DEFAULT_TEXT = 'Add your text here'; const TITLE_TEXT = 'Title'; const IMAGE_SRC = popoverImageSrcStub; const IMAGE_ALT = 'Example image'; const ID = 'popover-id'; const ACTIONS = [ { title: 'Primary', event: () => action('Primary')(), }, { title: 'Secondary', event: () => action('Secondary')(), }, ]; const DEFAULT_PROPS = { position: 'bottom-start', disableFocusTrap: true, default: DEFAULT_TEXT, }; export default { title: 'Popover', component: BentoPopover, parameters: { layout: 'centered', }, argTypes: { // Slots default: { defaultValue: '', description: 'Popover content', table: { type: { summary: 'VNode[]' }, }, control: { type: 'text' }, }, headerIcon: { defaultValue: '', description: 'Vue Icon from the library @adyen/ui-assets-icons-16.', table: { type: { summary: 'VNode[]' }, }, control: { disable: true }, }, image: { defaultValue: '', description: 'Popover image container', table: { type: { summary: 'BentoPopoverImage' }, }, control: { disable: true }, }, // Enum props actionsLayout: { options: Object.values(BentoButtonActionsLayoutBasic), mapping: BentoButtonActionsLayoutBasic, control: { type: 'select', }, }, position: { options: Object.values(BentoPopoverPositions), mapping: BentoPopoverPositions, control: { type: 'select', }, }, }, } as Meta; type Story = StoryObj<typeof BentoPopover>; export const Default: Story = { render: (_args, { argTypes }) => ({ components: { BentoButton, BentoPopover, BentoTypography, }, props: Object.keys(argTypes), template: ` <div class="b-popover-story--default"> <bento-popover v-if="targetElement" :open="isOpen" v-bind="args" :targetElement="targetElement" @placement="onPlacementUpdate" > <bento-typography el="span"> {{args.default}} </bento-typography> </bento-popover> <bento-button ref="targetElement" @click="togglePopover" :aria-expanded="isOpen" aria-controls="${ID}" > Open popover </bento-button> </div> `, setup(props) { const args = isVue2 ? props : _args; const isOpen = ref(false); const targetElement = ref(null); const togglePopover = () => { isOpen.value = !isOpen.value; }; watch( () => args.open, (val: boolean) => { isOpen.value = val; } ); const show = () => action('input'); onMounted(() => (isOpen.value = args.open === undefined ? true : args.open)); const onPlacementUpdate = action('placement'); return { // Values args, isOpen, targetElement, // Methods onPlacementUpdate, show, togglePopover, }; }, }), args: { ...DEFAULT_PROPS, id: ID }, parameters: storybookDocsParameter(` <div> <bento-popover v-if="targetElement" id="${ID}" :open="isOpen" position="bottom-start" :targetElement="targetElement" > <bento-typography el="span"> ${DEFAULT_TEXT} </bento-typography> </bento-popover> <bento-button ref="targetElement" :aria-expanded="isOpen" @click="togglePopover" aria-controls="${ID}" > Open popover </bento-button> </div> `), }; export const WithHeader: Story = { render: (_args, { argTypes }) => ({ components: { BentoButton, BentoPopover, BentoTypography, InfoFilledIcon, }, props: Object.keys(argTypes), setup(props) { const args = isVue2 ? props : _args; const isOpen = ref(false); const targetElement = ref(null); const togglePopover = () => { isOpen.value = !isOpen.value; }; watch( () => args.open, (val: boolean) => { isOpen.value = val; } ); const show = () => action('input'); const dismissPopover = () => { action('dismiss')(); isOpen.value = false; }; onMounted(() => (isOpen.value = args.open === undefined ? true : args.open)); return { // Values args, isOpen, targetElement, // Methods togglePopover, show, dismissPopover, }; }, template: ` <div class="b-popover-story--with-header"> <bento-popover v-if="targetElement" :open="isOpen" v-bind="args" :targetElement="targetElement" @dismiss="dismissPopover" > <template #headerIcon> <info-filled-icon /> </template> <bento-typography el="span"> {{args.default}} </bento-typography> </bento-popover> <bento-button ref="targetElement" @click="togglePopover" :aria-expanded="isOpen" aria-controls="${ID}" > Open popover </bento-button> </div> `, }), args: { ...DEFAULT_PROPS, title: TITLE_TEXT, id: ID }, parameters: storybookDocsParameter(` <div> <bento-popover v-if="targetElement" id="${ID}" :open="isOpen" title="${TITLE_TEXT}" position="bottom-start" :targetElement="targetElement" @dismiss="dismissPopover" > <template #headerIcon> <info-filled-icon /> </template> <bento-typography el="span"> ${DEFAULT_TEXT} </bento-typography> </bento-popover> <bento-button ref="targetElement" :aria-expanded="isOpen" @click="togglePopover" aria-controls="${ID}" > Open popover </bento-button> </div> `), }; export const WithActions: Story = { render: (_args, { argTypes }) => ({ components: { BentoButton, BentoPopover, BentoTypography, InfoFilledIcon, }, props: Object.keys(argTypes), setup(props) { const args = isVue2 ? props : _args; const isOpen = ref(false); const targetElement = ref(null); const togglePopover = () => { isOpen.value = !isOpen.value; }; watch( () => args.open, (val: boolean) => { isOpen.value = val; } ); const show = () => action('input'); const dismissPopover = () => { action('dismiss')(); isOpen.value = false; }; onMounted(() => (isOpen.value = args.open === undefined ? true : args.open)); return { // Values args, isOpen, targetElement, // Methods togglePopover, show, dismissPopover, }; }, template: ` <div class="b-popover-story--with-actions"> <bento-popover v-if="targetElement" :open="isOpen" v-bind="args" :targetElement="targetElement" @dismiss="dismissPopover" > <bento-typography el="span"> {{args.default}} </bento-typography> </bento-popover> <bento-button ref="targetElement" @click="togglePopover" :aria-expanded="isOpen" aria-controls="${ID}" > Open popover </bento-button> </div> `, }), args: { ...DEFAULT_PROPS, title: TITLE_TEXT, actions: ACTIONS, id: ID, }, parameters: storybookDocsParameter(` <div> <bento-popover v-if="targetElement" id="${ID}" :open="isOpen" title="${TITLE_TEXT}" :actions="[ { title: 'Primary', event: () => action('Primary')(), }, { title: 'Secondary', event: () => action('Secondary')(), }, ]" position="bottom-start" :targetElement="targetElement" @dismiss="dismissPopover" > <template #headerIcon> <info-filled-icon /> </template> <bento-typography el="span"> ${DEFAULT_TEXT} </bento-typography> </bento-popover> <bento-button ref="targetElement" :aria-expanded="isOpen" @click="togglePopover" aria-controls="${ID}" > Open popover </bento-button> </div> `), }; export const WithImage: Story = { render: (_args, { argTypes }) => ({ components: { BentoButton, BentoPopover, BentoPopoverImage, BentoTypography, InfoFilledIcon, }, props: Object.keys(argTypes), setup(props) { const args = isVue2 ? props : _args; const isOpen = ref(false); const targetElement = ref(null); const togglePopover = () => { isOpen.value = !isOpen.value; }; watch( () => args.open, (val: boolean) => { isOpen.value = val; } ); const show = () => action('input'); const dismissPopover = () => { action('dismiss')(); isOpen.value = false; }; onMounted(() => (isOpen.value = args.open === undefined ? true : args.open)); return { // Values args, isOpen, targetElement, // Methods togglePopover, show, dismissPopover, }; }, template: ` <div class="b-popover-story--with-image"> <bento-popover v-if="targetElement" :open="isOpen" v-bind="args" :targetElement="targetElement" @dismiss="dismissPopover" > <template #image> <bento-popover-image src="${IMAGE_SRC}" alt="${IMAGE_ALT}" /> </template> <bento-typography el="span"> {{args.default}} </bento-typography> </bento-popover> <bento-button ref="targetElement" @click="togglePopover" :aria-expanded="isOpen" aria-controls="${ID}" > Open popover </bento-button> </div> `, }), args: { ...DEFAULT_PROPS, title: TITLE_TEXT, actions: ACTIONS, id: ID, }, parameters: storybookDocsParameter(` <div> <bento-popover v-if="targetElement" id="${ID}" :open="isOpen" title="${TITLE_TEXT}" :actions="[ { title: 'Primary', event: () => action('Primary')(), }, { title: 'Secondary', event: () => action('Secondary')(), }, ]" position="bottom-start" :targetElement="targetElement" @dismiss="dismissPopover" > <template #image> <bento-popover-image src="${IMAGE_SRC}" alt="${IMAGE_ALT}" /> </template> <bento-typography el="span"> ${DEFAULT_TEXT} </bento-typography> </bento-popover> <bento-button ref="targetElement" :aria-expanded="isOpen" @click="togglePopover" aria-controls="${ID}" > Open popover </bento-button> </div> `), };
1
+ import { onMounted, ref, watch } from 'vue'; import { isVue2 } from 'vue-demi'; import { type Meta, type StoryObj } from '@storybook/vue'; import { storybookDocsParameter } from '../../utils/ts/storybook'; import { action } from '@storybook/addon-actions'; import { BentoButton, BentoButtonActionsLayoutBasic } from '@/components/button'; import { BentoLink } from '@/components/link'; import { BentoTag } from '@/components/tag'; import { BentoTypography } from '@/components/typography'; import { useBentoDelayedHover } from '@/composables'; import { BentoPopoverImage } from './components/popover-image'; import BentoPopover from './popover.vue'; import InfoFilledIcon from '@adyen/ui-assets-icons-16/vue/info-filled'; import { BentoPopoverPositions } from './popover.types'; import { popoverImageSrcStub } from '../../__tests__/img-stub.json'; import BentoDelayedHoverExample from '@/composables/use-bento-delayed-hover/__tests__/use-bento-delayed-hover-example.vue?raw'; import './popover.stories.scss?module'; const DEFAULT_TEXT = 'Add your text here'; const TITLE_TEXT = 'Title'; const IMAGE_SRC = popoverImageSrcStub; const IMAGE_ALT = 'Example image'; const ID = 'popover-id'; const ACTIONS = [ { title: 'Primary', event: () => action('Primary')(), }, { title: 'Secondary', event: () => action('Secondary')(), }, ]; const DEFAULT_PROPS = { position: 'bottom-start', disableFocusTrap: true, default: DEFAULT_TEXT, }; export default { title: 'Popover', component: BentoPopover, parameters: { layout: 'centered', }, argTypes: { // Slots default: { defaultValue: '', description: 'Popover content', table: { type: { summary: 'VNode[]' }, }, control: { type: 'text' }, }, headerIcon: { defaultValue: '', description: 'Vue Icon from the library @adyen/ui-assets-icons-16.', table: { type: { summary: 'VNode[]' }, }, control: { disable: true }, }, image: { defaultValue: '', description: 'Popover image container', table: { type: { summary: 'BentoPopoverImage' }, }, control: { disable: true }, }, // Enum props actionsLayout: { options: Object.values(BentoButtonActionsLayoutBasic), mapping: BentoButtonActionsLayoutBasic, control: { type: 'select', }, }, position: { options: Object.values(BentoPopoverPositions), mapping: BentoPopoverPositions, control: { type: 'select', }, }, }, } as Meta; type Story = StoryObj<typeof BentoPopover>; export const Default: Story = { render: (_args, { argTypes }) => ({ components: { BentoButton, BentoPopover, BentoTypography, }, props: Object.keys(argTypes), template: ` <div class="b-popover-story--default"> <bento-popover v-if="targetElement" :open="isOpen" v-bind="args" :targetElement="targetElement" @placement="onPlacementUpdate" > <bento-typography el="span"> {{args.default}} </bento-typography> </bento-popover> <bento-button ref="targetElement" @click="togglePopover" :aria-expanded="isOpen" aria-controls="${ID}" > Open popover </bento-button> </div> `, setup(props) { const args = isVue2 ? props : _args; const isOpen = ref(false); const targetElement = ref(null); const togglePopover = () => { isOpen.value = !isOpen.value; }; watch( () => args.open, (val: boolean) => { isOpen.value = val; } ); const show = () => action('input'); onMounted(() => (isOpen.value = args.open === undefined ? true : args.open)); const onPlacementUpdate = action('placement'); return { // Values args, isOpen, targetElement, // Methods onPlacementUpdate, show, togglePopover, }; }, }), args: { ...DEFAULT_PROPS, id: ID }, parameters: storybookDocsParameter(` <div> <bento-popover v-if="targetElement" id="${ID}" :open="isOpen" position="bottom-start" :targetElement="targetElement" > <bento-typography el="span"> ${DEFAULT_TEXT} </bento-typography> </bento-popover> <bento-button ref="targetElement" :aria-expanded="isOpen" @click="togglePopover" aria-controls="${ID}" > Open popover </bento-button> </div> `), }; export const WithHeader: Story = { render: (_args, { argTypes }) => ({ components: { BentoButton, BentoPopover, BentoTypography, InfoFilledIcon, }, props: Object.keys(argTypes), setup(props) { const args = isVue2 ? props : _args; const isOpen = ref(false); const targetElement = ref(null); const togglePopover = () => { isOpen.value = !isOpen.value; }; watch( () => args.open, (val: boolean) => { isOpen.value = val; } ); const show = () => action('input'); const dismissPopover = () => { action('dismiss')(); isOpen.value = false; }; onMounted(() => (isOpen.value = args.open === undefined ? true : args.open)); return { // Values args, isOpen, targetElement, // Methods togglePopover, show, dismissPopover, }; }, template: ` <div class="b-popover-story--with-header"> <bento-popover v-if="targetElement" :open="isOpen" v-bind="args" :targetElement="targetElement" @dismiss="dismissPopover" > <template #headerIcon> <info-filled-icon /> </template> <bento-typography el="span"> {{args.default}} </bento-typography> </bento-popover> <bento-button ref="targetElement" @click="togglePopover" :aria-expanded="isOpen" aria-controls="${ID}" > Open popover </bento-button> </div> `, }), args: { ...DEFAULT_PROPS, title: TITLE_TEXT, id: ID }, parameters: storybookDocsParameter(` <div> <bento-popover v-if="targetElement" id="${ID}" :open="isOpen" title="${TITLE_TEXT}" position="bottom-start" :targetElement="targetElement" @dismiss="dismissPopover" > <template #headerIcon> <info-filled-icon /> </template> <bento-typography el="span"> ${DEFAULT_TEXT} </bento-typography> </bento-popover> <bento-button ref="targetElement" :aria-expanded="isOpen" @click="togglePopover" aria-controls="${ID}" > Open popover </bento-button> </div> `), }; export const WithActions: Story = { render: (_args, { argTypes }) => ({ components: { BentoButton, BentoPopover, BentoTypography, InfoFilledIcon, }, props: Object.keys(argTypes), setup(props) { const args = isVue2 ? props : _args; const isOpen = ref(false); const targetElement = ref(null); const togglePopover = () => { isOpen.value = !isOpen.value; }; watch( () => args.open, (val: boolean) => { isOpen.value = val; } ); const show = () => action('input'); const dismissPopover = () => { action('dismiss')(); isOpen.value = false; }; onMounted(() => (isOpen.value = args.open === undefined ? true : args.open)); return { // Values args, isOpen, targetElement, // Methods togglePopover, show, dismissPopover, }; }, template: ` <div class="b-popover-story--with-actions"> <bento-popover v-if="targetElement" :open="isOpen" v-bind="args" :targetElement="targetElement" @dismiss="dismissPopover" > <bento-typography el="span"> {{args.default}} </bento-typography> </bento-popover> <bento-button ref="targetElement" @click="togglePopover" :aria-expanded="isOpen" aria-controls="${ID}" > Open popover </bento-button> </div> `, }), args: { ...DEFAULT_PROPS, title: TITLE_TEXT, actions: ACTIONS, id: ID, }, parameters: storybookDocsParameter(` <div> <bento-popover v-if="targetElement" id="${ID}" :open="isOpen" title="${TITLE_TEXT}" :actions="[ { title: 'Primary', event: () => action('Primary')(), }, { title: 'Secondary', event: () => action('Secondary')(), }, ]" position="bottom-start" :targetElement="targetElement" @dismiss="dismissPopover" > <template #headerIcon> <info-filled-icon /> </template> <bento-typography el="span"> ${DEFAULT_TEXT} </bento-typography> </bento-popover> <bento-button ref="targetElement" :aria-expanded="isOpen" @click="togglePopover" aria-controls="${ID}" > Open popover </bento-button> </div> `), }; export const OnHover: Story = { render: (_args, { argTypes }) => ({ components: { BentoLink, BentoPopover, BentoTag, BentoTypography, }, props: Object.keys(argTypes), setup(props) { const args = isVue2 ? props : _args; const triggerElement = ref(null); const { isOpen, open, close, hoverEvents } = useBentoDelayedHover(); return { args, isOpen, open, close, triggerElement, hoverEvents, }; }, template: ` <div class="b-popover-story--on-hover"> <bento-tag ref="triggerElement" label="Hover over me" tabindex="0" :aria-expanded="isOpen.toString()" aria-controls="${ID}" @mouseenter.native="open" @mouseleave.native="close" @focusin.native="open" @focusout.native="close" /> <span v-on="hoverEvents"> <bento-popover v-if="triggerElement" :open="isOpen" v-bind="args" :target-element="triggerElement" role="tooltip" disable-focus-trap > <bento-typography el="span"> {{ args.default }} <bento-link to="https://www.adyen.com/" external> Learn more here </bento-link> </bento-typography> </bento-popover> </span> </div> `, }), args: { ...DEFAULT_PROPS, id: ID }, parameters: storybookDocsParameter(BentoDelayedHoverExample), }; export const WithImage: Story = { render: (_args, { argTypes }) => ({ components: { BentoButton, BentoPopover, BentoPopoverImage, BentoTypography, InfoFilledIcon, }, props: Object.keys(argTypes), setup(props) { const args = isVue2 ? props : _args; const isOpen = ref(false); const targetElement = ref(null); const togglePopover = () => { isOpen.value = !isOpen.value; }; watch( () => args.open, (val: boolean) => { isOpen.value = val; } ); const show = () => action('input'); const dismissPopover = () => { action('dismiss')(); isOpen.value = false; }; onMounted(() => (isOpen.value = args.open === undefined ? true : args.open)); return { // Values args, isOpen, targetElement, // Methods togglePopover, show, dismissPopover, }; }, template: ` <div class="b-popover-story--with-image"> <bento-popover v-if="targetElement" :open="isOpen" v-bind="args" :targetElement="targetElement" @dismiss="dismissPopover" > <template #image> <bento-popover-image src="${IMAGE_SRC}" alt="${IMAGE_ALT}" /> </template> <bento-typography el="span"> {{args.default}} </bento-typography> </bento-popover> <bento-button ref="targetElement" @click="togglePopover" :aria-expanded="isOpen" aria-controls="${ID}" > Open popover </bento-button> </div> `, }), args: { ...DEFAULT_PROPS, title: TITLE_TEXT, actions: ACTIONS, id: ID, }, parameters: storybookDocsParameter(` <div> <bento-popover v-if="targetElement" id="${ID}" :open="isOpen" title="${TITLE_TEXT}" :actions="[ { title: 'Primary', event: () => action('Primary')(), }, { title: 'Secondary', event: () => action('Secondary')(), }, ]" position="bottom-start" :targetElement="targetElement" @dismiss="dismissPopover" > <template #image> <bento-popover-image src="${IMAGE_SRC}" alt="${IMAGE_ALT}" /> </template> <bento-typography el="span"> ${DEFAULT_TEXT} </bento-typography> </bento-popover> <bento-button ref="targetElement" :aria-expanded="isOpen" @click="togglePopover" aria-controls="${ID}" > Open popover </bento-button> </div> `), };
@@ -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" />