@adyen/bento-mcp 0.4.0 → 0.5.1

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 (87) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +41 -9
  3. package/dist/assets/components/alert/alert.types.ts +1 -1
  4. package/dist/assets/components/button/button.types.ts +1 -1
  5. package/dist/assets/components/button/components/button-actions/button-actions.types.ts +1 -1
  6. package/dist/assets/components/card/card.types.ts +1 -1
  7. package/dist/assets/components/checkbox/checkbox.vue +1 -1
  8. package/dist/assets/components/checkbox/components/checkbox-group/checkbox-group.types.ts +1 -1
  9. package/dist/assets/components/checkbox/components/checkbox-group/checkbox-group.vue +1 -1
  10. package/dist/assets/components/code-snippet/code-snippet.types.ts +1 -1
  11. package/dist/assets/components/country/country.types.ts +1 -1
  12. package/dist/assets/components/data-grid/components/data-grid-cell-actions/data-grid-cell-actions.types.ts +1 -1
  13. package/dist/assets/components/data-grid/components/data-grid-row/data-grid-row.vue +1 -1
  14. package/dist/assets/components/data-grid/data-grid.vue +1 -1
  15. package/dist/assets/components/date-picker/date-picker.types.ts +1 -1
  16. package/dist/assets/components/date-range-picker/components/date-range-picker-calendar/date-range-picker-calendar.vue +1 -1
  17. package/dist/assets/components/date-range-picker/date-range-picker.types.ts +1 -1
  18. package/dist/assets/components/drawer/drawer.docs.mdx +68 -0
  19. package/dist/assets/components/drawer/drawer.stories.ts +1 -0
  20. package/dist/assets/components/drawer/drawer.types.ts +1 -0
  21. package/dist/assets/components/drawer/drawer.vue +1 -0
  22. package/dist/assets/components/dropdown/components/variants/dropdown-avatar/dropdown-avatar.vue +1 -1
  23. package/dist/assets/components/dropdown/components/variants/dropdown-country/dropdown-country.vue +1 -1
  24. package/dist/assets/components/dropdown/components/variants/dropdown-currency/dropdown-currency.vue +1 -1
  25. package/dist/assets/components/dropdown/components/variants/dropdown-payment-method/dropdown-payment-method.vue +1 -1
  26. package/dist/assets/components/dropdown/components/variants/dropdown-tag/dropdown-tag.vue +1 -1
  27. package/dist/assets/components/dropdown/composables/use-keyboard-navigation.types.ts +1 -1
  28. package/dist/assets/components/dropdown/dropdown.stories.ts +1 -1
  29. package/dist/assets/components/dropdown/dropdown.types.ts +1 -1
  30. package/dist/assets/components/dropdown/dropdown.vue +1 -1
  31. package/dist/assets/components/file-uploader/file-uploader.vue +1 -1
  32. package/dist/assets/components/filter-bar/components/boolean-filter/boolean-filter.vue +1 -1
  33. package/dist/assets/components/filter-bar/components/input-filter/input-filter.types.ts +1 -1
  34. package/dist/assets/components/filter-bar/filter-bar.types.ts +1 -1
  35. package/dist/assets/components/header/header.types.ts +1 -1
  36. package/dist/assets/components/input-field/input-field.types.ts +1 -1
  37. package/dist/assets/components/input-field/input-field.vue +1 -1
  38. package/dist/assets/components/input-field-password/input-field-password.types.ts +1 -1
  39. package/dist/assets/components/input-field-password/input-field-password.vue +1 -1
  40. package/dist/assets/components/input-field-phone-number/input-field-phone-number.types.ts +1 -1
  41. package/dist/assets/components/input-field-phone-number/input-field-phone-number.vue +1 -1
  42. package/dist/assets/components/internal/calendar/calendar.types.ts +1 -1
  43. package/dist/assets/components/internal/calendar/composables/use-range-pane.types.ts +1 -0
  44. package/dist/assets/components/internal/listbox/components/listbox-single-select/listbox-single-select.vue +1 -1
  45. package/dist/assets/components/internal/listbox/listbox.vue +1 -1
  46. package/dist/assets/components/internal/teleport/teleport.vue +1 -1
  47. package/dist/assets/components/link/link.types.ts +1 -1
  48. package/dist/assets/components/menu/menu.types.ts +1 -1
  49. package/dist/assets/components/menu/menu.vue +1 -1
  50. package/dist/assets/components/modal/modal.types.ts +1 -1
  51. package/dist/assets/components/modal/modal.vue +1 -1
  52. package/dist/assets/components/navigation-menu/components/navigation-menu-group/navigation-menu-group.stories.ts +1 -0
  53. package/dist/assets/components/navigation-menu/components/navigation-menu-group/navigation-menu-group.types.ts +1 -0
  54. package/dist/assets/components/navigation-menu/components/navigation-menu-group/navigation-menu-group.vue +1 -0
  55. package/dist/assets/components/navigation-menu/components/navigation-menu-item/navigation-menu-item.stories.ts +1 -0
  56. package/dist/assets/components/navigation-menu/components/navigation-menu-item/navigation-menu-item.types.ts +1 -0
  57. package/dist/assets/components/navigation-menu/components/navigation-menu-item/navigation-menu-item.vue +1 -0
  58. package/dist/assets/components/navigation-menu/navigation-menu.docs.mdx +321 -0
  59. package/dist/assets/components/navigation-menu/navigation-menu.stories.ts +1 -0
  60. package/dist/assets/components/navigation-menu/navigation-menu.types.ts +1 -0
  61. package/dist/assets/components/navigation-menu/navigation-menu.vue +1 -0
  62. package/dist/assets/components/pagination/pagination.types.ts +1 -1
  63. package/dist/assets/components/payment-method/payment-method.types.ts +1 -1
  64. package/dist/assets/components/popover/popover.types.ts +1 -1
  65. package/dist/assets/components/popper-container/popper-container.types.ts +1 -1
  66. package/dist/assets/components/promo-banner/promo-banner.types.ts +1 -1
  67. package/dist/assets/components/radio-group/radio-group.types.ts +1 -1
  68. package/dist/assets/components/segmented-control/segmented-control.types.ts +1 -1
  69. package/dist/assets/components/status/status.types.ts +1 -1
  70. package/dist/assets/components/summary-grid/components/summary-grid-item/summary-grid-item.types.ts +1 -1
  71. package/dist/assets/components/summary-grid/summary-grid.types.ts +1 -1
  72. package/dist/assets/components/tag/tag.types.ts +1 -1
  73. package/dist/assets/components/textarea/textarea.types.ts +1 -1
  74. package/dist/assets/components/textarea/textarea.vue +1 -1
  75. package/dist/assets/components/timeline/timeline.types.ts +1 -1
  76. package/dist/assets/components/toggle/toggle.types.ts +1 -1
  77. package/dist/assets/components/toggle/toggle.vue +1 -1
  78. package/dist/assets/components/typography/typography.types.ts +1 -1
  79. package/dist/assets/components.json +4 -0
  80. package/dist/assets/composables/use-bento-theme/install.types.ts +1 -1
  81. package/dist/assets/composables/use-form-validate/use-form.types.ts +1 -1
  82. package/dist/assets/icons.json +18 -0
  83. package/dist/assets/index.ts +1 -1
  84. package/dist/assets/usage.json +107 -0
  85. package/dist/assets/variables.css +3 -0
  86. package/dist/main.js +52 -1
  87. package/package.json +1 -1
@@ -1 +1 @@
1
- <template> <bento-typography class="b-input-field" v-bind="computedRootAttributes" data-testid="input-text-container" :class="inputFieldConditionalClasses" el="div" variant="body" > <field-label v-if="hasSlot('default') || label" :for="inputFieldId" data-testid="input-field-label" :tooltip-text="tooltipText" :optional="optional" :required="required" :label="label" @click="focusInput" > <slot></slot> </field-label> <div class="b-input-field__input-box" data-testid="input-box" :aria-disabled="disabled" @click="focusInput"> <!-- Optional Icon for Default text variant --> <span v-if="isDefaultVariant && (hasSlot('defaultIconBefore') || hasSlot('iconBefore'))" :aria-hidden="ariaHidden ? 'true' : undefined" class="b-input-field__icon-before" > <slot v-if="hasSlot('iconBefore')" name="iconBefore" /> <slot v-if="hasSlot('defaultIconBefore') && !hasSlot('iconBefore')" name="defaultIconBefore" /> </span> <!-- Mandatory Icon for Payment Method variant --> <span v-if="isPaymentMethodVariant && hasSlot('paymentMethod')" :aria-hidden="ariaHidden ? 'true' : undefined" class="b-input-field__payment-method" > <slot name="paymentMethod" /> </span> <!-- Dropdown at start --> <div v-if="shouldDisplayDropdownAtStart" :id="dropdownId" class="b-input-field__dropdown b-input-field__dropdown--start" @click.stop > <bento-dropdown v-if="dropdown" ref="inputDropdownElement" v-bind="inputFieldDropdownProps" :disabled="disabled" :readonly="isDropdownReadOnly" @update:model-value="onDropdownInput" /> </div> <!-- Static Value at start --> <bento-typography v-if="shouldDisplayStaticValueAtStart" :id="staticValueId" class="b-input-field__static-value b-input-field__static-value--start" el="span" variant="body" > <slot name="staticValue" /> </bento-typography> <div class="b-input-field__input-container"> <input :id="inputFieldId" v-bind="attrs" ref="inputFieldElement" class="b-input-field__input" :aria-label="computedAriaLabel" :aria-describedby="computedAriaDescribedBy" :aria-owns="computedAriaOwns" :aria-invalid="shouldShowError" :placeholder="placeholder" :required="required" :aria-required="required" :type="type" :value="modelValue ?? value" :disabled="disabled" :readonly="isReadOnly" @input="onInput" @change="emit('change')" @focus="onFocus" @blur="onBlur" @keydown="emit('keydown', $event)" @keyup="emit('keyup', $event)" @keydown.esc="emit('escape-pressed')" @click="emit('click', $event)" /> <!-- Hint at the end --> <bento-typography v-if="hint && isFocused && (modelValue || value)" el="span" class="b-input-field__hint" > {{ hint }} </bento-typography> </div> <span v-if="hasSlot('iconAfter')" :aria-hidden="ariaHidden ? 'true' : undefined" class="b-input-field__icon-after" data-testid="input-text__icon-after" > <slot name="iconAfter" /> </span> <!-- Static Value at the end --> <bento-typography v-if="shouldDisplayStaticValueAtEnd" :id="staticValueId" class="b-input-field__static-value b-input-field__static-value--end" el="span" variant="body" > <slot name="staticValue" /> </bento-typography> <!-- Dropdown at end --> <div v-if="shouldDisplayDropdownAtEnd" :id="dropdownId" data-testid="input-field-dropdown-container-end" class="b-input-field__dropdown b-input-field__dropdown--end" @click.stop > <bento-dropdown v-if="dropdown" :id="dropdownId" ref="inputDropdownElement" v-bind="inputFieldDropdownProps" :disabled="disabled" :readonly="isDropdownReadOnly" @update:model-value="onDropdownInput" /> </div> </div> <error-message v-if="shouldShowError && !!errorMessage" :id="errorId" :error-message="errorMessage" class="b-input-field__error-message" /> <span v-if="hasSlot('description') || description" :id="descriptionId" class="b-input-field__description" @click="focusInput" > <bento-typography el="span" variant="body"> <slot id="description" name="description"> {{ description }} </slot> </bento-typography> </span> </bento-typography> </template> <script setup lang="ts"> import { computed, inject, provide, type Ref, ref, toRef, toRefs, useAttrs, useSlots } from 'vue'; // Components import { BentoDropdown } from '@/components/dropdown'; import { BentoTypography } from '@/components/typography'; import { ErrorMessage, FieldLabel } from '@/internal'; // Utils import { deprecate } from '@/utils/ts/deprecate'; import { useFormFieldEmits } from '@/utils/ts/form-field/form-field'; import { generateUid } from '@/core/utils/ts'; import { getSlotText } from '@/utils/ts/get-slot-text'; import { useAriaLabel } from '@/utils/ts/aria-label'; import { useI18n } from '@/utils/ts/i18n'; import { printDevelopmentWarning } from '@/utils/ts/print-development-warning'; import { INPUT_FIELD_COMPONENT_INJECTION_KEY, INPUT_FIELD_HINT_INJECTION_KEY, INPUT_FIELD_PARENT_COMPONENT_INJECTION_KEY, } from '@/components/input-field/input-field.keys'; import { type BentoListboxSelectedValue } from '@/types/listbox'; // Composables import { useFormLayoutFieldLoading, useHasSlot } from '@/composables'; // Types import { type BentoInputDropdownProps, BentoInputFieldElementPosition, type BentoInputFieldProps, BentoInputFieldStateClass, BentoInputFieldType, type BentoInputFieldValue, BentoInputFieldVariant, } from './input-field.types'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const attrs = useAttrs(); const slots = useSlots(); const props = withDefaults(defineProps<BentoInputFieldProps>(), { ariaHidden: true, ariaLabel: undefined, condensed: false, description: '', disabled: false, dropdown: undefined, dropdownPosition: BentoInputFieldElementPosition.START, error: false, errorMessage: null, label: '', modelValue: undefined, optional: false, placeholder: '', required: false, readonly: false, slashedZero: false, staticValue: '', staticValuePosition: BentoInputFieldElementPosition.START, tooltipText: null, type: BentoInputFieldType.TEXT, value: undefined, variant: BentoInputFieldVariant.DEFAULT, }); const emit = defineEmits<{ /** * Emitted when the element has lost focus */ (e: 'blur'): void; /** * Emitted when the input is clicked */ (e: 'click', event: MouseEvent); /** * Emitted when the user modifies the element's value. Unlike the 'input' event, the change event is not necessarily fired for each alteration to an element's value */ (e: 'change'): void; /** * Emitted when the Input Field's internal dropdown changes it's value */ (e: 'dropdown-input', selectedValue: BentoListboxSelectedValue): void; /** * Emitted when the "ESC" key is pressed */ (e: 'escape-pressed'): void; /** * Emitted when the element has received focus */ (e: 'focus'): void; /** * Emitted when a key is pressed down */ (e: 'keydown', event: KeyboardEvent): void; /** * Emitted when a key is pressed and lifted up */ (e: 'keyup', event: KeyboardEvent): void; /** * Emitted when the component's model changes * @deprecated since version 2.0. Use `v-model` or `update:model-value` instead. */ (e: 'input', inputValue: string): void; /** * Emitted when the Input Field's internal dropdown changes it's value. Contains every dropdown prop passed with the updated selected value as 'value' */ (e: 'update:dropdown', updatedDropdownProps: BentoInputDropdownProps): void; /** * Emitted when the component's model changes */ (e: 'update:model-value', inputValue: BentoInputFieldValue): void; }>(); const { emitValue } = useFormFieldEmits<BentoInputFieldValue>(emit); const computedRootAttributes = computed(() => { return Object.fromEntries(Object.entries(attrs).filter(([key]) => !key.startsWith('aria-'))); }); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const hasSlot = useHasSlot(slots); const inputFieldElement = ref(null); const inputDropdownElement = ref(null); const inputFieldId = generateUid('input'); const dropdownId = generateUid('input-dropdown'); const staticValueId = generateUid('input-static-value'); const descriptionId = generateUid('input-description'); const errorId = generateUid('input-error'); const isFocused = ref(false); // Provide INPUT_FIELD_COMPONENT_INJECTION_KEY as true, to set the input only dropdown size variant in the BentoDropdown comp provide(INPUT_FIELD_COMPONENT_INJECTION_KEY, true); // Renders the input with a hint if a string is provided const hint = inject<Ref<string | undefined>>(INPUT_FIELD_HINT_INJECTION_KEY, ref(undefined)); const onFocus = () => { isFocused.value = true; emit('focus'); }; const onBlur = () => { isFocused.value = false; emit('blur'); }; const { isReadOnly } = useFormLayoutFieldLoading(toRef(props, 'readonly')); const isDropdownReadOnly = computed<BentoInputDropdownProps['readonly']>( () => isReadOnly.value || props.dropdown?.readonly ); const shouldShowStaticValue = computed( () => props.variant === BentoInputFieldVariant.STATIC_VALUE && !!slots.staticValue ); const isDefaultVariant = computed(() => props.variant === BentoInputFieldVariant.DEFAULT); const isPaymentMethodVariant = computed(() => props.variant === BentoInputFieldVariant.PAYMENT_METHOD); const shouldDisplayStaticValueAtStart = computed( () => shouldShowStaticValue.value && props.staticValuePosition === BentoInputFieldElementPosition.START ); const shouldDisplayStaticValueAtEnd = computed( () => shouldShowStaticValue.value && props.staticValuePosition === BentoInputFieldElementPosition.END ); const shouldShowDropdown = computed(() => props.variant === BentoInputFieldVariant.DROPDOWN && !!props.dropdown); const shouldDisplayDropdownAtStart = computed( () => shouldShowDropdown.value && props.dropdownPosition === BentoInputFieldElementPosition.START ); const shouldDisplayDropdownAtEnd = computed( () => shouldShowDropdown.value && props.dropdownPosition === BentoInputFieldElementPosition.END ); const shouldShowError = computed( () => !props.disabled && !isReadOnly.value && (!!props.errorMessage || props.error) ); const computedLabel = computed(() => (getSlotText(slots)('default') as string) || props.label); const computedDescription = computed(() => (getSlotText(slots)('description') as string) || props.description); const computedAriaOwns = computed(() => { if (shouldShowDropdown.value === true && shouldShowStaticValue.value === true) { return `${staticValueId} ${dropdownId}`; } if (shouldShowDropdown.value === true) { return `${dropdownId}`; } if (shouldShowStaticValue.value === true) { return `${staticValueId}`; } return null; }); // vue-i18n issue with t() used in script - direct usage in template works // requires either createI18n({ legacy: false }) or to be wrapped in computed() const computedAriaLabelFallbackMessage = computed(() => attrs?.['aria-labelledby'] ? undefined : t('ariaLabelFallback') ); const computedAriaLabelDropdowFallback = computed(() => t('ariaLabelDropdownFallback', { inputLabel: computedAriaLabel.value }) ); const ariaLabelAttribute = ref(attrs['aria-label']); const { ariaLabel } = toRefs(props); const computedAriaLabel = useAriaLabel({ ariaLabel: ariaLabelAttribute.value as string, // TODO: take this line away when the aria-label props is removed label: ariaLabel, defaultFallback: computedLabel.value || computedAriaLabelFallbackMessage, }); const computedAriaDescribedBy = computed( () => `${computedDescription.value ? descriptionId : ''} ${ shouldShowError.value && !!props.errorMessage ? errorId : '' }` ); const inputFieldConditionalClasses = computed(() => ({ [`b-input-field--${BentoInputFieldStateClass.CONDENSED}`]: props.condensed, [`b-input-field--${BentoInputFieldStateClass.DISABLED}`]: props.disabled, [`b-input-field--${BentoInputFieldStateClass.READONLY}`]: isReadOnly.value, [`b-input-field--${BentoInputFieldStateClass.ERROR}`]: shouldShowError.value, [`b-input-field--slashed-zero`]: props.slashedZero, })); const inputFieldDropdownProps = computed(() => ({ 'aria-label': computedAriaLabelDropdowFallback.value, ...props.dropdown, })); const onInput = (event: Event) => { const inputValue = (event.target as HTMLInputElement).value; if (!props.disabled && !isReadOnly.value) { emitValue(inputValue); } }; const onDropdownInput = (selectedValue: BentoListboxSelectedValue) => { emit('dropdown-input', selectedValue); emit('update:dropdown', { ...props?.dropdown, value: selectedValue }); }; const focusInput = () => { inputFieldElement.value.focus(); }; const focus = () => { if (props.variant === 'dropdown') { inputDropdownElement.value.focus(); } else { focusInput(); } }; // Expose method so that other components can focus on it in Vue3 defineExpose({ focusInput, focus, }); const inputFieldParentComponent = inject(INPUT_FIELD_PARENT_COMPONENT_INJECTION_KEY, ''); if (!Object.values(BentoInputFieldType).includes(props.type as BentoInputFieldType) && !inputFieldParentComponent) { if (props.type === 'tel') { printDevelopmentWarning( `"bento-input-field" dropdown does not support the type '${props.type}', use "bento-input-field-phone-number" instead.` ); } else if (props.type === 'password') { printDevelopmentWarning( `"bento-input-field" dropdown does not support the type '${props.type}', use "bento-input-field-password" instead.` ); } else { printDevelopmentWarning(`"bento-input-field" dropdown does not support the type '${props.type}'`); } } /** * Deprecations */ if (props.error) { deprecate( 'BentoInputField "error" property', `Use the BentoInputField "errorMessage" property instead to indicate that there is a modifier error. Having the field as "undefined" will hide/remove the error. <bento-dropdown errorMessage="Error message text" />`, '2.0.0' ); } if (hasSlot('defaultIconBefore')) { deprecate( 'BentoInputField "defaultIconBefore" slot', `Use the BentoInputField "iconBefore" slot instead to add an icon at the left side of the input field <bento-input-field> \t<template #iconBefore> \t\t<my-icon-goes-here /> \t</template> </bento-input-field>`, '2.0.0' ); } if (props.value) { deprecate( 'BentoInputField "value" property', `The use of "value" prop in "BentoInputField" is no longer supported. Use the "v-model" or "model-value" property instead.`, '2.0.0' ); } </script> <script lang="ts"> /** * Input field component is used to create interactive controls for web-based * forms in order to accept data from the user. * * @example * import { BentoInputField } from '@adyen/bento-vue2' * * const inputValue = ref(''); * * export default { * components: { BentoInputField }, * tempate: ` * <bento-input-field * :model-value="inputValue" * :@update:model-value="newValue => inputValue.value = newValue" * > * } */ export default { name: 'bento-input-field', inheritAttrs: false, model: { prop: 'modelValue' }, i18n: { messages }, }; </script> <style lang="scss" scoped src="./input-field.scss" />
1
+ <template> <bento-typography class="b-input-field" v-bind="getRootAttributes($attrs)" :data-testid="getDataTestId($attrs)" :class="inputFieldConditionalClasses" el="div" variant="body" > <field-label v-if="hasSlot('default') || label" :for="inputFieldId" data-testid="input-field-label" :tooltip-text="tooltipText" :optional="optional" :required="required" :label="label" @click="focusInput" > <slot></slot> </field-label> <div class="b-input-field__input-box" data-testid="input-box" :aria-disabled="disabled" @click="focusInput"> <!-- Optional Icon for Default text variant --> <span v-if="isDefaultVariant && (hasSlot('defaultIconBefore') || hasSlot('iconBefore'))" :aria-hidden="ariaHidden ? 'true' : undefined" class="b-input-field__icon-before" > <slot v-if="hasSlot('iconBefore')" name="iconBefore" /> <slot v-if="hasSlot('defaultIconBefore') && !hasSlot('iconBefore')" name="defaultIconBefore" /> </span> <!-- Mandatory Icon for Payment Method variant --> <span v-if="isPaymentMethodVariant && hasSlot('paymentMethod')" :aria-hidden="ariaHidden ? 'true' : undefined" class="b-input-field__payment-method" > <slot name="paymentMethod" /> </span> <!-- Dropdown at start --> <div v-if="shouldDisplayDropdownAtStart" :id="dropdownId" class="b-input-field__dropdown b-input-field__dropdown--start" @click.stop > <bento-dropdown v-if="dropdown" ref="inputDropdownElement" v-bind="inputFieldDropdownProps" :disabled="disabled" :readonly="isDropdownReadOnly" @update:model-value="onDropdownInput" /> </div> <!-- Static Value at start --> <bento-typography v-if="shouldDisplayStaticValueAtStart" :id="staticValueId" class="b-input-field__static-value b-input-field__static-value--start" el="span" variant="body" > <slot name="staticValue" /> </bento-typography> <div class="b-input-field__input-container"> <input :id="inputFieldId" v-bind="$attrs" ref="inputFieldElement" class="b-input-field__input" :aria-label="computedAriaLabel" :aria-describedby="computedAriaDescribedBy" :aria-owns="computedAriaOwns" :aria-invalid="shouldShowError" :placeholder="placeholder" :required="required" :aria-required="required" :type="type" :value="modelValue ?? value" :disabled="disabled" :readonly="isReadOnly" @input="onInput" @change="emit('change')" @focus="onFocus" @blur="onBlur" @keydown="emit('keydown', $event)" @keyup="emit('keyup', $event)" @keydown.esc="emit('escape-pressed')" @click="emit('click', $event)" /> <!-- Hint at the end --> <bento-typography v-if="hint && isFocused && (modelValue || value)" el="span" class="b-input-field__hint" > {{ hint }} </bento-typography> </div> <span v-if="hasSlot('iconAfter')" :aria-hidden="ariaHidden ? 'true' : undefined" class="b-input-field__icon-after" data-testid="input-text__icon-after" > <slot name="iconAfter" /> </span> <!-- Static Value at the end --> <bento-typography v-if="shouldDisplayStaticValueAtEnd" :id="staticValueId" class="b-input-field__static-value b-input-field__static-value--end" el="span" variant="body" > <slot name="staticValue" /> </bento-typography> <!-- Dropdown at end --> <div v-if="shouldDisplayDropdownAtEnd" :id="dropdownId" data-testid="input-field-dropdown-container-end" class="b-input-field__dropdown b-input-field__dropdown--end" @click.stop > <bento-dropdown v-if="dropdown" :id="dropdownId" ref="inputDropdownElement" v-bind="inputFieldDropdownProps" :disabled="disabled" :readonly="isDropdownReadOnly" @update:model-value="onDropdownInput" /> </div> </div> <error-message v-if="shouldShowError && !!errorMessage" :id="errorId" :error-message="errorMessage" class="b-input-field__error-message" /> <span v-if="hasSlot('description') || description" :id="descriptionId" class="b-input-field__description" @click="focusInput" > <bento-typography el="span" variant="body"> <slot id="description" name="description"> {{ description }} </slot> </bento-typography> </span> </bento-typography> </template> <script setup lang="ts"> import { computed, inject, provide, type Ref, ref, toRef, toRefs, useAttrs, useSlots } from 'vue'; // Components import { BentoDropdown } from '@/components/dropdown'; import { BentoTypography } from '@/components/typography'; import { ErrorMessage, FieldLabel } from '@/internal'; // Utils import { deprecate } from '@/utils/ts/deprecate'; import { useFormFieldEmits } from '@/utils/ts/form-field/form-field'; import { generateUid } from '@/core/utils/ts'; import { getSlotText } from '@/utils/ts/get-slot-text'; import { useAriaLabel } from '@/utils/ts/aria-label'; import { useI18n } from '@/utils/ts/i18n'; import { printDevelopmentWarning } from '@/utils/ts/print-development-warning'; import { INPUT_FIELD_COMPONENT_INJECTION_KEY, INPUT_FIELD_HINT_INJECTION_KEY, INPUT_FIELD_PARENT_COMPONENT_INJECTION_KEY, } from '@/components/input-field/input-field.keys'; import { type BentoListboxSelectedValue } from '@/types/listbox'; // Composables import { useFormLayoutFieldLoading, useHasSlot } from '@/composables'; // Types import { type BentoInputDropdownProps, BentoInputFieldElementPosition, type BentoInputFieldProps, BentoInputFieldStateClass, BentoInputFieldType, type BentoInputFieldValue, BentoInputFieldVariant, } from './input-field.types'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const attrs = useAttrs(); const slots = useSlots(); const props = withDefaults(defineProps<BentoInputFieldProps>(), { ariaHidden: true, ariaLabel: undefined, condensed: false, description: '', disabled: false, dropdown: undefined, dropdownPosition: BentoInputFieldElementPosition.START, error: false, errorMessage: null, label: '', modelValue: undefined, optional: false, placeholder: '', required: false, readonly: false, slashedZero: false, staticValue: '', staticValuePosition: BentoInputFieldElementPosition.START, tooltipText: null, type: BentoInputFieldType.TEXT, value: undefined, variant: BentoInputFieldVariant.DEFAULT, }); const emit = defineEmits<{ /** * Emitted when the element has lost focus */ (e: 'blur'): void; /** * Emitted when the input is clicked */ (e: 'click', event: MouseEvent); /** * Emitted when the user modifies the element's value. Unlike the 'input' event, the change event is not necessarily fired for each alteration to an element's value */ (e: 'change'): void; /** * Emitted when the Input Field's internal dropdown changes it's value */ (e: 'dropdown-input', selectedValue: BentoListboxSelectedValue): void; /** * Emitted when the "ESC" key is pressed */ (e: 'escape-pressed'): void; /** * Emitted when the element has received focus */ (e: 'focus'): void; /** * Emitted when a key is pressed down */ (e: 'keydown', event: KeyboardEvent): void; /** * Emitted when a key is pressed and lifted up */ (e: 'keyup', event: KeyboardEvent): void; /** * Emitted when the component's model changes * @deprecated Since v2.0.0. Use `v-model` or `update:model-value` instead. */ (e: 'input', inputValue: string): void; /** * Emitted when the Input Field's internal dropdown changes it's value. Contains every dropdown prop passed with the updated selected value as 'value' */ (e: 'update:dropdown', updatedDropdownProps: BentoInputDropdownProps): void; /** * Emitted when the component's model changes */ (e: 'update:model-value', inputValue: BentoInputFieldValue): void; }>(); const { emitValue } = useFormFieldEmits<BentoInputFieldValue>(emit); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const hasSlot = useHasSlot(slots); const inputFieldElement = ref(null); const inputDropdownElement = ref(null); const inputFieldId = generateUid('input'); const dropdownId = generateUid('input-dropdown'); const staticValueId = generateUid('input-static-value'); const descriptionId = generateUid('input-description'); const errorId = generateUid('input-error'); const isFocused = ref(false); /** * Gets the overriden or the default data-testid attribute * @param attributes - the live attributes object */ function getDataTestId(attributes: Record<string, unknown>) { return (attributes['data-testid'] as string) ?? 'input-text-container'; } /** * Filters out all aria attributes for the root element. * @param attributes - the live attributes object */ function getRootAttributes(attributes: Record<string, unknown>) { return Object.fromEntries( Object.entries(attributes).filter(([key]) => !key.startsWith('aria-') && key !== 'data-testid') ); } // Provide INPUT_FIELD_COMPONENT_INJECTION_KEY as true, to set the input only dropdown size variant in the BentoDropdown comp provide(INPUT_FIELD_COMPONENT_INJECTION_KEY, true); // Renders the input with a hint if a string is provided const hint = inject<Ref<string | undefined>>(INPUT_FIELD_HINT_INJECTION_KEY, ref(undefined)); const onFocus = () => { isFocused.value = true; emit('focus'); }; const onBlur = () => { isFocused.value = false; emit('blur'); }; const { isReadOnly } = useFormLayoutFieldLoading(toRef(props, 'readonly')); const isDropdownReadOnly = computed<BentoInputDropdownProps['readonly']>( () => isReadOnly.value || props.dropdown?.readonly ); const shouldShowStaticValue = computed( () => props.variant === BentoInputFieldVariant.STATIC_VALUE && !!slots.staticValue ); const isDefaultVariant = computed(() => props.variant === BentoInputFieldVariant.DEFAULT); const isPaymentMethodVariant = computed(() => props.variant === BentoInputFieldVariant.PAYMENT_METHOD); const shouldDisplayStaticValueAtStart = computed( () => shouldShowStaticValue.value && props.staticValuePosition === BentoInputFieldElementPosition.START ); const shouldDisplayStaticValueAtEnd = computed( () => shouldShowStaticValue.value && props.staticValuePosition === BentoInputFieldElementPosition.END ); const shouldShowDropdown = computed(() => props.variant === BentoInputFieldVariant.DROPDOWN && !!props.dropdown); const shouldDisplayDropdownAtStart = computed( () => shouldShowDropdown.value && props.dropdownPosition === BentoInputFieldElementPosition.START ); const shouldDisplayDropdownAtEnd = computed( () => shouldShowDropdown.value && props.dropdownPosition === BentoInputFieldElementPosition.END ); const shouldShowError = computed( () => !props.disabled && !isReadOnly.value && (!!props.errorMessage || props.error) ); const computedLabel = computed(() => (getSlotText(slots)('default') as string) || props.label); const computedDescription = computed(() => (getSlotText(slots)('description') as string) || props.description); const computedAriaOwns = computed(() => { if (shouldShowDropdown.value === true && shouldShowStaticValue.value === true) { return `${staticValueId} ${dropdownId}`; } if (shouldShowDropdown.value === true) { return `${dropdownId}`; } if (shouldShowStaticValue.value === true) { return `${staticValueId}`; } return null; }); // vue-i18n issue with t() used in script - direct usage in template works // requires either createI18n({ legacy: false }) or to be wrapped in computed() const computedAriaLabelFallbackMessage = computed(() => attrs?.['aria-labelledby'] ? undefined : t('ariaLabelFallback') ); const computedAriaLabelDropdowFallback = computed(() => t('ariaLabelDropdownFallback', { inputLabel: computedAriaLabel.value }) ); const ariaLabelAttribute = ref(attrs['aria-label']); const { ariaLabel } = toRefs(props); const computedAriaLabel = useAriaLabel({ ariaLabel: ariaLabelAttribute.value as string, // TODO: take this line away when the aria-label props is removed label: ariaLabel, defaultFallback: computedLabel.value || computedAriaLabelFallbackMessage, }); const computedAriaDescribedBy = computed( () => `${computedDescription.value ? descriptionId : ''} ${ shouldShowError.value && !!props.errorMessage ? errorId : '' }` ); const inputFieldConditionalClasses = computed(() => ({ [`b-input-field--${BentoInputFieldStateClass.CONDENSED}`]: props.condensed, [`b-input-field--${BentoInputFieldStateClass.DISABLED}`]: props.disabled, [`b-input-field--${BentoInputFieldStateClass.READONLY}`]: isReadOnly.value, [`b-input-field--${BentoInputFieldStateClass.ERROR}`]: shouldShowError.value, [`b-input-field--slashed-zero`]: props.slashedZero, })); const inputFieldDropdownProps = computed(() => ({ 'aria-label': computedAriaLabelDropdowFallback.value, ...props.dropdown, })); const onInput = (event: Event) => { const inputValue = (event.target as HTMLInputElement).value; if (!props.disabled && !isReadOnly.value) { emitValue(inputValue); } }; const onDropdownInput = (selectedValue: BentoListboxSelectedValue) => { emit('dropdown-input', selectedValue); emit('update:dropdown', { ...props?.dropdown, value: selectedValue }); }; const focusInput = () => { inputFieldElement.value.focus(); }; const focus = () => { if (props.variant === 'dropdown') { inputDropdownElement.value.focus(); } else { focusInput(); } }; // Expose method so that other components can focus on it in Vue3 defineExpose({ focusInput, focus, }); const inputFieldParentComponent = inject(INPUT_FIELD_PARENT_COMPONENT_INJECTION_KEY, ''); if (!Object.values(BentoInputFieldType).includes(props.type as BentoInputFieldType) && !inputFieldParentComponent) { if (props.type === 'tel') { printDevelopmentWarning( `"bento-input-field" dropdown does not support the type '${props.type}', use "bento-input-field-phone-number" instead.` ); } else if (props.type === 'password') { printDevelopmentWarning( `"bento-input-field" dropdown does not support the type '${props.type}', use "bento-input-field-password" instead.` ); } else { printDevelopmentWarning(`"bento-input-field" dropdown does not support the type '${props.type}'`); } } /** * Deprecations */ if (props.error) { deprecate( 'BentoInputField "error" property', `Use the BentoInputField "errorMessage" property instead to indicate that there is a modifier error. Having the field as "undefined" will hide/remove the error. <bento-dropdown errorMessage="Error message text" />`, '2.0.0' ); } if (hasSlot('defaultIconBefore')) { deprecate( 'BentoInputField "defaultIconBefore" slot', `Use the BentoInputField "iconBefore" slot instead to add an icon at the left side of the input field <bento-input-field> \t<template #iconBefore> \t\t<my-icon-goes-here /> \t</template> </bento-input-field>`, '2.0.0' ); } if (props.value) { deprecate( 'BentoInputField "value" property', `The use of "value" prop in "BentoInputField" is no longer supported. Use the "v-model" or "model-value" property instead.`, '2.0.0' ); } </script> <script lang="ts"> /** * Input field component is used to create interactive controls for web-based * forms in order to accept data from the user. * * @example * import { BentoInputField } from '@adyen/bento-vue2' * * const inputValue = ref(''); * * export default { * components: { BentoInputField }, * tempate: ` * <bento-input-field * :model-value="inputValue" * :@update:model-value="newValue => inputValue.value = newValue" * > * } */ export default { name: 'bento-input-field', inheritAttrs: false, model: { prop: 'modelValue' }, i18n: { messages }, }; </script> <style lang="scss" scoped src="./input-field.scss" />
@@ -1 +1 @@
1
- import type { BentoInputFieldProps, BentoInputFieldValue } from '@/components/input-field/input-field.types'; import { type FormField } from '../../types/form-field'; /** * Public */ export interface BentoInputFieldPasswordValidationListItem { label?: string; key?: BentoInputFieldPasswordDefaultRuleKeys | string; // eslint-disable-next-line @typescript-eslint/no-explicit-any validate?: (value: BentoInputFieldValue, ...args: Array<any>) => boolean; // eslint-disable-next-line @typescript-eslint/no-explicit-any additionalArgs?: Array<any>; disabled?: boolean; } export type BentoInputFieldPasswordDefaultRuleKeys = | 'minLength' | 'requireLowercase' | 'requireNumeric' | 'requireSpecialCharacter' | 'requireUppercase'; export type BentoInputFieldPasswordErrorsList = Record<BentoInputFieldPasswordDefaultRuleKeys | string, string>; export interface BentoInputFieldPasswordValidation { title?: string; suggestion?: string; extendDefaultValidation?: boolean; list: Array<BentoInputFieldPasswordValidationListItem>; } export interface BentoInputFieldPasswordProps extends FormField { /** * Reduces height of the input */ condensed?: BentoInputFieldProps['condensed']; /** * The debounce time in milliseconds for running validation and emitting the `input` event after the user has stopped typing. * This helps to avoid excessive validation checks on every keystroke. Defaults to `500`. */ debounceTime?: number; /** * Performs real-time validations over the input value. Default: `false`. * Disabling the option (set to `true`) will not run pre-defined validations or custom validations defined in the `validation` field. */ disableValidation?: boolean; /** * Text displayed as the value of the input-field * @deprecated for usage with properties since version 2.0. Use `v-model` or `model-value` instead. */ value?: BentoInputFieldProps['value']; /** * Text displayed as the value of the input-field */ modelValue?: BentoInputFieldProps['modelValue']; /** * Configuration object for password field validation. * * This object defines the validation criteria and associated messages for a password field. * It includes the following properties: * * - `title`: The title displayed at the top of the validation hint. * - `suggestion`: The message displayed at the bottom of the validation hint, providing additional guidance. * - `extendDefaultValidation`: Set to true if you want to add your custom rules to the default validation instead of overriding the list. * - `list`: An array of callback functions to be executed on the value after each user input, validating the password against the specified rules. * * By default, the validation checks that: * * - The value has a minimum length of 12 characters. * - The value contains at least one lowercase letter. * - The value contains at least one uppercase letter. * - The value contains at least one numeric digit. * - The value contains at least one special character. * * */ validation?: BentoInputFieldPasswordValidation; /** * Whether the password validation hint is displayed when the input field is activated via focus or input */ withHint?: boolean; } /** * Private */ export enum BentoInputFieldPasswordType { TEXT = 'text', PASSWORD = 'password', } export enum BentoInputFieldPasswordDefaultRule { MIN_LENGTH = 'minLength', REQUIRE_LOWERCASE = 'requireLowercase', REQUIRE_NUMERIC = 'requireNumeric', REQUIRE_SPECIAL_CHARACTER = 'requireSpecialCharacter', REQUIRE_UPPERCASE = 'requireUppercase', }
1
+ import type { BentoInputFieldProps, BentoInputFieldValue } from '@/components/input-field/input-field.types'; import { type FormField } from '../../types/form-field'; /** * Public */ export interface BentoInputFieldPasswordValidationListItem { label?: string; key?: BentoInputFieldPasswordDefaultRuleKeys | string; // eslint-disable-next-line @typescript-eslint/no-explicit-any validate?: (value: BentoInputFieldValue, ...args: Array<any>) => boolean; // eslint-disable-next-line @typescript-eslint/no-explicit-any additionalArgs?: Array<any>; disabled?: boolean; } export type BentoInputFieldPasswordDefaultRuleKeys = | 'minLength' | 'requireLowercase' | 'requireNumeric' | 'requireSpecialCharacter' | 'requireUppercase'; export type BentoInputFieldPasswordErrorsList = Record<BentoInputFieldPasswordDefaultRuleKeys | string, string>; export interface BentoInputFieldPasswordValidation { title?: string; suggestion?: string; extendDefaultValidation?: boolean; list: Array<BentoInputFieldPasswordValidationListItem>; } export interface BentoInputFieldPasswordProps extends FormField { /** * Reduces height of the input */ condensed?: BentoInputFieldProps['condensed']; /** * The debounce time in milliseconds for running validation and emitting the `input` event after the user has stopped typing. * This helps to avoid excessive validation checks on every keystroke. Defaults to `500`. */ debounceTime?: number; /** * Performs real-time validations over the input value. Default: `false`. * Disabling the option (set to `true`) will not run pre-defined validations or custom validations defined in the `validation` field. */ disableValidation?: boolean; /** * Text displayed as the value of the input-field * @deprecated Since v2.0.0. Use `v-model` or `model-value` instead. */ value?: BentoInputFieldProps['value']; /** * Text displayed as the value of the input-field */ modelValue?: BentoInputFieldProps['modelValue']; /** * Configuration object for password field validation. * * This object defines the validation criteria and associated messages for a password field. * It includes the following properties: * * - `title`: The title displayed at the top of the validation hint. * - `suggestion`: The message displayed at the bottom of the validation hint, providing additional guidance. * - `extendDefaultValidation`: Set to true if you want to add your custom rules to the default validation instead of overriding the list. * - `list`: An array of callback functions to be executed on the value after each user input, validating the password against the specified rules. * * By default, the validation checks that: * * - The value has a minimum length of 12 characters. * - The value contains at least one lowercase letter. * - The value contains at least one uppercase letter. * - The value contains at least one numeric digit. * - The value contains at least one special character. * * */ validation?: BentoInputFieldPasswordValidation; /** * Whether the password validation hint is displayed when the input field is activated via focus or input */ withHint?: boolean; } /** * Private */ export enum BentoInputFieldPasswordType { TEXT = 'text', PASSWORD = 'password', } export enum BentoInputFieldPasswordDefaultRule { MIN_LENGTH = 'minLength', REQUIRE_LOWERCASE = 'requireLowercase', REQUIRE_NUMERIC = 'requireNumeric', REQUIRE_SPECIAL_CHARACTER = 'requireSpecialCharacter', REQUIRE_UPPERCASE = 'requireUppercase', }
@@ -1 +1 @@
1
- <template> <div ref="inputFieldPasswordRef"> <bento-input-field ref="inputFieldPasswordInputRef" v-bind="inputFieldArgs" class="b-input-field-password__input" spellcheck="false" @blur="onBlur" @focus="onFocus" @update:model-value="onUpdateModelValue" @keydown="checkCapsLock" @keyup="checkCapsLock" > <template v-if="label">{{ label }}</template> <template v-else><slot /></template> <template v-if="description" #description> <slot name="description">{{ description }}</slot> </template> <template v-if="isCapsLockOn" #iconBefore> <div class="b-input-field-password__caps-lock-indicator" @mouseenter="handleCapsLockMouseEnter" @mouseleave="handleCapsLockMouseLeave" > <bento-image ref="inputFieldPasswordCapsLockIconRef" :src="CapsLockIcon" :alt="t('capsLockEnabled')" /> </div> <tooltip v-if="inputFieldPasswordCapsLockIconRef" :class="tooltipConditionalClass" :content="t('capsLockEnabled')" :disabled-focus-trap="true" :fallback-position="['top', 'bottom']" :is-shown="isTooltipDisplayed" :target-element="inputFieldPasswordCapsLockIconRef" /> </template> <template v-if="!disabled" #iconAfter> <bento-button variant="tertiary" @click="toggleInputType"> <template #iconLeft> <show-icon v-if="!isPasswordReadable" :svg-title="t('showPassword')" /> <hide-icon v-if="isPasswordReadable" :svg-title="t('hidePassword')" /> </template> </bento-button> </template> </bento-input-field> <bento-popover v-if="hasValidations" :open="isHintDisplayed" :target-element="inputFieldPasswordInputRef" :disable-focus-trap="true" :aria-label="computedValidations.title" :aria-describedby="validationListId" position="right-start" > <div :id="validationPopoverId"> <bento-typography v-if="computedValidations.title" class="b-input-field-password__validation-title"> {{ computedValidations.title }} </bento-typography> <p v-if="!hasErrors" class="b-input-field-password__validation-status--valid" role="alert"> {{ t('validationValid') }} </p> <ul :id="validationListId" class="b-input-field-password__validation-list"> <li v-for="(rule, index) in computedValidationList" :key="index" :class="passwordValidityConditionalClass[index]" aria-live="polite" aria-atomic="true" > <span class="b-input-field-password__validation-list-icon"> <checkmark-circle-fill-icon v-if="passwordValidity[index]" :svg-title="t('validationChecked')" /> <dot-icon v-else :svg-title="t('validationUnchecked')" /> </span> <bento-typography class="b-input-field-password__validation-list-text" el="span"> {{ rule.label }} </bento-typography> </li> </ul> <bento-typography v-if="computedValidations.suggestion" class="b-input-field-password__validation-suggestion" > {{ computedValidations.suggestion }} </bento-typography> </div> </bento-popover> </div> </template> <script setup lang="ts"> import { BentoButton } from '@/components/button'; import { BentoImage } from '@/components/image'; import { BentoInputField } from '@/components/input-field'; import { BentoPopover } from '@/components/popover'; import { BentoTypography } from '@/components/typography'; import { Tooltip } from '@/internal'; import type { BentoInputFieldProps } from '@/components/input-field/input-field.types'; import { BentoInputFieldPasswordDefaultRule, type BentoInputFieldPasswordErrorsList, type BentoInputFieldPasswordProps, BentoInputFieldPasswordType, type BentoInputFieldPasswordValidation, type BentoInputFieldPasswordValidationListItem, } from './input-field-password.types'; import CheckmarkCircleFillIcon from '@adyen/ui-assets-icons-16/vue/checkmark-circle-fill'; import DotIcon from '@adyen/ui-assets-icons-16/vue/dot'; import HideIcon from '@adyen/ui-assets-icons-16/vue/hide'; import ShowIcon from '@adyen/ui-assets-icons-16/vue/show'; import CapsLockIcon from './assets/capslock.svg'; import { useFocusWithin, watchDebounced } from '@vueuse/core'; import { computed, provide, ref, type SetupContext, useAttrs, watch } from 'vue'; import { useI18n } from '@/utils/ts/i18n'; import { minLength, requireLowercase, requireNumeric, requireSpecialCharacter, requireUppercase, } from '@/components/input-field-password/utilities/input-field-password.validation'; import { generateUid } from '@/core/utils/ts'; import { useFormFieldEmits } from '@/utils/ts/form-field/form-field'; import { deprecate } from '@/utils/ts/deprecate'; import messages from './messages.json'; import { INPUT_FIELD_PARENT_COMPONENT_INJECTION_KEY } from '@/components/input-field/input-field.keys'; type MessageSchema = (typeof messages)['en-US']; const { t, n } = useI18n<{ message: MessageSchema }>({ messages }); const props = withDefaults(defineProps<BentoInputFieldPasswordProps>(), { condensed: false, debounceTime: DEBOUNCE_DURATION, description: null, disabled: false, disableValidation: false, errorMessage: null, label: null, optional: false, placeholder: '', readonly: false, required: false, tooltipText: null, validation: undefined, value: undefined, modelValue: undefined, withHint: true, }); const emit = defineEmits<{ /** * Emitted when the element has lost focus */ (e: 'blur'): void; /** * Emitted when the element has received focus */ (e: 'focus'): void; /** * Emits an `input:valid` event when the value is validated against the provided validations. * This emit has a debounce and is emitted after an `input` event. */ (e: 'input:valid'): void; /** * Emits an `input:error` event when the value fails the validation. * This emit has a debounce and is emitted after an `input` event. */ (e: 'input:error', errors: BentoInputFieldPasswordErrorsList): void; /** * Emits an `input` event whenever the value in the input is changed. * @deprecated from version 2.0.0. Use `v-model` or `update:model-value` instead. */ (e: 'input', value: BentoInputFieldPasswordProps['value']): void; /** * Emits an `update:model-value` event whenever the value in the input is changed. */ (e: 'update:model-value', value: BentoInputFieldPasswordProps['modelValue']): void; }>(); const attrs = useAttrs(); const inputType = ref(BentoInputFieldPasswordType.PASSWORD); const inputValue = ref(props.modelValue ?? props.value); const isHintDisplayed = ref(false); const isTouched = ref(false); const passwordValidity = ref<Array<boolean>>([]); const errorsList = ref<BentoInputFieldPasswordErrorsList>({}); const capsLockTooltipTimeoutID = ref<ReturnType<typeof setTimeout>>(null); const isTooltipDisplayed = ref(false); const inputFieldPasswordRef = ref(null); const inputFieldPasswordInputRef = ref(null); const inputFieldPasswordCapsLockIconRef = ref(null); const isCapsLockOn = ref(false); provide(INPUT_FIELD_PARENT_COMPONENT_INJECTION_KEY, 'bento-input-field-password'); const { focused } = useFocusWithin(inputFieldPasswordRef); const { emitValue } = useFormFieldEmits<BentoInputFieldPasswordProps['modelValue']>(emit); const validationListId = generateUid('validationListId'); const validationPopoverId = generateUid('validationPopoverId'); const isPasswordReadable = computed(() => inputType.value === BentoInputFieldPasswordType.TEXT); const passwordValidityConditionalClass = computed(() => passwordValidity.value.map(validity => validity ? 'b-input-field-password__validation-list--valid' : 'b-input-field-password__validation-list--invalid' ) ); const tooltipConditionalClass = computed(() => capsLockTooltipTimeoutID.value ? 'b-input-field-password__input-tooltip--timed' : '' ); const inputFieldArgs = computed<BentoInputFieldProps & SetupContext['attrs']>(() => ({ ariaHidden: false, condensed: props.condensed, disabled: props.disabled, errorMessage: props.errorMessage, optional: props.optional, placeholder: props.placeholder, readonly: props.readonly, required: props.required, tooltipText: props.tooltipText, type: inputType.value, modelValue: inputValue.value, 'aria-haspopup': hasValidations.value ? 'dialog' : false, 'aria-describedby': hasValidations.value ? validationPopoverId : '', ...attrs, })); const computedValidations = computed<BentoInputFieldPasswordValidation>(() => { if (props.validation && !props.validation.extendDefaultValidation) { return props.validation; } const defaultValidationList = [ { label: t('validationMinLength', { numberOfCharacters: n(MIN_PASSWORD_LENGTH) }), key: BentoInputFieldPasswordDefaultRule.MIN_LENGTH, validate: minLength, }, { label: t('validationRequireLowercase', { numberOne: n(1) }), key: BentoInputFieldPasswordDefaultRule.REQUIRE_LOWERCASE, validate: requireLowercase, }, { label: t('validationRequireUppercase', { numberOne: n(1) }), key: BentoInputFieldPasswordDefaultRule.REQUIRE_UPPERCASE, validate: requireUppercase, }, { label: t('validationRequireNumeric', { numberOne: n(1) }), key: BentoInputFieldPasswordDefaultRule.REQUIRE_NUMERIC, validate: requireNumeric, }, { label: t('validationRequireSpecialCharacter', { numberOne: n(1) }), key: BentoInputFieldPasswordDefaultRule.REQUIRE_SPECIAL_CHARACTER, validate: requireSpecialCharacter, }, ]; let validationList: Array<BentoInputFieldPasswordValidationListItem> = defaultValidationList; if (props.validation) { const defaultValidationKeys: Array<string> = defaultValidationList.map(rule => rule.key); const customRuleKeys = props.validation?.list?.map(rule => rule.key); // remove from the custom list any rules with default keys (rules that are overriding existent rules) const customValidationList = props.validation?.list?.filter(rule => !defaultValidationKeys.includes(rule.key)) ?? []; // loop through the default list and replace default rule with the custom rule if it exists const validationListWithOverrides = defaultValidationList.map(rule => { if (customRuleKeys?.includes(rule.key)) { const customRule = props.validation?.list?.find(customRule => customRule.key === rule.key); return customRule; } else { return rule; } }); validationList = [...validationListWithOverrides, ...customValidationList]; } return { title: props.validation?.title ?? t('validationTitle'), suggestion: props.validation?.suggestion ?? t('validationSuggestion'), list: validationList, }; }); const computedValidationList = computed(() => computedValidations.value?.list?.filter(rule => !rule.disabled)); const hasErrors = computed(() => passwordValidity.value?.some(validation => !validation)); const hasValidations = computed( () => inputFieldPasswordInputRef.value && computedValidations.value?.list?.length > 0 ); const checkCapsLock = (e: KeyboardEvent) => { // Check if getModifierState exists and is a function in case the user's browser is autofilling if (typeof e.getModifierState === 'function') { isCapsLockOn.value = e.getModifierState('CapsLock'); } }; const toggleInputType = () => { if (isPasswordReadable.value) { inputType.value = BentoInputFieldPasswordType.PASSWORD; } else { inputType.value = BentoInputFieldPasswordType.TEXT; } }; const onUpdateModelValue = (value: string) => { if (props.disabled) { return; } inputValue.value = value; emitValue(inputValue.value); }; const onFocus = () => { emit('focus'); }; const onBlur = () => { emit('blur'); }; const checkValidity = (value: BentoInputFieldPasswordProps['value']) => { const rulesValidityObj = computedValidationList.value?.map(validationItem => ({ key: validationItem.key, label: validationItem.label, isValid: validationItem.validate(value, ...(validationItem.additionalArgs ?? [])), })); let customRuleIndex = 1; passwordValidity.value = rulesValidityObj?.map(error => error.isValid); errorsList.value = rulesValidityObj ?.filter(rule => !rule.isValid) .reduce((errorsObject, error) => { const key = error.key ?? `custom-rule-${customRuleIndex++}`; return { ...errorsObject, [key]: error.label, }; }, {}); }; const handleCapsLockMouseLeave = () => { isTooltipDisplayed.value = false; capsLockTooltipTimeoutID.value = null; }; const handleCapsLockMouseEnter = () => { isTooltipDisplayed.value = true; }; watch( () => [props.value, props.modelValue], () => { inputValue.value = props.modelValue ?? props.value; } ); watch(focused, focused => { // Always set dirty to true when blur occurs if (!focused) { isTouched.value = true; } // Disable validations & hint if "disableValidation" is "true" if (!props.disableValidation) { if (props.withHint) { isHintDisplayed.value = focused; } checkValidity(inputValue.value); } }); watch(isCapsLockOn, isCapsLockOn => { if (!isCapsLockOn) { clearTimeout(capsLockTooltipTimeoutID.value); capsLockTooltipTimeoutID.value = null; isTooltipDisplayed.value = false; } else { isTooltipDisplayed.value = true; capsLockTooltipTimeoutID.value = setTimeout(() => { capsLockTooltipTimeoutID.value = null; isTooltipDisplayed.value = false; }, TOOLTIP_TIMEOUT); } }); watchDebounced( inputValue, inputValue => { checkValidity(inputValue); if (!hasErrors.value) { emit('input:valid'); } else { emit('input:error', errorsList.value); } }, // wait for 0.5s before checking validity OR 2s of continuous typing { debounce: props.debounceTime, maxWait: DEBOUNCE_MAX_WAIT } ); if (props.value) { deprecate( 'BentoInputFieldPassword "value" property', `The use of "value" prop in "BentoInputFieldPassword" is no longer supported. Use the "v-model" or "model-value" property instead.`, '2.0.0' ); } </script> <script lang="ts"> const MIN_PASSWORD_LENGTH = 12; const DEBOUNCE_DURATION = 500; const DEBOUNCE_MAX_WAIT = 2000; const TOOLTIP_TIMEOUT = 2000; /** * The input field password is a component designed to support users' preference for entering values securely. * This is an extension of the `bento-input-field` component. * * @example * import { BentoInputFieldPassword } from '@adyen/bento-vue2'; * * export default { * components: { BentoInputFieldPassword }, * template: ` * <bento-input-field-password * label="Label" * error-message="Error" * :validation="{ * extendDefaultValidation: true, * list: [ * { label: '18 characters', key: 'minLength', validate: minLength, additionalArgs: [18] }, * { label: 'Always true', key: 'alwaysTrue', validate: () => true }, * { key: 'requireSpecialCharacter', disabled: true } * ], * }" * /> * ` * } */ export default { i18n: { messages }, name: 'bento-input-field-password', model: { prop: 'modelValue' }, }; </script> <style lang="scss" scoped src="./input-field-password.scss" />
1
+ <template> <div ref="inputFieldPasswordRef"> <bento-input-field ref="inputFieldPasswordInputRef" v-bind="inputFieldArgs" class="b-input-field-password__input" spellcheck="false" @blur="onBlur" @focus="onFocus" @update:model-value="onUpdateModelValue" @keydown="checkCapsLock" @keyup="checkCapsLock" > <template v-if="label">{{ label }}</template> <template v-else><slot /></template> <template v-if="description" #description> <slot name="description">{{ description }}</slot> </template> <template v-if="isCapsLockOn" #iconBefore> <div class="b-input-field-password__caps-lock-indicator" @mouseenter="handleCapsLockMouseEnter" @mouseleave="handleCapsLockMouseLeave" > <bento-image ref="inputFieldPasswordCapsLockIconRef" :src="CapsLockIcon" :alt="t('capsLockEnabled')" /> </div> <tooltip v-if="inputFieldPasswordCapsLockIconRef" :class="tooltipConditionalClass" :content="t('capsLockEnabled')" :disabled-focus-trap="true" :fallback-position="['top', 'bottom']" :is-shown="isTooltipDisplayed" :target-element="inputFieldPasswordCapsLockIconRef" /> </template> <template v-if="!disabled" #iconAfter> <bento-button variant="tertiary" @click="toggleInputType"> <template #iconLeft> <show-icon v-if="!isPasswordReadable" :svg-title="t('showPassword')" /> <hide-icon v-if="isPasswordReadable" :svg-title="t('hidePassword')" /> </template> </bento-button> </template> </bento-input-field> <bento-popover v-if="hasValidations" :open="isHintDisplayed" :target-element="inputFieldPasswordInputRef" :disable-focus-trap="true" :aria-label="computedValidations.title" :aria-describedby="validationListId" position="right-start" > <div :id="validationPopoverId"> <bento-typography v-if="computedValidations.title" class="b-input-field-password__validation-title"> {{ computedValidations.title }} </bento-typography> <p v-if="!hasErrors" class="b-input-field-password__validation-status--valid" role="alert"> {{ t('validationValid') }} </p> <ul :id="validationListId" class="b-input-field-password__validation-list"> <li v-for="(rule, index) in computedValidationList" :key="index" :class="passwordValidityConditionalClass[index]" aria-live="polite" aria-atomic="true" > <span class="b-input-field-password__validation-list-icon"> <checkmark-circle-fill-icon v-if="passwordValidity[index]" :svg-title="t('validationChecked')" /> <dot-icon v-else :svg-title="t('validationUnchecked')" /> </span> <bento-typography class="b-input-field-password__validation-list-text" el="span"> {{ rule.label }} </bento-typography> </li> </ul> <bento-typography v-if="computedValidations.suggestion" class="b-input-field-password__validation-suggestion" > {{ computedValidations.suggestion }} </bento-typography> </div> </bento-popover> </div> </template> <script setup lang="ts"> import { BentoButton } from '@/components/button'; import { BentoImage } from '@/components/image'; import { BentoInputField } from '@/components/input-field'; import { BentoPopover } from '@/components/popover'; import { BentoTypography } from '@/components/typography'; import { Tooltip } from '@/internal'; import type { BentoInputFieldProps } from '@/components/input-field/input-field.types'; import { BentoInputFieldPasswordDefaultRule, type BentoInputFieldPasswordErrorsList, type BentoInputFieldPasswordProps, BentoInputFieldPasswordType, type BentoInputFieldPasswordValidation, type BentoInputFieldPasswordValidationListItem, } from './input-field-password.types'; import CheckmarkCircleFillIcon from '@adyen/ui-assets-icons-16/vue/checkmark-circle-fill'; import DotIcon from '@adyen/ui-assets-icons-16/vue/dot'; import HideIcon from '@adyen/ui-assets-icons-16/vue/hide'; import ShowIcon from '@adyen/ui-assets-icons-16/vue/show'; import CapsLockIcon from './assets/capslock.svg'; import { useFocusWithin, watchDebounced } from '@vueuse/core'; import { computed, provide, ref, type SetupContext, useAttrs, watch } from 'vue'; import { useI18n } from '@/utils/ts/i18n'; import { minLength, requireLowercase, requireNumeric, requireSpecialCharacter, requireUppercase, } from '@/components/input-field-password/utilities/input-field-password.validation'; import { generateUid } from '@/core/utils/ts'; import { useFormFieldEmits } from '@/utils/ts/form-field/form-field'; import { deprecate } from '@/utils/ts/deprecate'; import messages from './messages.json'; import { INPUT_FIELD_PARENT_COMPONENT_INJECTION_KEY } from '@/components/input-field/input-field.keys'; type MessageSchema = (typeof messages)['en-US']; const { t, n } = useI18n<{ message: MessageSchema }>({ messages }); const props = withDefaults(defineProps<BentoInputFieldPasswordProps>(), { condensed: false, debounceTime: DEBOUNCE_DURATION, description: null, disabled: false, disableValidation: false, errorMessage: null, label: null, optional: false, placeholder: '', readonly: false, required: false, tooltipText: null, validation: undefined, value: undefined, modelValue: undefined, withHint: true, }); const emit = defineEmits<{ /** * Emitted when the element has lost focus */ (e: 'blur'): void; /** * Emitted when the element has received focus */ (e: 'focus'): void; /** * Emits an `input:valid` event when the value is validated against the provided validations. * This emit has a debounce and is emitted after an `input` event. */ (e: 'input:valid'): void; /** * Emits an `input:error` event when the value fails the validation. * This emit has a debounce and is emitted after an `input` event. */ (e: 'input:error', errors: BentoInputFieldPasswordErrorsList): void; /** * Emits an `input` event whenever the value in the input is changed. * @deprecated Since v2.0.0. Use `v-model` or `update:model-value` instead. */ (e: 'input', value: BentoInputFieldPasswordProps['value']): void; /** * Emits an `update:model-value` event whenever the value in the input is changed. */ (e: 'update:model-value', value: BentoInputFieldPasswordProps['modelValue']): void; }>(); const attrs = useAttrs(); const inputType = ref(BentoInputFieldPasswordType.PASSWORD); const inputValue = ref(props.modelValue ?? props.value); const isHintDisplayed = ref(false); const isTouched = ref(false); const passwordValidity = ref<Array<boolean>>([]); const errorsList = ref<BentoInputFieldPasswordErrorsList>({}); const capsLockTooltipTimeoutID = ref<ReturnType<typeof setTimeout>>(null); const isTooltipDisplayed = ref(false); const inputFieldPasswordRef = ref(null); const inputFieldPasswordInputRef = ref(null); const inputFieldPasswordCapsLockIconRef = ref(null); const isCapsLockOn = ref(false); provide(INPUT_FIELD_PARENT_COMPONENT_INJECTION_KEY, 'bento-input-field-password'); const { focused } = useFocusWithin(inputFieldPasswordRef); const { emitValue } = useFormFieldEmits<BentoInputFieldPasswordProps['modelValue']>(emit); const validationListId = generateUid('validationListId'); const validationPopoverId = generateUid('validationPopoverId'); const isPasswordReadable = computed(() => inputType.value === BentoInputFieldPasswordType.TEXT); const passwordValidityConditionalClass = computed(() => passwordValidity.value.map(validity => validity ? 'b-input-field-password__validation-list--valid' : 'b-input-field-password__validation-list--invalid' ) ); const tooltipConditionalClass = computed(() => capsLockTooltipTimeoutID.value ? 'b-input-field-password__input-tooltip--timed' : '' ); const inputFieldArgs = computed<BentoInputFieldProps & SetupContext['attrs']>(() => ({ ariaHidden: false, condensed: props.condensed, disabled: props.disabled, errorMessage: props.errorMessage, optional: props.optional, placeholder: props.placeholder, readonly: props.readonly, required: props.required, tooltipText: props.tooltipText, type: inputType.value, modelValue: inputValue.value, 'aria-haspopup': hasValidations.value ? 'dialog' : false, 'aria-describedby': hasValidations.value ? validationPopoverId : '', ...attrs, })); const computedValidations = computed<BentoInputFieldPasswordValidation>(() => { if (props.validation && !props.validation.extendDefaultValidation) { return props.validation; } const defaultValidationList = [ { label: t('validationMinLength', { numberOfCharacters: n(MIN_PASSWORD_LENGTH) }), key: BentoInputFieldPasswordDefaultRule.MIN_LENGTH, validate: minLength, }, { label: t('validationRequireLowercase', { numberOne: n(1) }), key: BentoInputFieldPasswordDefaultRule.REQUIRE_LOWERCASE, validate: requireLowercase, }, { label: t('validationRequireUppercase', { numberOne: n(1) }), key: BentoInputFieldPasswordDefaultRule.REQUIRE_UPPERCASE, validate: requireUppercase, }, { label: t('validationRequireNumeric', { numberOne: n(1) }), key: BentoInputFieldPasswordDefaultRule.REQUIRE_NUMERIC, validate: requireNumeric, }, { label: t('validationRequireSpecialCharacter', { numberOne: n(1) }), key: BentoInputFieldPasswordDefaultRule.REQUIRE_SPECIAL_CHARACTER, validate: requireSpecialCharacter, }, ]; let validationList: Array<BentoInputFieldPasswordValidationListItem> = defaultValidationList; if (props.validation) { const defaultValidationKeys: Array<string> = defaultValidationList.map(rule => rule.key); const customRuleKeys = props.validation?.list?.map(rule => rule.key); // remove from the custom list any rules with default keys (rules that are overriding existent rules) const customValidationList = props.validation?.list?.filter(rule => !defaultValidationKeys.includes(rule.key)) ?? []; // loop through the default list and replace default rule with the custom rule if it exists const validationListWithOverrides = defaultValidationList.map(rule => { if (customRuleKeys?.includes(rule.key)) { const customRule = props.validation?.list?.find(customRule => customRule.key === rule.key); return customRule; } else { return rule; } }); validationList = [...validationListWithOverrides, ...customValidationList]; } return { title: props.validation?.title ?? t('validationTitle'), suggestion: props.validation?.suggestion ?? t('validationSuggestion'), list: validationList, }; }); const computedValidationList = computed(() => computedValidations.value?.list?.filter(rule => !rule.disabled)); const hasErrors = computed(() => passwordValidity.value?.some(validation => !validation)); const hasValidations = computed( () => inputFieldPasswordInputRef.value && computedValidations.value?.list?.length > 0 ); const checkCapsLock = (e: KeyboardEvent) => { // Check if getModifierState exists and is a function in case the user's browser is autofilling if (typeof e.getModifierState === 'function') { isCapsLockOn.value = e.getModifierState('CapsLock'); } }; const toggleInputType = () => { if (isPasswordReadable.value) { inputType.value = BentoInputFieldPasswordType.PASSWORD; } else { inputType.value = BentoInputFieldPasswordType.TEXT; } }; const onUpdateModelValue = (value: string) => { if (props.disabled) { return; } inputValue.value = value; emitValue(inputValue.value); }; const onFocus = () => { emit('focus'); }; const onBlur = () => { emit('blur'); }; const checkValidity = (value: BentoInputFieldPasswordProps['value']) => { const rulesValidityObj = computedValidationList.value?.map(validationItem => ({ key: validationItem.key, label: validationItem.label, isValid: validationItem.validate(value, ...(validationItem.additionalArgs ?? [])), })); let customRuleIndex = 1; passwordValidity.value = rulesValidityObj?.map(error => error.isValid); errorsList.value = rulesValidityObj ?.filter(rule => !rule.isValid) .reduce((errorsObject, error) => { const key = error.key ?? `custom-rule-${customRuleIndex++}`; return { ...errorsObject, [key]: error.label, }; }, {}); }; const handleCapsLockMouseLeave = () => { isTooltipDisplayed.value = false; capsLockTooltipTimeoutID.value = null; }; const handleCapsLockMouseEnter = () => { isTooltipDisplayed.value = true; }; watch( () => [props.value, props.modelValue], () => { inputValue.value = props.modelValue ?? props.value; } ); watch(focused, focused => { // Always set dirty to true when blur occurs if (!focused) { isTouched.value = true; } // Disable validations & hint if "disableValidation" is "true" if (!props.disableValidation) { if (props.withHint) { isHintDisplayed.value = focused; } checkValidity(inputValue.value); } }); watch(isCapsLockOn, isCapsLockOn => { if (!isCapsLockOn) { clearTimeout(capsLockTooltipTimeoutID.value); capsLockTooltipTimeoutID.value = null; isTooltipDisplayed.value = false; } else { isTooltipDisplayed.value = true; capsLockTooltipTimeoutID.value = setTimeout(() => { capsLockTooltipTimeoutID.value = null; isTooltipDisplayed.value = false; }, TOOLTIP_TIMEOUT); } }); watchDebounced( inputValue, inputValue => { checkValidity(inputValue); if (!hasErrors.value) { emit('input:valid'); } else { emit('input:error', errorsList.value); } }, // wait for 0.5s before checking validity OR 2s of continuous typing { debounce: props.debounceTime, maxWait: DEBOUNCE_MAX_WAIT } ); if (props.value) { deprecate( 'BentoInputFieldPassword "value" property', `The use of "value" prop in "BentoInputFieldPassword" is no longer supported. Use the "v-model" or "model-value" property instead.`, '2.0.0' ); } </script> <script lang="ts"> const MIN_PASSWORD_LENGTH = 12; const DEBOUNCE_DURATION = 500; const DEBOUNCE_MAX_WAIT = 2000; const TOOLTIP_TIMEOUT = 2000; /** * The input field password is a component designed to support users' preference for entering values securely. * This is an extension of the `bento-input-field` component. * * @example * import { BentoInputFieldPassword } from '@adyen/bento-vue2'; * * export default { * components: { BentoInputFieldPassword }, * template: ` * <bento-input-field-password * label="Label" * error-message="Error" * :validation="{ * extendDefaultValidation: true, * list: [ * { label: '18 characters', key: 'minLength', validate: minLength, additionalArgs: [18] }, * { label: 'Always true', key: 'alwaysTrue', validate: () => true }, * { key: 'requireSpecialCharacter', disabled: true } * ], * }" * /> * ` * } */ export default { i18n: { messages }, name: 'bento-input-field-password', model: { prop: 'modelValue' }, }; </script> <style lang="scss" scoped src="./input-field-password.scss" />
@@ -1 +1 @@
1
- import { type CountryCode } from 'libphonenumber-js'; import { type FormField } from '../../types/form-field'; import { type BentoInputDropdownProps } from '@/components/input-field/input-field.types'; export type BentoInputFieldPhoneNumberDropdownProps = Pick<BentoInputDropdownProps, 'readonly'>; /** * String literal with all the available country codes. */ export type BentoInputFieldPhoneNumberCountryCode = CountryCode | any; export interface BentoInputFieldPhoneNumberProps extends FormField { /** * Array of country codes to show on the dropdown */ countries?: Array<BentoInputFieldPhoneNumberCountryCode>; /** * Dropdown props for the small selectable dropdown menu. * Properties supported: * * - `readonly`: Sets the dropdown in read-only mode */ dropdown?: BentoInputFieldPhoneNumberDropdownProps; /** * If set, allows filtering on the countries dropdown */ dynamicFiltering?: boolean; /** * If true, applies error styling to the field. * @deprecated Use `errorMessage` prop instead. */ error?: boolean; /** * Selected country code */ selectedCountry?: BentoInputFieldPhoneNumberCountryCode; /** * The current value of the field. * @deprecated for usage with properties since version 2.0. Use `v-model` or `model-value` instead. */ value?: string | number; /** * The current value of the field. */ modelValue?: string | number; }
1
+ import { type CountryCode } from 'libphonenumber-js'; import { type FormField } from '../../types/form-field'; import { type BentoInputDropdownProps } from '@/components/input-field/input-field.types'; export type BentoInputFieldPhoneNumberDropdownProps = Pick<BentoInputDropdownProps, 'readonly'>; /** * String literal with all the available country codes. */ export type BentoInputFieldPhoneNumberCountryCode = CountryCode | any; export interface BentoInputFieldPhoneNumberProps extends FormField { /** * Array of country codes to show on the dropdown */ countries?: Array<BentoInputFieldPhoneNumberCountryCode>; /** * Dropdown props for the small selectable dropdown menu. * Properties supported: * * - `readonly`: Sets the dropdown in read-only mode */ dropdown?: BentoInputFieldPhoneNumberDropdownProps; /** * If set, allows filtering on the countries dropdown */ dynamicFiltering?: boolean; /** * If true, applies error styling to the field. * @deprecated Since v2.0.0. Use `errorMessage` instead. */ error?: boolean; /** * Selected country code */ selectedCountry?: BentoInputFieldPhoneNumberCountryCode; /** * The current value of the field. * @deprecated Since v2.0.0. Use `v-model` or `model-value` instead. */ value?: string | number; /** * The current value of the field. */ modelValue?: string | number; }
@@ -1 +1 @@
1
- <template> <bento-input-field v-bind="computedProps" ref="inputField" variant="dropdown" :dropdown="dropdown" class="b-input-field-phone-number" :model-value="displayValue" :placeholder="computedPlaceholder" type="tel" @blur="onBlur" @update:model-value="onUpdateModelValue" @keydown="onKeydown" @dropdown-input="onCountryChange" > <template v-if="label">{{ label }}</template> <template v-else><slot /></template> <template #description> <slot name="description">{{ description }}</slot> </template> </bento-input-field> </template> <script setup lang="ts"> import { computed, nextTick, provide, ref, watch } from 'vue'; import { type BentoInputDropdownProps, BentoInputField } from '@/components/input-field'; import { AsYouType, type CountryCode } from 'libphonenumber-js'; import { useI18n } from '@/utils/ts/i18n'; import { appendRegionCode, BentoInputFieldPhoneNumberCountries, getCountriesDropdownItems, getFormattedPhoneCode, getPhoneNumberPlaceholder, handleInputCursor, hasDefaultRegionCode, parsePhoneAndCountry, preventRegionCodeRepetition, stripRegionCode, } from './utils'; import { useFormFieldEmits } from '@/utils/ts/form-field/form-field'; import { deprecate } from '@/utils/ts/deprecate'; import type { BentoInputFieldPhoneNumberProps } from './input-field-phone-number.types'; import messages from './messages.json'; import { INPUT_FIELD_PARENT_COMPONENT_INJECTION_KEY } from '@/components/input-field/input-field.keys'; type MessageSchema = (typeof messages)['en-US']; const { t, locale } = useI18n<{ message: MessageSchema }>({ messages }); const props = withDefaults(defineProps<BentoInputFieldPhoneNumberProps>(), { countries: () => BentoInputFieldPhoneNumberCountries, description: undefined, dynamicFiltering: true, }); /** * Avoid passing `value` to `bento-input-field`. * This component uses `displayValue` instead. */ const computedProps = computed(() => { const { value, ...allProps } = props; return allProps; }); const emit = defineEmits<{ /** * Emitted when the element has lost focus */ (e: 'blur'): void; /** * Emitted when the value changes. The value is the unformatted phone number in E.164 format. * @deprecated from version 2.0.0. Use `v-model` or `update:model-value` instead. */ (e: 'input', value: string): void; /** * Emitted when the value changes. The value is the unformatted phone number in E.164 format. */ (e: 'update:model-value', value: string): void; /** * Emitted when the selected country is updated. */ (e: 'update:selected-country', country: CountryCode): void; /** * Emitted when the phone number's validity changes. * The payload is a boolean indicating whether the number is valid. */ (e: 'input:valid', value: boolean): void; }>(); const inputField = ref(null); const displayValue = ref(''); const internalValue = ref(''); const insertedChar = ref(''); const { emitValue } = useFormFieldEmits<string>(emit); provide(INPUT_FIELD_PARENT_COMPONENT_INJECTION_KEY, 'bento-input-field-phone-number'); const computedPlaceholder = computed(() => { if (props.placeholder) { return props.placeholder; } if (props.selectedCountry) { return getPhoneNumberPlaceholder(props.selectedCountry); } return ''; }); const isDropdownReadonly = computed(() => props.dropdown?.readonly); const countryItems = computed(() => getCountriesDropdownItems(props.countries, locale.value)); const dropdown = computed<BentoInputDropdownProps>(() => ({ items: countryItems.value, modelValue: props.selectedCountry, readonly: isDropdownReadonly.value, dynamicFiltering: props.dynamicFiltering, 'aria-label': t('countrySelectorDropdown'), })); const onBlur = () => emit('blur'); const onUpdateModelValue = async (value: string) => { const inputEl = inputField.value.$refs.inputFieldElement; let formatter: AsYouType; // Check if the input value starts with '+' or '00', indicating it includes a country code. if (value.startsWith('+') || value.startsWith('00')) { // Reset displayValue to ensure changes are always reflected. // For example, if you paste '+1 264 123 4567' multiple times, the display value should always be '1234567'. // Without this, the displayValue will be '1234567' the first time but subsequently stay as '+1 264 123 4567'. displayValue.value = ''; // Normalize the value by replacing '00' with '+'. const normalizedValue = value.startsWith('00') ? `+${value.slice(2)}` : value; // Create a new AsYouType formatter to format the number as it's typed. formatter = new AsYouType(); formatter.input(normalizedValue); // Get the country code from the formatted number. const country = formatter.getCountry(); // Check if the extracted country code is valid and included in the allowed countries. const isValidCountry = country && props.countries.includes(country); // If the dropdown is readonly, determine whether to strip the region code based on the selected country. if (isDropdownReadonly.value) { displayValue.value = props.selectedCountry === country ? stripRegionCode(formatter.getNationalNumber(), country) : value; } else { // Emit an event to update the selected country, if valid. Otherwise, set it to null. emit('update:selected-country', isValidCountry ? country : null); // Strip the region code from the formatted number if the country is valid. displayValue.value = isValidCountry ? stripRegionCode(formatter.getNationalNumber(), country) : value; } const numberValue = formatter.getNumberValue() ?? ''; const formattedPhoneCode = getFormattedPhoneCode(country); const shouldClearInput = isValidCountry && numberValue.startsWith(formattedPhoneCode) && numberValue.length <= formattedPhoneCode.length; internalValue.value = shouldClearInput ? '' : numberValue; emitValue(internalValue.value); } else { // If the input doesn't start with a country code, format the number based on the selected country. formatter = new AsYouType(props.selectedCountry); const formatted = formatter.input(appendRegionCode(value, props.selectedCountry)); // Prevent repetition of the region code and get the unformatted number. const numberValue = formatter.getNumberValue(); const unformattedNumber = numberValue ? preventRegionCodeRepetition(value, numberValue, props.selectedCountry) : value; // Emit raw input when parsing fails i.e. number do not have country code // Emit the unformatted number value and update the display value with the stripped region code. internalValue.value = unformattedNumber; emitValue(internalValue.value); displayValue.value = stripRegionCode(formatted, props.selectedCountry); } await nextTick(); // Re-parse the number to ensure correct formatting, e.g., adding a leading '0' for NL numbers if valid and `hasDefaultRegionCode` returns false. if (formatter.isValid() && props.selectedCountry && !hasDefaultRegionCode(props.selectedCountry)) { const { displayNumber, isValid } = parsePhoneAndCountry( displayValue.value?.toString(), props.selectedCountry, props.countries ); displayValue.value = displayNumber; emitValidation(isValid); } else { emitValidation(formatter.isValid()); } handleInputCursor(inputEl, displayValue.value.toString(), insertedChar.value); }; const emitValidation = (isValid: boolean) => { // Numbers shorter than 5 digits are never valid phone numbers. // `libphonenumber-js` rejects numbers under 5 digits (`TOO_SHORT`). // We don’t call that check directly here, but use the same value for consistency. const MIN_PHONE_NUMBER_LENGTH_FOR_VALIDATION = 5; const numberLength = displayValue.value?.toString().replace(/\D/g, '')?.length ?? 0; const isValidNumberLength = numberLength >= MIN_PHONE_NUMBER_LENGTH_FOR_VALIDATION; emit('input:valid', isValidNumberLength && isValid); }; const onKeydown = event => { if (/^[a-zA-Z]$/.test(event.key) && !event.metaKey && !event.ctrlKey) { event.preventDefault(); return; } insertedChar.value = event.key; }; const onCountryChange = (country: CountryCode) => { emit('update:selected-country', country); const isUnformatted = displayValue.value?.toString().startsWith('+'); if (hasDefaultRegionCode(country) && !isUnformatted) { let formatter = new AsYouType(country); const formatted = formatter.input(appendRegionCode(displayValue.value?.toString(), country)); const unformattedNumber = preventRegionCodeRepetition(displayValue.value?.toString(), formatter.getNumberValue(), country) ?? ''; internalValue.value = unformattedNumber; emitValue(internalValue.value); displayValue.value = stripRegionCode(formatted, country); emitValidation(formatter.isValid()); } else { const { displayNumber, unformattedNumber, isValid } = parsePhoneAndCountry( displayValue.value?.toString(), country, props.countries ); internalValue.value = unformattedNumber ?? ''; emitValue(internalValue.value); displayValue.value = displayNumber; emitValidation(isValid); } }; watch( () => [props.value, props.modelValue], () => { const newValue = props.modelValue ?? props.value; if (newValue === internalValue.value) { return; } const { displayNumber, country, isValid } = parsePhoneAndCountry( newValue?.toString(), props.selectedCountry, props.countries ); if (country && country !== props.selectedCountry && !isDropdownReadonly.value) { emit('update:selected-country', country); } displayValue.value = stripRegionCode(displayNumber, country); emitValidation(isValid); }, { immediate: true } ); if (props.value) { deprecate( 'BentoInputFieldPhoneNumber "value" property', `The use of "value" prop in "BentoInputFieldPhoneNumber" is no longer supported. Use the "v-model" or "model-value" property instead.`, '2.0.0' ); } </script> <script lang="ts"> /** * Input field phone number can be used to display phone numbers that come with a country code. * * @example * import { BentoInputFieldPhoneNumber } from '@adyen/bento-vue2'; * * export default { * components: { BentoInputFieldPhoneNumber }, * template: ` * <bento-input-field-phone-number * label="Phone number" * :countries="['NL', 'BE']" * model-value="0612345678" * selected-country="NL" * /> * ` * } */ export default { i18n: { messages }, model: { prop: 'modelValue' }, }; </script> <style lang="scss" scoped src="./input-field-phone-number.scss" />
1
+ <template> <bento-input-field v-bind="computedProps" ref="inputField" variant="dropdown" :dropdown="dropdown" class="b-input-field-phone-number" :model-value="displayValue" :placeholder="computedPlaceholder" type="tel" @blur="onBlur" @update:model-value="onUpdateModelValue" @keydown="onKeydown" @dropdown-input="onCountryChange" > <template v-if="label">{{ label }}</template> <template v-else><slot /></template> <template #description> <slot name="description">{{ description }}</slot> </template> </bento-input-field> </template> <script setup lang="ts"> import { computed, nextTick, provide, ref, watch } from 'vue'; import { type BentoInputDropdownProps, BentoInputField } from '@/components/input-field'; import { AsYouType, type CountryCode } from 'libphonenumber-js'; import { useI18n } from '@/utils/ts/i18n'; import { appendRegionCode, BentoInputFieldPhoneNumberCountries, getCountriesDropdownItems, getFormattedPhoneCode, getPhoneNumberPlaceholder, handleInputCursor, hasDefaultRegionCode, parsePhoneAndCountry, preventRegionCodeRepetition, stripRegionCode, } from './utils'; import { useFormFieldEmits } from '@/utils/ts/form-field/form-field'; import { deprecate } from '@/utils/ts/deprecate'; import type { BentoInputFieldPhoneNumberProps } from './input-field-phone-number.types'; import messages from './messages.json'; import { INPUT_FIELD_PARENT_COMPONENT_INJECTION_KEY } from '@/components/input-field/input-field.keys'; type MessageSchema = (typeof messages)['en-US']; const { t, locale } = useI18n<{ message: MessageSchema }>({ messages }); const props = withDefaults(defineProps<BentoInputFieldPhoneNumberProps>(), { countries: () => BentoInputFieldPhoneNumberCountries, description: undefined, dynamicFiltering: true, }); /** * Avoid passing `value` to `bento-input-field`. * This component uses `displayValue` instead. */ const computedProps = computed(() => { const { value, ...allProps } = props; return allProps; }); const emit = defineEmits<{ /** * Emitted when the element has lost focus */ (e: 'blur'): void; /** * Emitted when the value changes. The value is the unformatted phone number in E.164 format. * @deprecated Since v2.0.0. Use `v-model` or `update:model-value` instead. */ (e: 'input', value: string): void; /** * Emitted when the value changes. The value is the unformatted phone number in E.164 format. */ (e: 'update:model-value', value: string): void; /** * Emitted when the selected country is updated. */ (e: 'update:selected-country', country: CountryCode): void; /** * Emitted when the phone number's validity changes. * The payload is a boolean indicating whether the number is valid. */ (e: 'input:valid', value: boolean): void; }>(); const inputField = ref(null); const displayValue = ref(''); const internalValue = ref(''); const insertedChar = ref(''); const { emitValue } = useFormFieldEmits<string>(emit); provide(INPUT_FIELD_PARENT_COMPONENT_INJECTION_KEY, 'bento-input-field-phone-number'); const computedPlaceholder = computed(() => { if (props.placeholder) { return props.placeholder; } if (props.selectedCountry) { return getPhoneNumberPlaceholder(props.selectedCountry); } return ''; }); const isDropdownReadonly = computed(() => props.dropdown?.readonly); const countryItems = computed(() => getCountriesDropdownItems(props.countries, locale.value)); const dropdown = computed<BentoInputDropdownProps>(() => ({ items: countryItems.value, modelValue: props.selectedCountry, readonly: isDropdownReadonly.value, dynamicFiltering: props.dynamicFiltering, 'aria-label': t('countrySelectorDropdown'), })); const onBlur = () => emit('blur'); const onUpdateModelValue = async (value: string) => { const inputEl = inputField.value.$refs.inputFieldElement; let formatter: AsYouType; // Check if the input value starts with '+' or '00', indicating it includes a country code. if (value.startsWith('+') || value.startsWith('00')) { // Reset displayValue to ensure changes are always reflected. // For example, if you paste '+1 264 123 4567' multiple times, the display value should always be '1234567'. // Without this, the displayValue will be '1234567' the first time but subsequently stay as '+1 264 123 4567'. displayValue.value = ''; // Normalize the value by replacing '00' with '+'. const normalizedValue = value.startsWith('00') ? `+${value.slice(2)}` : value; // Create a new AsYouType formatter to format the number as it's typed. formatter = new AsYouType(); formatter.input(normalizedValue); // Get the country code from the formatted number. const country = formatter.getCountry(); // Check if the extracted country code is valid and included in the allowed countries. const isValidCountry = country && props.countries.includes(country); // If the dropdown is readonly, determine whether to strip the region code based on the selected country. if (isDropdownReadonly.value) { displayValue.value = props.selectedCountry === country ? stripRegionCode(formatter.getNationalNumber(), country) : value; } else { // Emit an event to update the selected country, if valid. Otherwise, set it to null. emit('update:selected-country', isValidCountry ? country : null); // Strip the region code from the formatted number if the country is valid. displayValue.value = isValidCountry ? stripRegionCode(formatter.getNationalNumber(), country) : value; } const numberValue = formatter.getNumberValue() ?? ''; const formattedPhoneCode = getFormattedPhoneCode(country); const shouldClearInput = isValidCountry && numberValue.startsWith(formattedPhoneCode) && numberValue.length <= formattedPhoneCode.length; internalValue.value = shouldClearInput ? '' : numberValue; emitValue(internalValue.value); } else { // If the input doesn't start with a country code, format the number based on the selected country. formatter = new AsYouType(props.selectedCountry); const formatted = formatter.input(appendRegionCode(value, props.selectedCountry)); // Prevent repetition of the region code and get the unformatted number. const numberValue = formatter.getNumberValue(); const unformattedNumber = numberValue ? preventRegionCodeRepetition(value, numberValue, props.selectedCountry) : value; // Emit raw input when parsing fails i.e. number do not have country code // Emit the unformatted number value and update the display value with the stripped region code. internalValue.value = unformattedNumber; emitValue(internalValue.value); displayValue.value = stripRegionCode(formatted, props.selectedCountry); } await nextTick(); // Re-parse the number to ensure correct formatting, e.g., adding a leading '0' for NL numbers if valid and `hasDefaultRegionCode` returns false. if (formatter.isValid() && props.selectedCountry && !hasDefaultRegionCode(props.selectedCountry)) { const { displayNumber, isValid } = parsePhoneAndCountry( displayValue.value?.toString(), props.selectedCountry, props.countries ); displayValue.value = displayNumber; emitValidation(isValid); } else { emitValidation(formatter.isValid()); } handleInputCursor(inputEl, displayValue.value.toString(), insertedChar.value); }; const emitValidation = (isValid: boolean) => { // Numbers shorter than 5 digits are never valid phone numbers. // `libphonenumber-js` rejects numbers under 5 digits (`TOO_SHORT`). // We don’t call that check directly here, but use the same value for consistency. const MIN_PHONE_NUMBER_LENGTH_FOR_VALIDATION = 5; const numberLength = displayValue.value?.toString().replace(/\D/g, '')?.length ?? 0; const isValidNumberLength = numberLength >= MIN_PHONE_NUMBER_LENGTH_FOR_VALIDATION; emit('input:valid', isValidNumberLength && isValid); }; const onKeydown = event => { if (/^[a-zA-Z]$/.test(event.key) && !event.metaKey && !event.ctrlKey) { event.preventDefault(); return; } insertedChar.value = event.key; }; const onCountryChange = (country: CountryCode) => { emit('update:selected-country', country); const isUnformatted = displayValue.value?.toString().startsWith('+'); if (hasDefaultRegionCode(country) && !isUnformatted) { let formatter = new AsYouType(country); const formatted = formatter.input(appendRegionCode(displayValue.value?.toString(), country)); const unformattedNumber = preventRegionCodeRepetition(displayValue.value?.toString(), formatter.getNumberValue(), country) ?? ''; internalValue.value = unformattedNumber; emitValue(internalValue.value); displayValue.value = stripRegionCode(formatted, country); emitValidation(formatter.isValid()); } else { const { displayNumber, unformattedNumber, isValid } = parsePhoneAndCountry( displayValue.value?.toString(), country, props.countries ); internalValue.value = unformattedNumber ?? ''; emitValue(internalValue.value); displayValue.value = displayNumber; emitValidation(isValid); } }; watch( () => [props.value, props.modelValue], () => { const newValue = props.modelValue ?? props.value; if (newValue === internalValue.value) { return; } const { displayNumber, country, isValid } = parsePhoneAndCountry( newValue?.toString(), props.selectedCountry, props.countries ); if (country && country !== props.selectedCountry && !isDropdownReadonly.value) { emit('update:selected-country', country); } displayValue.value = stripRegionCode(displayNumber, country); emitValidation(isValid); }, { immediate: true } ); if (props.value) { deprecate( 'BentoInputFieldPhoneNumber "value" property', `The use of "value" prop in "BentoInputFieldPhoneNumber" is no longer supported. Use the "v-model" or "model-value" property instead.`, '2.0.0' ); } </script> <script lang="ts"> /** * Input field phone number can be used to display phone numbers that come with a country code. * * @example * import { BentoInputFieldPhoneNumber } from '@adyen/bento-vue2'; * * export default { * components: { BentoInputFieldPhoneNumber }, * template: ` * <bento-input-field-phone-number * label="Phone number" * :countries="['NL', 'BE']" * model-value="0612345678" * selected-country="NL" * /> * ` * } */ export default { i18n: { messages }, model: { prop: 'modelValue' }, }; </script> <style lang="scss" scoped src="./input-field-phone-number.scss" />
@@ -1 +1 @@
1
- import { type Ref, type UnwrapNestedRefs } from 'vue'; import type { Day } from 'date-fns'; export { CalendarMonthFirstDayOfWeek as CalendarFirstDayOfWeek } from './components/calendar-month/calendar-month.types'; export type { CalendarMonthIsDateDisabled as CalendarIsDateDisabled } from './components/calendar-month/calendar-month.types'; export { CalendarYearPosition as CalendarPosition } from './components/calendar-year/calendar-year.types'; export type CalendarGranularityType = 'daily' | 'weekly' | 'monthly' | 'quarterly'; export interface CalendarGranularityConfig<T extends CalendarGranularityType = CalendarGranularityType> { type: T; earliest?: Date; latest?: Date; disabled?: boolean; } export interface CalendarRangeDateValue { startDate: Date; endDate: Date; granularity?: CalendarGranularityType; range?: string; } export type CalendarSingleDateValue = Date; export type CalendarValue = CalendarSingleDateValue | CalendarRangeDateValue; interface UseCalendarPaneProps { isDateDisabled?: (date: Date) => boolean; maxRange?: Ref<number>; selectedDate: Ref<CalendarValue>; granularity?: Ref<CalendarGranularityConfig>; firstDayOfWeek: Day; min?: Ref<Date>; max?: Ref<Date>; } export type UseCalendarPane = ( propRefs: UseCalendarPaneProps, emit ) => { internalValue: UnwrapNestedRefs<CalendarRangeDateValue>; onDateInput: (date: Date) => void; onMouseOver: (date: Date) => void; }; export enum CalendarEvent { INPUT = 'input', START_DATE_SELECTED = 'start-date-selected', END_DATE_SELECTED = 'end-date-selected', } /** * Private types */ export enum CalendarVariant { DAY = 'day', MONTH = 'month', }
1
+ import { type Ref, type UnwrapNestedRefs } from 'vue'; import type { Day } from 'date-fns'; export { CalendarMonthFirstDayOfWeek as CalendarFirstDayOfWeek } from './components/calendar-month/calendar-month.types'; export type { CalendarMonthIsDateDisabled as CalendarIsDateDisabled } from './components/calendar-month/calendar-month.types'; export { CalendarYearPosition as CalendarPosition } from './components/calendar-year/calendar-year.types'; export type CalendarGranularityType = 'daily' | 'weekly' | 'monthly' | 'quarterly'; export interface CalendarGranularityConfig<T extends CalendarGranularityType = CalendarGranularityType> { type: T; earliest?: Date; latest?: Date; disabled?: boolean; maxRange?: number; } export interface CalendarRangeDateValue { startDate: Date; endDate: Date; granularity?: CalendarGranularityType; range?: string; } export type CalendarSingleDateValue = Date; export type CalendarValue = CalendarSingleDateValue | CalendarRangeDateValue; interface UseCalendarPaneProps { isDateDisabled?: (date: Date) => boolean; maxRange?: Ref<number>; selectedDate: Ref<CalendarValue>; granularity?: Ref<CalendarGranularityConfig>; firstDayOfWeek: Day; min?: Ref<Date>; max?: Ref<Date>; } export type UseCalendarPane = ( propRefs: UseCalendarPaneProps, emit ) => { internalValue: UnwrapNestedRefs<CalendarRangeDateValue>; onDateInput: (date: Date) => void; onMouseOver: (date: Date) => void; }; export enum CalendarEvent { INPUT = 'input', START_DATE_SELECTED = 'start-date-selected', END_DATE_SELECTED = 'end-date-selected', } /** * Private types */ export enum CalendarVariant { DAY = 'day', MONTH = 'month', }
@@ -0,0 +1 @@
1
+ /** * Defines how to measure and adjust a date range for a specific granularity (daily, weekly, etc.). * Used by `clampToMaxRange` to enforce maximum range limits in a granularity-agnostic way. */ export interface GranularityClampConfig { /** Returns how many units (days, weeks, months, quarters) fit between two dates. */ getCount: (end: Date, start: Date) => number; /** Subtracts a number of units from a date (e.g. `subDays`, `subWeeks`). */ sub: (date: Date, amount: number) => Date; /** Adds a number of units to a date (e.g. `addDays`, `addWeeks`). */ add: (date: Date, amount: number) => Date; /** Snaps a date to the start of its unit boundary (e.g. `startOfMonth`). Only needed for granularities whose units don't align to individual days. */ snapStart?: (date: Date) => Date; /** Snaps a date to the end of its unit boundary (e.g. `endOfMonth`). Only needed for granularities whose units don't align to individual days. */ snapEnd?: (date: Date) => Date; }
@@ -1 +1 @@
1
- <template> <div ref="listboxRef" v-bento-keyboard-navigation-directive v-bind="ariaAttributes" role="listbox" v-on="listboxListeners" > <template v-if="hasCategories && staticCategories"> <bento-listbox-single-select-category v-for="category in items" :key="category.value" :category-label="category.label" > <bento-listbox-single-select-option v-for="option in category.items" :key="option.value" ref="listboxItemRef" role="option" :option="option" :disabled="isOptionDisabled ? isOptionDisabled(option) : null" :selected="option.value === selectedItem.value" v-on="listboxOptionListeners" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </bento-listbox-single-select-option> </bento-listbox-single-select-category> </template> <template v-else> <bento-listbox-single-select-option v-for="option in items" :key="option.value" ref="listboxItemRef" role="option" :option="option" :disabled="isOptionDisabled ? isOptionDisabled(option) : null" :selected="option.value === selectedItem.value" v-on="listboxOptionListeners" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </bento-listbox-single-select-option> </template> </div> </template> <script lang="ts"> import { computed, defineComponent, type HTMLAttributes, type PropType, ref, toRef } from 'vue'; import { BentoListboxSingleSelectCategory } from './components/listbox-single-select-category'; import { BentoListboxSingleSelectOption } from '../listbox-single-select-option'; import { useListboxKeyboardNavigation } from '../../composables/useListboxKeyboardNavigation'; import { useVisibleDomOptions } from '@/internal/listbox/composables/use-visible-dom-options/use-visible-dom-options'; import { BentoListboxEvent, type BentoListboxIsOptionDisabled, type BentoListboxOptions } from '@/types/listbox'; import { BentoKeyboardNavigationKeyDownEvent } from '@/types/keyboard-navigation'; import { type Booleanish } from '@/types/prop-types'; import { BentoKeyboardNavigationDirective } from '@/directives'; /** * Listbox single-select options list * * @example * import { BentoListboxMultiSelect } from '@adyen/bento-vue2'; * * export default { * components: { BentoListboxMultiSelect }, * template: ` * <bento-listbox-multi-select * :items="[{ label: 'Option 1', value: 'option-1' }]" * :selected-values="['option-1']" * /> * ` * } */ export default defineComponent({ name: 'bento-listbox-single-select', components: { BentoListboxSingleSelectCategory, BentoListboxSingleSelectOption, }, directives: { BentoKeyboardNavigationDirective }, inheritAttrs: false, props: { /** * Function that allows the options to be disabled * * @type {BentoListboxIsOptionDisabled} * @param {BentoListboxOptionItem} option - listbox option object * @param {string} option.label - Option's label text * @param {string|number} option.value - Option's value * @param {unknown} option.data - Option's extra data to be passed to the default slot */ isOptionDisabled: { type: Function as PropType<BentoListboxIsOptionDisabled>, default: () => false, }, /** * The option elements to populate the listbox with. * It must be an array of {@see BentoListboxOptionItem }. * * @property {string} value.label - Text to be displayed in the option * @property {string,number} value.value - Value of the option */ items: { type: Array as PropType<BentoListboxOptions>, required: true, }, /** * The `input` value. * Providing an empty string will select no options. * Set to an empty string `''` if you don't want any of the available options to be selected */ selectedValue: { type: Array as PropType<BentoListboxOptions>, default: undefined, }, /** * If enabled, dropdown will display non-selectable, static categories */ staticCategories: { type: Boolean, default: false }, }, emits: [BentoListboxEvent.SELECT, BentoListboxEvent.ENTER, BentoListboxEvent.SPACE, BentoListboxEvent.TAB], setup(props, { emit, expose, slots, attrs }) { const listboxRef = ref(null); const listboxItemRef = ref([]); const selectedItem = computed(() => !!props.selectedValue?.length && props.selectedValue.at(0)); const ariaAttributes = computed( () => ({ 'aria-label': attrs['aria-label']?.toString() as string, 'aria-busy': attrs['aria-busy']?.toString() as Booleanish, 'aria-atomic': attrs['aria-atomic']?.toString() as Booleanish, 'aria-live': attrs['aria-live']?.toString() as string, }) as HTMLAttributes ); /** * Finds out if items have sub items */ const hasCategories = computed(() => !!props.items.some(({ items }) => !!items)); const onOptionSelected = eventName => (selectedOption: BentoListboxOptions) => { emit(eventName, [selectedOption]); }; // Keyboard navigation const { visibleDomOptions } = useVisibleDomOptions(listboxRef, toRef(props, 'items'), ref('option'), false); const focusItem = (nextFocusedItemIndex: number) => { visibleDomOptions.value[nextFocusedItemIndex]?.focus(); }; const { moveToNextElement, moveToPreviousElement, moveToFirstElement, moveToLastElement, setListboxFocusedIndex, } = useListboxKeyboardNavigation( { items: visibleDomOptions, }, focusItem ); const listboxListeners = { [BentoKeyboardNavigationKeyDownEvent.ARROW_UP]: moveToPreviousElement, [BentoKeyboardNavigationKeyDownEvent.ARROW_DOWN]: moveToNextElement, [BentoKeyboardNavigationKeyDownEvent.HOME]: moveToFirstElement, [BentoKeyboardNavigationKeyDownEvent.END]: moveToLastElement, }; const listboxOptionListeners = { [BentoListboxEvent.SELECT]: onOptionSelected(BentoListboxEvent.SELECT), [BentoListboxEvent.SPACE]: onOptionSelected(BentoListboxEvent.SPACE), [BentoListboxEvent.TAB]: onOptionSelected(BentoListboxEvent.TAB), [BentoListboxEvent.ENTER]: onOptionSelected(BentoListboxEvent.ENTER), }; expose({ /** * Reference for a single listbox item. * Required for keyboard navigation in dropdown as $children is removed in vue@3 */ listboxItemRef, /** * Method to set the index of the item to be focused on * Used for focus management to be kept in-sync with parent e.g. dropdowns */ setListboxFocusedIndex, }); return { // Enums BentoListboxEvent, // Values listboxRef, listboxItemRef, selectedItem, slots, ariaAttributes, // Computed hasCategories, // Methods onOptionSelected, // Keyboard navigation listboxListeners, listboxOptionListeners, }; }, }); </script>
1
+ <template> <div ref="listboxRef" v-bento-keyboard-navigation-directive v-bind="ariaAttributes" role="listbox" v-on="listboxListeners" @focusin="handleVirtualScrollingFocus" > <template v-if="hasCategories && staticCategories"> <template v-for="item in items"> <bento-listbox-single-select-category v-if="item.items && item.items.length > 0" :key="`category-${item.value}`" :category-label="item.label" > <bento-listbox-single-select-option v-for="option in item.items" :key="option.value" ref="listboxItemRef" role="option" :option="option" :disabled="isOptionDisabled ? isOptionDisabled(option) : null" :selected="option.value === selectedItem.value" v-on="listboxOptionListeners" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </bento-listbox-single-select-option> </bento-listbox-single-select-category> <bento-listbox-single-select-option v-else :key="`option-${item.value}`" ref="listboxItemRef" role="option" :option="item" :disabled="isOptionDisabled ? isOptionDisabled(item) : null" :selected="item.value === selectedItem.value" v-on="listboxOptionListeners" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </bento-listbox-single-select-option> </template> </template> <template v-else> <bento-listbox-single-select-option v-for="option in items" :key="option.value" ref="listboxItemRef" role="option" :option="option" :disabled="isOptionDisabled ? isOptionDisabled(option) : null" :selected="option.value === selectedItem.value" v-on="listboxOptionListeners" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </bento-listbox-single-select-option> </template> </div> </template> <script lang="ts"> import { computed, defineComponent, type HTMLAttributes, nextTick, type PropType, ref, toRef } from 'vue'; import { BentoListboxSingleSelectCategory } from './components/listbox-single-select-category'; import { BentoListboxSingleSelectOption } from '../listbox-single-select-option'; import { useListboxKeyboardNavigation } from '../../composables/useListboxKeyboardNavigation'; import { useVisibleDomOptions } from '@/internal/listbox/composables/use-visible-dom-options/use-visible-dom-options'; import { BentoListboxEvent, type BentoListboxIsOptionDisabled, type BentoListboxOptions } from '@/types/listbox'; import { BentoKeyboardNavigationKeyDownEvent } from '@/types/keyboard-navigation'; import { type Booleanish } from '@/types/prop-types'; import { BentoKeyboardNavigationDirective } from '@/directives'; /** * Listbox single-select options list * * @example * import { BentoListboxMultiSelect } from '@adyen/bento-vue2'; * * export default { * components: { BentoListboxMultiSelect }, * template: ` * <bento-listbox-multi-select * :items="[{ label: 'Option 1', value: 'option-1' }]" * :selected-values="['option-1']" * /> * ` * } */ export default defineComponent({ name: 'bento-listbox-single-select', components: { BentoListboxSingleSelectCategory, BentoListboxSingleSelectOption, }, directives: { BentoKeyboardNavigationDirective }, inheritAttrs: false, props: { /** * Function that allows the options to be disabled * * @type {BentoListboxIsOptionDisabled} * @param {BentoListboxOptionItem} option - listbox option object * @param {string} option.label - Option's label text * @param {string|number} option.value - Option's value * @param {unknown} option.data - Option's extra data to be passed to the default slot */ isOptionDisabled: { type: Function as PropType<BentoListboxIsOptionDisabled>, default: () => false, }, /** * The option elements to populate the listbox with. * It must be an array of {@see BentoListboxOptionItem }. * * @property {string} value.label - Text to be displayed in the option * @property {string,number} value.value - Value of the option */ items: { type: Array as PropType<BentoListboxOptions>, required: true, }, /** * The `input` value. * Providing an empty string will select no options. * Set to an empty string `''` if you don't want any of the available options to be selected */ selectedValue: { type: Array as PropType<BentoListboxOptions>, default: undefined, }, /** * If enabled, dropdown will display non-selectable, static categories */ staticCategories: { type: Boolean, default: false }, /** * Total number of items in the full list (used for virtual scroll keyboard navigation). * When greater than 0, virtual scroll keyboard navigation is enabled. */ totalItemCount: { type: Number, default: 0 }, /** * Function to scroll the virtual list container to a given item index. */ scrollToIndex: { type: Function as PropType<(index: number) => void>, default: undefined }, /** * The start index of the currently rendered virtual scroll slice. */ virtualScrollStartIndex: { type: Number, default: 0 }, }, emits: [BentoListboxEvent.SELECT, BentoListboxEvent.ENTER, BentoListboxEvent.SPACE, BentoListboxEvent.TAB], setup(props, { emit, expose, slots, attrs }) { const listboxRef = ref(null); const listboxItemRef = ref([]); const selectedItem = computed(() => !!props.selectedValue?.length && props.selectedValue.at(0)); const ariaAttributes = computed( () => ({ 'aria-label': attrs['aria-label']?.toString() as string, 'aria-busy': attrs['aria-busy']?.toString() as Booleanish, 'aria-atomic': attrs['aria-atomic']?.toString() as Booleanish, 'aria-live': attrs['aria-live']?.toString() as string, }) as HTMLAttributes ); /** * Finds out if items have sub items */ const hasCategories = computed(() => !!props.items.some(({ items }) => !!items)); const onOptionSelected = eventName => (selectedOption: BentoListboxOptions) => { emit(eventName, [selectedOption]); }; // Keyboard navigation const { visibleDomOptions, updateVisibleDomOptions } = useVisibleDomOptions( listboxRef, toRef(props, 'items'), ref('option'), false ); const isVirtualScrollEnabled = computed(() => props.totalItemCount > 0); // When virtual scroll re-renders items (e.g. on mouse wheel scroll), the // browser may auto-focus an option. Sync the keyboard navigation index to // match the focused element so subsequent arrow key presses navigate from // the correct position. const handleVirtualScrollingFocus = (event: FocusEvent) => { if (!isVirtualScrollEnabled.value) { return; } const target = event.target as HTMLElement; if (target === listboxRef.value || target?.getAttribute('role') !== 'option') { return; } const localIndex = visibleDomOptions.value.indexOf(target as HTMLDivElement); if (localIndex !== -1) { const globalIndex = localIndex + props.virtualScrollStartIndex; setListboxFocusedIndex(globalIndex); } }; const focusItem = async (globalIndex: number) => { if (isVirtualScrollEnabled.value && props.scrollToIndex) { props.scrollToIndex(globalIndex); await nextTick(); await updateVisibleDomOptions(); const newDomIndex = globalIndex - props.virtualScrollStartIndex; visibleDomOptions.value[newDomIndex]?.focus(); } else { visibleDomOptions.value[globalIndex]?.focus(); } }; const navigateToItem = async (globalIndex: number) => { setListboxFocusedIndex(globalIndex); await focusItem(globalIndex); }; const { moveToNextElement, moveToPreviousElement, moveToFirstElement, moveToLastElement, setListboxFocusedIndex, } = useListboxKeyboardNavigation( { items: visibleDomOptions, totalItemCount: toRef(props, 'totalItemCount'), }, focusItem ); const listboxListeners = { [BentoKeyboardNavigationKeyDownEvent.ARROW_UP]: moveToPreviousElement, [BentoKeyboardNavigationKeyDownEvent.ARROW_DOWN]: moveToNextElement, [BentoKeyboardNavigationKeyDownEvent.HOME]: moveToFirstElement, [BentoKeyboardNavigationKeyDownEvent.END]: moveToLastElement, }; const listboxOptionListeners = { [BentoListboxEvent.SELECT]: onOptionSelected(BentoListboxEvent.SELECT), [BentoListboxEvent.SPACE]: onOptionSelected(BentoListboxEvent.SPACE), [BentoListboxEvent.TAB]: onOptionSelected(BentoListboxEvent.TAB), [BentoListboxEvent.ENTER]: onOptionSelected(BentoListboxEvent.ENTER), }; expose({ /** * Reference for a single listbox item. * Required for keyboard navigation in dropdown as $children is removed in vue@3 */ listboxItemRef, /** * Method to set the index of the item to be focused on * Used for focus management to be kept in-sync with parent e.g. dropdowns */ setListboxFocusedIndex, /** * Navigates to an item by global index, handling virtual scroll if enabled. * Scrolls the virtual list and focuses the item. */ navigateToItem, }); return { // Enums BentoListboxEvent, // Values listboxRef, listboxItemRef, selectedItem, slots, ariaAttributes, // Computed hasCategories, // Methods onOptionSelected, // Keyboard navigation listboxListeners, listboxOptionListeners, // Virtual scroll focus management handleVirtualScrollingFocus, }; }, }); </script>
@@ -1 +1 @@
1
- <template> <div :id="id" ref="listboxRef" v-bento-keyboard-navigation-directive class="b-listbox" tabindex="-1" v-bind="listboxContainerAttributes" :style="containerProps?.style" data-testid="listbox-container" @scroll="onListboxContainerScroll" > <div v-bind="wrapperProps"> <!-- Loading filtered items loading --> <div v-if="componentLoading" class="b-listbox__loading-filtered-items"> <bento-loading-indicator /> </div> <!-- Empty search message --> <div v-else-if="isSearchResultEmpty" class="b-listbox__empty-search-message"> <bento-empty-state v-if="emptyState" v-bind="limitedEmptyStateProps" /> <bento-typography v-else el="span">{{ noResultsMessage }}</bento-typography> </div> <template v-else> <!-- Multi select --> <template v-if="multiple"> <bento-listbox-multi-select ref="listboxItemRef" :has-more-items="hasMoreItems" v-bind="ariaAttributes" :items="items" :lazy-load-type="lazyLoadType" :static-categories="staticCategories" :selected-values="selectedValue" :searching="searching" :is-option-disabled="isOptionDisabled" v-on="listboxListeners" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </bento-listbox-multi-select> </template> <!-- Single select --> <template v-else> <bento-listbox-single-select ref="listboxItemRef" :items="virtualisedItems" v-bind="ariaAttributes" :selected-value="selectedValue" :static-categories="staticCategories" :is-option-disabled="isOptionDisabled" v-on="listboxListeners" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </bento-listbox-single-select> </template> <!-- Lazy Loading --> <div v-if="shouldDisplayShowMoreButton" class="b-listbox__lazy-load"> <bento-loading-button :state="loadingButtonState" variant="secondary" @click.capture="emitShowMore"> {{ showMoreButtonText }} </bento-loading-button> </div> <div v-else-if="loading" class="b-listbox__lazy-load"> <bento-loading-indicator /> </div> </template> </div> </div> </template> <script lang="ts"> import { computed, defineComponent, type PropType, ref, toRef } from 'vue'; import { type BentoListboxEmptyStateProps, BentoListboxEvent, type BentoListboxIsOptionDisabled, BentoListboxLazyLoadType, type BentoListboxOptionItem, type BentoListboxOptions, type BentoListboxVirtulisationOptions, } from '@/types/listbox'; import { BentoKeyboardNavigationKeyDownEvent } from '@/types/keyboard-navigation'; import { BentoKeyboardNavigationDirective } from '@/directives'; import { BentoListboxSingleSelect } from './components/listbox-single-select'; import { BentoListboxMultiSelect } from './components/listbox-multi-select'; import { BentoTypography } from '@/components/typography'; import { BentoLoadingIndicator } from '@/components/loading-indicator'; import { BentoLoadingButton, BentoLoadingButtonState } from '@/components/button'; import { BentoEmptyState } from '@/components/empty-state'; import { useI18n } from '@/utils/ts/i18n'; import { debounce } from '@/utils/ts/debounce'; import { useVirtualList } from '@/composables/use-virtual-list'; import type { HTMLAttributes } from 'vue/types/jsx.d.ts'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const DEFAULT_DROPDOWN_ITEM_HEIGHT = 36; /** * Listbox container. * It lists all the available options. * * @example * <bento-listbox * v-slot="{label, value}" * :id="listboxOptionsContainerId" * :aria-label="ariaLabel" * :disabled="disabled" * :multiple="multiple" * :selected="value" * :isOptionDisabled="option => option.value === 2" * :items="[{ * { label: 'Option 1', value: 1 }, * { label: 'Option 2', value: 2 }, * }]" * @select="onOptionSelected" * > * </bento-listbox> */ export default defineComponent({ name: 'bento-listbox', components: { BentoEmptyState, BentoListboxSingleSelect, BentoListboxMultiSelect, BentoTypography, BentoLoadingIndicator, BentoLoadingButton, }, directives: { BentoKeyboardNavigationDirective }, i18n: { messages }, props: { /** * Defines a string value that labels an interactive element. */ ariaLabel: { type: String, default: null }, /** * Empty state props that will be used with the inner empty state component to be displayed when no search results are found. */ emptyState: { type: Object as PropType<BentoListboxEmptyStateProps>, default: undefined, }, /** * Indicates whether there are more items to load */ hasMoreItems: { type: Boolean, default: false }, /** * Identifies the listbox whose contents are controlled by the the combobox on which the aria-controls attribute is set. */ id: { type: String, required: true }, /** * Function that allows the options to be disabled * * @type {BentoListboxIsOptionDisabled} * @param {BentoListboxOptionItem} option - Listbox option object * @param {string} option.label - Option's label text * @param {string|number} option.value - Option's value * @param {unknown} option.data - Option's extra data to be passed to the default slot */ isOptionDisabled: { type: Function as PropType<BentoListboxIsOptionDisabled>, default: () => undefined, }, /** * The option elements to populate the listbox with. * It must be an array of {@see BentoListboxOptionItem } * * @property {string} value.label - Text to be displayed in the option * * @property {string,number} value.value - Value of the option */ items: { type: Array as PropType<BentoListboxOptions>, required: true, }, /** * Indicates if new options are lazy loading. */ loading: { type: Boolean, default: false }, /** * The type of `Lazy Load`. * Type "automatic" will enable infinite scrolling * Type "button" will enable lazy loading with "Show more" button */ lazyLoadType: { type: String as PropType<BentoListboxLazyLoadType | `${BentoListboxLazyLoadType}`>, default: BentoListboxLazyLoadType.NONE, validator: (value: BentoListboxLazyLoadType) => Object.values(BentoListboxLazyLoadType).includes(value), }, /** * Indicates the listbox is loading filtered items. */ componentLoading: { type: Boolean, default: false }, /** * If enabled, the user will be able to select multiple items. */ multiple: { type: Boolean, default: false }, /** * If enabled, dropdown will display non-selectable, static categories */ staticCategories: { type: Boolean, default: false }, /** * Messaged displayed when no items are listed in the listbox */ noResultsMessage: { type: String, required: true }, /** * Flag that indicates that an external search it's being made * to filter out the items inside the the multi-select listbox * * If enabled, hides the "Select all" checkbox in "multiple" mode. */ searching: BentoListboxMultiSelect.props.searching, /** * The `input` value. * Providing an empty string or empty array will select no options. * Set to an empty string `''` if you don't want any of the available options to be selected for single select */ selectedValue: { type: Array as PropType<BentoListboxOptions>, default: () => [], }, /** * Enables virtual scrolling if set to true or by providing an object with itemHeight function. * The itemHeight function is used to calculate the height of rendered item given it's index. */ virtualScroll: { type: [Boolean, Object] as PropType<BentoListboxVirtulisationOptions>, default: false, }, }, emits: [ BentoListboxEvent.SELECT, BentoListboxEvent.SPACE, BentoListboxEvent.SHOW_MORE, BentoListboxEvent.ENTER, BentoListboxEvent.ESCAPE, BentoListboxEvent.TAB, ], setup(props, { emit, slots }) { const listboxItemRef = ref([]); const items = toRef(props, 'items'); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const isLazyLoadListbox = computed(() => props.lazyLoadType !== BentoListboxLazyLoadType.AUTOMATIC); const hasAutomaticLazyLoader = computed(() => props.lazyLoadType === BentoListboxLazyLoadType.AUTOMATIC); // Virtual scrolling const virtualScrollItemHeight = () => { if (typeof props?.virtualScroll === 'object' && props?.virtualScroll?.itemHeight) { return props.virtualScroll.itemHeight; } return DEFAULT_DROPDOWN_ITEM_HEIGHT; }; const { containerRef: listboxRef, containerProps, onScroll: onContainerScroll, list: virtualisedItems, wrapperProps, } = useVirtualList<BentoListboxOptionItem>(items, { itemHeight: virtualScrollItemHeight(), // Disable virtual scrolling if not enabled or if lazy loading is enabled or if multi-select disabled: props?.multiple || props?.virtualScroll === false || props?.lazyLoadType !== 'none', }); // Aria a11y const ariaAtomic = computed<HTMLAttributes['aria-atomic']>(() => props.componentLoading !== undefined || isLazyLoadListbox.value ? 'true' : undefined ); const ariaBusy = computed<HTMLAttributes['aria-busy']>(() => (isLazyLoadListbox.value && props.loading) || props.componentLoading ? 'true' : 'false' ); const ariaLive = computed<HTMLAttributes['aria-live']>(() => isLazyLoadListbox.value || props.componentLoading !== undefined ? 'polite' : undefined ); const ariaAttributes = computed( () => ({ 'aria-label': props.ariaLabel, 'aria-busy': ariaBusy.value, 'aria-atomic': ariaAtomic.value, 'aria-live': ariaLive.value, }) as HTMLAttributes ); const listboxContainerAttributes = computed( () => (props.componentLoading ? { ...ariaAttributes.value, role: 'alert', } : {}) as HTMLAttributes ); const onOptionSelected = eventName => (selectedItem: BentoListboxOptions) => { emit(eventName, selectedItem); }; const emitShowMore = () => { emit(BentoListboxEvent.SHOW_MORE); }; const emitShowMoreOnScrollEnd = debounce(() => { const container = listboxRef.value; if (!container) { return; } const isAtBottom = Math.round(container.scrollTop) + container.clientHeight >= container.scrollHeight; if (props.hasMoreItems && isAtBottom && !props.loading) { emitShowMore(); } }); const onListboxContainerScroll = () => { if (hasAutomaticLazyLoader.value) { emitShowMoreOnScrollEnd(); } else { onContainerScroll(); } }; const isSearchResultEmpty = computed(() => props.items?.length === 0); const shouldDisplayShowMoreButton = computed( () => props.lazyLoadType === BentoListboxLazyLoadType.BUTTON && props.hasMoreItems ); const loadingButtonState = computed(() => props.loading ? BentoLoadingButtonState.LOADING : BentoLoadingButtonState.START ); const showMoreButtonText = computed(() => (props.loading ? t('loading') : t('showMore'))); const limitedEmptyStateProps = computed<InstanceType<typeof BentoEmptyState>['$props']>(() => ({ ...props.emptyState, variant: 'condensed', })); const listboxListeners = { [BentoListboxEvent.SELECT]: onOptionSelected(BentoListboxEvent.SELECT), [BentoListboxEvent.SPACE]: onOptionSelected(BentoListboxEvent.SPACE), [BentoListboxEvent.TAB]: onOptionSelected(BentoListboxEvent.TAB), [BentoListboxEvent.ENTER]: onOptionSelected(BentoListboxEvent.ENTER), }; return { // Refs listboxRef, listboxItemRef, // a11y ariaAttributes, listboxContainerAttributes, // Values containerProps, isSearchResultEmpty, limitedEmptyStateProps, loadingButtonState, slots, shouldDisplayShowMoreButton, showMoreButtonText, virtualisedItems, wrapperProps, // Enums BentoKeyboardNavigationKeyDownEvent, BentoListboxEvent, // Methods onListboxContainerScroll, onOptionSelected, emitShowMore, listboxListeners, }; }, }); </script> <style lang="scss" scoped src="./listbox.scss" />
1
+ <template> <div :id="id" ref="listboxRef" v-bento-keyboard-navigation-directive class="b-listbox" tabindex="-1" v-bind="listboxContainerAttributes" :style="containerProps?.style" data-testid="listbox-container" @scroll="onListboxContainerScroll" > <div v-bind="wrapperProps"> <!-- Loading filtered items loading --> <div v-if="componentLoading" class="b-listbox__loading-filtered-items"> <bento-loading-indicator /> </div> <!-- Empty search message --> <div v-else-if="isSearchResultEmpty" class="b-listbox__empty-search-message"> <bento-empty-state v-if="emptyState" v-bind="limitedEmptyStateProps" /> <bento-typography v-else el="span">{{ noResultsMessage }}</bento-typography> </div> <template v-else> <!-- Multi select --> <template v-if="multiple"> <bento-listbox-multi-select ref="listboxItemRef" :has-more-items="hasMoreItems" v-bind="ariaAttributes" :items="items" :lazy-load-type="lazyLoadType" :static-categories="staticCategories" :selected-values="selectedValue" :searching="searching" :is-option-disabled="isOptionDisabled" v-on="listboxListeners" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </bento-listbox-multi-select> </template> <!-- Single select --> <template v-else> <bento-listbox-single-select ref="listboxItemRef" :items="virtualisedItems" :total-item-count="totalItemCount" :scroll-to-index="scrollToIndex" :virtual-scroll-start-index="virtualScrollStartIndex" v-bind="ariaAttributes" :selected-value="selectedValue" :static-categories="staticCategories" :is-option-disabled="isOptionDisabled" v-on="listboxListeners" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </bento-listbox-single-select> </template> <!-- Lazy Loading --> <div v-if="shouldDisplayShowMoreButton" class="b-listbox__lazy-load"> <bento-loading-button :state="loadingButtonState" variant="secondary" @click.capture="emitShowMore"> {{ showMoreButtonText }} </bento-loading-button> </div> <div v-else-if="loading" class="b-listbox__lazy-load"> <bento-loading-indicator /> </div> </template> </div> </div> </template> <script lang="ts"> import { computed, defineComponent, type PropType, ref, toRef } from 'vue'; import { type BentoListboxEmptyStateProps, BentoListboxEvent, type BentoListboxIsOptionDisabled, BentoListboxLazyLoadType, type BentoListboxOptionItem, type BentoListboxOptions, type BentoListboxVirtulisationOptions, } from '@/types/listbox'; import { BentoKeyboardNavigationKeyDownEvent } from '@/types/keyboard-navigation'; import { BentoKeyboardNavigationDirective } from '@/directives'; import { BentoListboxSingleSelect } from './components/listbox-single-select'; import { BentoListboxMultiSelect } from './components/listbox-multi-select'; import { BentoTypography } from '@/components/typography'; import { BentoLoadingIndicator } from '@/components/loading-indicator'; import { BentoLoadingButton, BentoLoadingButtonState } from '@/components/button'; import { BentoEmptyState } from '@/components/empty-state'; import { useI18n } from '@/utils/ts/i18n'; import { debounce } from '@/utils/ts/debounce'; import { useVirtualList } from '@/composables/use-virtual-list'; import type { HTMLAttributes } from 'vue/types/jsx.d.ts'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const DEFAULT_DROPDOWN_ITEM_HEIGHT = 36; /** * Listbox container. * It lists all the available options. * * @example * <bento-listbox * v-slot="{label, value}" * :id="listboxOptionsContainerId" * :aria-label="ariaLabel" * :disabled="disabled" * :multiple="multiple" * :selected="value" * :isOptionDisabled="option => option.value === 2" * :items="[{ * { label: 'Option 1', value: 1 }, * { label: 'Option 2', value: 2 }, * }]" * @select="onOptionSelected" * > * </bento-listbox> */ export default defineComponent({ name: 'bento-listbox', components: { BentoEmptyState, BentoListboxSingleSelect, BentoListboxMultiSelect, BentoTypography, BentoLoadingIndicator, BentoLoadingButton, }, directives: { BentoKeyboardNavigationDirective }, i18n: { messages }, props: { /** * Defines a string value that labels an interactive element. */ ariaLabel: { type: String, default: null }, /** * Empty state props that will be used with the inner empty state component to be displayed when no search results are found. */ emptyState: { type: Object as PropType<BentoListboxEmptyStateProps>, default: undefined, }, /** * Indicates whether there are more items to load */ hasMoreItems: { type: Boolean, default: false }, /** * Identifies the listbox whose contents are controlled by the the combobox on which the aria-controls attribute is set. */ id: { type: String, required: true }, /** * Function that allows the options to be disabled * * @type {BentoListboxIsOptionDisabled} * @param {BentoListboxOptionItem} option - Listbox option object * @param {string} option.label - Option's label text * @param {string|number} option.value - Option's value * @param {unknown} option.data - Option's extra data to be passed to the default slot */ isOptionDisabled: { type: Function as PropType<BentoListboxIsOptionDisabled>, default: () => undefined, }, /** * The option elements to populate the listbox with. * It must be an array of {@see BentoListboxOptionItem } * * @property {string} value.label - Text to be displayed in the option * * @property {string,number} value.value - Value of the option */ items: { type: Array as PropType<BentoListboxOptions>, required: true, }, /** * Indicates if new options are lazy loading. */ loading: { type: Boolean, default: false }, /** * The type of `Lazy Load`. * Type "automatic" will enable infinite scrolling * Type "button" will enable lazy loading with "Show more" button */ lazyLoadType: { type: String as PropType<BentoListboxLazyLoadType | `${BentoListboxLazyLoadType}`>, default: BentoListboxLazyLoadType.NONE, validator: (value: BentoListboxLazyLoadType) => Object.values(BentoListboxLazyLoadType).includes(value), }, /** * Indicates the listbox is loading filtered items. */ componentLoading: { type: Boolean, default: false }, /** * If enabled, the user will be able to select multiple items. */ multiple: { type: Boolean, default: false }, /** * If enabled, dropdown will display non-selectable, static categories */ staticCategories: { type: Boolean, default: false }, /** * Messaged displayed when no items are listed in the listbox */ noResultsMessage: { type: String, required: true }, /** * Flag that indicates that an external search it's being made * to filter out the items inside the the multi-select listbox * * If enabled, hides the "Select all" checkbox in "multiple" mode. */ searching: BentoListboxMultiSelect.props.searching, /** * The `input` value. * Providing an empty string or empty array will select no options. * Set to an empty string `''` if you don't want any of the available options to be selected for single select */ selectedValue: { type: Array as PropType<BentoListboxOptions>, default: () => [], }, /** * Enables virtual scrolling if set to true or by providing an object with itemHeight function. * The itemHeight function is used to calculate the height of rendered item given it's index. */ virtualScroll: { type: [Boolean, Object] as PropType<BentoListboxVirtulisationOptions>, default: false, }, }, emits: [ BentoListboxEvent.SELECT, BentoListboxEvent.SPACE, BentoListboxEvent.SHOW_MORE, BentoListboxEvent.ENTER, BentoListboxEvent.ESCAPE, BentoListboxEvent.TAB, ], setup(props, { emit, slots }) { const listboxItemRef = ref([]); const items = toRef(props, 'items'); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const isLazyLoadListbox = computed(() => props.lazyLoadType !== BentoListboxLazyLoadType.AUTOMATIC); const hasAutomaticLazyLoader = computed(() => props.lazyLoadType === BentoListboxLazyLoadType.AUTOMATIC); // Virtual scrolling const virtualScrollItemHeight = () => { if (typeof props?.virtualScroll === 'object' && props?.virtualScroll?.itemHeight) { return props.virtualScroll.itemHeight; } return DEFAULT_DROPDOWN_ITEM_HEIGHT; }; const isVirtualScrollEnabled = !props?.multiple && props?.virtualScroll !== false && props?.lazyLoadType === 'none'; const { containerRef: listboxRef, containerProps, onScroll: onContainerScroll, list: virtualisedItems, wrapperProps, scrollToIndex, startIndex: virtualScrollStartIndex, } = useVirtualList<BentoListboxOptionItem>(items, { itemHeight: virtualScrollItemHeight(), // Disable virtual scrolling if not enabled or if lazy loading is enabled or if multi-select disabled: !isVirtualScrollEnabled, }); const totalItemCount = computed(() => (isVirtualScrollEnabled ? items.value.length : 0)); // Aria a11y const ariaAtomic = computed<HTMLAttributes['aria-atomic']>(() => props.componentLoading !== undefined || isLazyLoadListbox.value ? 'true' : undefined ); const ariaBusy = computed<HTMLAttributes['aria-busy']>(() => (isLazyLoadListbox.value && props.loading) || props.componentLoading ? 'true' : 'false' ); const ariaLive = computed<HTMLAttributes['aria-live']>(() => isLazyLoadListbox.value || props.componentLoading !== undefined ? 'polite' : undefined ); const ariaAttributes = computed( () => ({ 'aria-label': props.ariaLabel, 'aria-busy': ariaBusy.value, 'aria-atomic': ariaAtomic.value, 'aria-live': ariaLive.value, }) as HTMLAttributes ); const listboxContainerAttributes = computed( () => (props.componentLoading ? { ...ariaAttributes.value, role: 'alert', } : {}) as HTMLAttributes ); const onOptionSelected = eventName => (selectedItem: BentoListboxOptions) => { emit(eventName, selectedItem); }; const emitShowMore = () => { emit(BentoListboxEvent.SHOW_MORE); }; const emitShowMoreOnScrollEnd = debounce(() => { const container = listboxRef.value; if (!container) { return; } const isAtBottom = Math.round(container.scrollTop) + container.clientHeight >= container.scrollHeight; if (props.hasMoreItems && isAtBottom && !props.loading) { emitShowMore(); } }); const onListboxContainerScroll = () => { if (hasAutomaticLazyLoader.value) { emitShowMoreOnScrollEnd(); } else { onContainerScroll(); } }; const isSearchResultEmpty = computed(() => props.items?.length === 0); const shouldDisplayShowMoreButton = computed( () => props.lazyLoadType === BentoListboxLazyLoadType.BUTTON && props.hasMoreItems ); const loadingButtonState = computed(() => props.loading ? BentoLoadingButtonState.LOADING : BentoLoadingButtonState.START ); const showMoreButtonText = computed(() => (props.loading ? t('loading') : t('showMore'))); const limitedEmptyStateProps = computed<InstanceType<typeof BentoEmptyState>['$props']>(() => ({ ...props.emptyState, variant: 'condensed', })); const listboxListeners = { [BentoListboxEvent.SELECT]: onOptionSelected(BentoListboxEvent.SELECT), [BentoListboxEvent.SPACE]: onOptionSelected(BentoListboxEvent.SPACE), [BentoListboxEvent.TAB]: onOptionSelected(BentoListboxEvent.TAB), [BentoListboxEvent.ENTER]: onOptionSelected(BentoListboxEvent.ENTER), }; return { // Refs listboxRef, listboxItemRef, // a11y ariaAttributes, listboxContainerAttributes, // Values containerProps, isSearchResultEmpty, limitedEmptyStateProps, loadingButtonState, slots, shouldDisplayShowMoreButton, showMoreButtonText, virtualisedItems, wrapperProps, // Virtual scroll totalItemCount, scrollToIndex, virtualScrollStartIndex, // Enums BentoKeyboardNavigationKeyDownEvent, BentoListboxEvent, // Methods onListboxContainerScroll, onOptionSelected, emitShowMore, listboxListeners, }; }, }); </script> <style lang="scss" scoped src="./listbox.scss" />
@@ -1 +1 @@
1
- <!-- Taken from: https://github.com/Mechazawa/vue2-teleport/blob/master/src/Teleport.vue --> <template> <div v-if="isVue2" :class="classes"> <slot /> </div> <Teleport v-else :to="defaultTo" :where="where" :disabled="disabled" :class="classes"> <slot /> </Teleport> </template> <script setup lang="ts"> import { isVue2 } from 'vue-demi'; import { defineComponent, inject, type PropType } from 'vue'; import { MODAL_DIALOG_ID_INJECTION_KEY } from '@/components/modal/components/base-modal/base-modal.keys'; import { SIDEPANEL_DIALOG_ID_INJECTION_KEY } from '@/components/sidepanel/sidepanel.keys'; import { MODAL_FULLSCREEN_DIALOG_ID_INJECTION_KEY } from '@/components/modal-fullscreen/modal-fullscreen.keys'; import type { BentoTeleportProps } from './teleport.types'; </script> <script lang="ts"> export default defineComponent({ name: 'teleport', props: { to: { type: String as PropType<BentoTeleportProps['to']>, default: '', }, where: { type: String, default: 'after', }, disabled: Boolean as PropType<BentoTeleportProps['disabled']>, }, data: () => ({ childObserver: null, nodes: [], waiting: false, observer: null, parent: null, isVue2, }), computed: { classes() { if (this.disabled) { return ['b-teleport']; } return ['b-teleport', 'b-teleport--hidden']; }, defaultTo() { if (this.to) { return this.to; } const injectionKey = inject<string>(MODAL_DIALOG_ID_INJECTION_KEY, '') || inject<string>(MODAL_FULLSCREEN_DIALOG_ID_INJECTION_KEY, '') || inject<string>(SIDEPANEL_DIALOG_ID_INJECTION_KEY, ''); const teleportElement = injectionKey ? `#${injectionKey}` : 'body'; return teleportElement; }, }, watch: { to: 'maybeMove', where: 'maybeMove', disabled(value) { if (value) { this.disable(); // Ensure all event done. this.$nextTick(() => { this.teardownObserver(); }); } else { this.bootObserver(); this.move(); } }, }, mounted() { // Store a reference to the nodes this.nodes = Array.from(this.$el.childNodes); if (!this.disabled) { this.bootObserver(); } // Move slot content to target this.maybeMove(); }, beforeDestroy() { // Fix nodes reference this.nodes = this.getComponentChildrenNode(); // Move back this.disable(); // Stop observing this.teardownObserver(); }, methods: { // Using a fragment is faster because it'll trigger only a single reflow // See https://developer.mozilla.org/en-US/docs/Web/API/DocumentFragment getFragment(): DocumentFragment { const fragment = document.createDocumentFragment(); this.nodes.forEach(node => fragment.appendChild(node)); return fragment; }, disable() { this.$el.appendChild(this.getFragment()); this.parent = null; }, move() { this.waiting = false; this.parent = document.querySelector(this.defaultTo); if (!this.parent) { this.disable(); this.waiting = true; return; } if (this.where === 'before') { this.parent.prepend(this.getFragment()); } else { this.parent.appendChild(this.getFragment()); } }, maybeMove() { if (!this.disabled) { this.move(); } }, onMutations(mutations) { // Makes sure the move operation is only done once let shouldMove = false; for (let i = 0; i < mutations.length; i++) { const mutation = mutations[i]; const filteredAddedNodes = Array.from(mutation.addedNodes).filter( node => !this.nodes.includes(node) ); if (Array.from(mutation.removedNodes).includes(this.parent)) { this.disable(); this.waiting = !this.disabled; } else if (this.waiting && filteredAddedNodes.length > 0) { shouldMove = true; } } if (shouldMove) { this.move(); } }, bootObserver() { if (this.observer) { return; } this.observer = new MutationObserver(mutations => this.onMutations(mutations)); this.observer.observe(document.body, { childList: true, subtree: true, attributes: false, characterData: false, }); if (this.childObserver) { return; } // watch childNodes change this.childObserver = new MutationObserver(mutations => { const childChangeRecord = mutations.find(i => i.target === this.$el); if (childChangeRecord) { // Remove old nodes before update position. this.nodes.forEach(node => node.parentNode && node.parentNode.removeChild(node)); this.nodes = this.getComponentChildrenNode(); this.maybeMove(); } }); this.childObserver.observe(this.$el, { childList: true, subtree: false, attributes: false, characterData: false, }); }, teardownObserver() { if (this.observer) { this.observer.disconnect(); this.observer = null; } if (this.childObserver) { this.childObserver.disconnect(); this.childObserver = null; } }, getComponentChildrenNode() { // Vue3 if (!isVue2) { return []; } // Trick for Vue3 type checking return (this as any).$vnode.componentOptions.children.map(i => i.elm).filter(i => i); }, }, }); </script> <style lang="scss" scoped src="./teleport.scss" />
1
+ <!-- Taken from: https://github.com/Mechazawa/vue2-teleport/blob/master/src/Teleport.vue --> <template> <div v-if="isVue2" :class="classes"> <slot /> </div> <Teleport v-else :to="defaultTo" :where="where" :disabled="disabled" :class="classes"> <slot /> </Teleport> </template> <script setup lang="ts"> import { isVue2 } from 'vue-demi'; import { defineComponent, inject, type PropType } from 'vue'; import { MODAL_DIALOG_ID_INJECTION_KEY } from '@/components/modal/components/base-modal/base-modal.keys'; import { SIDEPANEL_DIALOG_ID_INJECTION_KEY } from '@/components/sidepanel/sidepanel.keys'; import { MODAL_FULLSCREEN_DIALOG_ID_INJECTION_KEY } from '@/components/modal-fullscreen/modal-fullscreen.keys'; import type { BentoTeleportProps } from './teleport.types'; </script> <script lang="ts"> export default defineComponent({ name: 'teleport', props: { to: { type: String as PropType<BentoTeleportProps['to']>, default: '', }, where: { type: String, default: 'after', }, disabled: Boolean as PropType<BentoTeleportProps['disabled']>, }, data: () => ({ childObserver: null, nodes: [], waiting: false, observer: null, parent: null, isVue2, }), computed: { classes() { if (this.disabled) { return ['b-teleport']; } return ['b-teleport', 'b-teleport--hidden']; }, defaultTo() { if (this.to) { return this.to; } const injectionKey = inject<string>(MODAL_DIALOG_ID_INJECTION_KEY, '') || inject<string>(MODAL_FULLSCREEN_DIALOG_ID_INJECTION_KEY, '') || inject<string>(SIDEPANEL_DIALOG_ID_INJECTION_KEY, ''); const teleportElement = injectionKey ? `#${injectionKey}` : 'body'; return teleportElement; }, }, watch: { to: 'maybeMove', where: 'maybeMove', disabled(value) { if (!isVue2) { return; } if (value) { this.disable(); // Ensure all event done. this.$nextTick(() => { this.teardownObserver(); }); } else { this.bootObserver(); this.move(); } }, }, mounted() { // In Vue 3, the built-in <Teleport> handles DOM movement. // Manual DOM manipulation and MutationObservers must be skipped // to avoid conflicting with Vue 3's virtual DOM tracking. if (!isVue2) { return; } // Store a reference to the nodes this.nodes = Array.from(this.$el.childNodes); if (!this.disabled) { this.bootObserver(); } // Move slot content to target this.maybeMove(); }, beforeDestroy() { // In Vue 3, cleanup is not needed as the built-in <Teleport> manages its own lifecycle. if (!isVue2) { return; } // Fix nodes reference this.nodes = this.getComponentChildrenNode(); // Move back this.disable(); // Stop observing this.teardownObserver(); }, methods: { // Using a fragment is faster because it'll trigger only a single reflow // See https://developer.mozilla.org/en-US/docs/Web/API/DocumentFragment getFragment(): DocumentFragment { const fragment = document.createDocumentFragment(); this.nodes.forEach(node => fragment.appendChild(node)); return fragment; }, disable() { this.$el.appendChild(this.getFragment()); this.parent = null; }, move() { this.waiting = false; this.parent = document.querySelector(this.defaultTo); if (!this.parent) { this.disable(); this.waiting = true; return; } if (this.where === 'before') { this.parent.prepend(this.getFragment()); } else { this.parent.appendChild(this.getFragment()); } }, maybeMove() { if (!isVue2 || this.disabled) { return; } this.move(); }, onMutations(mutations) { // Makes sure the move operation is only done once let shouldMove = false; for (let i = 0; i < mutations.length; i++) { const mutation = mutations[i]; const filteredAddedNodes = Array.from(mutation.addedNodes).filter( node => !this.nodes.includes(node) ); if (Array.from(mutation.removedNodes).includes(this.parent)) { this.disable(); this.waiting = !this.disabled; } else if (this.waiting && filteredAddedNodes.length > 0) { shouldMove = true; } } if (shouldMove) { this.move(); } }, bootObserver() { if (this.observer) { return; } this.observer = new MutationObserver(mutations => this.onMutations(mutations)); this.observer.observe(document.body, { childList: true, subtree: true, attributes: false, characterData: false, }); if (this.childObserver) { return; } // watch childNodes change this.childObserver = new MutationObserver(mutations => { const childChangeRecord = mutations.find(i => i.target === this.$el); if (childChangeRecord) { // Remove old nodes before update position. this.nodes.forEach(node => node.parentNode && node.parentNode.removeChild(node)); this.nodes = this.getComponentChildrenNode(); this.maybeMove(); } }); this.childObserver.observe(this.$el, { childList: true, subtree: false, attributes: false, characterData: false, }); }, teardownObserver() { if (this.observer) { this.observer.disconnect(); this.observer = null; } if (this.childObserver) { this.childObserver.disconnect(); this.childObserver = null; } }, getComponentChildrenNode() { // Vue3 if (!isVue2) { return []; } // Trick for Vue3 type checking return (this as any).$vnode.componentOptions.children.map(i => i.elm).filter(i => i); }, }, }); </script> <style lang="scss" scoped src="./teleport.scss" />
@@ -1 +1 @@
1
- import type { Location } from 'vue-router/types/router.d.ts'; // TODO: revert to import once fully migrated to Vue 3 // import type { RouterLinkProps } from 'vue-router/types/router.d.ts'; /** * Manually defined RouterLinkProps to ensure compatibility * with Vue Macros and Vue 2.7 / 3 monorepos. */ export interface RouterLinkProps { /** * Denotes the target route of the link. When clicked, the value of the `to` prop will be passed to * `router.push()` internally, so the value can be either a string or a location descriptor object. */ to: string | Location; /** * Setting `replace` prop will call `router.replace()` instead of `router.push()` when clicked, so the navigation will * not create a new history record. * * @default false */ replace?: boolean; /** * Setting `append` prop always appends the relative path to the current path. For example, assuming we are navigating * from `/a` to a relative link `b`, without `append` we will end up at `/b`, but with append we will end up at * `/a/b`. * * @default false */ append?: boolean; /** * Sometimes we want <RouterLink> to render as another tag, e.g <li>. Then we can use tag prop to specify which tag to * render to, and it will still listen to click events for navigation. * * @default "a" */ tag?: string; /** * Configure the active CSS class applied when the link is active. Note the default value can also be configured * globally via the `linkActiveClass` router constructor option. * * @default "router-link-active" */ activeClass?: string; /** * The default active class matching behavior is **inclusive match**. For example, `<RouterLink to="/a">` will get * this class applied as long as the current path starts with `/a/` or is `/a`. * * @default false */ exact?: boolean; /** * Allows matching only using the `path` section of the url, effectively ignoring the `query` and the `hash` sections. * * @default false */ exactPath?: boolean; /** * Configure the active CSS class applied when the link is active with exact path match. Note the default value can * also be configured globally via the `linkExactPathActiveClass` router constructor option. * * @default "router-link-exact-path-active" */ exactPathActiveClass?: string; /** * Specify the event(s) that can trigger the link navigation. * * @default 'click' */ event?: string | ReadonlyArray<string>; /** * Configure the active CSS class applied when the link is active with exact match. Note the default value can also be * configured globally via the `linkExactActiveClass` router constructor option. * * @default "router-link-exact-active" */ exactActiveClass?: string; /** * Configure the value of `aria-current` when the link is active with exact match. It must be one of the allowed * values for [aria-current](https://www.w3.org/TR/wai-aria-1.2/#aria-current) in the ARIA spec. In most cases, the * default of page should be the best fit. * * @default "page" */ ariaCurrentValue?: 'page' | 'step' | 'location' | 'date' | 'time' | 'true' | 'false'; } /** * @deprecated for usage with properties since version 2.0. Use the string literal instead. */ export enum BentoLinkVariant { DEFAULT = 'default', QUIET = 'quiet', } export type BentoLinkProps = RouterLinkProps & { /** * Disable the default typography for the component. Only use this when the component is inside another typography component. */ disableTypography?: boolean; /** * Set the rendered link to a regular external anchor link and renders an external link icon. */ external?: boolean; /** * Set to true if used inside a `BentoButton` component. */ isButton?: boolean; /** * Set the rendered link to a regular external anchor link. */ isNotRouting?: boolean; /** * Sets the target of the rendered link. */ target?: string; /** * Set to true to truncate link. Doesn't work with disableTypography. */ truncate?: boolean; /** * Link variant type affecting visual style. * @values default, quiet */ variant?: BentoLinkVariant | `${BentoLinkVariant}`; };
1
+ import type { Location } from 'vue-router/types/router.d.ts'; // TODO: revert to import once fully migrated to Vue 3 // import type { RouterLinkProps } from 'vue-router/types/router.d.ts'; /** * Manually defined RouterLinkProps to ensure compatibility * with Vue Macros and Vue 2.7 / 3 monorepos. */ export interface RouterLinkProps { /** * Denotes the target route of the link. When clicked, the value of the `to` prop will be passed to * `router.push()` internally, so the value can be either a string or a location descriptor object. */ to: string | Location; /** * Setting `replace` prop will call `router.replace()` instead of `router.push()` when clicked, so the navigation will * not create a new history record. * * @default false */ replace?: boolean; /** * Setting `append` prop always appends the relative path to the current path. For example, assuming we are navigating * from `/a` to a relative link `b`, without `append` we will end up at `/b`, but with append we will end up at * `/a/b`. * * @default false */ append?: boolean; /** * Sometimes we want <RouterLink> to render as another tag, e.g <li>. Then we can use tag prop to specify which tag to * render to, and it will still listen to click events for navigation. * * @default "a" */ tag?: string; /** * Configure the active CSS class applied when the link is active. Note the default value can also be configured * globally via the `linkActiveClass` router constructor option. * * @default "router-link-active" */ activeClass?: string; /** * The default active class matching behavior is **inclusive match**. For example, `<RouterLink to="/a">` will get * this class applied as long as the current path starts with `/a/` or is `/a`. * * @default false */ exact?: boolean; /** * Allows matching only using the `path` section of the url, effectively ignoring the `query` and the `hash` sections. * * @default false */ exactPath?: boolean; /** * Configure the active CSS class applied when the link is active with exact path match. Note the default value can * also be configured globally via the `linkExactPathActiveClass` router constructor option. * * @default "router-link-exact-path-active" */ exactPathActiveClass?: string; /** * Specify the event(s) that can trigger the link navigation. * * @default 'click' */ event?: string | ReadonlyArray<string>; /** * Configure the active CSS class applied when the link is active with exact match. Note the default value can also be * configured globally via the `linkExactActiveClass` router constructor option. * * @default "router-link-exact-active" */ exactActiveClass?: string; /** * Configure the value of `aria-current` when the link is active with exact match. It must be one of the allowed * values for [aria-current](https://www.w3.org/TR/wai-aria-1.2/#aria-current) in the ARIA spec. In most cases, the * default of page should be the best fit. * * @default "page" */ ariaCurrentValue?: 'page' | 'step' | 'location' | 'date' | 'time' | 'true' | 'false'; } /** * @deprecated Since v2.0.0. Use string literal instead. */ export enum BentoLinkVariant { DEFAULT = 'default', QUIET = 'quiet', } export type BentoLinkProps = RouterLinkProps & { /** * Disable the default typography for the component. Only use this when the component is inside another typography component. */ disableTypography?: boolean; /** * Set the rendered link to a regular external anchor link and renders an external link icon. */ external?: boolean; /** * Set to true if used inside a `BentoButton` component. */ isButton?: boolean; /** * Set the rendered link to a regular external anchor link. */ isNotRouting?: boolean; /** * Sets the target of the rendered link. */ target?: string; /** * Set to true to truncate link. Doesn't work with disableTypography. */ truncate?: boolean; /** * Link variant type affecting visual style. * @values default, quiet */ variant?: BentoLinkVariant | `${BentoLinkVariant}`; };
@@ -1 +1 @@
1
- import { type OnClickOutsideOptions } from '@/directives/click-outside/types'; import { PopperContainerPosition } from '../popper-container/popper-container.types'; import { type BentoMenuItem } from './components/menu-item/menu-item.types'; import { type HTMLAttributes } from 'vue'; import { type BentoButtonProps } from '../button/button.types'; /** * @deprecated for usage with properties since version 2.0. Use the string literal instead. */ const BentoMenuPositions = { ...PopperContainerPosition }; type BentoMenuPositions = PopperContainerPosition; export type * from './components/menu-item/menu-item.types'; export enum BentoMenuEvent { BACK = 'back', CLOSE = 'close', OPEN = 'open', } export interface BentoMenuButtonProps { 'aria-label'?: HTMLAttributes['aria-label']; condensed?: boolean; disabled?: boolean; variant?: BentoButtonProps['variant']; } export interface BentoMenuProps { /** * Object containing props to change the menu button appearance. Accepts the same props present in BentoButton. */ button?: BentoMenuButtonProps; /** * Controls if the menu should close when an item is clicked. * Defaults to `true`. */ closeMenuOnItemSelect?: boolean; /** * The data to be displayed in the list */ data: Array<BentoMenuItem>; /** * Disables opening the menu when the down arrow key is pressed. */ disableArrowDownOpeningMenu?: boolean; /** * The position of where the menu should open. */ menuPosition?: BentoMenuPositions | `${BentoMenuPositions}`; /** * Instructs the menu popover to be visible outside of the containing container. * @see {@link https://floating-ui.com/docs/usefloating#strategy} */ menuFixedPositioning?: boolean; /** * Sets a custom width for the menu popover. Values are clamped between 150 - 350. */ menuWidth?: number; /** * Returns focus to the menu button after popover is closed. * Disable if focus management is delegated to the parent component. * Defaults to `true`. */ revertFocusToButton?: boolean; /** * Can be used to set the inner teleport component to render the menu's popover in the root `body` DOM node * (or `dialog` if popover is inside a dialog). */ teleport?: boolean; /** * Can be used to set the inner teleport component to render the menu's popover in the root `body` DOM node * (or `dialog` if popover is inside a dialog). * * @see {@link OnClickOutsideOptions} */ closeOnClickOutsideOptions?: OnClickOutsideOptions; }
1
+ import { type OnClickOutsideOptions } from '@/directives/click-outside/types'; import { PopperContainerPosition } from '../popper-container/popper-container.types'; import { type BentoMenuItem } from './components/menu-item/menu-item.types'; import { type HTMLAttributes } from 'vue'; import { type BentoButtonProps } from '../button/button.types'; /** * @deprecated Since v2.0.0. Use string literal instead. */ const BentoMenuPositions = { ...PopperContainerPosition }; type BentoMenuPositions = PopperContainerPosition; export type * from './components/menu-item/menu-item.types'; export enum BentoMenuEvent { BACK = 'back', CLOSE = 'close', OPEN = 'open', } export interface BentoMenuButtonProps { 'aria-label'?: HTMLAttributes['aria-label']; condensed?: boolean; disabled?: boolean; variant?: BentoButtonProps['variant']; } export interface BentoMenuProps { /** * Object containing props to change the menu button appearance. Accepts the same props present in BentoButton. */ button?: BentoMenuButtonProps; /** * Controls if the menu should close when an item is clicked. * Defaults to `true`. */ closeMenuOnItemSelect?: boolean; /** * The data to be displayed in the list */ data: Array<BentoMenuItem>; /** * Disables opening the menu when the down arrow key is pressed. */ disableArrowDownOpeningMenu?: boolean; /** * The position of where the menu should open. */ menuPosition?: BentoMenuPositions | `${BentoMenuPositions}`; /** * Instructs the menu popover to be visible outside of the containing container. * @see {@link https://floating-ui.com/docs/usefloating#strategy} */ menuFixedPositioning?: boolean; /** * Sets a custom width for the menu popover. Values are clamped between 150 - 350. */ menuWidth?: number; /** * Returns focus to the menu button after popover is closed. * Disable if focus management is delegated to the parent component. * Defaults to `true`. */ revertFocusToButton?: boolean; /** * Can be used to set the inner teleport component to render the menu's popover in the root `body` DOM node * (or `dialog` if popover is inside a dialog). */ teleport?: boolean; /** * Can be used to set the inner teleport component to render the menu's popover in the root `body` DOM node * (or `dialog` if popover is inside a dialog). * * @see {@link OnClickOutsideOptions} */ closeOnClickOutsideOptions?: OnClickOutsideOptions; }
@@ -1 +1 @@
1
- <template> <div :class="computedClasses" class="b-menu" data-testid="menu" @keydown.down="onKeyDown" @keydown.up="onKeyUp" @keydown.esc="closeMenu()" > <bento-toggle-button ref="menuButtonRef" class="b-menu__toggle-button" aria-haspopup="true" v-bind="computedButtonPropsWithDefaults" :aria-controls="showMenu ? menuId : null" :toggled="showMenu" :inverse="isInverseColorSet" @click="toggleShowMenu" > <template v-if="!hasSlot('default') || hasSlot('iconLeft')" #iconLeft> <slot name="iconLeft"> <options-vertical-icon :title="t('menuButton')" /> </slot> </template> <template v-if="hasSlot('default')" #default> <slot /> </template> <template v-if="hasSlot('default')" #iconRight> <chevron-down-icon /> </template> </bento-toggle-button> <bento-teleport v-if="menuButtonRef" :disabled="!teleport"> <bento-popover v-if="menuButtonRef" :id="menuId" ref="popoverRef" :role="null" :open="showMenu" class="b-menu__popover" :style="computedMenuWidth" :target-element="menuButtonRef" :position="menuPosition" :fixed-positioning="menuFixedPositioning" :fallback-position="[ 'bottom-start', 'bottom-end', 'top-start', 'top-end', 'right-start', 'right-end', 'left-start', 'left-end', ]" :teleport="teleport" without-space > <menu-item-list ref="menuItemListRef" :close-menu-on-item-select="closeMenuOnItemSelect" :data="data" :focus-last-element="isLastElementFocused" :popover-width="menuWidth" @close="closeMenu" @menu-item-click="menuItemClick" /> </bento-popover> </bento-teleport> </div> </template> <script setup lang="ts"> import BentoToggleButton from '@/components/button/components/toggle-button/toggle-button.vue'; import { DIVIDER_INVERSE_INJECTION_KEY } from '@/components/divider/divider.keys'; import BentoPopover from '@/components/popover/popover.vue'; import { useClickOutside, useHasSlot } from '@/composables'; import { generateUid } from '@/core/utils/ts'; import { BentoTeleport } from '@/internal/teleport'; import { useI18n } from '@/utils/ts/i18n'; import { printDevelopmentWarning } from '@/utils/ts/print-development-warning'; import ChevronDownIcon from '@adyen/ui-assets-icons-16/vue/chevron-down'; import OptionsVerticalIcon from '@adyen/ui-assets-icons-16/vue/options-vertical'; import { computed, inject, nextTick, onMounted, provide, ref, useSlots } from 'vue'; import MenuItemList from './components/menu-item-list/menu-item-list.vue'; import { MENU_INVERSE_INJECTION_KEY } from './menu.keys'; import { clampMenuWidth } from './composables/use-dynamic-menu-size/use-dynamic-menu-size'; import { type BentoMenuButtonProps, BentoMenuEvent, type BentoMenuProps } from './menu.types'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const props = withDefaults(defineProps<BentoMenuProps>(), { button: () => ({ variant: 'secondary', }) as BentoMenuButtonProps, closeMenuOnItemSelect: true, disableArrowDownOpeningMenu: false, menuPosition: 'bottom-start', menuFixedPositioning: false, menuWidth: null, revertFocusToButton: true, teleport: false, closeOnClickOutsideOptions: undefined, }); const emit = defineEmits<{ (e: BentoMenuEvent.CLOSE): void; (e: BentoMenuEvent.OPEN): void; (e: 'menu-item-click'): void; (e: 'click-outside'): void; }>(); const slots = useSlots(); const hasSlot = useHasSlot(slots); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const menuButtonRef = ref(null); const menuItemListRef = ref(null); const showMenu = ref(false); const isLastElementFocused = ref(false); const popoverRef = ref<HTMLElement>(null); const menuId = generateUid('menu'); const isInverseColorSet = inject(MENU_INVERSE_INJECTION_KEY, ref(false)); provide(DIVIDER_INVERSE_INJECTION_KEY, isInverseColorSet); const computedClasses = computed(() => ({ 'b-menu--inverse': isInverseColorSet.value, })); const computedMenuWidth = computed(() => props.menuWidth ? { width: `${clampMenuWidth(props.menuWidth)}px` } : null ); const computedButtonPropsWithDefaults = computed<BentoMenuButtonProps>(() => { if (!props?.button) { return undefined; } return { variant: 'secondary', ...props.button, }; }); const openMenu = () => { showMenu.value = true; emit(BentoMenuEvent.OPEN); }; const closeMenu = async (revertFocus = props.revertFocusToButton) => { if (!showMenu.value) { return; } showMenu.value = false; if (revertFocus) { // ensure that menu is closed and re-rendering is completed before focusing await nextTick(); menuButtonRef.value?.$el?.focus(); } emit(BentoMenuEvent.CLOSE); }; const toggleShowMenu = (e: MouseEvent) => { e.stopPropagation(); if (!showMenu.value) { openMenu(); } else { closeMenu(); } }; const onKeyDown = (event: KeyboardEvent) => { event.preventDefault(); isLastElementFocused.value = false; if (!props.disableArrowDownOpeningMenu) { openMenu(); } }; const onKeyUp = (event: KeyboardEvent) => { event.preventDefault(); isLastElementFocused.value = true; openMenu(); }; const menuItemClick = () => { emit('menu-item-click'); }; // Ignore the events inside the Button and the Popover useClickOutside( popoverRef, () => { closeMenu(); emit('click-outside'); }, { ...props.closeOnClickOutsideOptions, ignore: [menuButtonRef, ...(props.closeOnClickOutsideOptions?.ignore ?? [])], } ); defineExpose({ /** * Control to enable programmatic closing of the menu */ closeMenu, }); onMounted(() => { if (hasSlot('iconRight')) { printDevelopmentWarning( '"bento-menu" no longer supports the "iconRight" slot. The right-aligned icon is now fixed by design.' ); } }); </script> <script lang="ts"> /** * Menu component to render a series of buttons. Csan render nested submenus. * * @example * import { BentoMenu } from '@adyen/bento-vue2'; * * export default { * components: { BentoMenu }, * template: ` * <bento-menu * :data=[ * { text: 'Menu item text', handler: () => { some code here } }, * { text: 'Another menu item text', handler: () => { some other code here }, critical: true } * ] * /> * } */ export default { i18n: { messages }, name: 'bento-menu', }; </script> <style lang="scss" scoped src="./menu.scss" />
1
+ <template> <div :class="computedClasses" class="b-menu" data-testid="menu" @keydown.down="onKeyDown" @keydown.up="onKeyUp" @keydown.esc="closeMenu()" > <bento-toggle-button ref="menuButtonRef" class="b-menu__toggle-button" aria-haspopup="true" v-bind="computedButtonPropsWithDefaults" :aria-controls="showMenu ? menuId : null" :toggled="showMenu" :inverse="isInverseColorSet" @click="toggleShowMenu" > <template v-if="!hasSlot('default') || hasSlot('iconLeft')" #iconLeft> <slot name="iconLeft"> <options-vertical-icon :svg-title="t('menuButton')" /> </slot> </template> <template v-if="hasSlot('default')" #default> <slot /> </template> <template v-if="hasSlot('default')" #iconRight> <chevron-down-icon /> </template> </bento-toggle-button> <bento-teleport v-if="menuButtonRef" :disabled="!teleport"> <bento-popover v-if="menuButtonRef" :id="menuId" ref="popoverRef" :role="null" :open="showMenu" class="b-menu__popover" :style="computedMenuWidth" :target-element="menuButtonRef" :position="menuPosition" :fixed-positioning="menuFixedPositioning" :fallback-position="[ 'bottom-start', 'bottom-end', 'top-start', 'top-end', 'right-start', 'right-end', 'left-start', 'left-end', ]" :teleport="teleport" without-space > <menu-item-list ref="menuItemListRef" :close-menu-on-item-select="closeMenuOnItemSelect" :data="data" :focus-last-element="isLastElementFocused" :popover-width="menuWidth" @close="closeMenu" @menu-item-click="menuItemClick" /> </bento-popover> </bento-teleport> </div> </template> <script setup lang="ts"> import BentoToggleButton from '@/components/button/components/toggle-button/toggle-button.vue'; import { DIVIDER_INVERSE_INJECTION_KEY } from '@/components/divider/divider.keys'; import BentoPopover from '@/components/popover/popover.vue'; import { useClickOutside, useHasSlot } from '@/composables'; import { generateUid } from '@/core/utils/ts'; import { BentoTeleport } from '@/internal/teleport'; import { useI18n } from '@/utils/ts/i18n'; import { printDevelopmentWarning } from '@/utils/ts/print-development-warning'; import ChevronDownIcon from '@adyen/ui-assets-icons-16/vue/chevron-down'; import OptionsVerticalIcon from '@adyen/ui-assets-icons-16/vue/options-vertical'; import { computed, inject, nextTick, onMounted, provide, ref, useSlots } from 'vue'; import MenuItemList from './components/menu-item-list/menu-item-list.vue'; import { MENU_INVERSE_INJECTION_KEY } from './menu.keys'; import { clampMenuWidth } from './composables/use-dynamic-menu-size/use-dynamic-menu-size'; import { type BentoMenuButtonProps, BentoMenuEvent, type BentoMenuProps } from './menu.types'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const props = withDefaults(defineProps<BentoMenuProps>(), { button: () => ({ variant: 'secondary', }) as BentoMenuButtonProps, closeMenuOnItemSelect: true, disableArrowDownOpeningMenu: false, menuPosition: 'bottom-start', menuFixedPositioning: false, menuWidth: null, revertFocusToButton: true, teleport: false, closeOnClickOutsideOptions: undefined, }); const emit = defineEmits<{ (e: BentoMenuEvent.CLOSE): void; (e: BentoMenuEvent.OPEN): void; (e: 'menu-item-click'): void; (e: 'click-outside'): void; }>(); const slots = useSlots(); const hasSlot = useHasSlot(slots); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const menuButtonRef = ref(null); const menuItemListRef = ref(null); const showMenu = ref(false); const isLastElementFocused = ref(false); const popoverRef = ref<HTMLElement>(null); const menuId = generateUid('menu'); const isInverseColorSet = inject(MENU_INVERSE_INJECTION_KEY, ref(false)); provide(DIVIDER_INVERSE_INJECTION_KEY, isInverseColorSet); const computedClasses = computed(() => ({ 'b-menu--inverse': isInverseColorSet.value, })); const computedMenuWidth = computed(() => props.menuWidth ? { width: `${clampMenuWidth(props.menuWidth)}px` } : null ); const computedButtonPropsWithDefaults = computed<BentoMenuButtonProps>(() => { if (!props?.button) { return undefined; } return { variant: 'secondary', ...props.button, }; }); const openMenu = () => { showMenu.value = true; emit(BentoMenuEvent.OPEN); }; const closeMenu = async (revertFocus = props.revertFocusToButton) => { if (!showMenu.value) { return; } showMenu.value = false; if (revertFocus) { // ensure that menu is closed and re-rendering is completed before focusing await nextTick(); menuButtonRef.value?.$el?.focus(); } emit(BentoMenuEvent.CLOSE); }; const toggleShowMenu = (e: MouseEvent) => { e.stopPropagation(); if (!showMenu.value) { openMenu(); } else { closeMenu(); } }; const onKeyDown = (event: KeyboardEvent) => { event.preventDefault(); isLastElementFocused.value = false; if (!props.disableArrowDownOpeningMenu) { openMenu(); } }; const onKeyUp = (event: KeyboardEvent) => { event.preventDefault(); isLastElementFocused.value = true; openMenu(); }; const menuItemClick = () => { emit('menu-item-click'); }; // Ignore the events inside the Button and the Popover useClickOutside( popoverRef, () => { closeMenu(); emit('click-outside'); }, { ...props.closeOnClickOutsideOptions, ignore: [menuButtonRef, ...(props.closeOnClickOutsideOptions?.ignore ?? [])], } ); defineExpose({ /** * Control to enable programmatic closing of the menu */ closeMenu, }); onMounted(() => { if (hasSlot('iconRight')) { printDevelopmentWarning( '"bento-menu" no longer supports the "iconRight" slot. The right-aligned icon is now fixed by design.' ); } }); </script> <script lang="ts"> /** * Menu component to render a series of buttons. Csan render nested submenus. * * @example * import { BentoMenu } from '@adyen/bento-vue2'; * * export default { * components: { BentoMenu }, * template: ` * <bento-menu * :data=[ * { text: 'Menu item text', handler: () => { some code here } }, * { text: 'Another menu item text', handler: () => { some other code here }, critical: true } * ] * /> * } */ export default { i18n: { messages }, name: 'bento-menu', }; </script> <style lang="scss" scoped src="./menu.scss" />
@@ -1 +1 @@
1
- import { type ComputedRef, type Ref } from 'vue'; /** * Public */ /** * @deprecated for usage with properties since version 2.0. Use the string literal instead. */ export enum BentoModalVariant { DEFAULT = 'default', DIALOG = 'dialog', } /** * @deprecated for usage with properties since version 2.0. Use the string literal instead. */ export enum BentoModalVariantMapping { DEFAULT = 'BentoModalDefault', DIALOG = 'BentoModalDialog', } /** * @deprecated for usage with properties since version 2.0. Use the string literal instead. */ export enum BentoModalSize { SMALL = 'small', MEDIUM = 'medium', LARGE = 'large', } /** * @deprecated for usage with properties since version 2.0. Use the string literal instead. */ export enum BentoModalColumnLayoutVariant { SMALLER = 'smaller', EQUAL = 'equal', BIGGER = 'bigger', } export enum BentoModalRole { DIALOG = 'dialog', ALERT_DIALOG = 'alertdialog', } /** * @deprecated for usage with properties since version 2.0. Use the string literal instead. */ export enum BentoModalDialogType { CONFIRMATION = 'confirmation', DESTRUCTIVE = 'destructive', INFO = 'info', ACKNOWLEDGEMENT = 'acknowledgement', } export enum BentoModalEvent { CLOSE_MODAL = 'close-modal', } /** * Private */ export interface BentoModalConfigData { activePage: Ref<string>; height: Ref<string>; isAnimationActive: Ref<boolean>; pageCustomPadding: Ref<string>; showCloseButton: ComputedRef<boolean>; size: Ref<BentoModalSize | `${BentoModalSize}`>; }
1
+ import { type ComputedRef, type Ref } from 'vue'; /** * Public */ /** * @deprecated Since v2.0.0. Use string literal instead. */ export enum BentoModalVariant { DEFAULT = 'default', DIALOG = 'dialog', } /** * @deprecated Since v2.0.0. Use string literal instead. */ export enum BentoModalVariantMapping { DEFAULT = 'BentoModalDefault', DIALOG = 'BentoModalDialog', } /** * @deprecated Since v2.0.0. Use string literal instead. */ export enum BentoModalSize { SMALL = 'small', MEDIUM = 'medium', LARGE = 'large', } /** * @deprecated Since v2.0.0. Use string literal instead. */ export enum BentoModalColumnLayoutVariant { SMALLER = 'smaller', EQUAL = 'equal', BIGGER = 'bigger', } export enum BentoModalRole { DIALOG = 'dialog', ALERT_DIALOG = 'alertdialog', } /** * @deprecated Since v2.0.0. Use string literal instead. */ export enum BentoModalDialogType { CONFIRMATION = 'confirmation', DESTRUCTIVE = 'destructive', INFO = 'info', ACKNOWLEDGEMENT = 'acknowledgement', } export enum BentoModalEvent { CLOSE_MODAL = 'close-modal', } /** * Private */ export interface BentoModalConfigData { activePage: Ref<string>; height: Ref<string>; isAnimationActive: Ref<boolean>; pageCustomPadding: Ref<string>; showCloseButton: ComputedRef<boolean>; size: Ref<BentoModalSize | `${BentoModalSize}`>; }
@@ -1 +1 @@
1
- <template> <component :is="computedVariant" class="b-modal" :is-open="isOpen" :actions="actions" :initially-open="initiallyOpen" v-bind="customProps" @close-modal="onCloseModal" @update:active-page="emit('update:active-page', $event)" > <template v-if="hasSlot('default')" #default> <slot></slot> </template> <template v-if="hasSlot('content-col-1')" #content-col-1><slot name="content-col-1"></slot></template> <template v-if="hasSlot('content-col-2')" #content-col-2><slot name="content-col-2"></slot></template> <template v-if="hasSlot('content')" #content><slot name="content"></slot></template> </component> </template> <script setup lang="ts"> import { computed, defineComponent, type PropType, useAttrs, useSlots, watch } from 'vue'; import { BentoModalColumnLayoutVariant, BentoModalDialogType, BentoModalSize, BentoModalVariant, BentoModalVariantMapping, } from './modal.types'; import { BentoModalDefault, BentoModalDialog } from '@/components/modal/components'; import { useHasSlot } from '@/composables'; import { type BentoButtonActionsList } from '@/components/button'; import { deprecate } from '@/utils/ts/deprecate'; const LOADING_BTN_CLOSE_AFTER_SUCCESS_DELAY_TIME = 500; type BentoModalVariantProp = BentoModalVariant | `${BentoModalVariant}` | `${BentoModalVariantMapping}`; const slots = useSlots(); const hasSlot = useHasSlot(slots); const attrs = useAttrs(); const emit = defineEmits<{ /** * Emits update event when page is changed internally */ (e: 'update:active-page', page: string): void; /** * Emitted when the modal is closed */ (e: 'close-modal'); }>(); const props = defineProps({ /** * List of actions that will be used to render the buttons. * First element in the list will be rendered as "primary". * All elements after the first will be rendered as "secondary". * * Each object in the list should have a `title`, an `event` and * (optional) a Vue `icon` from the library `@adyen/ui-assets-icons-16`. * * `title`: Button's text. * * `event`: Event triggered when clicking the Button. * * `icon`: Vue Icon from the library `@adyen/ui-assets-icons-16`. * * ``` * [{ * title: 'Primary',2 * event: () => { ... }, * icon: FourPeopleIcon * }, { * title: 'Secondary', * event: () => { ... } * }] * ``` */ actions: { type: Array as PropType<BentoButtonActionsList>, default: null, }, /** * The id of the current active page * Should be the same id as the BentoModalPage to be displayed */ activePage: { type: String, default: null, }, /** * Text label that is displayed left to the action buttons */ actionsLabel: { type: String, default: null }, /** * Text that will be displayed in the content section of Modal */ content: { type: String, default: null }, /** * If a column layout is provided, it renders the respective left column width variant. This can be smaller than the second column, the same size as the second column or bigger than the second column. */ columnsSize: { type: String as PropType<BentoModalColumnLayoutVariant | `${BentoModalColumnLayoutVariant}`>, default: BentoModalColumnLayoutVariant.SMALLER, validator: (value: BentoModalColumnLayoutVariant) => Object.values(BentoModalColumnLayoutVariant).includes(value), }, /** * If provided, manages the padding of the whole modal container */ customPadding: { type: String, default: null }, /** * If passed - sets first button color to red */ destructiveActions: { type: Boolean, default: false }, /** * Changes the visual styling of action buttons */ dialogType: { type: String as PropType<BentoModalDialogType | `${BentoModalDialogType}`>, default: BentoModalDialogType.CONFIRMATION, validator: (value: BentoModalDialogType) => Object.values(BentoModalDialogType).includes(value), }, /** * Hides a cross icon on the right side of the title */ hideCloseButton: { type: Boolean, default: false }, /** * If set to true, it allows the modal to be closed without performing an action */ isDismissible: { type: Boolean, default: null }, /** * Controls the modal's state from the outside. Set it to true to open the modal */ isOpen: { type: Boolean, default: false, }, /** * Sets the open attribute on the dialog element, use isOpen instead to toggle modal visbility. */ initiallyOpen: { type: Boolean, default: false }, /** * Sets the width of the modal */ size: { type: String as PropType<BentoModalSize | `${BentoModalSize}`>, default: BentoModalSize.SMALL, validator: (value: BentoModalSize) => Object.values(BentoModalSize).includes(value), }, /** * Choose between default or dialog versions of modal */ variant: { type: String as PropType<BentoModalVariantProp>, default: 'default', validator: (value: BentoModalVariantProp) => Object.values<BentoModalVariantProp>(BentoModalVariant).includes(value) || Object.values<BentoModalVariantProp>(BentoModalVariantMapping).includes(value), }, /** * If provided, renders the modal's content in a two-column layout an wth dividers separating its content */ withColumnLayout: { type: Boolean, default: false }, }); const computedVariant = computed(() => { if (Object.values<BentoModalVariantProp>(BentoModalVariantMapping).includes(props.variant)) { return props.variant; } return BentoModalVariantMapping[props.variant.toUpperCase()]; }); const customProps = computed(() => computedVariant.value === BentoModalVariantMapping.DEFAULT ? { activePage: props.activePage, hideCloseButton: props.hideCloseButton, size: props.size, customPadding: props.customPadding, actionsLabel: props.actionsLabel, destructiveActions: props.destructiveActions, isDismissible: props.isDismissible ?? true, withColumnLayout: props.withColumnLayout, columnsSize: props.columnsSize, ...attrs, } : { activePage: props.activePage, dialogType: props.dialogType, content: props.content, isDismissible: props.isDismissible ?? false, ...attrs, } ); const onCloseModal = () => { emit('close-modal'); }; // If the first button is a loading button then close the modal have 0.5 seconds // if the state is changed to 'success'. watch( () => props.actions, () => { if (props.actions?.[0]?.state === 'success') { setTimeout(onCloseModal, LOADING_BTN_CLOSE_AFTER_SUCCESS_DELAY_TIME); } } ); if (Object.keys(attrs).includes('overflow-visible')) { deprecate( 'BentoModal "overflow-visible" property', `"overflow-visible" property was made redundant. BentoModal can work correctly without the need to pass the propery. Remove the "overflow-visible" prop from BentoModal to remove this error.`, '2.0.0' ); } </script> <script lang="ts"> /** * Modal is a window that appear over any other page content * and focus user attention exclusively on one task or piece of * information at the moment. * * @example * import { BentoModal } from '@adyen/bento-vue2'; * * export default { * components: { BentoModal }, * template: ` * <bento-modal :isOpen="true"> * Title of the Modal * <template #content> * Mandatory content slot * </template> * </bento-modal> * ` * }; */ export default defineComponent({ components: { BentoModalDefault, BentoModalDialog }, }); </script>
1
+ <template> <component :is="computedVariant" class="b-modal" :is-open="isOpen" :actions="actions" :initially-open="initiallyOpen" v-bind="customProps" @close-modal="onCloseModal" @update:active-page="emit('update:active-page', $event)" > <template v-if="hasSlot('default')" #default> <slot></slot> </template> <template v-if="hasSlot('content-col-1')" #content-col-1><slot name="content-col-1"></slot></template> <template v-if="hasSlot('content-col-2')" #content-col-2><slot name="content-col-2"></slot></template> <template v-if="hasSlot('content')" #content><slot name="content"></slot></template> </component> </template> <script setup lang="ts"> import { computed, defineComponent, type PropType, useAttrs, useSlots } from 'vue'; import { BentoModalColumnLayoutVariant, BentoModalDialogType, BentoModalSize, BentoModalVariant, BentoModalVariantMapping, } from './modal.types'; import { BentoModalDefault, BentoModalDialog } from '@/components/modal/components'; import { useHasSlot } from '@/composables'; import { type BentoButtonActionsList } from '@/components/button'; import { deprecate } from '@/utils/ts/deprecate'; type BentoModalVariantProp = BentoModalVariant | `${BentoModalVariant}` | `${BentoModalVariantMapping}`; const slots = useSlots(); const hasSlot = useHasSlot(slots); const attrs = useAttrs(); const emit = defineEmits<{ /** * Emits update event when page is changed internally */ (e: 'update:active-page', page: string): void; /** * Emitted when the modal is closed */ (e: 'close-modal'); }>(); const props = defineProps({ /** * List of actions that will be used to render the buttons. * First element in the list will be rendered as "primary". * All elements after the first will be rendered as "secondary". * * Each object in the list should have a `title`, an `event` and * (optional) a Vue `icon` from the library `@adyen/ui-assets-icons-16`. * * `title`: Button's text. * * `event`: Event triggered when clicking the Button. * * `icon`: Vue Icon from the library `@adyen/ui-assets-icons-16`. * * ``` * [{ * title: 'Primary',2 * event: () => { ... }, * icon: FourPeopleIcon * }, { * title: 'Secondary', * event: () => { ... } * }] * ``` */ actions: { type: Array as PropType<BentoButtonActionsList>, default: null, }, /** * The id of the current active page * Should be the same id as the BentoModalPage to be displayed */ activePage: { type: String, default: null, }, /** * Text label that is displayed left to the action buttons */ actionsLabel: { type: String, default: null }, /** * Text that will be displayed in the content section of Modal */ content: { type: String, default: null }, /** * If a column layout is provided, it renders the respective left column width variant. This can be smaller than the second column, the same size as the second column or bigger than the second column. */ columnsSize: { type: String as PropType<BentoModalColumnLayoutVariant | `${BentoModalColumnLayoutVariant}`>, default: BentoModalColumnLayoutVariant.SMALLER, validator: (value: BentoModalColumnLayoutVariant) => Object.values(BentoModalColumnLayoutVariant).includes(value), }, /** * If provided, manages the padding of the whole modal container */ customPadding: { type: String, default: null }, /** * If passed - sets first button color to red */ destructiveActions: { type: Boolean, default: false }, /** * Changes the visual styling of action buttons */ dialogType: { type: String as PropType<BentoModalDialogType | `${BentoModalDialogType}`>, default: BentoModalDialogType.CONFIRMATION, validator: (value: BentoModalDialogType) => Object.values(BentoModalDialogType).includes(value), }, /** * Hides a cross icon on the right side of the title */ hideCloseButton: { type: Boolean, default: false }, /** * If set to true, it allows the modal to be closed without performing an action */ isDismissible: { type: Boolean, default: null }, /** * Controls the modal's state from the outside. Set it to true to open the modal */ isOpen: { type: Boolean, default: false, }, /** * Sets the open attribute on the dialog element, use isOpen instead to toggle modal visbility. */ initiallyOpen: { type: Boolean, default: false }, /** * Sets the width of the modal */ size: { type: String as PropType<BentoModalSize | `${BentoModalSize}`>, default: BentoModalSize.SMALL, validator: (value: BentoModalSize) => Object.values(BentoModalSize).includes(value), }, /** * Choose between default or dialog versions of modal */ variant: { type: String as PropType<BentoModalVariantProp>, default: 'default', validator: (value: BentoModalVariantProp) => Object.values<BentoModalVariantProp>(BentoModalVariant).includes(value) || Object.values<BentoModalVariantProp>(BentoModalVariantMapping).includes(value), }, /** * If provided, renders the modal's content in a two-column layout an wth dividers separating its content */ withColumnLayout: { type: Boolean, default: false }, }); const computedVariant = computed(() => { if (Object.values<BentoModalVariantProp>(BentoModalVariantMapping).includes(props.variant)) { return props.variant; } return BentoModalVariantMapping[props.variant.toUpperCase()]; }); const customProps = computed(() => computedVariant.value === BentoModalVariantMapping.DEFAULT ? { activePage: props.activePage, hideCloseButton: props.hideCloseButton, size: props.size, customPadding: props.customPadding, actionsLabel: props.actionsLabel, destructiveActions: props.destructiveActions, isDismissible: props.isDismissible ?? true, withColumnLayout: props.withColumnLayout, columnsSize: props.columnsSize, ...attrs, } : { activePage: props.activePage, dialogType: props.dialogType, content: props.content, isDismissible: props.isDismissible ?? false, ...attrs, } ); const onCloseModal = () => { emit('close-modal'); }; if (Object.keys(attrs).includes('overflow-visible')) { deprecate( 'BentoModal "overflow-visible" property', `"overflow-visible" property was made redundant. BentoModal can work correctly without the need to pass the propery. Remove the "overflow-visible" prop from BentoModal to remove this error.`, '2.0.0' ); } </script> <script lang="ts"> /** * Modal is a window that appear over any other page content * and focus user attention exclusively on one task or piece of * information at the moment. * * @example * import { BentoModal } from '@adyen/bento-vue2'; * * export default { * components: { BentoModal }, * template: ` * <bento-modal :isOpen="true"> * Title of the Modal * <template #content> * Mandatory content slot * </template> * </bento-modal> * ` * }; */ export default defineComponent({ components: { BentoModalDefault, BentoModalDialog }, }); </script>
@@ -0,0 +1 @@
1
+ import BentoNavigationMenuGroup from './navigation-menu-group.vue'; import BentoNavigationMenu from '../../navigation-menu.vue'; import BentoNavigationMenuItem from '../navigation-menu-item/navigation-menu-item.vue'; import { BentoButton } from '@/components/button'; import type { Meta, StoryObj } from '@storybook/vue'; import NavPaymentsIcon from '@adyen/ui-assets-icons-16/vue/nav-payments'; import StarIcon from '@adyen/ui-assets-icons-16/vue/star'; import { isVue2 } from 'vue-demi'; const meta: Meta = { title: 'Navigation Menu/Navigation Menu Group', component: BentoNavigationMenuGroup, argTypes: { showIcon: { table: { category: 'Storybook controls', }, control: { type: 'boolean', }, }, icon: { table: { disable: true, }, }, default: { table: { disable: true, }, }, }, }; export default meta; type Story = StoryObj<typeof BentoNavigationMenuGroup>; export const Default: Story = { render: (_args, { argTypes }) => ({ components: { BentoButton, BentoNavigationMenu, BentoNavigationMenuGroup, BentoNavigationMenuItem, NavPaymentsIcon, StarIcon, }, props: Object.keys(argTypes), setup(props) { return { args: isVue2 ? props : _args, }; }, template: ` <div style="width: 248px;"> <bento-navigation-menu label="Pages"> <bento-navigation-menu-group :label="args.label" :is-expanded="args.isExpanded" > <template v-if="args.showIcon" #icon> <nav-payments-icon /> </template> <bento-navigation-menu-item nested value="payments" label="Payments" :link="{ to: '#payments', isNotRouting: true }"> <template v-if="args.showAction" #action> <bento-button variant="tertiary"> <template #iconLeft> <star-icon /> </template> </bento-button> </template> </bento-navigation-menu-item> <bento-navigation-menu-item nested value="preauth" label="Pre-authorized" :link="{ to: '#preauth', isNotRouting: true }"> <template v-if="args.showAction" #action> <bento-button variant="tertiary"> <template #iconLeft> <star-icon /> </template> </bento-button> </template> </bento-navigation-menu-item> </bento-navigation-menu-group> </bento-navigation-menu> </div> `, }), args: { label: 'Payments', isExpanded: false, showIcon: true, }, };
@@ -0,0 +1 @@
1
+ import type { Ref } from 'vue'; export interface BentoNavigationMenuGroupProps { /** * Whether the group is expanded */ isExpanded?: boolean; /** * Label text for the group */ label: string; } /** * @private */ export interface BentoNavigationMenuItemRegistration { id: string; isSelected: () => boolean; } /** * @private */ export interface BentoNavigationMenuGroupState { registerItem: (itemId: string, isSelected: () => boolean) => void; unregisterItem: (itemId: string) => void; isSemiCollapsed: Ref<boolean>; }
@@ -0,0 +1 @@
1
+ <template> <li class="b-navigation-menu-group"> <button :id="buttonId" type="button" class="b-navigation-menu-group__header" :class="{ ...headerClasses, 'b-navigation-menu-group__header--no-icon': !hasSlot('icon'), }" :aria-expanded="`${isOpen && !isSemiCollapsed}`" :aria-controls="contentId" :aria-description="headerAriaDescription" @click="toggleGroup" > <div v-if="hasSlot('icon')" class="b-navigation-menu-group__icon"> <slot name="icon" /> </div> <bento-typography ref="labelRef" v-bento-tooltip-directive="isTruncated ? label : undefined" el="span" variant="body" stronger class="b-navigation-menu-group__label" > {{ label }} </bento-typography> <div class="b-navigation-menu-group__icon b-navigation-menu-group__icon--chevron"> <chevron-up-icon v-if="isOpen && !isSemiCollapsed" aria-hidden="true" /> <chevron-down-icon v-else aria-hidden="true" /> </div> </button> <Transition name="b-navigation-menu-group__animation--content"> <div v-show="isOpen" :id="contentId" class="b-navigation-menu-group__content-wrapper" role="region" :aria-labelledby="buttonId" :style="contentMaxHeight" > <ul ref="contentDiv" class="b-navigation-menu-group__content"> <slot /> </ul> </div> </Transition> </li> </template> <script setup lang="ts"> import { computed, inject, onMounted, onUnmounted, provide, ref, useSlots, watch } from 'vue'; import { useResizeObserver } from '@vueuse/core'; import { BentoTypography } from '@/components/typography'; import { 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" />
@@ -0,0 +1 @@
1
+ import BentoNavigationMenuItem from './navigation-menu-item.vue'; import BentoNavigationMenu from '../../navigation-menu.vue'; import { BentoButton } from '@/components/button'; import type { Meta, StoryObj } from '@storybook/vue'; import NavHomeIcon from '@adyen/ui-assets-icons-16/vue/nav-home'; import StarIcon from '@adyen/ui-assets-icons-16/vue/star'; import { isVue2 } from 'vue-demi'; const meta: Meta = { title: 'Navigation Menu/Navigation Menu Item', component: BentoNavigationMenuItem, argTypes: { showIcon: { table: { category: 'Storybook controls', }, control: { type: 'boolean', }, }, showAction: { table: { category: 'Storybook controls', }, control: { type: 'boolean', }, }, icon: { table: { disable: true, }, }, action: { table: { disable: true, }, }, default: { table: { disable: true, }, }, }, }; export default meta; type Story = StoryObj<typeof BentoNavigationMenuItem>; export const Default: Story = { render: (_args, { argTypes }) => ({ components: { BentoButton, BentoNavigationMenu, BentoNavigationMenuItem, NavHomeIcon, StarIcon }, props: Object.keys(argTypes), setup(props) { return { args: isVue2 ? props : _args, }; }, template: ` <div style="width: 248px;"> <bento-navigation-menu label="Pages"> <bento-navigation-menu-item :value="args.value" :label="args.label" :link="args.link" :nested="args.nested" :always-show-actions="args.alwaysShowActions" > <template v-if="args.showIcon" #icon> <nav-home-icon /> </template> <template v-if="args.showAction" #action> <bento-button variant="tertiary"> <template #iconLeft> <star-icon /> </template> </bento-button> </template> </bento-navigation-menu-item> </bento-navigation-menu> </div> `, }), args: { value: 'home', label: 'Home', link: { to: '#home', isNotRouting: true }, nested: false, alwaysShowActions: false, showIcon: true, showAction: true, }, };