@adyen/bento-mcp 0.5.3 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/dist/assets/components/alert/alert.docs.mdx +17 -17
  3. package/dist/assets/components/alert/alert.vue +1 -1
  4. package/dist/assets/components/country/country.vue +1 -1
  5. package/dist/assets/components/data-grid/data-grid.docs.mdx +3 -0
  6. package/dist/assets/components/data-grid/data-grid.vue +1 -1
  7. package/dist/assets/components/date/date.vue +1 -1
  8. package/dist/assets/components/date-picker/date-picker.docs.mdx +6 -0
  9. package/dist/assets/components/date-picker/date-picker.stories.ts +1 -1
  10. package/dist/assets/components/date-picker/date-picker.types.ts +1 -1
  11. package/dist/assets/components/date-picker/date-picker.vue +1 -1
  12. package/dist/assets/components/date-range-picker/components/date-range-picker-calendar/date-range-picker-calendar.vue +1 -1
  13. package/dist/assets/components/date-range-picker/components/date-time-input/date-time-input.types.ts +1 -0
  14. package/dist/assets/components/date-range-picker/components/date-time-input/date-time-input.vue +1 -0
  15. package/dist/assets/components/date-range-picker/date-range-picker.docs.mdx +6 -0
  16. package/dist/assets/components/date-range-picker/date-range-picker.stories.ts +1 -1
  17. package/dist/assets/components/date-range-picker/date-range-picker.types.ts +1 -1
  18. package/dist/assets/components/date-range-picker/date-range-picker.vue +1 -1
  19. package/dist/assets/components/date-time-picker/date-time-picker.vue +1 -1
  20. package/dist/assets/components/draggable/draggable.vue +1 -1
  21. package/dist/assets/components/dropdown/components/dropdown-options-container/dropdown-options-container.vue +1 -1
  22. package/dist/assets/components/dropdown/components/variants/dropdown-country/dropdown-country.types.ts +1 -1
  23. package/dist/assets/components/dropdown/dropdown.types.ts +1 -1
  24. package/dist/assets/components/dropdown/dropdown.vue +1 -1
  25. package/dist/assets/components/filter-bar/components/checkbox-group-filter/checkbox-group-filter.vue +1 -1
  26. package/dist/assets/components/filter-bar/components/date-filter/date-filter.vue +1 -1
  27. package/dist/assets/components/filter-bar/components/date-range-filter/date-range-filter.vue +1 -1
  28. package/dist/assets/components/filter-bar/filter-bar.docs.mdx +8 -2
  29. package/dist/assets/components/filter-bar/filter-bar.stories.ts +1 -1
  30. package/dist/assets/components/filter-bar/filter-bar.types.ts +1 -1
  31. package/dist/assets/components/filter-bar/filter-bar.vue +1 -1
  32. package/dist/assets/components/input-field-password/input-field-password.vue +1 -1
  33. package/dist/assets/components/internal/calendar/calendar.stories.ts +1 -1
  34. package/dist/assets/components/internal/calendar/calendar.types.ts +1 -1
  35. package/dist/assets/components/internal/calendar/calendar.vue +1 -1
  36. package/dist/assets/components/internal/calendar/components/calendar-month/calendar-month.types.ts +1 -1
  37. package/dist/assets/components/internal/calendar/components/calendar-month/calendar-month.vue +1 -1
  38. package/dist/assets/components/internal/calendar/components/calendar-month/components/calendar-month-day/calendar-month-day.vue +1 -1
  39. package/dist/assets/components/internal/calendar/components/calendar-year/calendar-year.types.ts +1 -1
  40. package/dist/assets/components/internal/calendar/components/calendar-year/calendar-year.vue +1 -1
  41. package/dist/assets/components/internal/calendar/components/calendar-year-selector/calendar-year-selector.types.ts +1 -0
  42. package/dist/assets/components/internal/calendar/components/calendar-year-selector/calendar-year-selector.vue +1 -0
  43. package/dist/assets/components/internal/header-meta/header-meta.vue +1 -1
  44. package/dist/assets/components/internal/listbox/components/listbox-multi-select/listbox-multi-select.vue +1 -1
  45. package/dist/assets/components/internal/listbox/components/listbox-single-select/listbox-single-select.vue +1 -1
  46. package/dist/assets/components/list/components/list-item/list-item.types.ts +1 -1
  47. package/dist/assets/components/list/components/list-item/list-item.vue +1 -1
  48. package/dist/assets/components/list/list.docs.mdx +62 -0
  49. package/dist/assets/components/list/list.stories.ts +1 -1
  50. package/dist/assets/components/list/list.types.ts +1 -1
  51. package/dist/assets/components/list/list.vue +1 -1
  52. package/dist/assets/components/popover/popover.docs.mdx +5 -1
  53. package/dist/assets/components/summary-grid/summary-grid.vue +1 -1
  54. package/dist/assets/components/tabs/tabs.docs.mdx +6 -1
  55. package/dist/assets/composables/use-bento-delayed-hover/use-bento-delayed-hover.docs.mdx +5 -1
  56. package/dist/assets/deprecations.md +156 -156
  57. package/dist/assets/usage.json +74 -74
  58. package/dist/main.js +135 -48
  59. package/package.json +1 -1
@@ -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 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
+ <template> <div ref="inputFieldPasswordRef"> <bento-input-field ref="inputFieldPasswordInputRef" v-bind="inputFieldArgs" class="b-input-field-password__input" autocapitalize="none" autocorrect="off" 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 Meta, type StoryObj } from '@storybook/vue'; import { action } from '@storybook/addon-actions'; import { computed, reactive, ref } from 'vue'; import { CalendarEvent } from './calendar.types'; import { isVue2 } from 'vue-demi'; import Calendar from './calendar.vue'; import { subDays } from 'date-fns/subDays'; import { subMonths } from 'date-fns/subMonths'; import { addMonths } from 'date-fns/addMonths'; const meta: Meta = { title: 'Calendar', component: Calendar, argTypes: { // Props month: { name: 'month', defaultValue: undefined, description: 'Month of the year to be displayed in the calendar', control: 'date', }, value: { name: 'value', defaultValue: undefined, description: 'Date selected', control: { disable: true, }, }, // Funcs isDateDisabled: { name: 'isDateDisabled', defaultValue: '', description: 'Disables the dates that meet the criteria defined in the function.', table: { type: { summary: '(currentDate: Date) => boolean' } }, control: { disable: true }, }, // Events input: { name: 'input', defaultValue: '', description: 'Triggered when a date is selected', table: { type: { summary: '(selectedDate: Date) => void' } }, control: { disable: true }, }, }, }; export default meta; type Story = StoryObj<typeof Calendar>; export const Default: Story = { render: (_args, { argTypes }) => ({ components: { Calendar }, props: Object.keys(argTypes), template: ` <calendar v-bind="args" :value="currentValue" @input="onInputEvent" /> `, setup(props) { const args = isVue2 ? props : _args; const currentMonth = computed(() => new Date(args.month)); const currentValue = ref(new Date(args.value)); const onInputEvent = value => { action('input')(value); currentValue.value = value; }; return { // Values args, currentMonth, currentValue, // Events onInputEvent, }; }, }), args: { isRange: false, month: new Date(), value: (() => { const date = new Date(); date.setDate(date.getDate() + 1); return date; })(), numberOfMonths: 1, }, }; export const MonthVariant: Story = { render: (_args, { argTypes }) => ({ components: { Calendar }, props: Object.keys(argTypes), template: ` <calendar v-bind="args" variant="month" :value="currentValue" @input="onInputEvent" /> `, setup(props) { const args = isVue2 ? props : _args; const currentValue = ref(new Date(args.value)); const onInputEvent = value => { action('input')(value); currentValue.value = value; }; return { // Values args, currentValue, // Events onInputEvent, }; }, }), args: { isRange: false, year: new Date(), value: subMonths(new Date(), 1), max: addMonths(new Date(), 1), numberOfMonths: 1, }, }; export const Range: Story = { render: (_args, { argTypes }) => ({ components: { Calendar }, props: Object.keys(argTypes), template: ` <calendar v-bind="args" :value="currentValue" @input="onInputEvent" /> `, setup(props) { const args = isVue2 ? props : _args; const currentMonth = computed(() => new Date(args.month)); const currentValue = reactive({ startDate: null, endDate: null }); const onInputEvent = value => { action(CalendarEvent.INPUT)(value); currentValue.startDate = value.startDate; currentValue.endDate = value.endDate; }; return { // Values args, currentMonth, currentValue, // Events onInputEvent, }; }, }), args: { isRange: true, month: new Date(), min: subDays(new Date(), 8), max: new Date(), value: { startDate: undefined, endDate: undefined, }, }, };
1
+ import { type Meta, type StoryObj } from '@storybook/vue'; import { action } from '@storybook/addon-actions'; import { computed, reactive, ref } from 'vue'; import { CalendarEvent } from './calendar.types'; import { isVue2 } from 'vue-demi'; import Calendar from './calendar.vue'; import { subDays } from 'date-fns/subDays'; import { subMonths } from 'date-fns/subMonths'; import { addMonths } from 'date-fns/addMonths'; const meta: Meta = { title: 'Calendar', component: Calendar, argTypes: { // Props month: { name: 'month', defaultValue: undefined, description: 'Month of the year to be displayed in the calendar', control: 'date', }, value: { name: 'value', defaultValue: undefined, description: 'Date selected', control: { disable: true, }, }, // Funcs isDateDisabled: { name: 'isDateDisabled', defaultValue: '', description: 'Disables the dates that meet the criteria defined in the function.', table: { type: { summary: '(currentDate: Date) => boolean' } }, control: { disable: true }, }, // Events input: { name: 'input', defaultValue: '', description: 'Triggered when a date is selected', table: { type: { summary: '(selectedDate: Date) => void' } }, control: { disable: true }, }, }, }; export default meta; type Story = StoryObj<typeof Calendar>; export const Default: Story = { render: (_args, { argTypes }) => ({ components: { Calendar }, props: Object.keys(argTypes), template: ` <calendar v-bind="args" :value="currentValue" @input="onInputEvent" /> `, setup(props) { const args = isVue2 ? props : _args; const currentMonth = computed(() => new Date(args.month)); const currentValue = ref(new Date(args.value)); const onInputEvent = value => { action('input')(value); currentValue.value = value; }; return { // Values args, currentMonth, currentValue, // Events onInputEvent, }; }, }), args: { yearRange: { minYear: undefined, maxYear: undefined }, isRange: false, month: new Date(), value: (() => { const date = new Date(); date.setDate(date.getDate() + 1); return date; })(), numberOfMonths: 1, }, }; export const MonthVariant: Story = { render: (_args, { argTypes }) => ({ components: { Calendar }, props: Object.keys(argTypes), template: ` <calendar v-bind="args" variant="month" :value="currentValue" @input="onInputEvent" /> `, setup(props) { const args = isVue2 ? props : _args; const currentValue = ref(new Date(args.value)); const onInputEvent = value => { action('input')(value); currentValue.value = value; }; return { // Values args, currentValue, // Events onInputEvent, }; }, }), args: { yearRange: { minYear: undefined, maxYear: undefined }, isRange: false, year: new Date(), value: subMonths(new Date(), 1), max: addMonths(new Date(), 1), numberOfMonths: 1, }, }; export const Range: Story = { render: (_args, { argTypes }) => ({ components: { Calendar }, props: Object.keys(argTypes), template: ` <calendar v-bind="args" :value="currentValue" @input="onInputEvent" /> `, setup(props) { const args = isVue2 ? props : _args; const currentMonth = computed(() => new Date(args.month)); const currentValue = reactive({ startDate: null, endDate: null }); const onInputEvent = value => { action(CalendarEvent.INPUT)(value); currentValue.startDate = value.startDate; currentValue.endDate = value.endDate; }; return { // Values args, currentMonth, currentValue, // Events onInputEvent, }; }, }), args: { yearRange: { minYear: undefined, maxYear: undefined }, isRange: true, month: new Date(), min: subDays(new Date(), 8), max: new Date(), value: { startDate: undefined, endDate: undefined, }, }, };
@@ -1 +1 @@
1
- import { type Ref, type UnwrapNestedRefs } from 'vue'; import type { Day } from 'date-fns'; /** * Defines the first day of the week */ export enum CalendarFirstDayOfWeek { MONDAY = 1, SATURDAY = 6, SUNDAY = 0, } export type CalendarIsDateDisabled = (date: Date) => boolean; /** * Defines the position in which the calendar will be displayed */ export enum CalendarPosition { FIRST = 'first', LAST = 'last', MIDDLE = 'middle', UNIQUE = 'unique', } 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 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', } export type UseCalendarPane = ( propRefs: UseCalendarPaneProps, emit ) => { internalValue: UnwrapNestedRefs<CalendarRangeDateValue>; onDateInput: (date: Date) => void; onMouseOver: (date: Date) => void; }; export interface CalendarEmit { /** * Triggered when a date is selected */ (e: 'input', date: Date): void; /** * Triggered when the active day changes */ (e: 'changeDay', day: Date): void; /** * Triggered when navigating to the next month */ (e: 'nextMonth', month: number): void; /** * Triggered when navigating to the previous month */ (e: 'previousMonth', month: number): void; /** * Triggered when the mouse hovers over a date, used for range selection */ (e: 'mouseover', date: Date): void; }
1
+ import { type Ref, type UnwrapNestedRefs } from 'vue'; import type { Day } from 'date-fns'; /** * Defines the first day of the week */ export enum CalendarFirstDayOfWeek { MONDAY = 1, SATURDAY = 6, SUNDAY = 0, } export type CalendarIsDateDisabled = (date: Date) => boolean; /** * Defines the position in which the calendar will be displayed */ export enum CalendarPosition { FIRST = 'first', LAST = 'last', MIDDLE = 'middle', UNIQUE = 'unique', } 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 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', } export type UseCalendarPane = ( propRefs: UseCalendarPaneProps, emit ) => { internalValue: UnwrapNestedRefs<CalendarRangeDateValue>; onDateInput: (date: Date) => void; onMouseOver: (date: Date) => void; }; /** * Defines the range of selectable years in the calendar's year selector dropdown */ export interface CalendarYearRange { /** * The earliest selectable year (inclusive) */ minYear?: number; /** * The latest selectable year (inclusive) */ maxYear?: number; } export interface CalendarEmit { /** * Triggered when a date is selected */ (e: 'input', date: Date): void; /** * Triggered when the active day changes */ (e: 'changeDay', day: Date): void; /** * Triggered when navigating to the next month */ (e: 'nextMonth', month: number): void; /** * Triggered when navigating to the previous month */ (e: 'previousMonth', month: number): void; /** * Triggered when the mouse hovers over a date, used for range selection */ (e: 'mouseover', date: Date): void; }
@@ -1 +1 @@
1
- <template> <div class="b-calendar" :class="conditionalClasses"> <component :is="calendarComponent" v-for="(_, index) in numberOfMonths" :ref="numberOfMonths === 1 ? 'calendarRef' : null" :key="`calendar-${index}`" :first-day-of-week="firstDayOfWeek" :is-date-disabled="isDateDisabled" :month="calculateOffsetMonth(index)" :year="calculateOffsetYear(index)" :position="calendarPosition(index)" :min="min" :max="max" :is-range="isRange" :start-date="internalValue.startDate" :end-date="internalValue.endDate" :granularity="granularity" @input="onDateInput" @mouseover="onMouseOver" @changeDay="onMouseOver" @nextMonth="calculateNextMonths" @previousMonth="calculatePreviousMonths" /> </div> </template> <script lang="ts"> import { computed, defineComponent, type PropType, ref, toRef, watch } from 'vue'; import CalendarMonth from './components/calendar-month/calendar-month.vue'; import CalendarYear from './components/calendar-year/calendar-year.vue'; import { CalendarEvent, CalendarFirstDayOfWeek, type CalendarGranularityConfig, type CalendarIsDateDisabled, CalendarPosition, type CalendarRangeDateValue, type CalendarValue, CalendarVariant, } from './calendar.types'; import { useSinglePane } from './composables/use-single-pane'; import { useRangePane } from './composables/use-range-pane'; import { addMonths } from 'date-fns/addMonths'; import { subMonths } from 'date-fns/subMonths'; import { addYears } from 'date-fns/addYears'; import { subYears } from 'date-fns/subYears'; /** * Handles the calendar logic to change visible months for Single calendar * and Range calendar, as well as the selection logic for range. * All the processing necessary for the calendar to select dates * * @example * import { Calendar } from '@/internal/bento-vue2'; * * export default { * components: { Calendar }, * template: ` * <calendar * :value="value" * :first-day-of-week="CalendarFirstDayOfWeek.Monday" * :is-date-disabled="(day: Date) => boolean" * :number-of-months="2" * is-range * @input="(selectedDate) => void" * @start-date-selected="(startDate: Date) => void" * @end-date-selected="(endDate: Date) => void" * /> * ` * } */ export default defineComponent({ name: 'calendar', components: { CalendarMonth, CalendarYear }, props: { /** * Allows you to set the first day of the week. 0 is sunday, 1 is monday, 6 is saturday */ firstDayOfWeek: { type: Number as PropType<CalendarFirstDayOfWeek>, default: CalendarFirstDayOfWeek.MONDAY, validator: (value: CalendarFirstDayOfWeek) => Object.values(CalendarFirstDayOfWeek).includes(value), }, /** * The selected granularity configuration */ granularity: { type: Object as PropType<CalendarGranularityConfig>, default: null, }, /** * Indicate if a date should be disabled or not */ isDateDisabled: { type: Function as PropType<CalendarIsDateDisabled>, default: undefined }, /** * Enables the Date Range calendar */ isRange: { type: Boolean, default: false }, /** * Set a maximum number of dates to be selectable by the range. */ maxRange: { type: Number, default: null, validator: (value: number) => !value || value > 0 }, /** * Sets a minimum limit for selectable dates - dates before `min` will not be selectable */ min: { type: Date, default: null }, /** * Sets a maximum limit for selectable dates - dates after `max` will not be selectable */ max: { type: Date, default: null }, /** * Number of months rendered on pane */ numberOfMonths: { type: Number, default: 3, validator: (n: number) => n >= 1, }, /** * Upon opening date range picker, end date's month will be pre-selected. */ showEndDateOnOpen: { type: Boolean, default: false }, /** * Selected date. Can be a single Date or a CalendarRangeDate * instance that contains startDate and endDate attributes. */ value: { type: [Date, Object] as PropType<CalendarValue>, default: undefined }, /** * Default start date for the calendar pane */ defaultDisplayMonth: { type: String, default: null, validator: (value: string) => { // Checks for YYYY-MM-DD format const dateRegex = /^\d{4}-(0?[1-9]|1[012])(-(0?[1-9]|[12][0-9]|3[01]))?$/; const isValidDate = dateRegex.test(value); return isValidDate; }, }, /** * The type of calendar to display. Defaults to showing days. */ variant: { type: String as PropType<`${CalendarVariant}`>, default: 'day', validator: (value: string) => Object.values(CalendarVariant).includes(value as CalendarVariant), }, }, emit: [CalendarEvent.INPUT, CalendarEvent.START_DATE_SELECTED, CalendarEvent.END_DATE_SELECTED], setup(props, { emit }) { // Single date calendar ref const calendarRef = ref(null); // Use a duplicate of "month" to move back and // forth with the arrows. Maintained in sync with the prop "value" const baseDate = ref(); watch( () => props.value, () => { if (props.isRange) { baseDate.value = props?.showEndDateOnOpen && (props.value as CalendarRangeDateValue)?.endDate ? (props.value as CalendarRangeDateValue)?.endDate : (props.value as CalendarRangeDateValue)?.startDate || new Date(); } else { const defaultDate = props.defaultDisplayMonth && new Date(props.defaultDisplayMonth).getTime() ? new Date(props.defaultDisplayMonth) : new Date(); baseDate.value = (props.value as Date) || defaultDate; } }, { immediate: true, deep: true } ); const calendarComponent = computed(() => props.variant === CalendarVariant.MONTH ? CalendarYear : CalendarMonth ); const conditionalClasses = computed(() => ({ 'b-calendar__range': props.isRange, })); const { internalValue, onDateInput, onMouseOver } = props.isRange ? useRangePane( { selectedDate: toRef(props, 'value'), isDateDisabled: props.isDateDisabled as (date: Date) => boolean, maxRange: toRef(props, 'maxRange'), granularity: toRef(props, 'granularity'), firstDayOfWeek: props.firstDayOfWeek, min: toRef(props, 'min'), max: toRef(props, 'max'), }, emit ) : useSinglePane( { selectedDate: toRef(props, 'value'), firstDayOfWeek: props.firstDayOfWeek, }, emit ); /** * Calculate the current month calendar. * This is used to render/hide the Previous & Next buttons on each calendar. * @param calendarIndex - Number to offset the calendar position */ const calendarPosition = (calendarIndex: number): CalendarPosition => { if (props.numberOfMonths === 1) { return CalendarPosition.UNIQUE; } if (calendarIndex === 0) { return CalendarPosition.FIRST; } return calendarIndex === props.numberOfMonths - 1 ? CalendarPosition.LAST : CalendarPosition.MIDDLE; }; /** * Calculates in relation to the amount of months defined in "numberOfMonths" * which calendar month should be render starting from the first (leftmost) * rendered calendar. * If showEndDateOnOpen is set to true, the last pane should be the baseDate month i.e. the end date * @param offsetMonth - Amount of months to add after the first month was rendered */ const calculateOffsetMonth = computed(() => (offsetMonth: number) => { const monthOffset = props?.showEndDateOnOpen && props.isRange ? subMonths(baseDate.value, props.numberOfMonths - 1) : baseDate.value; return addMonths(monthOffset, offsetMonth); }); const calculateOffsetYear = computed(() => (offsetYear: number) => { const yearOffset = props?.showEndDateOnOpen ? subYears(baseDate.value, props.numberOfMonths - 1) : baseDate.value; return addYears(yearOffset, offsetYear); }); /** * By default shows the following month * Can skip multiple months by passing an additional param * Used for keyboard nav to be able to skip a year */ const calculateNextMonths = (amountOfMonths = 1) => { baseDate.value = addMonths(baseDate.value, amountOfMonths); }; /** * By default shows the previous month * Can skip multiple months by passing an additional param * Used for keyboard nav to be able to go back a year */ const calculatePreviousMonths = (amountOfMonths = 1) => { baseDate.value = subMonths(baseDate.value, amountOfMonths); }; return { // Refs calendarRef, // Values baseDate, calendarComponent, conditionalClasses, internalValue, // Methods calculateOffsetMonth, calculateOffsetYear, calendarPosition, // Events onDateInput, onMouseOver, calculateNextMonths, calculatePreviousMonths, }; }, }); </script> <style lang="scss" scoped src="./calendar.scss" />
1
+ <template> <div class="b-calendar" :class="conditionalClasses"> <component :is="calendarComponent" v-for="(_, index) in numberOfMonths" :ref="numberOfMonths === 1 ? 'calendarRef' : null" :key="`calendar-${index}`" :first-day-of-week="firstDayOfWeek" :is-date-disabled="isDateDisabled" :month="calculateOffsetMonth(index)" :year="calculateOffsetYear(index)" :position="calendarPosition(index)" :min="min" :max="max" :is-range="isRange" :start-date="internalValue.startDate" :end-date="internalValue.endDate" :granularity="granularity" :year-range="yearRange" @input="onDateInput" @mouseover="onMouseOver" @changeDay="onMouseOver" @nextMonth="calculateNextMonths" @previousMonth="calculatePreviousMonths" /> </div> </template> <script lang="ts"> import { computed, defineComponent, type PropType, ref, toRef, watch } from 'vue'; import CalendarMonth from './components/calendar-month/calendar-month.vue'; import CalendarYear from './components/calendar-year/calendar-year.vue'; import { CalendarEvent, CalendarFirstDayOfWeek, type CalendarGranularityConfig, type CalendarIsDateDisabled, CalendarPosition, type CalendarRangeDateValue, type CalendarValue, CalendarVariant, type CalendarYearRange, } from './calendar.types'; import { useSinglePane } from './composables/use-single-pane'; import { useRangePane } from './composables/use-range-pane'; import { addMonths } from 'date-fns/addMonths'; import { subMonths } from 'date-fns/subMonths'; import { addYears } from 'date-fns/addYears'; import { subYears } from 'date-fns/subYears'; /** * Handles the calendar logic to change visible months for Single calendar * and Range calendar, as well as the selection logic for range. * All the processing necessary for the calendar to select dates * * @example * import { Calendar } from '@/internal/bento-vue2'; * * export default { * components: { Calendar }, * template: ` * <calendar * :value="value" * :first-day-of-week="CalendarFirstDayOfWeek.Monday" * :is-date-disabled="(day: Date) => boolean" * :number-of-months="2" * is-range * @input="(selectedDate) => void" * @start-date-selected="(startDate: Date) => void" * @end-date-selected="(endDate: Date) => void" * /> * ` * } */ export default defineComponent({ name: 'calendar', components: { CalendarMonth, CalendarYear }, props: { /** * Allows you to set the first day of the week. 0 is sunday, 1 is monday, 6 is saturday */ firstDayOfWeek: { type: Number as PropType<CalendarFirstDayOfWeek>, default: CalendarFirstDayOfWeek.MONDAY, validator: (value: CalendarFirstDayOfWeek) => Object.values(CalendarFirstDayOfWeek).includes(value), }, /** * The selected granularity configuration */ granularity: { type: Object as PropType<CalendarGranularityConfig>, default: null, }, /** * Indicate if a date should be disabled or not */ isDateDisabled: { type: Function as PropType<CalendarIsDateDisabled>, default: undefined }, /** * Enables the Date Range calendar */ isRange: { type: Boolean, default: false }, /** * Set a maximum number of dates to be selectable by the range. */ maxRange: { type: Number, default: null, validator: (value: number) => !value || value > 0 }, /** * Sets a minimum limit for selectable dates - dates before `min` will not be selectable */ min: { type: Date, default: null }, /** * Sets a maximum limit for selectable dates - dates after `max` will not be selectable */ max: { type: Date, default: null }, /** * Number of months rendered on pane */ numberOfMonths: { type: Number, default: 3, validator: (n: number) => n >= 1, }, /** * Upon opening date range picker, end date's month will be pre-selected. */ showEndDateOnOpen: { type: Boolean, default: false }, /** * Selected date. Can be a single Date or a CalendarRangeDate * instance that contains startDate and endDate attributes. */ value: { type: [Date, Object] as PropType<CalendarValue>, default: undefined }, /** * Default start date for the calendar pane */ defaultDisplayMonth: { type: String, default: null, validator: (value: string) => { // Checks for YYYY-MM-DD format const dateRegex = /^\d{4}-(0?[1-9]|1[012])(-(0?[1-9]|[12][0-9]|3[01]))?$/; const isValidDate = dateRegex.test(value); return isValidDate; }, }, /** * Range of years to be selectable from the year dropdown */ yearRange: { type: Object as PropType<CalendarYearRange>, default: () => ({}), }, /** * The type of calendar to display. Defaults to showing days. */ variant: { type: String as PropType<`${CalendarVariant}`>, default: 'day', validator: (value: string) => Object.values(CalendarVariant).includes(value as CalendarVariant), }, }, emit: [CalendarEvent.INPUT, CalendarEvent.START_DATE_SELECTED, CalendarEvent.END_DATE_SELECTED], setup(props, { emit }) { // Single date calendar ref const calendarRef = ref(null); // Use a duplicate of "month" to move back and // forth with the arrows. Maintained in sync with the prop "value" const baseDate = ref(); watch( () => props.value, () => { if (props.isRange) { baseDate.value = props?.showEndDateOnOpen && (props.value as CalendarRangeDateValue)?.endDate ? (props.value as CalendarRangeDateValue)?.endDate : (props.value as CalendarRangeDateValue)?.startDate || new Date(); } else { const defaultDate = props.defaultDisplayMonth && new Date(props.defaultDisplayMonth).getTime() ? new Date(props.defaultDisplayMonth) : new Date(); baseDate.value = (props.value as Date) || defaultDate; } }, { immediate: true, deep: true } ); const calendarComponent = computed(() => props.variant === CalendarVariant.MONTH ? CalendarYear : CalendarMonth ); const conditionalClasses = computed(() => ({ 'b-calendar__range': props.isRange, })); const { internalValue, onDateInput, onMouseOver } = props.isRange ? useRangePane( { selectedDate: toRef(props, 'value'), isDateDisabled: props.isDateDisabled as (date: Date) => boolean, maxRange: toRef(props, 'maxRange'), granularity: toRef(props, 'granularity'), firstDayOfWeek: props.firstDayOfWeek, min: toRef(props, 'min'), max: toRef(props, 'max'), }, emit ) : useSinglePane( { selectedDate: toRef(props, 'value'), firstDayOfWeek: props.firstDayOfWeek, }, emit ); /** * Calculate the current month calendar. * This is used to render/hide the Previous & Next buttons on each calendar. * @param calendarIndex - Number to offset the calendar position */ const calendarPosition = (calendarIndex: number): CalendarPosition => { if (props.numberOfMonths === 1) { return CalendarPosition.UNIQUE; } if (calendarIndex === 0) { return CalendarPosition.FIRST; } return calendarIndex === props.numberOfMonths - 1 ? CalendarPosition.LAST : CalendarPosition.MIDDLE; }; /** * Calculates in relation to the amount of months defined in "numberOfMonths" * which calendar month should be render starting from the first (leftmost) * rendered calendar. * If showEndDateOnOpen is set to true, the last pane should be the baseDate month i.e. the end date * @param offsetMonth - Amount of months to add after the first month was rendered */ const calculateOffsetMonth = computed(() => (offsetMonth: number) => { const monthOffset = props?.showEndDateOnOpen && props.isRange ? subMonths(baseDate.value, props.numberOfMonths - 1) : baseDate.value; return addMonths(monthOffset, offsetMonth); }); const calculateOffsetYear = computed(() => (offsetYear: number) => { const yearOffset = props?.showEndDateOnOpen ? subYears(baseDate.value, props.numberOfMonths - 1) : baseDate.value; return addYears(yearOffset, offsetYear); }); /** * By default shows the following month * Can skip multiple months by passing an additional param * Used for keyboard nav to be able to skip a year */ const calculateNextMonths = (amountOfMonths = 1) => { baseDate.value = addMonths(baseDate.value, amountOfMonths); }; /** * By default shows the previous month * Can skip multiple months by passing an additional param * Used for keyboard nav to be able to go back a year */ const calculatePreviousMonths = (amountOfMonths = 1) => { baseDate.value = subMonths(baseDate.value, amountOfMonths); }; return { // Refs calendarRef, // Values baseDate, calendarComponent, conditionalClasses, internalValue, // Methods calculateOffsetMonth, calculateOffsetYear, calendarPosition, // Events onDateInput, onMouseOver, calculateNextMonths, calculatePreviousMonths, }; }, }); </script> <style lang="scss" scoped src="./calendar.scss" />
@@ -1 +1 @@
1
- import { type CalendarFirstDayOfWeek, type CalendarIsDateDisabled, type CalendarPosition } from '../../calendar.types'; export interface CalendarMonthProps { /** * end of the selected range * @default null */ endDate?: Date; /** * Allows you to set the first day of the week. 0 is sunday, 1 is monday, 6 is saturday * @default CalendarMonthFirstDayOfWeek.MONDAY, */ firstDayOfWeek?: CalendarFirstDayOfWeek; /** * Indicate if a date should be disabled or not * @default undefined */ isDateDisabled?: CalendarIsDateDisabled; /** * Indicates if the calendar is part of a range date picker or a sigle date picker * @default false */ isRange?: boolean; /** * Sets a maximum limit for selectable dates - dates after `max` will not be selectable * @default null */ max?: Date; /** * Sets a minimum limit for selectable dates - dates before `min` will not be selectable * @default null */ min?: Date; /** * Month of the year to be displayed in the calendar */ month: Date; /** * When rendering multiple calendars, this property allows to render the * Next and Previous calendar buttons to the right, left or not at all * depending on the position: Unique (only 1), First, Middle or Last * @default 'unique' */ position?: `${CalendarPosition}`; /** * start of the selected range * @default undefined */ startDate?: Date; }
1
+ import { type CalendarFirstDayOfWeek, type CalendarIsDateDisabled, type CalendarPosition, type CalendarYearRange, } from '../../calendar.types'; export interface CalendarMonthProps { /** * end of the selected range * @default null */ endDate?: Date; /** * Allows you to set the first day of the week. 0 is sunday, 1 is monday, 6 is saturday * @default CalendarMonthFirstDayOfWeek.MONDAY, */ firstDayOfWeek?: CalendarFirstDayOfWeek; /** * Indicate if a date should be disabled or not * @default undefined */ isDateDisabled?: CalendarIsDateDisabled; /** * Indicates if the calendar is part of a range date picker or a sigle date picker * @default false */ isRange?: boolean; /** * Sets a maximum limit for selectable dates - dates after `max` will not be selectable * @default null */ max?: Date; /** * Sets a minimum limit for selectable dates - dates before `min` will not be selectable * @default null */ min?: Date; /** * Month of the year to be displayed in the calendar */ month: Date; /** * When rendering multiple calendars, this property allows to render the * Next and Previous calendar buttons to the right, left or not at all * depending on the position: Unique (only 1), First, Middle or Last * @default 'unique' */ position?: `${CalendarPosition}`; /** * start of the selected range * @default undefined */ startDate?: Date; /** * Range of years to be selectable from the year dropdown. * This is capped by the min & max properties (if present) * @default undefined */ yearRange?: CalendarYearRange; }
@@ -1 +1 @@
1
- <template> <div v-bento-keyboard-navigation-directive class="b-calendar-month" v-on="listeners"> <div class="b-calendar-month__month-selector"> <!-- go to previous month --> <div class="b-calendar-month__previous"> <bento-button v-if="shouldShowPreviousMonthButton" variant="tertiary" @click="moveToPreviousMonth($event, true)" > <template #iconLeft> <chevron-left-icon :svg-title="t('previousMonth')" /> </template> </bento-button> </div> <!-- month's name --> <bento-typography :id="monthLabelId" el="h2" variant="title" aria-live="polite"> {{ formattedMonthDate }} </bento-typography> <!-- go to next month --> <div class="b-calendar-month__next"> <bento-button v-if="shouldShowNextMonthButton" variant="tertiary" @click="moveToNextMonth($event, true)" > <template #iconLeft> <chevron-right-icon :svg-title="t('nextMonth')" /> </template> </bento-button> </div> </div> <table class="b-calendar-month__month-container" role="grid" :aria-labelledby="monthLabelId"> <thead> <tr class="b-calendar-month__week-header"> <th v-for="(dayOfWeekFormatted, index) in daysOfWeek" :key="index" class="b-calendar-month__day-of-week" scope="col" > <bento-typography class="b-calendar-month__day-of-week-font" el="span" variant="caption"> {{ dayOfWeekFormatted }} </bento-typography> </th> </tr> </thead> <tbody> <tr v-for="(week, index) in weeksOfMonth" :key="index"> <td v-if="index === 0 && week.length < 7" :colspan="7 - week.length"> </td> <calendar-month-day v-for="(day, dayIndex) in week" :ref="addDayRef(day)" :key="`calendar-day-${dayIndex}`" :day="day" :disabled="computedIsDateDisabled(day)" :selected="isSelectedDate(day)" :variant="dayVariant(day)" @click="onDayClicked" @mouseover="$emit('mouseover', day)" @keyup.up.native="$emit('changeDay', day)" @keyup.down.native="$emit('changeDay', day)" @keyup.left.native="$emit('changeDay', day)" @keyup.right.native="$emit('changeDay', day)" /> </tr> <template v-if="weeksOfMonth?.length < 6"> <tr v-for="n in 6 - weeksOfMonth?.length" :key="`calendar month filler row ${n}`"> <td aria-hidden="true" class="b-calendar-month__empty-cell-filler" :colspan="7"></td> </tr> </template> </tbody> </table> </div> </template> <script setup lang="ts"> import { computed, toRef } from 'vue'; import BentoButton from '@/components/button/button.vue'; import { CalendarMonthDay, CalendarMonthDayVariant } from './components/calendar-month-day'; import { BentoTypography } from '@/components/typography'; import { useI18n } from '@/utils/ts/i18n'; import type { CalendarMonthProps } from './calendar-month.types'; import { generateUid } from '@/core/utils/ts'; import { BentoKeyboardNavigationDirective as vBentoKeyboardNavigationDirective } from '@/directives'; import { type CalendarEmit, CalendarFirstDayOfWeek } from '../../calendar.types'; import { useMoveCalendarDate } from './composables/move-calendar-date'; import { setDay } from 'date-fns/setDay'; import { startOfMonth } from 'date-fns/startOfMonth'; import { startOfDay } from 'date-fns/startOfDay'; import { isSameDay } from 'date-fns/isSameDay'; import { isWithinInterval } from 'date-fns/isWithinInterval'; import { getDateTimeFormatter } from '@/utils/ts/format-date'; import ChevronLeftIcon from '@adyen/ui-assets-icons-16/vue/chevron-left'; import ChevronRightIcon from '@adyen/ui-assets-icons-16/vue/chevron-right'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const emit = defineEmits<CalendarEmit>(); const props = withDefaults(defineProps<CalendarMonthProps>(), { endDate: null, firstDayOfWeek: CalendarFirstDayOfWeek.MONDAY, isDateDisabled: undefined, isRange: false, max: null, min: null, position: 'unique', startDate: null, // can be undefined, eslint complains without default }); // Date formatter locale options const { locale, t } = useI18n<{ message: MessageSchema }>({ messages }); const monthLabelId = generateUid('bento-date-picker-month-label'); const computedIsDateDisabled = computed( () => (date: Date) => (props.min && date < props.min) || (props.max && date > props.max) || props.isDateDisabled?.(date) ); const { internalDate, baseDate, // Calendar refs addDayRef, // Day navigation moveToNextDay, moveToPreviousDay, moveToPreviousWeek, moveToNextWeek, moveToFirstDayOfWeek, moveToLastDayOfWeek, moveToPreviousMonth, moveToPreviousYear, moveToNextMonth, moveToNextYear, focusOnDate, } = useMoveCalendarDate( { currentSelectedDate: toRef(props, 'startDate'), month: toRef(props, 'month'), isDateDisabled: computedIsDateDisabled.value, firstDayOfWeek: props.firstDayOfWeek, }, emit ); /** * Default to Monday if the value passed in is not part of the defined Enum */ const fallbackFirstDayOfWeek = computed(() => Object.values(CalendarFirstDayOfWeek).includes(props.firstDayOfWeek) ? props.firstDayOfWeek : CalendarFirstDayOfWeek.MONDAY ); /** * Renders the previous month button if it's Unique or First calendar */ const shouldShowPreviousMonthButton = computed(() => ['first', 'unique'].includes(props.position)); /** * Renders the next month button if it's Unique or Last calendar */ const shouldShowNextMonthButton = computed(() => ['last', 'unique'].includes(props.position)); /** * Formats the month date to the name of the month and the year */ const formattedMonthDate = computed(() => getDateTimeFormatter(locale.value, { month: 'long', year: 'numeric' }).format(baseDate.value) ); /** * Formats all the days of the week to their day name */ const daysOfWeek = computed(() => new Array(7).fill(new Date()).map((date, index) => { const dayOfWeekDate = setDay(date, fallbackFirstDayOfWeek.value + index); return getDateTimeFormatter(locale.value, { weekday: 'short' }).format(dayOfWeekDate); }) ); /** * Calculates all the weeks of the current "props.month" * and adds the respective day number to each column and week */ const weeksOfMonth = computed(() => { const weeks = []; const date = startOfMonth(props.month); let week = weeks[weeks.push([]) - 1]; while (date.getMonth() === props.month.getMonth()) { if (date.getDay() - fallbackFirstDayOfWeek.value === 0 && week.length > 0) { week = weeks[weeks.push([]) - 1]; } week.push(startOfDay(date)); date.setDate(date.getDate() + 1); } return weeks; }); const onDayClicked = (selectedDate: Date) => { emit('input', selectedDate); }; /** * Calculates the variation of the days selected. * For single select it's always SINGLE * For ranges: * - Start & End is the same: SINGLE * - Start: BEGINNING * - End: END * - Within the range: MIDDLE * @param day Current day */ const dayVariant = (day: Date) => { if (!props.isRange || isSameDay(props.startDate, props.endDate)) { return CalendarMonthDayVariant.SINGLE; } if (props.startDate && isSameDay(props.startDate, day)) { return CalendarMonthDayVariant.BEGINNING; } if (props.endDate && isSameDay(props.endDate, day)) { return CalendarMonthDayVariant.END; } if (props.startDate && props.endDate && isWithinInterval(day, { start: props.startDate, end: props.endDate })) { return CalendarMonthDayVariant.MIDDLE; } return undefined; }; /** * Checks every date in the calendar to see if it's selected. * @param day Current day in the calendar */ const isSelectedDate = (day: Date) => { // Single selection if (internalDate.value && isSameDay(internalDate.value, day) && !props.endDate) { return true; } // Range return ( props.startDate && props.endDate && isWithinInterval(day, { start: props.startDate, end: props.endDate }) ); }; const listeners = { 'keydown:arrow-right': moveToNextDay, 'keydown:arrow-left': moveToPreviousDay, 'keydown:arrow-up': moveToPreviousWeek, 'keydown:arrow-down': moveToNextWeek, 'keydown:home': moveToFirstDayOfWeek, 'keydown:end': moveToLastDayOfWeek, 'keydown:page-up': moveToPreviousMonth, 'keydown:shift-page-up': moveToPreviousYear, 'keydown:page-down': moveToNextMonth, 'keydown:shift-page-down': moveToNextYear, }; defineExpose({ focusOnDate, }); </script> <script lang="ts"> /** * CalendarMonth that displays all the days of a given month and allows * to move back and forth the visible month with the Next and Previous buttons * * @example * import { CalendarMonthMonth, CalendarMonthFirstDayOfWeek, CalendarPosition } from '@/internal/calendar'; * * export default { * components: { CalendarMonthMonth }, * template: ` * <calendar * :first-day-of-week="CalendarMonthFirstDayOfWeek.SUNDAY" * :is-date-disabled="(date: Date) => boolean" * :month="new Date()" * position="last" * is-range * :start-date="new Date()" * :end-date="new Date()" * @input="(selectedDate: Date) => void" * @mouseover="(event: Event) => void" * @nextMonth="() => void" * @previousMonth="() => void" * /> * ` * } */ export default { i18n: { messages }, name: 'calendar-month', }; </script> <style lang="scss" scoped src="./calendar-month.scss" />
1
+ <template> <div v-bento-keyboard-navigation-directive class="b-calendar-month" v-on="listeners"> <div class="b-calendar-month__month-selector"> <!-- go to previous month --> <div class="b-calendar-month__previous"> <bento-button v-if="shouldShowPreviousMonthButton" variant="tertiary" @click="moveToPreviousMonth($event, true)" > <template #iconLeft> <chevron-left-icon :svg-title="t('previousMonth')" /> </template> </bento-button> </div> <div class="b-calendar-month__month-year-container"> <!-- Month name --> <bento-typography :id="monthLabelId" el="h2" variant="title" aria-live="polite"> {{ formattedMonthName }} <span class="b-calendar-month__visually-hidden">{{ baseDate.getFullYear() }}</span> </bento-typography> <!-- Year selector --> <calendar-year-selector :current-year="baseDate.getFullYear()" :year-range="yearRange" :min="min" :max="max" @update:year="onYearSelected" /> </div> <!-- go to next month --> <div class="b-calendar-month__next"> <bento-button v-if="shouldShowNextMonthButton" variant="tertiary" @click="moveToNextMonth($event, true)" > <template #iconLeft> <chevron-right-icon :svg-title="t('nextMonth')" /> </template> </bento-button> </div> </div> <table class="b-calendar-month__month-container" role="grid" :aria-labelledby="monthLabelId"> <thead> <tr class="b-calendar-month__week-header"> <th v-for="(dayOfWeekFormatted, index) in daysOfWeek" :key="index" class="b-calendar-month__day-of-week" scope="col" > <bento-typography class="b-calendar-month__day-of-week-font" el="span" variant="caption"> {{ dayOfWeekFormatted }} </bento-typography> </th> </tr> </thead> <tbody> <tr v-for="(week, index) in weeksOfMonth" :key="index"> <td v-if="index === 0 && week.length < 7" :colspan="7 - week.length"> </td> <calendar-month-day v-for="(day, dayIndex) in week" :ref="addDayRef(day)" :key="`calendar-day-${dayIndex}`" :day="day" :disabled="computedIsDateDisabled(day)" :selected="isSelectedDate(day)" :variant="dayVariant(day)" @click="onDayClicked" @mouseover="$emit('mouseover', day)" @keyup.up.native="$emit('changeDay', day)" @keyup.down.native="$emit('changeDay', day)" @keyup.left.native="$emit('changeDay', day)" @keyup.right.native="$emit('changeDay', day)" /> </tr> <template v-if="weeksOfMonth?.length < 6"> <tr v-for="n in 6 - weeksOfMonth?.length" :key="`calendar month filler row ${n}`"> <td aria-hidden="true" class="b-calendar-month__empty-cell-filler" :colspan="7"></td> </tr> </template> </tbody> </table> </div> </template> <script setup lang="ts"> import { computed, toRef } from 'vue'; import BentoButton from '@/components/button/button.vue'; import { CalendarYearSelector } from '../calendar-year-selector'; import { CalendarMonthDay, CalendarMonthDayVariant } from './components/calendar-month-day'; import { BentoTypography } from '@/components/typography'; import { useI18n } from '@/utils/ts/i18n'; import type { CalendarMonthProps } from './calendar-month.types'; import { generateUid } from '@/core/utils/ts'; import { BentoKeyboardNavigationDirective as vBentoKeyboardNavigationDirective } from '@/directives'; import { type CalendarEmit, CalendarFirstDayOfWeek } from '../../calendar.types'; import { useMoveCalendarDate } from './composables/move-calendar-date'; import { setDay } from 'date-fns/setDay'; import { startOfMonth } from 'date-fns/startOfMonth'; import { startOfDay } from 'date-fns/startOfDay'; import { isSameDay } from 'date-fns/isSameDay'; import { isWithinInterval } from 'date-fns/isWithinInterval'; import { getDateTimeFormatter } from '@/utils/ts/format-date'; import ChevronLeftIcon from '@adyen/ui-assets-icons-16/vue/chevron-left'; import ChevronRightIcon from '@adyen/ui-assets-icons-16/vue/chevron-right'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const emit = defineEmits<CalendarEmit>(); const props = withDefaults(defineProps<CalendarMonthProps>(), { endDate: null, firstDayOfWeek: CalendarFirstDayOfWeek.MONDAY, isDateDisabled: undefined, isRange: false, max: null, min: null, position: 'unique', startDate: undefined, yearRange: undefined, }); // Date formatter locale options const { locale, t } = useI18n<{ message: MessageSchema }>({ messages }); const monthLabelId = generateUid('bento-date-picker-month-label'); const computedIsDateDisabled = computed( () => (date: Date) => (props.min && date < props.min) || (props.max && date > props.max) || props.isDateDisabled?.(date) ); const { internalDate, baseDate, // Calendar refs addDayRef, // Day navigation moveToNextDay, moveToPreviousDay, moveToPreviousWeek, moveToNextWeek, moveToFirstDayOfWeek, moveToLastDayOfWeek, moveToPreviousMonth, moveToPreviousYear, moveToNextMonth, moveToNextYear, focusOnDate, showNextMonth, showPreviousMonth, } = useMoveCalendarDate( { currentSelectedDate: toRef(props, 'startDate'), month: toRef(props, 'month'), isDateDisabled: computedIsDateDisabled.value, firstDayOfWeek: props.firstDayOfWeek, }, emit ); /** * Default to Monday if the value passed in is not part of the defined Enum */ const fallbackFirstDayOfWeek = computed(() => Object.values(CalendarFirstDayOfWeek).includes(props.firstDayOfWeek) ? props.firstDayOfWeek : CalendarFirstDayOfWeek.MONDAY ); /** * Renders the previous month button if it's Unique or First calendar */ const shouldShowPreviousMonthButton = computed(() => ['first', 'unique'].includes(props.position)); /** * Renders the next month button if it's Unique or Last calendar */ const shouldShowNextMonthButton = computed(() => ['last', 'unique'].includes(props.position)); /** * Formats the month date to the name of the month */ const formattedMonthName = computed(() => getDateTimeFormatter(locale.value, { month: 'long' }).format(baseDate.value) ); /** * Navigates the calendar to the newly selected year, preserving the currently displayed month. * @param newYear The year selected from the dropdown */ const onYearSelected = (newYear: number) => { const currentYear = baseDate.value.getFullYear(); const diffYears = newYear - currentYear; const diffMonths = diffYears * 12; if (diffMonths > 0) { showNextMonth(diffMonths); } else if (diffMonths < 0) { showPreviousMonth(Math.abs(diffMonths)); } }; /** * Formats all the days of the week to their day name */ const daysOfWeek = computed(() => new Array(7).fill(new Date()).map((date, index) => { const dayOfWeekDate = setDay(date, fallbackFirstDayOfWeek.value + index); return getDateTimeFormatter(locale.value, { weekday: 'short' }).format(dayOfWeekDate); }) ); /** * Calculates all the weeks of the current "props.month" * and adds the respective day number to each column and week */ const weeksOfMonth = computed(() => { const weeks = []; const date = startOfMonth(props.month); let week = weeks[weeks.push([]) - 1]; while (date.getMonth() === props.month.getMonth()) { if (date.getDay() - fallbackFirstDayOfWeek.value === 0 && week.length > 0) { week = weeks[weeks.push([]) - 1]; } week.push(startOfDay(date)); date.setDate(date.getDate() + 1); } return weeks; }); const onDayClicked = (selectedDate: Date) => { emit('input', selectedDate); }; /** * Calculates the variation of the days selected. * For single select it's always SINGLE * For ranges: * - Start & End is the same: SINGLE * - Start: BEGINNING * - End: END * - Within the range: MIDDLE * @param day Current day */ const dayVariant = (day: Date) => { if (!props.isRange || isSameDay(props.startDate, props.endDate)) { return CalendarMonthDayVariant.SINGLE; } if (props.startDate && isSameDay(props.startDate, day)) { return CalendarMonthDayVariant.BEGINNING; } if (props.endDate && isSameDay(props.endDate, day)) { return CalendarMonthDayVariant.END; } if (props.startDate && props.endDate && isWithinInterval(day, { start: props.startDate, end: props.endDate })) { return CalendarMonthDayVariant.MIDDLE; } return undefined; }; /** * Checks every date in the calendar to see if it's selected. * @param day Current day in the calendar */ const isSelectedDate = (day: Date) => { // Single selection if (internalDate.value && isSameDay(internalDate.value, day) && !props.endDate) { return true; } // Range return ( props.startDate && props.endDate && isWithinInterval(day, { start: props.startDate, end: props.endDate }) ); }; const listeners = { 'keydown:arrow-right': moveToNextDay, 'keydown:arrow-left': moveToPreviousDay, 'keydown:arrow-up': moveToPreviousWeek, 'keydown:arrow-down': moveToNextWeek, 'keydown:home': moveToFirstDayOfWeek, 'keydown:end': moveToLastDayOfWeek, 'keydown:page-up': moveToPreviousMonth, 'keydown:shift-page-up': moveToPreviousYear, 'keydown:page-down': moveToNextMonth, 'keydown:shift-page-down': moveToNextYear, }; defineExpose({ focusOnDate, }); </script> <script lang="ts"> /** * CalendarMonth that displays all the days of a given month and allows * to move back and forth the visible month with the Next and Previous buttons * * @example * import { CalendarMonthMonth, CalendarMonthFirstDayOfWeek, CalendarPosition } from '@/internal/calendar'; * * export default { * components: { CalendarMonthMonth }, * template: ` * <calendar * :first-day-of-week="CalendarMonthFirstDayOfWeek.SUNDAY" * :is-date-disabled="(date: Date) => boolean" * :month="new Date()" * position="last" * is-range * :start-date="new Date()" * :end-date="new Date()" * @input="(selectedDate: Date) => void" * @mouseover="(event: Event) => void" * @nextMonth="() => void" * @previousMonth="() => void" * /> * ` * } */ export default { i18n: { messages }, name: 'calendar-month', }; </script> <style lang="scss" scoped src="./calendar-month.scss" />
@@ -1 +1 @@
1
- <template> <td class="b-calendar-month-day" :class="conditionalClasses" :tabindex="selected ? '0' : '-1'" :aria-selected="selected ? selected : undefined" :aria-disabled="disabled" :aria-current="isCurrentDay ? 'date' : undefined" :data-date="day.toDateString()" @click="e => onClick(e, day)" @mouseover="$emit(CalendarMonthDayEvent.MOUSE_OVER)" @keydown.space.enter="e => onClick(e, day)" > <div class="b-calendar-month-day__wrapper"> <bento-typography class="b-calendar-month-day__label" el="span" variant="body" strong> {{ formattedDate }} </bento-typography> </div> </td> </template> <script lang="ts"> import { computed, defineComponent } from 'vue'; import { BentoTypography } from '../../../../../../typography'; import { CalendarMonthDayClass, CalendarMonthDayEvent, CalendarMonthDayVariant } from './calendar-month-day.types'; import { CalendarMonthDateProps } from './calendar-month-day.props'; import { getDateTimeFormatter } from '@/utils/ts/format-date'; import { useI18n } from '@/utils/ts/i18n'; /** * Handles the styles and states of the individual dates of the calendar. * * @example * import { CalendarMonthDay, CalendarMonthDayVariant } from './calendar-day'; * * export default { * components: { CalendarMonthDay }, * template: ` * <table> * <tbody> * <tr> * <calendar-day * :day="new Date()" * disabled * selected * :variant="CalendarMonthDayVariant.MIDDLE" * /> * </tr> * </tbody> * </table> * ` * } */ export default defineComponent({ name: 'calendar-month-day', components: { BentoTypography }, inheritAttrs: true, props: CalendarMonthDateProps, emits: [CalendarMonthDayEvent.CLICK, CalendarMonthDayEvent.MOUSE_OVER], setup(props, { emit }) { const { locale } = useI18n(undefined, true); const isCurrentDay = computed(() => { const today = new Date(); return ( props.day.getDate() === today.getDate() && props.day.getMonth() === today.getMonth() && props.day.getFullYear() === today.getFullYear() ); }); /** * Only get translated day part of the date */ const formattedDate = computed(() => { const formatter = getDateTimeFormatter(locale.value, { day: 'numeric' }); const parts = formatter.formatToParts(props.day); const dayPart = parts.find(part => part.type === 'day'); return dayPart?.value ?? ''; }); const conditionalClasses = computed(() => ({ // Variants [`b-calendar-month-day--${CalendarMonthDayClass.BEGINNING}`]: props.variant === CalendarMonthDayVariant.BEGINNING, [`b-calendar-month-day--${CalendarMonthDayClass.MIDDLE}`]: props.variant === CalendarMonthDayVariant.MIDDLE, [`b-calendar-month-day--${CalendarMonthDayClass.END}`]: props.variant === CalendarMonthDayVariant.END, [`b-calendar-month-day--${CalendarMonthDayClass.SINGLE}`]: props.variant === CalendarMonthDayVariant.SINGLE, // Selected [`b-calendar-month-day--${CalendarMonthDayClass.UNSELECTED}`]: !props.selected, [`b-calendar-month-day--${CalendarMonthDayClass.SELECTED}`]: props.selected, // Current Day [`b-calendar-month-day--${CalendarMonthDayClass.CURRENT_DAY}`]: isCurrentDay.value, // Disabled [`b-calendar-month-day--${CalendarMonthDayClass.DISABLED}`]: props.disabled, })); const onClick = (e: MouseEvent | KeyboardEvent, selectedDay: Date) => { if (!props.disabled) { emit(CalendarMonthDayEvent.CLICK, selectedDay, props.day); } else { e.preventDefault(); e.stopPropagation(); } }; return { // Values conditionalClasses, isCurrentDay, formattedDate, // Enums CalendarMonthDayEvent, // Events onClick, }; }, }); </script> <style lang="scss" scoped src="./calendar-month-day.scss" />
1
+ <template> <td class="b-calendar-month-day" :class="conditionalClasses" :tabindex="selected ? '0' : '-1'" :aria-selected="selected ? selected : undefined" :aria-disabled="disabled" :aria-current="isCurrentDay ? 'date' : undefined" :data-date="day.toDateString()" @click="e => onClick(e, day)" @mouseover="$emit(CalendarMonthDayEvent.MOUSE_OVER)" @keydown.space.enter="e => onClick(e, day)" > <div class="b-calendar-month-day__wrapper"> <bento-typography class="b-calendar-month-day__label" el="span" variant="body" strong> <time :datetime="dateTime">{{ formattedDate }}</time> </bento-typography> </div> </td> </template> <script lang="ts"> import { computed, defineComponent } from 'vue'; import { BentoTypography } from '../../../../../../typography'; import { CalendarMonthDayClass, CalendarMonthDayEvent, CalendarMonthDayVariant } from './calendar-month-day.types'; import { CalendarMonthDateProps } from './calendar-month-day.props'; import { getDateTimeFormatter } from '@/utils/ts/format-date'; import { useI18n } from '@/utils/ts/i18n'; /** * Handles the styles and states of the individual dates of the calendar. * * @example * import { CalendarMonthDay, CalendarMonthDayVariant } from './calendar-day'; * * export default { * components: { CalendarMonthDay }, * template: ` * <table> * <tbody> * <tr> * <calendar-day * :day="new Date()" * disabled * selected * :variant="CalendarMonthDayVariant.MIDDLE" * /> * </tr> * </tbody> * </table> * ` * } */ export default defineComponent({ name: 'calendar-month-day', components: { BentoTypography }, inheritAttrs: true, props: CalendarMonthDateProps, emits: [CalendarMonthDayEvent.CLICK, CalendarMonthDayEvent.MOUSE_OVER], setup(props, { emit }) { const { locale } = useI18n(undefined, true); const isCurrentDay = computed(() => { const today = new Date(); return ( props.day.getDate() === today.getDate() && props.day.getMonth() === today.getMonth() && props.day.getFullYear() === today.getFullYear() ); }); /** * Only get translated day part of the date */ const formattedDate = computed(() => { const formatter = getDateTimeFormatter(locale.value, { day: 'numeric' }); const parts = formatter.formatToParts(props.day); const dayPart = parts.find(part => part.type === 'day'); return dayPart?.value ?? ''; }); const dateTime = computed(() => (Number.isNaN(props.day.getTime()) ? undefined : props.day.toISOString())); const conditionalClasses = computed(() => ({ // Variants [`b-calendar-month-day--${CalendarMonthDayClass.BEGINNING}`]: props.variant === CalendarMonthDayVariant.BEGINNING, [`b-calendar-month-day--${CalendarMonthDayClass.MIDDLE}`]: props.variant === CalendarMonthDayVariant.MIDDLE, [`b-calendar-month-day--${CalendarMonthDayClass.END}`]: props.variant === CalendarMonthDayVariant.END, [`b-calendar-month-day--${CalendarMonthDayClass.SINGLE}`]: props.variant === CalendarMonthDayVariant.SINGLE, // Selected [`b-calendar-month-day--${CalendarMonthDayClass.UNSELECTED}`]: !props.selected, [`b-calendar-month-day--${CalendarMonthDayClass.SELECTED}`]: props.selected, // Current Day [`b-calendar-month-day--${CalendarMonthDayClass.CURRENT_DAY}`]: isCurrentDay.value, // Disabled [`b-calendar-month-day--${CalendarMonthDayClass.DISABLED}`]: props.disabled, })); const onClick = (e: MouseEvent | KeyboardEvent, selectedDay: Date) => { if (!props.disabled) { emit(CalendarMonthDayEvent.CLICK, selectedDay, props.day); } else { e.preventDefault(); e.stopPropagation(); } }; return { // Values conditionalClasses, isCurrentDay, formattedDate, dateTime, // Enums CalendarMonthDayEvent, // Events onClick, }; }, }); </script> <style lang="scss" scoped src="./calendar-month-day.scss" />
@@ -1 +1 @@
1
- import { type CalendarIsDateDisabled, type CalendarPosition } from '../../calendar.types'; export interface CalendarYearProps { /** * end of the selected range */ endDate?: Date; /** * Indicate if a date should be disabled or not */ isDateDisabled?: CalendarIsDateDisabled; /** * Indicates if the calendar is part of a range date picker or a single date picker */ isRange?: boolean; /** * Sets a minimum limit for selectable dates - dates before `min` will not be selectable */ min?: Date; /** * Sets a maximum limit for selectable dates - dates after `max` will not be selectable */ max?: Date; /** * When rendering multiple calendars, this property allows to render the * Next and Previous calendar buttons to the right, left or not at all * depending on the position: Unique (only 1), First, Middle or Last */ position?: CalendarPosition; /** * start of the selected range */ startDate?: Date; /** * Year to be displayed in the calendar */ year: Date; }
1
+ import { type CalendarIsDateDisabled, type CalendarPosition, type CalendarYearRange } from '../../calendar.types'; export interface CalendarYearProps { /** * end of the selected range */ endDate?: Date; /** * Indicate if a date should be disabled or not */ isDateDisabled?: CalendarIsDateDisabled; /** * Indicates if the calendar is part of a range date picker or a single date picker */ isRange?: boolean; /** * Sets a minimum limit for selectable dates - dates before `min` will not be selectable */ min?: Date; /** * Sets a maximum limit for selectable dates - dates after `max` will not be selectable */ max?: Date; /** * Range of years to be selectable from the year dropdown */ yearRange?: CalendarYearRange; /** * When rendering multiple calendars, this property allows to render the * Next and Previous calendar buttons to the right, left or not at all * depending on the position: Unique (only 1), First, Middle or Last */ position?: CalendarPosition; /** * start of the selected range */ startDate?: Date; /** * Year to be displayed in the calendar */ year: Date; }
@@ -1 +1 @@
1
- <template> <div v-bento-keyboard-navigation-directive class="b-calendar-year" v-on="listeners"> <div class="b-calendar-year__year-selector"> <!-- go to previous year --> <div class="b-calendar-year__previous"> <bento-button v-if="shouldShowPreviousYearButton" variant="tertiary" @click="moveToPreviousYear($event, true)" > <template #iconLeft> <chevron-left-icon :svg-title="t('previousYear')" /> </template> </bento-button> </div> <!-- year --> <bento-typography :id="yearLabelId" el="h2" variant="title" aria-live="polite"> {{ year.getFullYear() }} </bento-typography> <!-- go to next year --> <div class="b-calendar-year__next"> <bento-button v-if="shouldShowNextYearButton" variant="tertiary" @click="moveToNextYear($event, true)"> <template #iconLeft> <chevron-right-icon :svg-title="t('nextYear')" /> </template> </bento-button> </div> </div> <table class="b-calendar-year__year-container" role="grid" :aria-labelledby="yearLabelId"> <tbody> <tr v-for="(row, index) in monthRows" :key="index"> <calendar-year-month v-for="(month, monthIndex) in row" :ref="addDayRef(month)" :key="`calendar-month-${monthIndex}`" :month="month" :disabled="isDateDisabled(month)" :selected="isSelectedDate(month)" :variant="monthVariant(month)" @click="onMonthClicked" @mouseover="emit('mouseover', month)" /> </tr> </tbody> </table> </div> </template> <script setup lang="ts"> import { computed, toRef } from 'vue'; import { isSameMonth } from 'date-fns/isSameMonth'; import { isWithinInterval } from 'date-fns/isWithinInterval'; import BentoButton from '@/components/button/button.vue'; import { BentoTypography } from '@/components/typography'; import CalendarYearMonth from './components/calendar-year-month/calendar-year-month.vue'; import ChevronLeftIcon from '@adyen/ui-assets-icons-16/vue/chevron-left'; import ChevronRightIcon from '@adyen/ui-assets-icons-16/vue/chevron-right'; import { BentoKeyboardNavigationDirective as vBentoKeyboardNavigationDirective } from '@/directives'; import { generateUid } from '@/core/utils/ts'; import { useI18n } from '@/utils/ts/i18n'; import { useMoveCalendarDate } from '@/internal/calendar/components/calendar-month/composables/move-calendar-date'; import { type CalendarYearProps } from './calendar-year.types'; import { CalendarYearMonthVariant } from './components/calendar-year-month/calendar-year-month.types'; import { type CalendarEmit, CalendarPosition } from '../../calendar.types'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const yearLabelId = generateUid('bento-date-picker-year-label'); const props = withDefaults(defineProps<CalendarYearProps>(), { endDate: null, isDateDisabled: undefined, isRange: false, min: null, max: null, position: CalendarPosition.UNIQUE, startDate: null, }); const emit = defineEmits<CalendarEmit>(); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const isDateDisabled = (date: Date) => (props.min && date < props.min) || (props.max && date > props.max) || props.isDateDisabled?.(date); /** * Renders the previous year button if it's Unique or First calendar */ const shouldShowPreviousYearButton = computed(() => (['first', 'unique'] as Array<CalendarPosition>).includes(props.position) ); /** * Renders the next year button if it's Unique or Last calendar */ const shouldShowNextYearButton = computed(() => (['last', 'unique'] as Array<CalendarPosition>).includes(props.position) ); const { internalDate, // Calendar refs addDayRef, // Day navigation moveToPreviousMonth, moveToPreviousYear, moveToNextMonth, moveToNextYear, moveToFirstMonthOfYear, moveToLastMonthOfYear, focusOnDate, } = useMoveCalendarDate( { currentSelectedDate: toRef(props, 'startDate'), month: toRef(props, 'year'), isDateDisabled, }, emit ); defineExpose({ focusOnDate }); /** * Calculates all the months of the current "props.month" * and adds the respective day number to each column and week */ const monthRows = computed(() => { const rows = []; let count = 0; for (let i = 0; i < 3; i++) { const innerArray = []; for (let j = 0; j < 4; j++) { innerArray.push(new Date(props.year.getFullYear(), count++, 1)); } rows.push(innerArray); } return rows; }); /** * Calculates the variation of the days selected. * For single select it's always SINGLE * For ranges: * - Start & End is the same: SINGLE * - Start: BEGINNING * - End: END * - Within the range: MIDDLE * @param {Date} day Current day */ const monthVariant = (day: Date) => { if (!props.isRange || isSameMonth(props.startDate, props.endDate)) { return CalendarYearMonthVariant.SINGLE; } if (props.startDate && isSameMonth(props.startDate, day)) { return CalendarYearMonthVariant.BEGINNING; } if (props.endDate && isSameMonth(props.endDate, day)) { return CalendarYearMonthVariant.END; } if (props.startDate && props.endDate && isWithinInterval(day, { start: props.startDate, end: props.endDate })) { return CalendarYearMonthVariant.MIDDLE; } return null; }; /** * Checks every date in the calendar to see if it's selected. * @param date Current date in the calendar */ const isSelectedDate = (date: Date) => { // Single selection if (internalDate.value && isSameMonth(internalDate.value, date)) { return true; } // Range return ( props.startDate && props.endDate && isWithinInterval(date, { start: props.startDate, end: props.endDate }) ); }; const onMonthClicked = (selectedDate: Date) => { emit('input', selectedDate); }; const listeners = !props.isRange ? { 'keydown:arrow-right': moveToNextMonth, 'keydown:arrow-left': moveToPreviousMonth, 'keydown:arrow-up': (ev: Event) => moveToPreviousMonth(ev, false, 4), 'keydown:arrow-down': (ev: Event) => moveToNextMonth(ev, false, 4), 'keydown:home': moveToFirstMonthOfYear, 'keydown:end': moveToLastMonthOfYear, 'keydown:page-up': moveToPreviousYear, 'keydown:page-down': moveToNextYear, } : {}; </script> <script lang="ts"> /** * CalendarYear that displays all the months of a given year and allows * to move back and forth the visible year with the Next and Previous buttons * * @example * import { CalendarYear } from '@/internal/calendar'; * * export default { * components: { CalendarYear }, * template: ` * <calendar-year * :is-date-disabled="(date: Date) => boolean" * :year="new Date()" * is-range * :start-date="new Date()" * :end-date="new Date()" * @input="(selectedDate: Date) => void" * @mouseover="(event: Event) => void" * @nextMonth="() => void" * @previousMonth="() => void" * /> * ` * } */ export default { i18n: { messages }, }; </script> <style lang="scss" scoped src="./calendar-year.scss" />
1
+ <template> <div v-bento-keyboard-navigation-directive class="b-calendar-year" v-on="listeners"> <div class="b-calendar-year__year-selector"> <!-- go to previous year --> <div class="b-calendar-year__previous"> <bento-button v-if="shouldShowPreviousYearButton" variant="tertiary" @click="moveToPreviousYear($event, true, 1)" > <template #iconLeft> <chevron-left-icon :svg-title="t('previousYear')" /> </template> </bento-button> </div> <!-- year --> <div class="b-calendar-year__year-label-container"> <!-- Year selector --> <calendar-year-selector :current-year="year.getFullYear()" :year-range="yearRange" :min="min" :max="max" @update:year="onYearSelected" /> </div> <!-- go to next year --> <div class="b-calendar-year__next"> <bento-button v-if="shouldShowNextYearButton" variant="tertiary" @click="moveToNextYear($event, true)"> <template #iconLeft> <chevron-right-icon :svg-title="t('nextYear')" /> </template> </bento-button> </div> </div> <table class="b-calendar-year__year-container" role="grid" :aria-labelledby="yearLabelId"> <tbody> <tr v-for="(row, index) in monthRows" :key="index"> <calendar-year-month v-for="(month, monthIndex) in row" :ref="addDayRef(month)" :key="`calendar-month-${monthIndex}`" :month="month" :disabled="isDateDisabled(month)" :selected="isSelectedDate(month)" :variant="monthVariant(month)" @click="onMonthClicked" @mouseover="emit('mouseover', month)" /> </tr> </tbody> </table> </div> </template> <script setup lang="ts"> import { computed, toRef } from 'vue'; import { isSameMonth } from 'date-fns/isSameMonth'; import { isWithinInterval } from 'date-fns/isWithinInterval'; import BentoButton from '@/components/button/button.vue'; import CalendarYearMonth from './components/calendar-year-month/calendar-year-month.vue'; import { CalendarYearSelector } from '../calendar-year-selector'; import ChevronLeftIcon from '@adyen/ui-assets-icons-16/vue/chevron-left'; import ChevronRightIcon from '@adyen/ui-assets-icons-16/vue/chevron-right'; import { BentoKeyboardNavigationDirective as vBentoKeyboardNavigationDirective } from '@/directives'; import { generateUid } from '@/core/utils/ts'; import { useI18n } from '@/utils/ts/i18n'; import { useMoveCalendarDate } from '@/internal/calendar/components/calendar-month/composables/move-calendar-date'; import { type CalendarYearProps } from './calendar-year.types'; import { CalendarYearMonthVariant } from './components/calendar-year-month/calendar-year-month.types'; import { type CalendarEmit, CalendarPosition } from '../../calendar.types'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const yearLabelId = generateUid('bento-date-picker-year-label'); const props = withDefaults(defineProps<CalendarYearProps>(), { endDate: null, isDateDisabled: undefined, isRange: false, min: null, max: null, yearRange: undefined, position: CalendarPosition.UNIQUE, startDate: null, }); const emit = defineEmits<CalendarEmit>(); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const isDateDisabled = (date: Date) => (props.min && date < props.min) || (props.max && date > props.max) || props.isDateDisabled?.(date); /** * Renders the previous year button if it's Unique or First calendar */ const shouldShowPreviousYearButton = computed(() => (['first', 'unique'] as Array<CalendarPosition>).includes(props.position) ); /** * Renders the next year button if it's Unique or Last calendar */ const shouldShowNextYearButton = computed(() => (['last', 'unique'] as Array<CalendarPosition>).includes(props.position) ); const { internalDate, // Calendar refs addDayRef, // Day navigation moveToPreviousMonth, moveToPreviousYear, moveToNextMonth, moveToNextYear, moveToFirstMonthOfYear, moveToLastMonthOfYear, focusOnDate, } = useMoveCalendarDate( { currentSelectedDate: toRef(props, 'startDate'), month: toRef(props, 'year'), isDateDisabled, }, emit ); const onYearSelected = (newYear: number) => { const currentYear = props.year.getFullYear(); const diffYears = newYear - currentYear; if (diffYears > 0) { moveToNextYear(null, true, diffYears); } else if (diffYears < 0) { moveToPreviousYear(null, true, Math.abs(diffYears)); } }; /** * Calculates all the months of the current "props.month" * and adds the respective day number to each column and week */ const monthRows = computed(() => { const rows = []; let count = 0; for (let i = 0; i < 3; i++) { const innerArray = []; for (let j = 0; j < 4; j++) { innerArray.push(new Date(props.year.getFullYear(), count++, 1)); } rows.push(innerArray); } return rows; }); /** * Calculates the variation of the days selected. * For single select it's always SINGLE * For ranges: * - Start & End is the same: SINGLE * - Start: BEGINNING * - End: END * - Within the range: MIDDLE * @param {Date} day Current day */ const monthVariant = (day: Date) => { if (!props.isRange || isSameMonth(props.startDate, props.endDate)) { return CalendarYearMonthVariant.SINGLE; } if (props.startDate && isSameMonth(props.startDate, day)) { return CalendarYearMonthVariant.BEGINNING; } if (props.endDate && isSameMonth(props.endDate, day)) { return CalendarYearMonthVariant.END; } if (props.startDate && props.endDate && isWithinInterval(day, { start: props.startDate, end: props.endDate })) { return CalendarYearMonthVariant.MIDDLE; } return null; }; /** * Checks every date in the calendar to see if it's selected. * @param date Current date in the calendar */ const isSelectedDate = (date: Date) => { // Single selection if (internalDate.value && isSameMonth(internalDate.value, date)) { return true; } // Range return ( props.startDate && props.endDate && isWithinInterval(date, { start: props.startDate, end: props.endDate }) ); }; const onMonthClicked = (selectedDate: Date) => { emit('input', selectedDate); }; const listeners = !props.isRange ? { 'keydown:arrow-right': moveToNextMonth, 'keydown:arrow-left': moveToPreviousMonth, 'keydown:arrow-up': (ev: Event) => moveToPreviousMonth(ev, false, 4), 'keydown:arrow-down': (ev: Event) => moveToNextMonth(ev, false, 4), 'keydown:home': moveToFirstMonthOfYear, 'keydown:end': moveToLastMonthOfYear, 'keydown:page-up': moveToPreviousYear, 'keydown:page-down': moveToNextYear, } : {}; defineExpose({ focusOnDate }); </script> <script lang="ts"> /** * CalendarYear that displays all the months of a given year and allows * to move back and forth the visible year with the Next and Previous buttons * * @example * import { CalendarYear } from '@/internal/calendar'; * * export default { * components: { CalendarYear }, * template: ` * <calendar-year * :is-date-disabled="(date: Date) => boolean" * :year="new Date()" * is-range * :start-date="new Date()" * :end-date="new Date()" * @input="(selectedDate: Date) => void" * @mouseover="(event: Event) => void" * @nextMonth="() => void" * @previousMonth="() => void" * /> * ` * } */ export default { i18n: { messages }, }; </script> <style lang="scss" scoped src="./calendar-year.scss" />
@@ -0,0 +1 @@
1
+ import type { CalendarYearRange } from '../../calendar.types'; export interface CalendarYearSelectorProps { /** * The currently selected/displayed year */ currentYear: number; /** * Range of years to be selectable from the year dropdown * @default undefined */ yearRange?: CalendarYearRange; /** * Sets a maximum limit for selectable dates - dates after `max` will not be selectable * @default null */ max?: Date; /** * Sets a minimum limit for selectable dates - dates before `min` will not be selectable * @default null */ min?: Date; }
@@ -0,0 +1 @@
1
+ <template> <bento-dropdown :items="yearOptions" :aria-label="t('selectYear')" :model-value="currentYear" @update:model-value="onYearSelected" /> </template> <script setup lang="ts"> import { computed, provide } from 'vue'; import BentoDropdown from '@/components/dropdown/dropdown.vue'; import { useI18n } from '@/utils/ts/i18n'; import type { CalendarYearSelectorProps } from './calendar-year-selector.types'; import { DROPDOWN_SMALL_SIZE_INJECTION_KEY, DROPDOWN_UNDERLINE_STYLE_INJECTION_KEY, } from '@/components/dropdown/dropdown.keys'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const DEFAULT_YEAR_RANGE_OFFSET = 100; const props = withDefaults(defineProps<CalendarYearSelectorProps>(), { yearRange: undefined, max: undefined, min: undefined, }); const emit = defineEmits<{ (e: 'update:year', year: number): void; }>(); const { t } = useI18n<{ message: MessageSchema }>({ messages }); // Provide dropdown styles provide(DROPDOWN_SMALL_SIZE_INJECTION_KEY, true); provide(DROPDOWN_UNDERLINE_STYLE_INJECTION_KEY, true); /** * Computes the available year options for the calendar's year selector dropdown. * The selectable range is determined by the `yearRange` prop, which dictates how many * years in the past and future should be available, while respecting `min` and `max` date constraints. */ const yearOptions = computed(() => { // Use the system's current year as the anchor so the range doesn't shift during month navigation const systemCurrentYear = new Date().getFullYear(); let startYear = props.yearRange?.minYear ?? systemCurrentYear - DEFAULT_YEAR_RANGE_OFFSET; let endYear = props.yearRange?.maxYear ?? systemCurrentYear + DEFAULT_YEAR_RANGE_OFFSET; // Prevent the user from scrolling to years earlier than the minimum allowed date if (props.min && props.min.getFullYear() > startYear) { startYear = props.min.getFullYear(); } // Prevent the user from scrolling to years earlier than the maximum allowed date if (props.max && props.max.getFullYear() < endYear) { endYear = props.max.getFullYear(); } // Populate options array const optionsLength = Math.max(0, endYear - startYear + 1); return new Array(optionsLength).fill(0).map((_, index) => { const year = startYear + index; return { label: String(year), value: year }; }); }); const onYearSelected = (newYear: number) => { emit('update:year', newYear); }; </script> <script lang="ts"> /** * A standalone year selector dropdown for the calendar component. */ export default { i18n: { messages }, name: 'calendar-year-selector', }; </script>
@@ -1 +1 @@
1
- <template> <div class="b-header-meta__container"> <bento-typography v-if="description || hasSlot('description')" class="b-header-meta__description" el="div"> <slot name="description">{{ description }}</slot> </bento-typography> <div v-if="lastUpdated" class="b-header-meta__timestamp-section"> <bento-typography class="b-header-meta__timestamp" el="div"> {{ timestamp }} </bento-typography> <bento-loading-button v-if="reloadButton" variant="tertiary" :state="reloadButton.state" @click="reloadButton.event" ><template #iconLeft><reload-icon aria-hidden="true" /></template >{{ reloadButton.title }}</bento-loading-button > </div> </div> </template> <script setup lang="ts"> import { computed, useSlots } from 'vue'; import { useDynamicDateFnsLocale, useHasSlot } from '@/composables'; import { formatDistanceToNow } from 'date-fns/formatDistanceToNow'; import { BentoTypography } from '@/components/typography'; import { BentoLoadingButton } from '@/components/button'; import ReloadIcon from '@adyen/ui-assets-icons-16/vue/refresh'; import { useI18n } from '@/utils/ts/i18n'; import type { HeaderMetaProps } from './header-meta.types'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const { t, locale } = useI18n<{ message: MessageSchema }>({ messages }); const props = withDefaults(defineProps<HeaderMetaProps>(), { description: null, lastUpdated: null, reloadButton: null, }); const activeLocale = computed(() => (props.lastUpdated ? locale.value : '')); const dateLocale = useDynamicDateFnsLocale(activeLocale); const timestamp = computed(() => props.lastUpdated ? t('lastUpdated', { date: formatDistanceToNow(props.lastUpdated, { locale: dateLocale.value, addSuffix: true, }), }) : null ); const slots = useSlots(); const hasSlot = useHasSlot(slots); </script> <script lang="ts"> /** * An internal component used by the BentoHeader and BentoHeaderWithViews components. * * @example * import { HeaderMeta } from '@/internal'; * * export default { * components: { HeaderMeta }, * template: ` * <header-meta description="header description" :last-updated="new Date()" /> * ` * } * @slot description - The description content. Overrides the `description` prop. * @property description - Text showed under the title. * @property lastUpdated - JavaScript "Date" object for when the content was last updated. * @property reloadButton - Reload button which shows next to the header's timestamp. */ export default { i18n: { messages }, }; </script> <style lang="scss" scoped src="./header-meta.scss" />
1
+ <template> <div class="b-header-meta__container"> <bento-typography v-if="description || hasSlot('description')" class="b-header-meta__description" el="div"> <slot name="description">{{ description }}</slot> </bento-typography> <div v-if="lastUpdated" class="b-header-meta__timestamp-section"> <bento-typography class="b-header-meta__timestamp" el="div"> <time :datetime="lastUpdatedDateTime">{{ timestamp }}</time> </bento-typography> <bento-loading-button v-if="reloadButton" variant="tertiary" :state="reloadButton.state" @click="reloadButton.event" ><template #iconLeft><reload-icon aria-hidden="true" /></template >{{ reloadButton.title }}</bento-loading-button > </div> </div> </template> <script setup lang="ts"> import { computed, useSlots } from 'vue'; import { useDynamicDateFnsLocale, useHasSlot } from '@/composables'; import { formatDistanceToNow } from 'date-fns/formatDistanceToNow'; import { BentoTypography } from '@/components/typography'; import { BentoLoadingButton } from '@/components/button'; import ReloadIcon from '@adyen/ui-assets-icons-16/vue/refresh'; import { useI18n } from '@/utils/ts/i18n'; import type { HeaderMetaProps } from './header-meta.types'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const { t, locale } = useI18n<{ message: MessageSchema }>({ messages }); const props = withDefaults(defineProps<HeaderMetaProps>(), { description: null, lastUpdated: null, reloadButton: null, }); const activeLocale = computed(() => (props.lastUpdated ? locale.value : '')); const dateLocale = useDynamicDateFnsLocale(activeLocale); const lastUpdatedDateTime = computed(() => { if (!props.lastUpdated) { return undefined; } const date = props.lastUpdated instanceof Date ? props.lastUpdated : new Date(props.lastUpdated); return Number.isNaN(date.getTime()) ? undefined : date.toISOString(); }); const timestamp = computed(() => props.lastUpdated ? t('lastUpdated', { date: formatDistanceToNow(props.lastUpdated, { locale: dateLocale.value, addSuffix: true, }), }) : null ); const slots = useSlots(); const hasSlot = useHasSlot(slots); </script> <script lang="ts"> /** * An internal component used by the BentoHeader and BentoHeaderWithViews components. * * @example * import { HeaderMeta } from '@/internal'; * * export default { * components: { HeaderMeta }, * template: ` * <header-meta description="header description" :last-updated="new Date()" /> * ` * } * @slot description - The description content. Overrides the `description` prop. * @property description - Text showed under the title. * @property lastUpdated - JavaScript "Date" object for when the content was last updated. * @property reloadButton - Reload button which shows next to the header's timestamp. */ export default { i18n: { messages }, }; </script> <style lang="scss" scoped src="./header-meta.scss" />
@@ -1 +1 @@
1
- <template> <div ref="listboxRef" class="b-listbox-multi-select"> <!-- Selected options --> <div v-if="showSelectedItems"> <ul v-bento-keyboard-navigation-directive class="b-listbox-multi-select__chips" v-on="chipsSectionListeners" > <li v-for="item in selectedOptions" :key="item.value"> <bento-chip ref="selectedListboxItemsRef" :disabled="isOptionDisabled(item)" :label="item.label" condensed @click="item.items ? unselectAllCategory(item) : toggleCheckboxSelection('select', item)" /> </li> </ul> <hr class="b-listbox-multi-select__divider" aria-hidden="true" /> </div> <div v-bento-keyboard-navigation-directive v-bind="ariaAttributes" :role="computedRole" v-on="listboxListeners"> <!-- Select all --> <bento-listbox-option v-if="showSelectAllCheckbox" ref="selectAllListboxItemsRef" class="b-listbox-multi-select__option" :tabindex="focusedIndex === 0 ? 0 : -1" :aria-selected="ariaCheckedComputed !== 'false'" :aria-checked="ariaCheckedComputed" :role="computedOptionRole" @click="handleSelectAll" @enter-pressed="handleSelectAll" @space-pressed.prevent="handleSelectAll" > <decorative-checkbox :checked="isAllSelected" :indeterminate="isIndeterminate" /> <bento-typography el="span"> {{ selectAllCheckboxText }} </bento-typography> </bento-listbox-option> <hr v-if="showSelectAllCheckbox" class="b-listbox-multi-select__divider" aria-hidden="true" /> <!-- Available options --> <listbox-multi-select-options ref="listboxItemsRef" :items="items" :is-option-disabled="isOptionDisabled" :is-multi-level="isMultiLevelSelect" :static-categories="staticCategories" :searching="searching" :selected-items-list="selectedItemsList" :focused-index="focusedIndex" :items-index="computedItemsIndex" :role="computedOptionRole" @toggle-checkbox-selection="toggleCheckboxSelection('select', $event)" @select-category="selectAllCategory" @unselect-category="unselectAllCategory" @toggle-category-visibility="toggleCategoryVisibility" @space-pressed="toggleCheckboxSelection(BentoListboxEvent.SPACE, $event)" @tab-pressed="toggleCheckboxSelection(BentoListboxEvent.TAB, $event)" @enter-pressed="toggleCheckboxSelection(BentoListboxEvent.ENTER, $event)" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </listbox-multi-select-options> </div> </div> </template> <script setup lang="ts"> import { computed, type HTMLAttributes, nextTick, ref, toRef, useAttrs, useSlots, watch } from 'vue'; import { isVue2 } from 'vue-demi'; import { DecorativeCheckbox } from '@/internal/decorative-checkbox'; import { BentoTypography } from '@/components/typography'; import { BentoChip } from '@/components/chip'; import { BentoListboxOption } from '../listbox-option'; import { ListboxMultiSelectOptions } from './components/listbox-multi-select-options'; import { useCheckboxSelectAll } from '@/composables'; import { useListboxKeyboardNavigation } from '../../composables/useListboxKeyboardNavigation'; import { useMultiLevelItems } from '../../composables/use-multi-level-items/use-multi-level-items'; import { useVisibleDomOptions } from '@/internal/listbox/composables/use-visible-dom-options/use-visible-dom-options'; import { useI18n } from '@/utils/ts/i18n'; import { BentoKeyboardNavigationDirective as vBentoKeyboardNavigationDirective } from '@/directives'; import { BentoListboxEvent, type BentoListboxMultiSelectValue, type BentoListboxOptionItem, type BentoListboxOptions, type BentoListboxValue, } from '@/types/listbox'; import { type Booleanish } from '@/types/prop-types'; import { BentoKeyboardNavigationKeyDownEvent } from '@/types/keyboard-navigation'; import { type ListboxMultiSelectProps } from './listbox-multi-select.types'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const props = withDefaults(defineProps<ListboxMultiSelectProps>(), { hasMoreItems: false, isOptionDisabled: isVue2 ? () => () => false : () => false, lazyLoadType: 'none', searching: false, staticCategories: false, }); const emit = defineEmits<{ /** * Emits when the selection changes */ (e: 'select', value: Array<BentoListboxOptionItem>): void; }>(); const { t, n } = useI18n<{ message: MessageSchema }>({ messages }); const slots = useSlots(); const attrs = useAttrs(); 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, 'aria-multiselectable': true, }) as HTMLAttributes ); const listboxRef = ref(null); const selectAllListboxItemsRef = ref(null); const selectedListboxItemsRef = ref(null); const listboxItemsRef = ref(null); const selectedItemsList = computed<Array<BentoListboxValue>>(() => props.selectedValues.map(({ value }) => value)); const { isMultiLevelSelect, flattenedItemsList } = useMultiLevelItems(toRef(props, 'items')); const isTreePattern = computed(() => isMultiLevelSelect.value && !props.staticCategories); const computedRole = computed(() => (isTreePattern.value ? 'tree' : 'listbox')); const computedOptionRole = computed(() => (isTreePattern.value ? 'treeitem' : 'option')); const optionStates = (option: BentoListboxOptionItem) => { const isDisabled = props.isOptionDisabled(option); const isPreSelected = (selectedItemsList.value as BentoListboxMultiSelectValue)?.includes(option.value) || false; return { isDisabled, isPreSelected }; }; // Items that are not disabled and pre-selected disabled items const enabledItems = computed(() => { if (props.searching && isMultiLevelSelect) { return props.items; } const fullItemsList = flattenedItemsList.value.filter(option => { const { isDisabled, isPreSelected } = optionStates(option); return (isDisabled && isPreSelected) || !isDisabled; }); props.selectedValues.forEach(selectedValue => { if (!fullItemsList.find(item => item.value === selectedValue.value)) { fullItemsList.push(selectedValue); } }); return fullItemsList; }); const visibleSelectedList = computed( () => enabledItems.value?.filter(option => { if (option.items && isMultiLevelSelect.value && props.searching) { const amountOfSelectedCategoryOptions = option.items.filter(categoryOption => (props.selectedValues ?? []).includes(categoryOption.value) ).length; return amountOfSelectedCategoryOptions === option.items.length; } return selectedItemsList.value?.includes(option.value); // Check for category }) as BentoListboxMultiSelectValue ); const calculateTotalItems = () => { if (isMultiLevelSelect.value && !props.searching) { return flattenedItemsList.value.length; } return props.items.length; }; const selectAllCheckboxText = computed(() => { const possibleSelections = enabledItems.value.length; const total = calculateTotalItems(); const amountSelectedValues = visibleSelectedList.value?.length || 0; if (amountSelectedValues === 0) { return t('selectAll', { total: n(possibleSelections) }); } if (amountSelectedValues === total) { return t('allSelected', { total: n(total) }); } return amountSelectedValues <= total ? t('numberOfTotalSelected', { number: n(amountSelectedValues), total: n(total) }) : t('numbersSelected', { number: n(amountSelectedValues) }); }); const selectAll = (selectableItems: BentoListboxOptions) => { const filterOutDisabledAndNotPreselectedOptions = option => { const { isDisabled, isPreSelected } = optionStates(option); return (isDisabled && isPreSelected) || !isDisabled; }; // Adding or removing this list of values const addOrRemoveList = selectableItems .filter(filterOutDisabledAndNotPreselectedOptions) .filter(item => !selectedItemsList.value.includes(item.value)); // MAKE SURE NOT TO ADD DUPLICATES emit('select', [...props.selectedValues, ...addOrRemoveList]); }; const unselectAll = (selectableItems: BentoListboxOptions) => { const findEnabledUnselectedItems = option => { const { isDisabled, isPreSelected } = optionStates(option); return !(isDisabled && isPreSelected); }; const unselectedItemsList = selectableItems.filter(findEnabledUnselectedItems).map(({ value }) => value); const newSelectedList = props.selectedValues.filter(item => !unselectedItemsList?.includes(item.value)); emit('select', newSelectedList); }; const selectAllItems = () => { selectAll(flattenedItemsList.value); }; const unselectAllItems = () => { unselectAll(flattenedItemsList.value); }; const { isAllSelected, isIndeterminate, toggleSelectAll } = useCheckboxSelectAll( visibleSelectedList, enabledItems, selectAllItems, unselectAllItems ); const ariaCheckedComputed = computed(() => { if (isIndeterminate.value) { return 'mixed'; } return `${isAllSelected.value}`; }); const isSelectAllOn = ref(isAllSelected.value); const handleSelectAll = () => { isSelectAllOn.value = !isSelectAllOn.value; toggleSelectAll(isSelectAllOn.value); }; watch(isAllSelected, () => { isSelectAllOn.value = isAllSelected.value; }); const selectAllCategory = (category: BentoListboxOptionItem) => { selectAll(category.items); }; const unselectAllCategory = (category: BentoListboxOptionItem) => { unselectAll(category.items); }; const toggleCheckboxSelection = (eventName, selectedOption: BentoListboxOptionItem) => { let exists = false; const newSelectedList = props.selectedValues.filter(({ value }) => { if (selectedOption.value === value) { exists = true; return false; } return true; }); if (!exists) { newSelectedList.push(selectedOption); } emit(eventName, newSelectedList); }; const selectedOptions = computed(() => { if (!isMultiLevelSelect.value) { return props.selectedValues; } const isOptionSelected = (option: BentoListboxOptionItem): boolean => (selectedItemsList.value as unknown as Array<string | number>).includes(option.value); const categoryOptionsSelected = (categoryOptions: Array<BentoListboxOptionItem>): boolean => !props.staticCategories && categoryOptions.every(option => isOptionSelected(option)); const optionsFlat = props.items.flatMap(option => option.items && !categoryOptionsSelected(option.items) ? option.items : option ); const options = optionsFlat.filter(option => { if (option.items) { return true; } return isOptionSelected(option); }); return options; }); const showSelectedItems = computed(() => { const isLazyLoad = ['automatic', 'button'].includes(props.lazyLoadType); const hasAllItemsSelected = props.selectedValues?.length > 0 && visibleSelectedList.value?.length !== flattenedItemsList.value?.length; if (isLazyLoad && props.hasMoreItems) { return !!props.selectedValues?.length; } return hasAllItemsSelected; }); const showSelectAllCheckbox = computed(() => { const isLazyLoad = ['automatic', 'button'].includes(props.lazyLoadType); if (!isLazyLoad) { return true; } return !props.hasMoreItems; }); // Keyboard navigation const expandedCategories = ref([]); const { visibleDomOptions, updateVisibleDomOptions } = useVisibleDomOptions( listboxRef, toRef(props, 'items'), computedOptionRole ); const toggleCategoryVisibility = category => { if (expandedCategories.value.includes(category)) { expandedCategories.value = expandedCategories.value.filter(item => item !== category); } else { expandedCategories.value.push(category); } nextTick(() => { updateVisibleDomOptions(); }); }; const focusItem = (nextFocusedItemIndex: number) => { visibleDomOptions.value[nextFocusedItemIndex]?.focus(); }; const { moveToNextElement, moveToPreviousElement, moveToFirstElement, moveToLastElement, preventDefault, focusedIndex, setListboxFocusedIndex, } = useListboxKeyboardNavigation( { items: visibleDomOptions, }, focusItem ); const listboxListeners = { [BentoKeyboardNavigationKeyDownEvent.ARROW_UP]: (event: CustomEvent<KeyboardEvent>) => moveToPreviousElement(event), [BentoKeyboardNavigationKeyDownEvent.ARROW_DOWN]: (event: CustomEvent<KeyboardEvent>) => moveToNextElement(event), [BentoKeyboardNavigationKeyDownEvent.HOME]: moveToFirstElement, [BentoKeyboardNavigationKeyDownEvent.END]: moveToLastElement, }; defineExpose({ /** * 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, }); const chipsSectionListeners = { [BentoKeyboardNavigationKeyDownEvent.ARROW_UP]: preventDefault, [BentoKeyboardNavigationKeyDownEvent.ARROW_DOWN]: preventDefault, }; const computedItemsIndex = computed(() => { let itemIndex = showSelectAllCheckbox.value ? 1 : 0; const indexMap = {}; props.items.forEach(item => { let subItemsVisible = expandedCategories.value.includes(item.value); if (item.items) { if (props.staticCategories) { subItemsVisible = true; } else { indexMap[item.value] = itemIndex++; } if (subItemsVisible) { item.items.forEach(subItem => (indexMap[subItem.value] = itemIndex++)); } } else { indexMap[item.value] = itemIndex++; } }); return indexMap; }); </script> <script lang="ts"> /** * Listbox multi-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 { i18n: { messages } }; </script> <style lang="scss" scoped src="./listbox-multi-select.scss" />
1
+ <template> <div ref="listboxRef" class="b-listbox-multi-select"> <!-- Selected options --> <div v-if="showSelectedItems"> <ul v-bento-keyboard-navigation-directive class="b-listbox-multi-select__chips" v-on="chipsSectionListeners" > <li v-for="item in selectedOptions" :key="item.value"> <bento-chip ref="selectedListboxItemsRef" :disabled="isOptionDisabled(item)" :label="item.label" condensed @click="item.items ? unselectAllCategory(item) : toggleCheckboxSelection('select', item)" /> </li> </ul> <hr class="b-listbox-multi-select__divider" aria-hidden="true" /> </div> <div v-bento-keyboard-navigation-directive v-bind="ariaAttributes" :role="computedRole" v-on="listboxListeners"> <!-- Select all --> <bento-listbox-option v-if="showSelectAllCheckbox" ref="selectAllListboxItemsRef" class="b-listbox-multi-select__option" :tabindex="focusedIndex === 0 ? 0 : -1" :aria-selected="ariaCheckedComputed !== 'false'" :aria-checked="ariaCheckedComputed" :role="computedOptionRole" @click="handleSelectAll" @enter-pressed="handleSelectAll" @space-pressed.prevent="handleSelectAll" > <decorative-checkbox :checked="isAllSelected" :indeterminate="isIndeterminate" /> <bento-typography el="span"> {{ selectAllCheckboxText }} </bento-typography> </bento-listbox-option> <hr v-if="showSelectAllCheckbox" class="b-listbox-multi-select__divider" aria-hidden="true" /> <!-- Available options --> <listbox-multi-select-options ref="listboxItemsRef" :items="items" :is-option-disabled="isOptionDisabled" :is-multi-level="isMultiLevelSelect" :static-categories="staticCategories" :searching="searching" :selected-items-list="selectedItemsList" :focused-index="focusedIndex" :items-index="computedItemsIndex" :role="computedOptionRole" @toggle-checkbox-selection="toggleCheckboxSelection('select', $event)" @select-category="selectAllCategory" @unselect-category="unselectAllCategory" @toggle-category-visibility="toggleCategoryVisibility" @space-pressed="toggleCheckboxSelection(BentoListboxEvent.SPACE, $event)" @tab-pressed="toggleCheckboxSelection(BentoListboxEvent.TAB, $event)" @enter-pressed="toggleCheckboxSelection(BentoListboxEvent.ENTER, $event)" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </listbox-multi-select-options> </div> </div> </template> <script setup lang="ts"> import { computed, type HTMLAttributes, nextTick, ref, toRef, useAttrs, useSlots, watch } from 'vue'; import { isVue2 } from 'vue-demi'; import { DecorativeCheckbox } from '@/internal/decorative-checkbox'; import { BentoTypography } from '@/components/typography'; import { BentoChip } from '@/components/chip'; import { BentoListboxOption } from '../listbox-option'; import { ListboxMultiSelectOptions } from './components/listbox-multi-select-options'; import { useCheckboxSelectAll } from '@/composables'; import { useListboxKeyboardNavigation } from '../../composables/useListboxKeyboardNavigation'; import { useMultiLevelItems } from '../../composables/use-multi-level-items/use-multi-level-items'; import { useVisibleDomOptions } from '@/internal/listbox/composables/use-visible-dom-options/use-visible-dom-options'; import { useI18n } from '@/utils/ts/i18n'; import { BentoKeyboardNavigationDirective as vBentoKeyboardNavigationDirective } from '@/directives'; import { BentoListboxEvent, type BentoListboxMultiSelectValue, type BentoListboxOptionItem, type BentoListboxOptions, type BentoListboxValue, } from '@/types/listbox'; import { type Booleanish } from '@/types/prop-types'; import { BentoKeyboardNavigationKeyDownEvent } from '@/types/keyboard-navigation'; import { type ListboxMultiSelectProps } from './listbox-multi-select.types'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const props = withDefaults(defineProps<ListboxMultiSelectProps>(), { hasMoreItems: false, isOptionDisabled: isVue2 ? () => () => false : () => false, lazyLoadType: 'none', searching: false, staticCategories: false, }); const emit = defineEmits<{ /** * Emits when the selection changes */ (e: 'select', value: Array<BentoListboxOptionItem>): void; }>(); const { t, n } = useI18n<{ message: MessageSchema }>({ messages }); const slots = useSlots(); const attrs = useAttrs(); 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, 'aria-multiselectable': true, }) as HTMLAttributes ); const listboxRef = ref(null); const selectAllListboxItemsRef = ref(null); const selectedListboxItemsRef = ref(null); const listboxItemsRef = ref(null); const selectedItemsList = computed<Array<BentoListboxValue>>(() => props.selectedValues.map(({ value }) => value)); const { isMultiLevelSelect, flattenedItemsList } = useMultiLevelItems(toRef(props, 'items')); const isTreePattern = computed(() => isMultiLevelSelect.value && !props.staticCategories); const computedRole = computed(() => (isTreePattern.value ? 'tree' : 'listbox')); const computedOptionRole = computed(() => (isTreePattern.value ? 'treeitem' : 'option')); const optionStates = (option: BentoListboxOptionItem) => { const isDisabled = props.isOptionDisabled(option); const isPreSelected = (selectedItemsList.value as BentoListboxMultiSelectValue)?.includes(option.value) || false; return { isDisabled, isPreSelected }; }; // Items that are not disabled and pre-selected disabled items const enabledItems = computed(() => { if (props.searching && isMultiLevelSelect) { return props.items; } const fullItemsList = flattenedItemsList.value.filter(option => { const { isDisabled, isPreSelected } = optionStates(option); return (isDisabled && isPreSelected) || !isDisabled; }); props.selectedValues.forEach(selectedValue => { if (!fullItemsList.find(item => item.value === selectedValue.value)) { fullItemsList.push(selectedValue); } }); return fullItemsList; }); const visibleSelectedList = computed( () => enabledItems.value?.filter(option => { if (option.items && isMultiLevelSelect.value && props.searching) { const amountOfSelectedCategoryOptions = option.items.filter(categoryOption => (props.selectedValues ?? []).includes(categoryOption.value) ).length; return amountOfSelectedCategoryOptions === option.items.length; } return selectedItemsList.value?.includes(option.value); // Check for category }) as BentoListboxMultiSelectValue ); const calculateTotalItems = () => { if (isMultiLevelSelect.value && !props.searching) { return flattenedItemsList.value.length; } return props.items.length; }; const selectAllCheckboxText = computed(() => { const possibleSelections = enabledItems.value.length; const total = calculateTotalItems(); const amountSelectedValues = visibleSelectedList.value?.length || 0; if (amountSelectedValues === 0) { return t('selectAll', { total: n(possibleSelections) }); } if (amountSelectedValues === total) { return t('allSelected', { total: n(total) }); } return amountSelectedValues <= total ? t('numberOfTotalSelected', { number: n(amountSelectedValues), total: n(total) }) : t('numbersSelected', { number: n(amountSelectedValues) }); }); const selectAll = (selectableItems: BentoListboxOptions) => { const filterOutDisabledAndNotPreselectedOptions = option => { const { isDisabled, isPreSelected } = optionStates(option); return (isDisabled && isPreSelected) || !isDisabled; }; // Adding or removing this list of values const addOrRemoveList = selectableItems .filter(filterOutDisabledAndNotPreselectedOptions) .filter(item => !selectedItemsList.value.includes(item.value)); // MAKE SURE NOT TO ADD DUPLICATES emit('select', [...props.selectedValues, ...addOrRemoveList]); }; const unselectAll = (selectableItems: BentoListboxOptions) => { const findEnabledUnselectedItems = option => { const { isDisabled, isPreSelected } = optionStates(option); return !(isDisabled && isPreSelected); }; const unselectedItemsList = selectableItems.filter(findEnabledUnselectedItems).map(({ value }) => value); const newSelectedList = props.selectedValues.filter(item => !unselectedItemsList?.includes(item.value)); emit('select', newSelectedList); }; const selectAllItems = () => { selectAll(flattenedItemsList.value); }; const unselectAllItems = () => { unselectAll(flattenedItemsList.value); }; const { isAllSelected, isIndeterminate, toggleSelectAll } = useCheckboxSelectAll( visibleSelectedList, enabledItems, selectAllItems, unselectAllItems ); const ariaCheckedComputed = computed(() => { if (isIndeterminate.value) { return 'mixed'; } return `${isAllSelected.value}`; }); const isSelectAllOn = ref(isAllSelected.value); const handleSelectAll = () => { isSelectAllOn.value = !isSelectAllOn.value; toggleSelectAll(isSelectAllOn.value); }; watch(isAllSelected, () => { isSelectAllOn.value = isAllSelected.value; }); const selectAllCategory = (category: BentoListboxOptionItem) => { selectAll(category.items); }; const unselectAllCategory = (category: BentoListboxOptionItem) => { unselectAll(category.items); }; const toggleCheckboxSelection = (eventName, selectedOption: BentoListboxOptionItem) => { let exists = false; const newSelectedList = props.selectedValues.filter(({ value }) => { if (selectedOption.value === value) { exists = true; return false; } return true; }); if (!exists) { newSelectedList.push(selectedOption); } emit(eventName, newSelectedList); }; const selectedOptions = computed(() => { if (!isMultiLevelSelect.value) { return props.selectedValues; } const isOptionSelected = (option: BentoListboxOptionItem): boolean => (selectedItemsList.value as unknown as Array<string | number>).includes(option.value); const categoryOptionsSelected = (categoryOptions: Array<BentoListboxOptionItem>): boolean => !props.staticCategories && categoryOptions.every(option => isOptionSelected(option)); const optionsFlat = props.items.flatMap(option => option.items && !categoryOptionsSelected(option.items) ? option.items : option ); const options = optionsFlat.filter(option => { if (option.items) { return true; } return isOptionSelected(option); }); // Append selected values not present in the current items list (e.g. not yet fetched) const knownValues = new Set<BentoListboxValue>(); optionsFlat.forEach(option => { knownValues.add(option.value); option.items?.forEach(sub => knownValues.add(sub.value)); }); const unfetchedSelected = props.selectedValues.filter(sv => !knownValues.has(sv.value)); return [...options, ...unfetchedSelected]; }); const showSelectedItems = computed(() => { const isLazyLoad = ['automatic', 'button'].includes(props.lazyLoadType); const hasAllItemsSelected = props.selectedValues?.length > 0 && visibleSelectedList.value?.length !== flattenedItemsList.value?.length; if (isLazyLoad && props.hasMoreItems) { return !!props.selectedValues?.length; } return hasAllItemsSelected; }); const showSelectAllCheckbox = computed(() => { const isLazyLoad = ['automatic', 'button'].includes(props.lazyLoadType); if (!isLazyLoad) { return true; } return !props.hasMoreItems; }); // Keyboard navigation const expandedCategories = ref([]); const { visibleDomOptions, updateVisibleDomOptions } = useVisibleDomOptions( listboxRef, toRef(props, 'items'), computedOptionRole ); const toggleCategoryVisibility = category => { if (expandedCategories.value.includes(category)) { expandedCategories.value = expandedCategories.value.filter(item => item !== category); } else { expandedCategories.value.push(category); } nextTick(() => { updateVisibleDomOptions(); }); }; const focusItem = (nextFocusedItemIndex: number) => { visibleDomOptions.value[nextFocusedItemIndex]?.focus(); }; const { moveToNextElement, moveToPreviousElement, moveToFirstElement, moveToLastElement, preventDefault, focusedIndex, setListboxFocusedIndex, } = useListboxKeyboardNavigation( { items: visibleDomOptions, }, focusItem ); const listboxListeners = { [BentoKeyboardNavigationKeyDownEvent.ARROW_UP]: (event: CustomEvent<KeyboardEvent>) => moveToPreviousElement(event), [BentoKeyboardNavigationKeyDownEvent.ARROW_DOWN]: (event: CustomEvent<KeyboardEvent>) => moveToNextElement(event), [BentoKeyboardNavigationKeyDownEvent.HOME]: moveToFirstElement, [BentoKeyboardNavigationKeyDownEvent.END]: moveToLastElement, }; defineExpose({ /** * 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, }); const chipsSectionListeners = { [BentoKeyboardNavigationKeyDownEvent.ARROW_UP]: preventDefault, [BentoKeyboardNavigationKeyDownEvent.ARROW_DOWN]: preventDefault, }; const computedItemsIndex = computed(() => { let itemIndex = showSelectAllCheckbox.value ? 1 : 0; const indexMap = {}; props.items.forEach(item => { let subItemsVisible = expandedCategories.value.includes(item.value); if (item.items) { if (props.staticCategories) { subItemsVisible = true; } else { indexMap[item.value] = itemIndex++; } if (subItemsVisible) { item.items.forEach(subItem => (indexMap[subItem.value] = itemIndex++)); } } else { indexMap[item.value] = itemIndex++; } }); return indexMap; }); </script> <script lang="ts"> /** * Listbox multi-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 { i18n: { messages } }; </script> <style lang="scss" scoped src="./listbox-multi-select.scss" />
@@ -1 +1 @@
1
- <template> <div ref="listboxRef" v-bento-keyboard-navigation-directive v-bind="ariaAttributes" role="listbox" v-on="listboxListeners" @focusin="handleVirtualScrollingFocus" > <template v-if="hasCategories && staticCategories"> <template v-for="item in items"> <bento-listbox-single-select-category v-if="item.items && item.items.length > 0" :key="`category-${item.value}`" :category-label="item.label" > <bento-listbox-single-select-option v-for="option in item.items" :key="option.value" ref="listboxItemRef" role="option" :option="option" :disabled="isOptionDisabled ? isOptionDisabled(option) : null" :selected="option.value === selectedItem.value" v-on="listboxOptionListeners" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </bento-listbox-single-select-option> </bento-listbox-single-select-category> <bento-listbox-single-select-option v-else :key="`option-${item.value}`" ref="listboxItemRef" role="option" :option="item" :disabled="isOptionDisabled ? isOptionDisabled(item) : null" :selected="item.value === selectedItem.value" v-on="listboxOptionListeners" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </bento-listbox-single-select-option> </template> </template> <template v-else> <bento-listbox-single-select-option v-for="option in items" :key="option.value" ref="listboxItemRef" role="option" :option="option" :disabled="isOptionDisabled ? isOptionDisabled(option) : null" :selected="option.value === selectedItem.value" v-on="listboxOptionListeners" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </bento-listbox-single-select-option> </template> </div> </template> <script lang="ts"> import { computed, defineComponent, type HTMLAttributes, nextTick, type PropType, ref, toRef } from 'vue'; import { BentoListboxSingleSelectCategory } from './components/listbox-single-select-category'; import { BentoListboxSingleSelectOption } from '../listbox-single-select-option'; import { useListboxKeyboardNavigation } from '../../composables/useListboxKeyboardNavigation'; import { useVisibleDomOptions } from '@/internal/listbox/composables/use-visible-dom-options/use-visible-dom-options'; import { BentoListboxEvent, type BentoListboxIsOptionDisabled, type BentoListboxOptions } from '@/types/listbox'; import { BentoKeyboardNavigationKeyDownEvent } from '@/types/keyboard-navigation'; import { type Booleanish } from '@/types/prop-types'; import { BentoKeyboardNavigationDirective } from '@/directives'; /** * Listbox single-select options list * * @example * import { BentoListboxMultiSelect } from '@adyen/bento-vue2'; * * export default { * components: { BentoListboxMultiSelect }, * template: ` * <bento-listbox-multi-select * :items="[{ label: 'Option 1', value: 'option-1' }]" * :selected-values="['option-1']" * /> * ` * } */ export default defineComponent({ name: 'bento-listbox-single-select', components: { BentoListboxSingleSelectCategory, BentoListboxSingleSelectOption, }, directives: { BentoKeyboardNavigationDirective }, inheritAttrs: false, props: { /** * Function that allows the options to be disabled * * @type {BentoListboxIsOptionDisabled} * @param {BentoListboxOptionItem} option - listbox option object * @param {string} option.label - Option's label text * @param {string|number} option.value - Option's value * @param {unknown} option.data - Option's extra data to be passed to the default slot */ isOptionDisabled: { type: Function as PropType<BentoListboxIsOptionDisabled>, default: () => false, }, /** * The option elements to populate the listbox with. * It must be an array of {@see BentoListboxOptionItem }. * * @property {string} value.label - Text to be displayed in the option * @property {string,number} value.value - Value of the option */ items: { type: Array as PropType<BentoListboxOptions>, required: true, }, /** * The `input` value. * Providing an empty string will select no options. * Set to an empty string `''` if you don't want any of the available options to be selected */ selectedValue: { type: Array as PropType<BentoListboxOptions>, default: undefined, }, /** * If enabled, dropdown will display non-selectable, static categories */ staticCategories: { type: Boolean, default: false }, /** * Total number of items in the full list (used for virtual scroll keyboard navigation). * When greater than 0, virtual scroll keyboard navigation is enabled. */ totalItemCount: { type: Number, default: 0 }, /** * Function to scroll the virtual list container to a given item index. */ scrollToIndex: { type: Function as PropType<(index: number) => void>, default: undefined }, /** * The start index of the currently rendered virtual scroll slice. */ virtualScrollStartIndex: { type: Number, default: 0 }, }, emits: [BentoListboxEvent.SELECT, BentoListboxEvent.ENTER, BentoListboxEvent.SPACE, BentoListboxEvent.TAB], setup(props, { emit, expose, slots, attrs }) { const listboxRef = ref(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
+ <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 scrollToItem = async (globalIndex: number) => { await updateVisibleDomOptions(); if (isVirtualScrollEnabled.value && props.scrollToIndex) { props.scrollToIndex(globalIndex); } else { visibleDomOptions.value[globalIndex]?.scrollIntoView?.({ block: 'nearest' }); } }; const { moveToNextElement, moveToPreviousElement, moveToFirstElement, moveToLastElement, setListboxFocusedIndex, } = useListboxKeyboardNavigation( { items: visibleDomOptions, totalItemCount: toRef(props, 'totalItemCount'), }, focusItem ); const listboxListeners = { [BentoKeyboardNavigationKeyDownEvent.ARROW_UP]: moveToPreviousElement, [BentoKeyboardNavigationKeyDownEvent.ARROW_DOWN]: moveToNextElement, [BentoKeyboardNavigationKeyDownEvent.HOME]: moveToFirstElement, [BentoKeyboardNavigationKeyDownEvent.END]: moveToLastElement, }; const listboxOptionListeners = { [BentoListboxEvent.SELECT]: onOptionSelected(BentoListboxEvent.SELECT), [BentoListboxEvent.SPACE]: onOptionSelected(BentoListboxEvent.SPACE), [BentoListboxEvent.TAB]: onOptionSelected(BentoListboxEvent.TAB), [BentoListboxEvent.ENTER]: onOptionSelected(BentoListboxEvent.ENTER), }; expose({ /** * Reference for a single listbox item. * Required for keyboard navigation in dropdown as $children is removed in vue@3 */ listboxItemRef, /** * Method to set the index of the item to be focused on * Used for focus management to be kept in-sync with parent e.g. dropdowns */ setListboxFocusedIndex, /** * Navigates to an item by global index, handling virtual scroll if enabled. * Scrolls the virtual list and focuses the item. */ navigateToItem, /** * Scrolls to an item by global index without changing focus. * Used when the dropdown opens via click to bring the selected option into view. */ scrollToItem, }); return { // Enums BentoListboxEvent, // Values listboxRef, listboxItemRef, selectedItem, slots, ariaAttributes, // Computed hasCategories, // Methods onOptionSelected, // Keyboard navigation listboxListeners, listboxOptionListeners, // Virtual scroll focus management handleVirtualScrollingFocus, }; }, }); </script>
@@ -1 +1 @@
1
- import { type BentoStatusVariant } from '@/components/status'; import { type BentoTagProps } from '@/components/tag'; export interface BentoListItemProps { /** * Label text shown in the content area when the `content` slot is not used. */ label?: string; /** * Description text shown below label when the `content` slot is not used. */ description?: string; /** * Toggles the status indicator. */ showStatus?: boolean; /** * Color for the status indicator. * @values blue, green, grey, orange, red, yellow */ statusColor?: BentoStatusVariant | `${BentoStatusVariant}`; /** * Tag props passed to the internal BentoTag component. */ tag?: BentoTagProps; /** * Shows a chevron icon in the item. */ withChevron?: boolean; /** * Shows the top divider line. */ showTopDivider?: boolean; /** * Shows the bottom divider line. */ showBottomDivider?: boolean; }
1
+ import type { BentoDraggableDisabledMenus } from '@/components/draggable/composables/use-bento-draggable/use-bento-draggable'; import { type BentoStatusVariant } from '@/components/status'; import { type BentoTagProps } from '@/components/tag'; export interface BentoListItemDraggableOptions { /** Override the disabled menu state for this item. */ disabledMenus?: BentoDraggableDisabledMenus; /** Disable the drag handle for this item. */ disabled?: boolean; /** * Custom accessible label for the built-in drag handle. */ label?: string; /** Override the default move-up handler. */ moveUp?: () => void; /** Override the default move-down handler. */ moveDown?: () => void; /** Override the default move-to-top handler. */ moveTop?: () => void; /** Override the default move-to-bottom handler. */ moveBottom?: () => void; } export interface BentoListItemProps { /** * Label text shown in the content area when the `content` slot is not used. */ label?: string; /** * Description text shown below label when the `content` slot is not used. */ description?: string; /** * Optional overrides for move actions and disabled menu state. * Falls back to the parent `BentoList` draggable context when not provided. */ draggableOptions?: BentoListItemDraggableOptions; /** * Toggles the status indicator. */ showStatus?: boolean; /** * Color for the status indicator. * @values blue, green, grey, orange, red, yellow */ statusColor?: BentoStatusVariant | `${BentoStatusVariant}`; /** * Tag props passed to the internal BentoTag component. */ tag?: BentoTagProps; /** * Shows a chevron icon in the item. */ withChevron?: boolean; /** * Shows the top divider line. */ showTopDivider?: boolean; /** * Shows the bottom divider line. */ showBottomDivider?: boolean; }
@@ -1 +1 @@
1
- <template> <li class="b-list-item" @click="onItemClick" @keydown="onItemKeydown"> <bento-typography class="b-list-item__inner" :class="conditionalClasses" el="div" :role="props.withChevron ? 'button' : undefined" :tabindex="props.withChevron ? 0 : undefined" > <div v-if="hasSlot('image')" class="b-list-item__image"> <slot name="image" /> </div> <div v-if="hasSlot('start')" class="b-list-item__start"> <slot name="start" /> </div> <div class="b-list-item__content"> <div v-if="hasSlot('content')" class="b-list-item__label"> <slot name="content" /> </div> <template v-else> <div class="b-list-item__header"> <bento-typography v-if="props.label" class="b-list-item__label" el="span" stronger> {{ props.label }} </bento-typography> <bento-status v-if="props.showStatus" class="b-list-item__status" :variant="props.statusColor" aria-hidden="true" /> <bento-tag v-if="props.tag" v-bind="props.tag" class="b-list-item__tag" /> </div> <span v-if="props.description" class="b-list-item__description"> {{ props.description }} </span> </template> </div> <div v-if="hasSlot('end')" class="b-list-item__end"> <slot name="end" /> </div> <chevron-right-icon v-if="props.withChevron" class="b-list-item__chevron" aria-hidden="true" /> </bento-typography> </li> </template> <script setup lang="ts"> import { computed, useSlots } from 'vue'; import ChevronRightIcon from '@adyen/ui-assets-icons-16/vue/chevron-right-small'; import { BentoStatus } from '@/components/status'; import { BentoTag } from '@/components/tag'; import { BentoTypography } from '@/components/typography'; import { useHasSlot } from '@/composables'; import type { BentoListItemProps } from './list-item.types'; const slots = useSlots(); const hasSlot = useHasSlot(slots); const props = withDefaults(defineProps<BentoListItemProps>(), { showStatus: false, statusColor: 'blue', tag: undefined, withChevron: false, showTopDivider: false, showBottomDivider: false, }); const emit = defineEmits<{ /** * Emitted when a clickable list item is triggered. */ (e: 'click'): void; }>(); const onItemClick = (): void => { if (!props.withChevron) { return; } emit('click'); }; const onItemKeydown = (event: KeyboardEvent): void => { if (!props.withChevron) { return; } if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); emit('click'); } }; const conditionalClasses = computed(() => ({ 'b-list-item__inner--clickable': props.withChevron, 'b-list-item__inner--top-divider': props.showTopDivider, 'b-list-item__inner--bottom-divider': props.showBottomDivider, })); </script> <script lang="ts"> /** * `BentoListItem` renders an item to be used inside `BentoList`. * * @example * import { BentoList, BentoListItem, BentoButton, BentoToggle } from '@adyen/bento-vue2'; * * export default { * components: { BentoList, BentoListItem, BentoButton, BentoToggle }, * template: ` * <bento-list> * <bento-list-item * label="Payment details" * description="Updated 2 hours ago" * :tag="{ label: 'Live', variant: 'green' }" * show-bottom-divider * > * <template #start> * <bento-toggle /> * </template> * <template #end> * <bento-button variant="secondary">Edit</bento-button> * </template> * </bento-list-item> * </bento-list> * ` * } */ export default { name: 'bento-list-item', }; </script> <style lang="scss" scoped src="./list-item.scss" />
1
+ <template> <li ref="itemRef" class="b-list-item" @click="onItemClick" @keydown="onItemKeydown"> <bento-typography class="b-list-item__inner" :class="conditionalClasses" el="div" :role="props.withChevron ? 'button' : undefined" :tabindex="props.withChevron ? 0 : undefined" > <div v-if="hasSlot('image')" class="b-list-item__image"> <slot name="image" /> </div> <bento-draggable-handle v-if="showDragHandle" condensed :label="dragHandleLabel" :disabled-menus="disabledMenus" :disabled="draggableOptions?.disabled" @move-up="moveItemUp" @move-down="moveItemDown" @move-top="moveItemTop" @move-bottom="moveItemBottom" /> <div v-if="hasSlot('start')" class="b-list-item__start"> <slot v-if="hasSlot('start')" name="start" /> </div> <div class="b-list-item__content"> <div v-if="hasSlot('content')" class="b-list-item__label" data-list-item-label> <slot name="content" /> </div> <template v-else> <div class="b-list-item__header"> <bento-typography v-if="props.label" class="b-list-item__label" data-list-item-label el="span" stronger > {{ props.label }} </bento-typography> <bento-status v-if="props.showStatus" class="b-list-item__status" :variant="props.statusColor" aria-hidden="true" /> <bento-tag v-if="props.tag" v-bind="props.tag" class="b-list-item__tag" /> </div> <span v-if="props.description" class="b-list-item__description"> {{ props.description }} </span> </template> </div> <div v-if="hasSlot('end')" class="b-list-item__end"> <slot name="end" /> </div> <chevron-right-icon v-if="props.withChevron" class="b-list-item__chevron" aria-hidden="true" /> </bento-typography> </li> </template> <script setup lang="ts"> import { computed, inject, ref, useSlots } from 'vue'; import ChevronRightIcon from '@adyen/ui-assets-icons-16/vue/chevron-right-small'; import { BentoDraggableHandle } from '@/components/draggable/components/draggable-handle'; import { BentoStatus } from '@/components/status'; import { BentoTag } from '@/components/tag'; import { BentoTypography } from '@/components/typography'; import { useHasSlot } from '@/composables'; import { useI18n } from '@/utils/ts/i18n'; import { BENTO_LIST_DRAGGABLE_INJECTION_KEY } from '../../list.keys'; import type { BentoListDraggableContext } from '../../list.keys'; import type { BentoListItemProps } from './list-item.types'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const slots = useSlots(); const hasSlot = useHasSlot(slots); const itemRef = ref<HTMLElement | null>(null); const draggableContext = inject<BentoListDraggableContext | null>(BENTO_LIST_DRAGGABLE_INJECTION_KEY, null); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const props = withDefaults(defineProps<BentoListItemProps>(), { draggableOptions: null, showStatus: false, statusColor: 'blue', tag: undefined, withChevron: false, showTopDivider: false, showBottomDivider: false, }); const emit = defineEmits<{ /** * Emitted when a clickable list item is triggered. */ (e: 'click'): void; /** * Emitted when the item is moved up via the drag handle menu. */ (e: 'move-up'): void; /** * Emitted when the item is moved down via the drag handle menu. */ (e: 'move-down'): void; /** * Emitted when the item is moved to the top via the drag handle menu. */ (e: 'move-top'): void; /** * Emitted when the item is moved to the bottom via the drag handle menu. */ (e: 'move-bottom'): void; }>(); const onItemClick = (): void => { if (!props.withChevron) { return; } emit('click'); }; const onItemKeydown = (event: KeyboardEvent): void => { if (!props.withChevron) { return; } if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); emit('click'); } }; const conditionalClasses = computed(() => ({ 'b-list-item__inner--clickable': props.withChevron, 'b-list-item__inner--top-divider': props.showTopDivider, 'b-list-item__inner--bottom-divider': props.showBottomDivider, })); const showDragHandle = computed(() => Boolean(draggableContext?.isDraggable.value)); const dragHandleLabel = computed(() => props.draggableOptions?.label || props.label || t('listItem')); const disabledMenus = computed(() => { if (props.draggableOptions?.disabledMenus) { return props.draggableOptions.disabledMenus; } return draggableContext?.getDisabledMenus(itemRef.value); }); const moveItemUp = () => { emit('move-up'); (props.draggableOptions?.moveUp ?? (() => draggableContext?.moveItemUp(itemRef.value)))(); }; const moveItemDown = () => { emit('move-down'); (props.draggableOptions?.moveDown ?? (() => draggableContext?.moveItemDown(itemRef.value)))(); }; const moveItemTop = () => { emit('move-top'); (props.draggableOptions?.moveTop ?? (() => draggableContext?.moveItemTop(itemRef.value)))(); }; const moveItemBottom = () => { emit('move-bottom'); (props.draggableOptions?.moveBottom ?? (() => draggableContext?.moveItemBottom(itemRef.value)))(); }; </script> <script lang="ts"> /** * `BentoListItem` renders an item to be used inside `BentoList`. * * @example * import { BentoList, BentoListItem, BentoButton, BentoToggle } from '@adyen/bento-vue2'; * * export default { * components: { BentoList, BentoListItem, BentoButton, BentoToggle }, * template: ` * <bento-list> * <bento-list-item * label="Payment details" * description="Updated 2 hours ago" * :tag="{ label: 'Live', variant: 'green' }" * show-bottom-divider * > * <template #start> * <bento-toggle /> * </template> * <template #end> * <bento-button variant="secondary">Edit</bento-button> * </template> * </bento-list-item> * </bento-list> * ` * } */ export default { name: 'bento-list-item', i18n: { messages }, }; </script> <style lang="scss" scoped src="./list-item.scss" />
@@ -33,6 +33,40 @@ features like grab handle, image, dividers, or chevron.
33
33
  - If a chevron is shown, avoid also showing buttons in the end slot (conflicts with navigation)
34
34
  - Don't place multiple competing controls (e.g. toggle + checkbox + two buttons)
35
35
 
36
+ ## Draggable
37
+
38
+ Enable drag-and-drop reordering by setting `draggable` on `bento-list`. Each item automatically receives a drag handle
39
+ with keyboard-accessible move actions (up, down, to top, to bottom).
40
+
41
+ <Canvas withSource="closed">
42
+ <Story of={ListStories.EverythingBagel} />
43
+ </Canvas>
44
+
45
+ ### List props
46
+
47
+ - `draggable`: enables drag-and-drop reordering for all items.
48
+ - `draggableOptions`: forwards SortableJS options (e.g. `{ animation: 150 }`) and accepts an optional `dragAnnouncement`
49
+ string (`'disabled'` to suppress screen reader announcements).
50
+
51
+ ### List item props
52
+
53
+ Each `bento-list-item` accepts a `draggableOptions` prop to override defaults on a per-item basis:
54
+
55
+ | Property | Type | Description |
56
+ | --------------- | ---------- | ------------------------------------------------------------------ |
57
+ | `disabled` | `boolean` | Disables the drag handle for this item. |
58
+ | `label` | `string` | Custom accessible label for the drag handle (defaults to `label`). |
59
+ | `disabledMenus` | `object` | Override which directional menu actions are disabled. |
60
+ | `moveUp` | `function` | Override the default move-up handler. |
61
+ | `moveDown` | `function` | Override the default move-down handler. |
62
+ | `moveTop` | `function` | Override the default move-to-top handler. |
63
+ | `moveBottom` | `function` | Override the default move-to-bottom handler. |
64
+
65
+ ### Events
66
+
67
+ Each `bento-list-item` emits `move-up`, `move-down`, `move-top`, and `move-bottom` events when the corresponding handle
68
+ action is triggered, before the move is executed.
69
+
36
70
  ## Variations
37
71
 
38
72
  ### Type
@@ -79,6 +113,24 @@ Each `bento-list-item` is composed of:
79
113
 
80
114
  The List component is rendered as an ordered or unordered list depending on the `type` prop.
81
115
 
116
+ ### Nested interactive elements
117
+
118
+ `withChevron` makes the entire row a `role="button"` element. Placing interactive children (toggles, buttons, drag
119
+ handles) inside it creates nested interactive content, which violates
120
+ [WCAG 4.1.2](https://www.w3.org/WAI/WCAG21/Understanding/name-role-value.html). Avoid these combinations:
121
+
122
+ - **`withChevron` + `draggable`**: the drag handle is a button nested inside the row button.
123
+ - **`withChevron` + interactive `start`/`end` slots**: toggles, checkboxes, or buttons nested inside the row button.
124
+
125
+ If an item needs to be both clickable and draggable, use a custom `content` slot with separate interactive regions
126
+ instead of `withChevron`.
127
+
128
+ ### Drag-and-drop announcements
129
+
130
+ When `draggable` is enabled, a live region (`role="status"`, `aria-live="polite"`) announces reorder actions to screen
131
+ readers. Each move produces a localized message such as _"You have moved Item from position 1 to position 2 of 3."_ Set
132
+ `draggableOptions.dragAnnouncement` to `'disabled'` to suppress announcements, or provide a custom template string.
133
+
82
134
  ### Keyboard Interaction
83
135
 
84
136
  By default, `bento-list` itself is non-interactive and keyboard behavior depends on interactive elements rendered inside
@@ -92,6 +144,16 @@ When `withChevron` is enabled on a `bento-list-item`, the full row becomes inter
92
144
  | `Space` | Activates the clickable list item, triggering the click event. |
93
145
  | `Enter` | Activates the clickable list item, triggering the click event. |
94
146
 
147
+ When `draggable` is enabled, each item's drag handle supports:
148
+
149
+ | Keyboard interaction | Action |
150
+ | -------------------- | ------------------------------------------------ |
151
+ | `Tab` | Moves focus to or from the drag handle button. |
152
+ | `Enter` / `Space` | Opens the reorder menu. |
153
+ | `Arrow keys` | Navigates menu items (Move up, Move down, etc.). |
154
+ | `Enter` | Executes the selected move action. |
155
+ | `Escape` | Closes the reorder menu. |
156
+
95
157
  ## Resources
96
158
 
97
159
  - [Figma link](https://www.figma.com/design/uLabwF3243jdMDsNSP7U9I/Bento---Components?node-id=46446-2486&p=f&t=mea9nPq0MXKb2lOX-0)