@adyen/bento-mcp 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/README.md +7 -6
- package/bin/add-skill.js +12 -1
- package/dist/assets/components/anchor-scroller/anchor-scroller.vue +1 -1
- package/dist/assets/components/anchor-scroller/components/anchor-scroller-list/anchor-scroller-list.types.ts +1 -1
- package/dist/assets/components/anchor-scroller/components/anchor-scroller-list/anchor-scroller-list.vue +1 -1
- package/dist/assets/components/button/button.docs.mdx +24 -0
- package/dist/assets/components/button/button.vue +1 -1
- package/dist/assets/components/button/components/button-actions/button-actions.docs.mdx +14 -1
- package/dist/assets/components/currency/currency.types.ts +1 -1
- package/dist/assets/components/currency/currency.vue +1 -1
- package/dist/assets/components/date-range-picker/components/date-range-picker-calendar/date-range-picker-calendar.vue +1 -1
- package/dist/assets/components/date-range-picker/date-range-picker.vue +1 -1
- package/dist/assets/components/draggable/components/draggable-handle/draggable-handle.vue +1 -1
- package/dist/assets/components/dropdown/components/dropdown-options-container/dropdown-options-container.vue +1 -1
- package/dist/assets/components/dropdown/dropdown.docs.mdx +58 -0
- package/dist/assets/components/dropdown/dropdown.stories.ts +1 -1
- package/dist/assets/components/dropdown/dropdown.vue +1 -1
- package/dist/assets/components/filter-bar/components/range-filter/range-filter.types.ts +1 -0
- package/dist/assets/components/filter-bar/components/range-filter/range-filter.vue +1 -1
- package/dist/assets/components/filter-bar/filter-bar.docs.mdx +74 -1
- package/dist/assets/components/filter-bar/filter-bar.stories.ts +1 -1
- package/dist/assets/components/filter-bar/filter-bar.types.ts +1 -1
- package/dist/assets/components/filter-bar/filter-bar.vue +1 -1
- package/dist/assets/components/header/header.docs.mdx +8 -0
- package/dist/assets/components/header-with-views/header-with-views.docs.mdx +12 -0
- package/dist/assets/components/input-field/input-field.vue +1 -1
- package/dist/assets/components/inspector/components/inspector-page/inspector-page.types.ts +1 -0
- package/dist/assets/components/inspector/components/inspector-page/inspector-page.vue +1 -0
- package/dist/assets/components/inspector/inspector.docs.mdx +14 -0
- package/dist/assets/components/inspector/inspector.stories.ts +1 -1
- package/dist/assets/components/inspector/inspector.types.ts +1 -1
- package/dist/assets/components/inspector/inspector.vue +1 -1
- package/dist/assets/components/internal/dialog-page/dialog-page.vue +1 -1
- package/dist/assets/components/internal/listbox/components/listbox-lazy-load/listbox-lazy-load.types.ts +1 -0
- package/dist/assets/components/internal/listbox/components/listbox-lazy-load/listbox-lazy-load.vue +1 -0
- package/dist/assets/components/internal/listbox/components/listbox-multi-select/components/listbox-multi-select-options/components/listbox-multi-select-category/listbox-multi-select-category.vue +1 -1
- package/dist/assets/components/internal/listbox/components/listbox-multi-select/components/listbox-multi-select-options/components/listbox-multi-select-nested-lazy-load/listbox-multi-select-nested-lazy-load.types.ts +1 -0
- package/dist/assets/components/internal/listbox/components/listbox-multi-select/components/listbox-multi-select-options/components/listbox-multi-select-nested-lazy-load/listbox-multi-select-nested-lazy-load.vue +1 -0
- package/dist/assets/components/internal/listbox/components/listbox-multi-select/components/listbox-multi-select-options/listbox-multi-select-options.vue +1 -1
- package/dist/assets/components/internal/listbox/components/listbox-multi-select/listbox-multi-select.vue +1 -1
- package/dist/assets/components/internal/listbox/components/listbox-single-select/listbox-single-select.vue +1 -1
- package/dist/assets/components/internal/listbox/listbox.vue +1 -1
- package/dist/assets/components/table-of-contents/components/table-of-contents-list/table-of-contents-list.types.ts +1 -0
- package/dist/assets/components/table-of-contents/components/table-of-contents-list/table-of-contents-list.vue +1 -0
- package/dist/assets/components/table-of-contents/table-of-contents.docs.mdx +34 -0
- package/dist/assets/components/table-of-contents/table-of-contents.stories.ts +1 -0
- package/dist/assets/components/table-of-contents/table-of-contents.types.ts +1 -0
- package/dist/assets/components/table-of-contents/table-of-contents.vue +1 -0
- package/dist/assets/components/tag/tag.types.ts +1 -1
- package/dist/assets/components.json +2 -0
- package/dist/assets/composables/use-numeric-input/use-numeric-input.types.ts +1 -0
- package/dist/assets/composables/use-section-navigation-keyboard/use-section-navigation-keyboard.types.ts +1 -0
- package/dist/assets/deprecations.md +136 -136
- package/dist/assets/index.ts +1 -1
- package/dist/assets/usage.json +81 -79
- package/dist/assets/utils/ts/scroll-to-section/scroll-to-section.types.ts +1 -0
- package/dist/main.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.0 (2026-07-08)
|
|
4
|
+
|
|
5
|
+
### Build System
|
|
6
|
+
|
|
7
|
+
- added NX implicit dependencies to the MCP ([5b8045faa](https://github.com/Adyen/bento/commit/5b8045faa))
|
|
8
|
+
|
|
9
|
+
### ❤️ Thank You
|
|
10
|
+
|
|
11
|
+
- daver
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
## 0.7.0 (2026-06-23)
|
|
15
|
+
|
|
16
|
+
### Features
|
|
17
|
+
|
|
18
|
+
- updated skill adding logic to include droid ([a8d725483](https://github.com/Adyen/bento/commit/a8d725483))
|
|
19
|
+
|
|
20
|
+
### ❤️ Thank You
|
|
21
|
+
|
|
22
|
+
- gerald
|
|
23
|
+
|
|
24
|
+
|
|
3
25
|
## 0.6.0 (2026-06-09)
|
|
4
26
|
|
|
5
27
|
### Features
|
package/README.md
CHANGED
|
@@ -34,11 +34,12 @@ To configure the MCP server, add the following to your client's settings file:
|
|
|
34
34
|
The location of this file may vary depending on your client and setup. For project-level and global configuration, you
|
|
35
35
|
can typically use:
|
|
36
36
|
|
|
37
|
-
| Client
|
|
38
|
-
|
|
|
39
|
-
| Claude
|
|
40
|
-
|
|
|
41
|
-
|
|
|
37
|
+
| Client | Local Path | Global Path |
|
|
38
|
+
| ------------- | ----------------------------- | ------------------------------------- |
|
|
39
|
+
| Claude | `.claude/settings.local.json` | `~/.claude/settings.json` |
|
|
40
|
+
| Droid/Factory | `.factory/mcp.json` | `~/.factory/mcp.json` |
|
|
41
|
+
| Gemini | `.gemini/settings.json` | `~/.gemini/settings.json` |
|
|
42
|
+
| Windsurf | `-` | `~/.codeium/windsurf/mcp_config.json` |
|
|
42
43
|
|
|
43
44
|
Once configured, you can invoke the tool by its name (`bento`).
|
|
44
45
|
|
|
@@ -51,7 +52,7 @@ The Bento AI Skill provides a "Verify & Interpret" implementation workflow. To a
|
|
|
51
52
|
Run the following command in your project root:
|
|
52
53
|
|
|
53
54
|
```bash
|
|
54
|
-
|
|
55
|
+
npm install -D @adyen/bento-mcp && npx bento-add-skill
|
|
55
56
|
```
|
|
56
57
|
|
|
57
58
|
#### For Other Agents (Claude, Cursor, etc.)
|
package/bin/add-skill.js
CHANGED
|
@@ -30,7 +30,18 @@ try {
|
|
|
30
30
|
console.log(`ℹ️ Gemini CLI: Skipping local installation (ensure you are in your project root)`);
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
// 2.
|
|
33
|
+
// 2. Install for Droid (Local)
|
|
34
|
+
const droidPath = join(process.cwd(), '.factory/skills/writing-bento-components/SKILL.md');
|
|
35
|
+
try {
|
|
36
|
+
mkdirSync(dirname(droidPath), { recursive: true });
|
|
37
|
+
copyFileSync(skillSource, droidPath);
|
|
38
|
+
console.log(`✅ Droid: Added to ${droidPath}`);
|
|
39
|
+
} catch (error) {
|
|
40
|
+
// Silently continue if Droid install fails (e.g. not in a project root)
|
|
41
|
+
console.log(`ℹ️ Droid: Skipping local installation (ensure you are in your project root)`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 3. Support for other agents via @codemcp/agentskills
|
|
34
45
|
console.log('\n🌟 Multi-Agent Support (Claude, Cursor, etc.)');
|
|
35
46
|
try {
|
|
36
47
|
// Check if agentskills is available
|
|
@@ -1 +1 @@
|
|
|
1
|
-
<template> <div v-if="computedItems.length" class="b-anchor-scroller"> <nav ref="navigationRef" class="b-anchor-scroller__navigation" :class="navigationConditionalClasses" :aria-labelledby="navLabelId" > <fixed-scroller condensed centered> <div class="b-anchor-scroller__navigation-panel"> <bento-typography :id="navLabelId" el="span" class="b-anchor-scroller__navigation-panel-label" stronger > {{ t('jumpTo') }} </bento-typography> <anchor-scroller-list :items="computedItems" :active-index="currentActiveIndex" :scroll-offset="bottom" /> </div> </fixed-scroller> </nav> <slot /> </div> </template> <script setup lang="ts"> import { computed, ref, toRef, watch } from 'vue'; import { refDebounced, useElementBounding } from '@vueuse/core'; import BentoTypography from '@/components/typography/typography.vue'; import { FixedScroller } from '@/internal'; import { type BentoAnchorScrollerItem, type BentoAnchorScrollerProps } from './anchor-scroller.types'; import { AnchorScrollerList } from './components/anchor-scroller-list'; import { useI18n } from '@/utils/ts/i18n'; import { generateUid } from '@/core/utils/ts'; import { unrefElement } from '@/directives/click-outside/utils'; import messages from './messages.json'; import { useScrollSpy } from '
|
|
1
|
+
<template> <div v-if="computedItems.length" class="b-anchor-scroller"> <nav ref="navigationRef" class="b-anchor-scroller__navigation" :class="navigationConditionalClasses" :aria-labelledby="navLabelId" > <fixed-scroller condensed centered> <div class="b-anchor-scroller__navigation-panel"> <bento-typography :id="navLabelId" el="span" class="b-anchor-scroller__navigation-panel-label" stronger > {{ t('jumpTo') }} </bento-typography> <anchor-scroller-list :items="computedItems" :active-index="currentActiveIndex" :scroll-offset="bottom" /> </div> </fixed-scroller> </nav> <slot /> </div> </template> <script setup lang="ts"> import { computed, ref, toRef, watch } from 'vue'; import { refDebounced, useElementBounding } from '@vueuse/core'; import BentoTypography from '@/components/typography/typography.vue'; import { FixedScroller } from '@/internal'; import { type BentoAnchorScrollerItem, type BentoAnchorScrollerProps } from './anchor-scroller.types'; import { AnchorScrollerList } from './components/anchor-scroller-list'; import { useI18n } from '@/utils/ts/i18n'; import { generateUid } from '@/core/utils/ts'; import { unrefElement } from '@/directives/click-outside/utils'; import messages from './messages.json'; import { useScrollSpy } from '@/composables/use-scroll-spy'; import { printDevelopmentWarning } from '@/utils/ts/print-development-warning'; type MessageSchema = (typeof messages)['en-US']; const { t } = useI18n<{ message: MessageSchema }>({ messages }); const props = withDefaults(defineProps<BentoAnchorScrollerProps>(), { activeIndex: undefined, }); const emit = defineEmits<{ /** * Emits update event when the active index is changed */ (e: 'update:active-index', value: number): void; }>(); const navigationRef = ref(null); const computedItems = computed<Array<BentoAnchorScrollerItem>>(() => props.items.map(item => ({ ...item }))); // Skip and cache only items that are enabled const enabledItems = computed(() => props.items .map((item, originalIndex) => ({ element: unrefElement(item.elementRef), originalIndex, disabled: item.disabled, })) .filter( (item): item is { element: Element; originalIndex: number; disabled: boolean } => !item.disabled && !!item.element ) ); const { top, bottom } = useElementBounding(navigationRef); // Extract strictly the elements for the ScrollSpy to watch const scrollToTargets = computed(() => enabledItems.value.map(c => c.element)); const navigationConditionalClasses = computed(() => ({ 'b-anchor-scroller__navigation--pinned': top.value <= 1, })); const { activeIndex: relativeActiveIndex } = useScrollSpy(scrollToTargets, { default: toRef(props, 'activeIndex'), }); const debouncedActiveIndex = refDebounced(relativeActiveIndex, 200); const currentActiveIndex = computed(() => { const candidate = enabledItems.value[debouncedActiveIndex.value]; return candidate ? candidate.originalIndex : 0; }); watch(currentActiveIndex, val => { if (Number.isFinite(val)) { emit('update:active-index', val); } }); watch( () => props.activeIndex, val => { if (Number.isFinite(val) && computedItems.value[val].disabled) { printDevelopmentWarning(`[bento-anchor-scroller] activeIndex should not be a disabled item`); } } ); const navLabelId = generateUid('bento-anchor-scroller-label'); </script> <script lang="ts"> /** * A navigation component that tracks page scroll and allows jumping to sections. * * @example * import { BentoAnchorScroller } from '@adyen/bento-vue2'; * import { ref } from 'vue'; * * export default { * components: { BentoAnchorScroller }, * template: ` * <bento-anchor-scroller :items="items"> * <section ref="section1">Section 1</section> * <section ref="section2">Section 2</section> * </bento-anchor-scroller> * `, * setup() { * const section1 = ref(null); * const section2 = ref(null); * const items = ref([ * { title: 'Section 1', elementRef: section1 }, * { title: 'Section 2', elementRef: section2 }, * ]); * return { * items, * section1, * section2, * } * } * } */ export default { i18n: { messages }, name: 'bento-anchor-scroller', }; </script> <style lang="scss" scoped src="./anchor-scroller.scss" />
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type SectionNavigationItem } from '@/types/section-navigation'; export interface AnchorScrollerListItem extends SectionNavigationItem { disabled?: boolean; } export interface AnchorScrollerListProps { activeIndex?: number; items: Array<AnchorScrollerListItem>; scrollOffset?: number; }
|
|
@@ -1 +1 @@
|
|
|
1
|
-
<template> <ul class="b-anchor-scroller-list" @keydown="onKeydownNavigation"> <li v-for="({ title, disabled, elementRef }, index) in items" :key="index" class="b-anchor-scroller-list__item"> <bento-button ref="
|
|
1
|
+
<template> <ul class="b-anchor-scroller-list" @keydown="onKeydownNavigation"> <li v-for="({ title, disabled, elementRef }, index) in items" :key="index" class="b-anchor-scroller-list__item"> <bento-button ref="navigationItemRefs" :aria-current="activeIndex === index ? 'location' : null" class="b-anchor-scroller-list__item-button" :class="{ 'b-anchor-scroller-list__item-button--active': activeIndex === index, 'b-anchor-scroller-list__item-button--disabled': disabled, }" :aria-disabled="disabled" :tabindex="isFocusable(index) ? null : '-1'" variant="tertiary-with-background" @click="onScrollToSection(elementRef, disabled)" @keydown.space.native.prevent="onScrollToSection(elementRef, disabled)" @keydown.enter.native.prevent="onScrollToSection(elementRef, disabled)" > {{ title }} </bento-button> </li> </ul> </template> <script setup lang="ts"> import BentoButton from '@/components/button/button.vue'; import { useSectionNavigationKeyboard } from '@/composables/use-section-navigation-keyboard'; import { scrollToSection, type ScrollToSectionTarget } from '@/utils/ts/scroll-to-section'; import { type AnchorScrollerListProps } from './anchor-scroller-list.types'; import { computed, toRef } from 'vue'; const props = withDefaults(defineProps<AnchorScrollerListProps>(), { activeIndex: undefined, scrollOffset: undefined, }); const navigationItems = computed(() => props.items); const { isFocusable, navigationItemRefs, onKeydownNavigation } = useSectionNavigationKeyboard(navigationItems, { isDisabled: item => item.disabled, initialIndex: toRef(props, 'activeIndex'), }); const onScrollToSection = ( elementRef: AnchorScrollerListProps['items'][number]['elementRef'], disabled?: boolean ) => scrollToSection(elementRef as ScrollToSectionTarget, { disabled, scrollOffset: props.scrollOffset, warningPrefix: 'bento-anchor-scroller', }); </script> <style lang="scss" scoped src="./anchor-scroller-list.scss" />
|
|
@@ -121,6 +121,30 @@ page. The following keys trigger the following actions:
|
|
|
121
121
|
| `Space` | Activates the button, triggering the click event. |
|
|
122
122
|
| `Enter` | Activates the button, triggering the click event. |
|
|
123
123
|
|
|
124
|
+
### Accessible Disabled State (`aria-disabled`)
|
|
125
|
+
|
|
126
|
+
By default, a button with the `disabled` prop uses the native HTML `disabled` attribute, which removes it from the tab
|
|
127
|
+
order and prevents all pointer events. This can be problematic when the button is used as a target for components like
|
|
128
|
+
`bento-popover` or `bento-tooltip`, because the popover or tooltip cannot be triggered on a non-interactive element.
|
|
129
|
+
|
|
130
|
+
To keep a disabled button **focusable and hoverable** while still appearing and behaving as disabled, use the
|
|
131
|
+
`aria-disabled` attribute instead of the `disabled` prop:
|
|
132
|
+
|
|
133
|
+
```html
|
|
134
|
+
<!-- Native disabled: not focusable, no hover events -->
|
|
135
|
+
<bento-button disabled>Submit</bento-button>
|
|
136
|
+
|
|
137
|
+
<!-- Accessible disabled: focusable, hoverable, clicks suppressed -->
|
|
138
|
+
<bento-button aria-disabled="true">Submit</bento-button>
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
When `aria-disabled` is set, the button will:
|
|
142
|
+
|
|
143
|
+
- Render `aria-disabled="true"` instead of the native `disabled` attribute.
|
|
144
|
+
- Apply the same disabled visual styling (`b-button--disabled`).
|
|
145
|
+
- Suppress click events (clicks are intercepted and stopped).
|
|
146
|
+
- Remain in the tab order and respond to hover, allowing popovers and tooltips to activate.
|
|
147
|
+
|
|
124
148
|
### Roles, States, and Properties
|
|
125
149
|
|
|
126
150
|
Adhering to the Adhering to the [WAI-ARIA 1.2 standard](https://www.w3.org/TR/wai-aria-1.2/) we follow the
|
|
@@ -1 +1 @@
|
|
|
1
|
-
<template> <component :is="componentType" v-bind="componentProps" class="b-button" :class="{ ...conditionalClasses, ...getIconOnlyClass() }" @click.capture="onClickButton" > <span v-if="hasSlot('iconLeft')" class="b-button__icon-left" data-testid="icon-button-left" :aria-hidden="hasSlot('default') ? 'true' : null" > <slot name="iconLeft" /> </span> <bento-typography v-if="hasSlot('default')" el="span" variant="body" stronger class="b-button__label"> <slot /> </bento-typography> <span v-if="hasSlot('iconRight')" class="b-button__icon-right" data-testid="icon-button-right" :aria-hidden="hasSlot('default') ? 'true' : null" > <slot name="iconRight" /> </span> </component> </template> <script setup lang="ts"> import { computed, type PropType, useAttrs, useSlots } from 'vue'; import { BentoButtonPropClass, BentoButtonVariant } from './button.types'; import { BentoTypography } from '@/components/typography'; import { useHasSlot } from '@/composables/use-has-slot/use-has-slot'; import { type Booleanish } from '../../types/prop-types'; import type { RouterLinkProps } from 'vue-router/types/router.d.ts'; import type { AnchorHTMLAttributes, ButtonHTMLAttributes } from 'vue/types/jsx.d.ts'; import { BentoLink, BentoLinkVariant } from '@/components/link'; // TODO: replace all this code when migrated to Vue 3 is complete as this is the only // current way to extend the `router-link` component in Vue 2 and Vue Router 3 export interface LinkProps extends RouterLinkProps, AnchorHTMLAttributes { args?: { props: RouterLinkProps }; /** * Set the rendered link to a regular external anchor link. */ external: boolean; } const props = defineProps({ /** * Reduces sizing of button */ condensed: { type: Boolean, default: false }, /** * Displays button as critical */ critical: { type: Boolean, default: false }, /** * Disables button functionality */ disabled: { type: Boolean, default: false }, /** * Sets color set to inverse */ inverse: { type: Boolean, default: false }, /** * Object containing props passed on to the BentoLink component. @see BentoLink documentation for which properties you can pass. */ link: { type: Object, default: undefined }, /** * The default behavior of the button. * Possible values are: 'button', 'reset', 'submit' */ type: { type: String as PropType<ButtonHTMLAttributes['type']>, default: 'button', }, /** * Types of flavour of Button * @values primary, secondary, tertiary, tertiary-with-background */ variant: { type: String as PropType<BentoButtonVariant | `${BentoButtonVariant}`>, default: BentoButtonVariant.PRIMARY, validator: (value: BentoButtonVariant) => Object.values(BentoButtonVariant).includes(value), }, }); const emit = defineEmits<{ /** * Emitted when the element has lost focus */ (e: 'blur'); /** * Emitted when the button is clicked */ (e: 'click', event: MouseEvent); /** * Emitted when the element has received focus */ (e: 'focus'); /** * Emitted when the "ESC" key is pressed */ (e: 'keydown.esc'); }>(); const attrs = useAttrs(); const slots = useSlots(); const hasSlot = useHasSlot(slots); const componentType = computed(() => { if (props?.link !== undefined) { /** * Display a "span" with role "link" and "aria-disabled=true" * if the link is disabled. * @link https://www.scottohara.me/blog/2021/05/28/disabled-links.html */ return
|
|
1
|
+
<template> <component :is="componentType" v-bind="componentProps" class="b-button" :class="{ ...conditionalClasses, ...getIconOnlyClass() }" @click.capture="onClickButton" > <span v-if="hasSlot('iconLeft')" class="b-button__icon-left" data-testid="icon-button-left" :aria-hidden="hasSlot('default') ? 'true' : null" > <slot name="iconLeft" /> </span> <bento-typography v-if="hasSlot('default')" el="span" variant="body" stronger class="b-button__label"> <slot /> </bento-typography> <span v-if="hasSlot('iconRight')" class="b-button__icon-right" data-testid="icon-button-right" :aria-hidden="hasSlot('default') ? 'true' : null" > <slot name="iconRight" /> </span> </component> </template> <script setup lang="ts"> import { computed, type PropType, useAttrs, useSlots } from 'vue'; import { BentoButtonPropClass, BentoButtonVariant } from './button.types'; import { BentoTypography } from '@/components/typography'; import { useHasSlot } from '@/composables/use-has-slot/use-has-slot'; import { type Booleanish } from '../../types/prop-types'; import type { RouterLinkProps } from 'vue-router/types/router.d.ts'; import type { AnchorHTMLAttributes, ButtonHTMLAttributes } from 'vue/types/jsx.d.ts'; import { BentoLink, BentoLinkVariant } from '@/components/link'; // TODO: replace all this code when migrated to Vue 3 is complete as this is the only // current way to extend the `router-link` component in Vue 2 and Vue Router 3 export interface LinkProps extends RouterLinkProps, AnchorHTMLAttributes { args?: { props: RouterLinkProps }; /** * Set the rendered link to a regular external anchor link. */ external: boolean; } const props = defineProps({ /** * Reduces sizing of button */ condensed: { type: Boolean, default: false }, /** * Displays button as critical */ critical: { type: Boolean, default: false }, /** * Disables button functionality */ disabled: { type: Boolean, default: false }, /** * Sets color set to inverse */ inverse: { type: Boolean, default: false }, /** * Object containing props passed on to the BentoLink component. @see BentoLink documentation for which properties you can pass. */ link: { type: Object, default: undefined }, /** * The default behavior of the button. * Possible values are: 'button', 'reset', 'submit' */ type: { type: String as PropType<ButtonHTMLAttributes['type']>, default: 'button', }, /** * Types of flavour of Button * @values primary, secondary, tertiary, tertiary-with-background */ variant: { type: String as PropType<BentoButtonVariant | `${BentoButtonVariant}`>, default: BentoButtonVariant.PRIMARY, validator: (value: BentoButtonVariant) => Object.values(BentoButtonVariant).includes(value), }, }); const emit = defineEmits<{ /** * Emitted when the element has lost focus */ (e: 'blur'); /** * Emitted when the button is clicked */ (e: 'click', event: MouseEvent); /** * Emitted when the element has received focus */ (e: 'focus'); /** * Emitted when the "ESC" key is pressed */ (e: 'keydown.esc'); }>(); const attrs = useAttrs(); const slots = useSlots(); const hasSlot = useHasSlot(slots); const isAriaDisabled = computed( () => attrs['aria-disabled'] === 'true' || attrs['aria-disabled'] === '' || attrs['aria-disabled'] === true ); const isDisabled = computed(() => props.disabled || isAriaDisabled.value); const componentType = computed(() => { if (props?.link !== undefined) { /** * Display a "span" with role "link" and "aria-disabled=true" * if the link is disabled. * @link https://www.scottohara.me/blog/2021/05/28/disabled-links.html */ return isDisabled.value ? 'span' : BentoLink; } return 'button'; }); const ariaAttributes = computed(() => ({ 'aria-controls': attrs['aria-controls']?.toString() as string, 'aria-expanded': attrs['aria-expanded']?.toString() as Booleanish, })); const componentProps = computed(() => componentType.value === 'button' ? { ...ariaAttributes.value, ...(isAriaDisabled.value ? { 'aria-disabled': 'true' } : { disabled: props.disabled }), type: props.type, } : { ...props.link, disableTypography: true, variant: BentoLinkVariant.DEFAULT, isButton: true, /** * When the button is a disabled link, it's rendered as a `<span>`. * These attributes ensure it's still announced as a disabled link by screen readers. */ 'aria-disabled': isDisabled.value ? 'true' : undefined, role: isDisabled.value ? 'link' : undefined, } ); const variantClass = computed(() => ({ [`b-button--${props.variant}${props.critical ? `-${BentoButtonPropClass.DESTRUCTIVE}` : ''}`]: true, })); const conditionalClasses = computed(() => ({ ...variantClass.value, [`b-button--${BentoButtonPropClass.CONDENSED}`]: props.condensed, [`b-button--${BentoButtonPropClass.DISABLED}`]: isDisabled.value, [`b-button--${BentoButtonPropClass.INVERSE}`]: props.inverse, })); const getIconOnlyClass = () => ({ [`b-button--${BentoButtonPropClass.ICON_ONLY}`]: !hasSlot('default'), }); const onClickButton = (e: MouseEvent) => { if (isDisabled.value) { e.preventDefault(); e.stopPropagation(); } else { emit('click', e); } }; </script> <script lang="ts"> /** * Button component for all the various types of bento buttons. * * @example * import { BentoButton } from '@adyen/bento-vue2'; * import { CheckMarkIcon } from '@adyen/ui-assets-icons-16'; * * export default { * components: { BentoButton }, * template: ` * <bento-button * disabled * variant="secondary" * > * <template #iconLeft> * <check-mark svg-title="checked" /> * </template> * * Button text * * <template #iconRight> * <check-mark svg-title="checked" /> * </template> * </bento-button> * `, * } */ export default { name: 'bento-button', }; </script> <style lang="scss" scoped src="./button.scss" />
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Canvas, Meta, Source } from '@storybook/blocks';
|
|
2
2
|
import * as ButtonActionsStories from './button-actions.stories';
|
|
3
|
+
import ButtonActionsDomEventsExample from './__tests__/button-actions-dom-events-example.vue?raw';
|
|
3
4
|
|
|
4
5
|
<Meta of={ButtonActionsStories} />
|
|
5
6
|
|
|
@@ -40,7 +41,7 @@ configured:
|
|
|
40
41
|
language="typescript"
|
|
41
42
|
code={`
|
|
42
43
|
interface BentoButtonActionObject {
|
|
43
|
-
|
|
44
|
+
|
|
44
45
|
// Shared properties
|
|
45
46
|
title: string;
|
|
46
47
|
condensed?: boolean;
|
|
@@ -91,6 +92,18 @@ configured:
|
|
|
91
92
|
- `variant` (optional): the button's variant background color.
|
|
92
93
|
- See [menu](../?path=/story/components-menu--default) for the menu properties
|
|
93
94
|
|
|
95
|
+
#### Additional DOM events on action buttons
|
|
96
|
+
|
|
97
|
+
Use the action's `event` property for click handling. To listen to other button DOM events, such as `mouseenter` or
|
|
98
|
+
`mouseleave`, pass `elementRef` on the action object and attach listeners to the ref — for example, using
|
|
99
|
+
`useEventListener` from `@vueuse/core`. Note that `elementRef` provides a component instance, so when using standard
|
|
100
|
+
event listeners you need to access `.$el` to get the underlying DOM element.
|
|
101
|
+
|
|
102
|
+
<Source dark language="html" code={ButtonActionsDomEventsExample} />
|
|
103
|
+
|
|
104
|
+
When `bento-button-actions` is used inside another component, such as `bento-header` or `bento-header-with-views`, pass
|
|
105
|
+
the same `elementRef` on the action object.
|
|
106
|
+
|
|
94
107
|
#### Styling Guidelines
|
|
95
108
|
|
|
96
109
|
As a developer, you should rely on these patterns of `bento-button-actions` styling:
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export interface BentoCurrencyProps { /** * Currency code based on the ISO 4217 standard */ currency?: BentoCurrencyISOCode | `${BentoCurrencyISOCode}`; /** * Disable the default typography for the component. Only use this when the component is inside another typography component. */ disableTypography?: boolean; /** * Accepts currency value in major units instead of minor ones */ majorUnits?: boolean; /** * Currency value to be formatted */ value?: number; /** * Determines how the currency symbol and negative values are displayed. * This is only applicable to certain countries such as `en-US`. * * - `'standard'`: Default format (e.g., `-1,234.56 USD`). * - `'accounting'`: Uses accounting conventions, such as wrapping negative numbers in parentheses (e.g., `(1,234.56) USD`). * * @default 'standard' */ currencySign?: Intl.NumberFormatOptions['currencySign']; } export enum BentoCurrencyISOCode { AED = 'AED', AFA = 'AFA', ALL = 'ALL', AMD = 'AMD', ANG = 'ANG', AOA = 'AOA', ARS = 'ARS', AUD = 'AUD', AWG = 'AWG', AZN = 'AZN', BAM = 'BAM', BBD = 'BBD', BDT = 'BDT', BEF = 'BEF', BGN = 'BGN', BHD = 'BHD', BIF = 'BIF', BMD = 'BMD', BND = 'BND', BOB = 'BOB', BRL = 'BRL', BSD = 'BSD', BTC = 'BTC', BTN = 'BTN', BWP = 'BWP', BYR = 'BYR', BZD = 'BZD', CAD = 'CAD', CDF = 'CDF', CHF = 'CHF', CLF = 'CLF', CLP = 'CLP', CNY = 'CNY', COP = 'COP', CRC = 'CRC', CUC = 'CUC', CVE = 'CVE', CZK = 'CZK', DEM = 'DEM', DJF = 'DJF', DKK = 'DKK', DOP = 'DOP', DZD = 'DZD', EEK = 'EEK', EGP = 'EGP', ERN = 'ERN', ETB = 'ETB', EUR = 'EUR', FJD = 'FJD', FKP = 'FKP', GBP = 'GBP', GEL = 'GEL', GHS = 'GHS', GIP = 'GIP', GMD = 'GMD', GNF = 'GNF', GRD = 'GRD', GTQ = 'GTQ', GYD = 'GYD', HKD = 'HKD', HNL = 'HNL', HRK = 'HRK', HTG = 'HTG', HUF = 'HUF', IDR = 'IDR', ILS = 'ILS', INR = 'INR', IQD = 'IQD', IRR = 'IRR', ISK = 'ISK', ITL = 'ITL', JMD = 'JMD', JOD = 'JOD', JPY = 'JPY', KES = 'KES', KGS = 'KGS', KHR = 'KHR', KMF = 'KMF', KPW = 'KPW', KRW = 'KRW', KWD = 'KWD', KYD = 'KYD', KZT = 'KZT', LAK = 'LAK', LBP = 'LBP', LKR = 'LKR', LRD = 'LRD', LSL = 'LSL', LTC = 'LTC', LTL = 'LTL', LVL = 'LVL', LYD = 'LYD', MAD = 'MAD', MDL = 'MDL', MGA = 'MGA', MKD = 'MKD', MMK = 'MMK', MNT = 'MNT', MOP = 'MOP', MRO = 'MRO', MRU = 'MRU', MUR = 'MUR', MVR = 'MVR', MWK = 'MWK', MXN = 'MXN', MYR = 'MYR', MZM = 'MZM', NAD = 'NAD', NGN = 'NGN', NIO = 'NIO', NOK = 'NOK', NPR = 'NPR', NZD = 'NZD', OMR = 'OMR', PAB = 'PAB', PEN = 'PEN', PGK = 'PGK', PHP = 'PHP', PKR = 'PKR', PLN = 'PLN', PYG = 'PYG', QAR = 'QAR', RON = 'RON', RSD = 'RSD', RUB = 'RUB', RWF = 'RWF', SAR = 'SAR', SBD = 'SBD', SCR = 'SCR', SDG = 'SDG', SEK = 'SEK', SGD = 'SGD', SHP = 'SHP', SKK = 'SKK', SLL = 'SLL', SOS = 'SOS', SRD = 'SRD', SSP = 'SSP', STD = 'STD', SVC = 'SVC', SYP = 'SYP', SZL = 'SZL', THB = 'THB', TJS = 'TJS', TMT = 'TMT', TND = 'TND', TOP = 'TOP', TRY = 'TRY', TTD = 'TTD', TWD = 'TWD', TZS = 'TZS', UAH = 'UAH', UGX = 'UGX', USD = 'USD', UYU = 'UYU', UZS = 'UZS', VEF = 'VEF', VND = 'VND', VUV = 'VUV', WST = 'WST', XAF = 'XAF', XCD = 'XCD', XDR = 'XDR', XOF = 'XOF', XPF = 'XPF', YER = 'YER', ZAR = 'ZAR', ZMK = 'ZMK', ZWL = 'ZWL', }
|
|
1
|
+
export interface BentoCurrencyProps { /** * Currency code based on the ISO 4217 standard */ currency?: BentoCurrencyISOCode | `${BentoCurrencyISOCode}`; /** * Disable the default typography for the component. Only use this when the component is inside another typography component. */ disableTypography?: boolean; /** * Accepts currency value in major units instead of minor ones */ majorUnits?: boolean; /** * Currency value to be formatted */ value?: number; /** * Determines how the currency symbol and negative values are displayed. * This is only applicable to certain countries such as `en-US`. * * - `'standard'`: Default format (e.g., `-1,234.56 USD`). * - `'accounting'`: Uses accounting conventions, such as wrapping negative numbers in parentheses (e.g., `(1,234.56) USD`). * * @default 'standard' */ currencySign?: Intl.NumberFormatOptions['currencySign']; /** * Makes only the numeric part of the formatted currency bold. */ strongerAmount?: boolean; } export enum BentoCurrencyISOCode { AED = 'AED', AFA = 'AFA', ALL = 'ALL', AMD = 'AMD', ANG = 'ANG', AOA = 'AOA', ARS = 'ARS', AUD = 'AUD', AWG = 'AWG', AZN = 'AZN', BAM = 'BAM', BBD = 'BBD', BDT = 'BDT', BEF = 'BEF', BGN = 'BGN', BHD = 'BHD', BIF = 'BIF', BMD = 'BMD', BND = 'BND', BOB = 'BOB', BRL = 'BRL', BSD = 'BSD', BTC = 'BTC', BTN = 'BTN', BWP = 'BWP', BYR = 'BYR', BZD = 'BZD', CAD = 'CAD', CDF = 'CDF', CHF = 'CHF', CLF = 'CLF', CLP = 'CLP', CNY = 'CNY', COP = 'COP', CRC = 'CRC', CUC = 'CUC', CVE = 'CVE', CZK = 'CZK', DEM = 'DEM', DJF = 'DJF', DKK = 'DKK', DOP = 'DOP', DZD = 'DZD', EEK = 'EEK', EGP = 'EGP', ERN = 'ERN', ETB = 'ETB', EUR = 'EUR', FJD = 'FJD', FKP = 'FKP', GBP = 'GBP', GEL = 'GEL', GHS = 'GHS', GIP = 'GIP', GMD = 'GMD', GNF = 'GNF', GRD = 'GRD', GTQ = 'GTQ', GYD = 'GYD', HKD = 'HKD', HNL = 'HNL', HRK = 'HRK', HTG = 'HTG', HUF = 'HUF', IDR = 'IDR', ILS = 'ILS', INR = 'INR', IQD = 'IQD', IRR = 'IRR', ISK = 'ISK', ITL = 'ITL', JMD = 'JMD', JOD = 'JOD', JPY = 'JPY', KES = 'KES', KGS = 'KGS', KHR = 'KHR', KMF = 'KMF', KPW = 'KPW', KRW = 'KRW', KWD = 'KWD', KYD = 'KYD', KZT = 'KZT', LAK = 'LAK', LBP = 'LBP', LKR = 'LKR', LRD = 'LRD', LSL = 'LSL', LTC = 'LTC', LTL = 'LTL', LVL = 'LVL', LYD = 'LYD', MAD = 'MAD', MDL = 'MDL', MGA = 'MGA', MKD = 'MKD', MMK = 'MMK', MNT = 'MNT', MOP = 'MOP', MRO = 'MRO', MRU = 'MRU', MUR = 'MUR', MVR = 'MVR', MWK = 'MWK', MXN = 'MXN', MYR = 'MYR', MZM = 'MZM', NAD = 'NAD', NGN = 'NGN', NIO = 'NIO', NOK = 'NOK', NPR = 'NPR', NZD = 'NZD', OMR = 'OMR', PAB = 'PAB', PEN = 'PEN', PGK = 'PGK', PHP = 'PHP', PKR = 'PKR', PLN = 'PLN', PYG = 'PYG', QAR = 'QAR', RON = 'RON', RSD = 'RSD', RUB = 'RUB', RWF = 'RWF', SAR = 'SAR', SBD = 'SBD', SCR = 'SCR', SDG = 'SDG', SEK = 'SEK', SGD = 'SGD', SHP = 'SHP', SKK = 'SKK', SLL = 'SLL', SOS = 'SOS', SRD = 'SRD', SSP = 'SSP', STD = 'STD', SVC = 'SVC', SYP = 'SYP', SZL = 'SZL', THB = 'THB', TJS = 'TJS', TMT = 'TMT', TND = 'TND', TOP = 'TOP', TRY = 'TRY', TTD = 'TTD', TWD = 'TWD', TZS = 'TZS', UAH = 'UAH', UGX = 'UGX', USD = 'USD', UYU = 'UYU', UZS = 'UZS', VEF = 'VEF', VND = 'VND', VUV = 'VUV', WST = 'WST', XAF = 'XAF', XCD = 'XCD', XDR = 'XDR', XOF = 'XOF', XPF = 'XPF', YER = 'YER', ZAR = 'ZAR', ZMK = 'ZMK', ZWL = 'ZWL', }
|
|
@@ -1 +1 @@
|
|
|
1
|
-
<template> <div class="b-currency"> <bento-typography v-if="!disableTypography" class="b-currency__amount" el="span" variant="body" strong> {{
|
|
1
|
+
<template> <div class="b-currency" :aria-label="formattedCurrencyParts.formattedCurrency"> <bento-typography v-if="!disableTypography" class="b-currency__amount" el="span" variant="body" strong> <bento-typography el="span" :strongest="strongerAmount">{{ formattedCurrencyParts.currencyNumber }}</bento-typography> <span>{{ formattedCurrencyCode }}</span> </bento-typography> <template v-else>{{ formattedCurrencyParts.formattedCurrency }}</template> </div> </template> <script setup lang="ts"> import { computed, onMounted, useSlots } from 'vue'; import { BentoTypography } from '@/components/typography'; import { useBentoCurrency } from '@/composables/use-bento-currency/use-bento-currency'; import { deprecate } from '@/utils/ts/deprecate'; import { getSlotText } from '@/utils/ts/get-slot-text'; import { BentoCurrencyISOCode, type BentoCurrencyProps } from './currency.types'; const slots = useSlots(); const props = withDefaults(defineProps<BentoCurrencyProps>(), { currency: BentoCurrencyISOCode.EUR, disableTypography: false, majorUnits: false, value: undefined, currencySign: 'standard', }); const { formatCurrencyParts } = useBentoCurrency(); const formattedCurrencyParts = computed(() => { const rawCurrencyValue = props.value ?? getSlotText(slots)('default'); return formatCurrencyParts(rawCurrencyValue, { currency: props.currency, currencySign: props.currencySign, majorUnits: props.majorUnits, }); }); const formattedCurrencyCode = computed(() => `\u00A0${formattedCurrencyParts.value.currencyCode}`); onMounted(() => { if (getSlotText(slots)('default')) { deprecate( 'BentoCurrency "default" slot', `The use of "default" slot in "BentoCurrency" is no longer supported as it causes issues with dynamic content. Use the "value" property instead. <bento-currency :value="12345.67" currency="USD" major-units="false" />`, '2.0.0' ); } }); </script> <script lang="ts"> /** * A Currency component is used to display a formatted currency value. * * @example * import { BentoCurrency, BentoCurrencyISOCode } from '@adyen/bento-vue2'; * * export default { * components: { BentoCurrency }, * template: ` * <bento-currency * :currency="BentoCurrencyISOCode.USD" * :value="76.33" * /> * ` * } */ export default { name: 'bento-currency', i18n: {}, // required to enable E2E to run as they do not have vue-i18n set up }; </script> <style lang="scss" scoped src="./currency.scss" />
|
|
@@ -1 +1 @@
|
|
|
1
|
-
<template> <div class="b-date-range-picker-calendar"> <!-- Form --> <div class="b-date-range-picker-calendar__form-container" :class="computedFormContainerClasses" data-testid="date-range-picker-calendar-form-container" > <div class="b-date-range-picker-calendar__form"> <template v-if="hasSlot('title')"> <slot name="title" /> </template> <bento-dropdown v-if="quickSelectRanges" :aria-label="t('customRange')" :items="quickSelectRangeDefaultItems" :model-value="selectedRangeSelectorValue" @update:model-value="onRangeSelectorInput" /> <bento-segmented-control v-if="granularities" :items="granularityItems" :model-value="internalRangeDate.granularity" full-width @update:model-value="onGranularityInput" > </bento-segmented-control> <bento-alert v-if="maxRangeMessage" variant="tip"> <template #description> {{ maxRangeMessage }} </template> </bento-alert> <div> <bento-typography stronger el="span" class="b-date-range-picker-calendar__label"> From </bento-typography> <date-time-input :date-model-value="startDateValue" :date-props="{ ariaLabel: t('dateFrom'), type: dateInputType, description: !isTouchDevice ? t(dateFormat) : undefined, errorMessage: startDateErrorText, }" :error-message="startSharedError" :show-time-input="allowTimeInput" :time-model-value="dateTextInput.startTime" :time-props="{ ariaLabel: t('timeFrom'), errorMessage: startSharedError, }" class="b-date-range-picker-calendar__form-input" @update:date-model-value="onStartDateInput" @date:keydown="onDateInputKeyDown" @update:time-model-value="onStartTimeInput" @time:error="onStartTimeError" @time:valid="onStartTimeValid" /> </div> <div> <bento-typography stronger el="span" class="b-date-range-picker-calendar__label"> To </bento-typography> <date-time-input :date-model-value="endDateValue" :date-props="{ ariaLabel: t('dateTo'), type: dateInputType, description: !isTouchDevice ? t(dateFormat) : undefined, errorMessage: endDateErrorText, }" :error-message="endSharedError" :show-time-input="allowTimeInput" :time-model-value="dateTextInput.endTime" :time-props="{ ariaLabel: t('timeTo'), errorMessage: endSharedError, }" class="b-date-range-picker-calendar__form-input" @update:date-model-value="onEndDateInput" @date:keydown="onDateInputKeyDown" @update:time-model-value="onEndTimeInput" @time:error="onEndTimeError" @time:valid="onEndTimeValid" /> </div> </div> <div v-if="hasSlot('actions')"> <slot name="actions" /> </div> </div> <!-- Calendar --> <div v-if="!hasNoCalendars && !isTouchDevice" class="b-date-range-picker-calendar__calendars-container" data-testid="date-range-picker-calendar-calendar-container" > <calendar :value="internalRangeDate" :first-day-of-week="firstDayOfWeek" :is-date-disabled="isDateDisabled" :max-range="maxRange" :min="computedMinMax.min" :max="computedMinMax.max" :number-of-months="numberOfMonths" :show-end-date-on-open="showEndDateOnOpen" :granularity="selectedGranularity" :variant="variant" :year-range="yearRange" is-range @input="onDateInput" @start-date-selected="onStartDateSelected" @end-date-selected="onEndDateSelected" /> </div> </div> </template> <script setup lang="ts"> import { computed, onMounted, type PropType, reactive, ref, toRaw, useSlots } from 'vue'; import { Calendar, type CalendarGranularityType } from '@/internal'; import { DateTimeInput } from '../date-time-input'; import { BentoAlert } from '@/components/alert'; import { BentoTypography } from '@/components/typography'; import BentoDropdown from '@/components/dropdown/dropdown.vue'; import { BentoSegmentedControl, type BentoSegmentedControlItem } from '@/components/segmented-control'; import { useI18n } from '@/utils/ts/i18n'; import { debounce } from '@/utils/ts/debounce'; import { useDateInputFormatter, useHasSlot, useTouchDevice } from '@/composables'; import { useGranularityAdjustments } from '@/components/internal/calendar/composables/use-granularity-adjustments'; import { useGranularMinMaxDate } from '@/components/internal/calendar/components/calendar-month/composables/granular-min-max-date'; import { useGranularityMemory } from '../../composables/use-granularity-memory'; import { DateRangePickerCalendarEvent, type DateRangePickerCalendarFormData, type DateRangePickerCalendarRangeSelectorItems, } from './date-range-picker-calendar.types'; import type { BentoDateRangePickerGranularityConfig, BentoDateRangePickerValue, } from '../../date-range-picker.types'; import { endOfDay } from 'date-fns/endOfDay'; import { startOfDay } from 'date-fns/startOfDay'; import { isSameSecond } from 'date-fns/isSameSecond'; import { isSameDay } from 'date-fns/isSameDay'; import { isEqual } from 'date-fns/isEqual'; import { isToday } from 'date-fns/isToday'; import { setMilliseconds } from 'date-fns/setMilliseconds'; import { dateToNativeDateString, dateToNativeMonthString, dateToTimeInputString, parseNativeMonthString, } from '@/utils/ts/format-date/format-date'; import { isValidTimeString } from '@/utils/ts/time-input'; import { setTimeInDateObject } from '@/utils/ts/format-date'; import messages from './messages.json'; const FORM_INPUT_DEBOUNCE_TIME = 300; const RANGE_SELECTOR_CUSTOM_RANGE_KEY = 'customRange'; type MessageSchema = (typeof messages)['en-US']; const maxRangeMessageKeyMap: Record<CalendarGranularityType, keyof MessageSchema> = { daily: 'maxRangeDays', weekly: 'maxRangeWeeks', monthly: 'maxRangeMonths', quarterly: 'maxRangeQuarters', }; const props = defineProps({ /** * Allows user to enter time values in the form */ allowTimeInput: { type: Boolean, default: false }, /** * Ranges form persistance data */ dateFormData: { type: Object as PropType<DateRangePickerCalendarFormData>, default: () => ({ startDate: undefined, endDate: undefined, startTime: undefined, endTime: undefined }), }, /** * Allows you to set the first day of the week. 0 is sunday, 1 is monday, 6 is saturday */ firstDayOfWeek: Calendar.props.firstDayOfWeek, /** * A list of available granularities. * If present, the date picker will display a segmented control to change granularity. */ granularities: { type: Array as PropType<Array<BentoDateRangePickerGranularityConfig>>, default: null, }, /** * Indicate if a date should be disabled or not */ isDateDisabled: Calendar.props.isDateDisabled, /** * Set a maximum number of dates to be selectable by the range. */ maxRange: Calendar.props.maxRange, /** * Sets a minimum limit for selectable dates - dates before `min` will not be selectable */ min: Calendar.props.min, /** * Sets a maximum limit for selectable dates - dates after `max` will not be selectable */ max: Calendar.props.max, /** * Number of months rendered on pane */ numberOfMonths: { type: Calendar.props.numberOfMonths.type, default: Calendar.props.numberOfMonths.default, validator: (n: number) => n >= 0, }, /** * Displays the end date's month when the calendar is opened. Defaults to false i.e. the start date's month is displayed. */ showEndDateOnOpen: { type: Boolean, default: false }, /** * Enables the custom range selector. * If provided, must be an array that sets the custom range dropdown items. Items are of the structure: * `label` - label of custom range item. * `value` - a unique key of the custom range item. * `data` - an object `{ startDate: Date; endDate: Date }` to set the date picker range to upon selecting. */ quickSelectRanges: { type: Array as PropType<DateRangePickerCalendarRangeSelectorItems>, default: undefined, }, /** * Selected date */ value: { type: Object as PropType<BentoDateRangePickerValue>, default: undefined }, /** * The type of calendar to display. Defaults to showing days. */ variant: Calendar.props.variant, /** * Range of years to be selectable from the year dropdown */ yearRange: Calendar.props.yearRange, }); const emit = defineEmits([ DateRangePickerCalendarEvent.CUSTOM_RANGE, DateRangePickerCalendarEvent.INPUT, DateRangePickerCalendarEvent.ERROR, DateRangePickerCalendarEvent.FORM_DATE, DateRangePickerCalendarEvent.START_DATE_SELECTED, ]); const slots = useSlots(); const hasSlot = useHasSlot(slots); const { t, tc } = useI18n<{ message: MessageSchema }>({ messages }); const { isTouchDevice } = useTouchDevice(); const { dateFormat, isFormatValid, parseDate, onKeyDown, autoFormat, formatDate: formatDateUtil, } = useDateInputFormatter(); const calculatedIsRelativeToNowQuickSelectRanges = ref({}); const defaultGranularity = computed(() => (props.variant === 'month' ? 'monthly' : 'daily')); const hasNoCalendars = computed(() => props.numberOfMonths === 0); const internalRangeDate = reactive<BentoDateRangePickerValue>({ startDate: props.value?.startDate, endDate: props.value?.endDate, range: props.value?.range, ...(props.granularities ? { granularity: props.value?.granularity || defaultGranularity.value, } : {}), }); /** * Currently range selector dropdown item that is selected. * The logic will try and find in each range item: * - If an endDate does not exist, then only check if the startDate day matches and if today's day matches. * - if time input has been enabled, then check if the seconds match. * - if an endDate does exist then just check if the startDate day and the endDate day match. * * Note: All date comparisons use local time consistently. Both sides of each comparison * originate from the same timezone context, so no UTC normalization is needed. */ const selectedRangeSelectorValue = computed(() => { return ( // eslint-disable-next-line consistent-return props?.quickSelectRanges?.find(({ value, data }) => { if (!data.endDate) { // Check if this quick select range is explicitly marked as "relative to now" if (data.isRelativeToNow) { // Retrieve previously calculated start/end dates for this relative range. // We use these if the user has already clicked on this time of quick range const newlyCalculatedQuickSelectDates = calculatedIsRelativeToNowQuickSelectRanges.value?.[value]; return newlyCalculatedQuickSelectDates ? isEqual(newlyCalculatedQuickSelectDates.startDate, internalRangeDate.startDate) && isEqual(newlyCalculatedQuickSelectDates.endDate, internalRangeDate.endDate) : // If not stored calculated dates, fall back to comparing the quick select item's value // with the internal range's stored range identifier. value === internalRangeDate.range; } // This block handles quick select ranges that do not have an endDate // AND are NOT explicitly marked as 'isRelativeToNow'. // This means the endDate will be today at the end of the day. // Uses isEqual for startDate to prevent false matches (e.g. same-day selection // accidentally matching a "This week" range). When time input is enabled, // also verify the end time is at end of day so that manual time changes // correctly fall back to "Custom range". return ( isEqual(data.startDate, internalRangeDate.startDate) && isToday(internalRangeDate.endDate) && (!props.allowTimeInput || isSameSecond(internalRangeDate.endDate, endOfDay(internalRangeDate.endDate))) && (props.granularities ? data.granularity === internalRangeDate.granularity : true) ); } if (props.allowTimeInput) { const res = isSameSecond(data.startDate, internalRangeDate.startDate) && isSameSecond(data.endDate, internalRangeDate.endDate) && (props.granularities ? data.granularity === internalRangeDate.granularity : true); return res; } if (data.endDate) { return ( isSameDay(data.startDate, internalRangeDate.startDate) && isSameDay(data.endDate, internalRangeDate.endDate) && (props.granularities ? data.granularity === internalRangeDate.granularity : true) ); } })?.value ?? RANGE_SELECTOR_CUSTOM_RANGE_KEY ); }); const quickSelectRangeDefaultItems = computed(() => [ { label: t('customRange'), value: RANGE_SELECTOR_CUSTOM_RANGE_KEY }, ...(props.quickSelectRanges ? props.quickSelectRanges : []), ]); const selectedGranularity = computed( () => props.granularities?.find(({ type }) => type === internalRangeDate.granularity) || null ); const granularityItems = computed<Array<BentoSegmentedControlItem>>( () => props.granularities?.map(({ type, disabled }) => ({ label: t(type), value: type, disabled, })) ?? [] ); const { getGranularCalendarLimits } = useGranularMinMaxDate(props); const computedMinMax = computed(() => props.granularities ? getGranularCalendarLimits(selectedGranularity.value) : { min: props.min ? startOfDay(props.min) : null, max: props.max ? endOfDay(props.max) : null } ); const { adjustDateForQuarterly, adjustDateForMonthly, adjustDateForWeekly, adjustStartDateInputForWeekly, adjustEndDateInputForWeekly, adjustStartDateInputForMonthly, adjustEndDateInputForMonthly, adjustStartDateInputForQuarterly, adjustEndDateInputForQuarterly, } = useGranularityAdjustments(props.firstDayOfWeek); const { savedRanges, saveRange, resetSavedRanges } = useGranularityMemory(); /** * Format date to a string value in current format * @param dateToFormat - Date to be formatted */ const formatDate = (dateToFormat?: Date) => { if (!dateToFormat) { return ''; } return formatDateUtil(dateToFormat); }; // Form error keys const startDateError = ref<keyof MessageSchema | null>(null); const endDateError = ref<keyof MessageSchema | null>(null); // Translated error messages for the template const startDateErrorText = computed(() => (startDateError.value ? t(startDateError.value) : null)); const endDateErrorText = computed(() => (endDateError.value ? t(endDateError.value) : null)); // Shared errors exclude format errors since those only concern the date input const startSharedError = computed(() => startDateError.value === 'invalidDateFormat' ? null : startDateErrorText.value ); const endSharedError = computed(() => (endDateError.value === 'invalidDateFormat' ? null : endDateErrorText.value)); /** * Returns a translated error message for a given date string, or null if valid. * @param dateText The date string to validate. * @param counterpartDate Optional date to compare against for range order validation. * @param role Whether the date being validated is the 'start' or 'end' of the range. */ const getDateErrorMessage = ( dateText: string, counterpartDate?: Date, role?: 'start' | 'end', resolvedDateTime?: Date ): keyof MessageSchema | null => { if (!dateText || !isFormatValid(dateText)) { return 'invalidDateFormat'; } const date = parseDate(dateText); const dayDate = startOfDay(date); if ((props.min && dayDate < startOfDay(props.min)) || (props.max && dayDate > startOfDay(props.max))) { return 'selectedDateIsNotAvailable'; } if (props.isDateDisabled && props.isDateDisabled(dayDate)) { return 'selectedDateIsNotAvailable'; } if (counterpartDate) { // When time input is enabled and we have a resolved datetime, compare full datetimes; // otherwise fall back to day-level comparison. const current = resolvedDateTime ? setMilliseconds(resolvedDateTime, 0).getTime() : dayDate.getTime(); const counterpart = resolvedDateTime ? setMilliseconds(counterpartDate, 0).getTime() : startOfDay(counterpartDate).getTime(); const isInvalid = role === 'start' ? current >= counterpart : current <= counterpart; if (isInvalid) { return 'startDateMustPrecedeEndDate'; } } return null; }; // Form input text used in range variant const dateTextInput = reactive({ startDate: props.dateFormData.startDate, endDate: props.dateFormData.endDate, startTime: props.dateFormData.startTime, endTime: props.dateFormData.endTime, }); /** * Checks if a time string is valid and within the min/max bounds if a date is provided. * @param timeText The time string to validate. * @param date The date string to check the time against. * @returns True if the time is valid, false otherwise. */ const isValidTime = (timeText: string, date?: string) => { const isTimeAllowed = () => { if (date && isValidDate(date)) { const dateToCheckWithTime = setTimeInDateObject(parseDate(date), timeText); // Check if date is between min/max bounds, if there are any if ( (props.min && dateToCheckWithTime < setMilliseconds(props.min, 0)) || (props.max && dateToCheckWithTime > setMilliseconds(props.max, 0)) ) { return false; } } return true; }; return isValidTimeString(timeText) && isTimeAllowed(); }; const isMonthVariant = computed(() => props.variant === 'month'); const formatNativeDate = (date: Date): string => { if (!date) { return ''; } return isMonthVariant.value ? dateToNativeMonthString(date) : dateToNativeDateString(date); }; const nativeStartDate = computed(() => formatNativeDate(internalRangeDate.startDate)); const nativeEndDate = computed(() => formatNativeDate(internalRangeDate.endDate)); const dateInputType = computed(() => (isTouchDevice.value ? (isMonthVariant.value ? 'month' : 'date') : 'text')); const startDateValue = computed(() => (isTouchDevice.value ? nativeStartDate.value : dateTextInput.startDate)); const endDateValue = computed(() => (isTouchDevice.value ? nativeEndDate.value : dateTextInput.endDate)); /** * Sets the time in a date object if the time is valid and the variant is not 'month'. * @param date The date object to modify. * @param time The time string to set. * @returns The modified date object or the original if time is not set. */ const setTimeInDateRef = (date: Date, time: string) => { // Do not set time if month variant if (!time || !isValidTimeString(time) || props.variant === 'month') { return date; } return setTimeInDateObject(new Date(date), time); }; const onDateInput = (selectedRange: BentoDateRangePickerValue) => { dateTextInput.startTime = dateToTimeInputString( props.min && props.allowTimeInput && isSameDay(props.min, selectedRange.startDate) ? props.min : startOfDay(selectedRange.startDate) ); dateTextInput.endTime = dateToTimeInputString( props.max && props.allowTimeInput && isSameDay(props.max, selectedRange.endDate) ? props.max : endOfDay(selectedRange.endDate) ); internalRangeDate.startDate = setTimeInDateRef(selectedRange.startDate, dateTextInput.startTime); internalRangeDate.endDate = setTimeInDateRef(selectedRange.endDate, dateTextInput.endTime); // Resetting internal range date when selecting a custom range through the calendar internalRangeDate.range = null; if (props.granularities) { // Reset saved ranges on input resetSavedRanges(); } emit(DateRangePickerCalendarEvent.INPUT, { ...internalRangeDate, range: selectedRangeSelectorValue.value, }); }; /** * Checks if a date string is in current format and if the date is enabled. * @param dateText The date string to validate. * @returns True if the date is valid and enabled, false otherwise. */ const isValidDate = (dateText: string): boolean => !!dateText && !getDateErrorMessage(dateText); /** * When mounted, set the input error states to true if the values are not valid */ onMounted(() => { // Start date validation startDateError.value = props.dateFormData.startDate ? getDateErrorMessage(props.dateFormData.startDate) : null; // Start time validation is delegated to bento-input-time via events // End date validation endDateError.value = props.dateFormData.endDate ? getDateErrorMessage(props.dateFormData.endDate) : null; // End time validation is delegated to bento-input-time via events }); const onStartDateSelected = (newStartDate: Date) => { dateTextInput.startDate = formatDate(newStartDate); if (!dateTextInput.startTime) { // Set the time to the beginning of the day dateTextInput.startTime = dateToTimeInputString(newStartDate); } startDateError.value = null; if (endDateError.value === 'startDateMustPrecedeEndDate') { endDateError.value = null; } emit(DateRangePickerCalendarEvent.START_DATE_SELECTED, newStartDate); }; const onEndDateSelected = (newEndDate: Date) => { dateTextInput.endDate = formatDate(newEndDate); if (!dateTextInput.endTime) { // Set the time to the end of the day dateTextInput.endTime = dateToTimeInputString(endOfDay(newEndDate)); } endDateError.value = null; if (startDateError.value === 'startDateMustPrecedeEndDate') { startDateError.value = null; } }; const getDate = (keyName: 'startDate' | 'endDate') => { const dateProp = props.value[keyName]; if (dateProp) { return dateProp; } if (props.dateFormData[keyName] && isValidDate(props.dateFormData[keyName])) { return parseDate(props.dateFormData[keyName]); } return undefined; }; const validateStartDate = (startDateText: string) => { if (isValidDate(startDateText)) { let dateText = startDateText; startDateError.value = null; switch (selectedGranularity.value?.type) { case 'weekly': dateText = adjustStartDateInputForWeekly( startDateText, formatDate, computedMinMax.value.min, computedMinMax.value.max ); dateTextInput.startDate = dateText; break; case 'monthly': dateText = adjustStartDateInputForMonthly( startDateText, formatDate, computedMinMax.value.min, computedMinMax.value.max ); dateTextInput.startDate = dateText; break; case 'quarterly': dateText = adjustStartDateInputForQuarterly( startDateText, formatDate, computedMinMax.value.min, computedMinMax.value.max ); dateTextInput.startDate = dateText; break; case 'daily': default: break; } const startDate = startOfDay(parseDate(dateText)); internalRangeDate.startDate = setTimeInDateRef(startDate, dateTextInput.startTime); if (isValidDate(dateTextInput.endDate)) { internalRangeDate.endDate = setTimeInDateRef( startOfDay(parseDate(dateTextInput.endDate)), dateTextInput.endTime ); } const endDate = getDate('endDate'); const rangeError = getDateErrorMessage( dateText, props.allowTimeInput ? internalRangeDate.endDate : endDate, 'start', props.allowTimeInput ? internalRangeDate.startDate : undefined ); if (rangeError) { startDateError.value = rangeError; emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); return; } if (endDateError.value === 'startDateMustPrecedeEndDate') { endDateError.value = null; } // Resetting internal range date when selecting a custom range through the calendar internalRangeDate.range = null; emit(DateRangePickerCalendarEvent.FORM_DATE, { startDate, endDate, }); emit(DateRangePickerCalendarEvent.INPUT, { ...internalRangeDate, range: selectedRangeSelectorValue.value, }); } else { // Indicate form error startDateError.value = getDateErrorMessage(startDateText); // Emit Date picker calendar error emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); } }; const validateStartDateDebounced = debounce(validateStartDate, FORM_INPUT_DEBOUNCE_TIME); const onStartDateInput = (startDateText: string) => { // Set the date as string when input through the form // Bypassing autoFormat for native month input since it doesn't support YYYY-MM dateTextInput.startDate = isTouchDevice.value && isMonthVariant.value ? startDateText : autoFormat(startDateText); if (isTouchDevice.value) { if (isMonthVariant.value && startDateText) { const startDate = parseNativeMonthString(startDateText); internalRangeDate.startDate = setTimeInDateRef(startDate, dateTextInput.startTime); internalRangeDate.range = null; emit(DateRangePickerCalendarEvent.INPUT, { ...internalRangeDate, range: selectedRangeSelectorValue.value, }); } else if (dateTextInput.startDate) { validateStartDate(dateTextInput.startDate); } else { // on touch devices, skip the any intermediate values e.g. pressing zero first startDateError.value = null; } } else { validateStartDateDebounced(dateTextInput.startDate); } }; const validateEndDate = (endDateText: string) => { if (isValidDate(endDateText)) { let dateText = endDateText; endDateError.value = null; switch (selectedGranularity.value?.type) { case 'weekly': dateText = adjustEndDateInputForWeekly( endDateText, formatDate, computedMinMax.value.min, computedMinMax.value.max ); dateTextInput.endDate = dateText; break; case 'monthly': dateText = adjustEndDateInputForMonthly( endDateText, formatDate, computedMinMax.value.min, computedMinMax.value.max ); dateTextInput.endDate = dateText; break; case 'quarterly': dateText = adjustEndDateInputForQuarterly( endDateText, formatDate, computedMinMax.value.min, computedMinMax.value.max ); dateTextInput.endDate = dateText; break; case 'daily': default: break; } // Format date string to Date object const endDate = startOfDay(parseDate(dateText)); internalRangeDate.endDate = setTimeInDateRef(endDate, dateTextInput.endTime); if (isValidDate(dateTextInput.startDate)) { internalRangeDate.startDate = setTimeInDateRef( startOfDay(parseDate(dateTextInput.startDate)), dateTextInput.startTime ); } const startDate = getDate('startDate'); const rangeError = getDateErrorMessage( dateText, props.allowTimeInput ? internalRangeDate.startDate : startDate, 'end', props.allowTimeInput ? internalRangeDate.endDate : undefined ); if (rangeError) { endDateError.value = rangeError; emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); return; } if (startDateError.value === 'startDateMustPrecedeEndDate') { startDateError.value = null; } // Resetting internal range date when selecting a custom range through the calendar internalRangeDate.range = null; emit(DateRangePickerCalendarEvent.FORM_DATE, { startDate, endDate, }); emit(DateRangePickerCalendarEvent.INPUT, { ...internalRangeDate, range: selectedRangeSelectorValue.value, }); } else { // Indicate form error endDateError.value = getDateErrorMessage(endDateText); // Emit Date picker calendar error emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); } }; const validateEndDateDebounced = debounce(validateEndDate, FORM_INPUT_DEBOUNCE_TIME); const onEndDateInput = (endDateText: string) => { // Set the date as string when input through the form // Bypassing autoFormat for native month input since it doesn't support YYYY-MM dateTextInput.endDate = isTouchDevice.value && isMonthVariant.value ? endDateText : autoFormat(endDateText); if (isTouchDevice.value) { if (isMonthVariant.value && endDateText) { const endDate = parseNativeMonthString(endDateText); internalRangeDate.endDate = setTimeInDateRef(endDate, dateTextInput.endTime); internalRangeDate.range = null; emit(DateRangePickerCalendarEvent.INPUT, { ...internalRangeDate, range: selectedRangeSelectorValue.value, }); } else if (dateTextInput.endDate) { validateEndDate(dateTextInput.endDate); } else { // on touch devices, skip the any intermediate values e.g. pressing zero first endDateError.value = null; } } else { validateEndDateDebounced(dateTextInput.endDate); } }; const onDateInputKeyDown = (event: KeyboardEvent) => { onKeyDown(event); }; const onStartTimeError = () => { emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); }; const onStartTimeValid = () => { const startTimeText = dateTextInput.startTime; if (!isValidTime(startTimeText, dateTextInput.startDate)) { emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); return; } internalRangeDate.startDate = setTimeInDateRef(internalRangeDate.startDate, startTimeText); // Validate range order with updated time if (dateTextInput.startDate && internalRangeDate.endDate) { const rangeError = getDateErrorMessage( dateTextInput.startDate, internalRangeDate.endDate, 'start', internalRangeDate.startDate ); if (rangeError) { startDateError.value = rangeError; emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); return; } } startDateError.value = null; if (endDateError.value === 'startDateMustPrecedeEndDate') { endDateError.value = null; } const endDate = getDate('endDate'); emit(DateRangePickerCalendarEvent.FORM_DATE, { startDate: internalRangeDate.startDate, endDate, startTime: startTimeText, endTime: dateTextInput.endTime ?? props.dateFormData.endTime, }); emit(DateRangePickerCalendarEvent.INPUT, { ...internalRangeDate, range: selectedRangeSelectorValue.value, }); }; const onEndTimeError = () => { emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); }; const onEndTimeValid = () => { const endTimeText = dateTextInput.endTime; if (!isValidTime(endTimeText, dateTextInput.endDate)) { emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); return; } internalRangeDate.endDate = setTimeInDateRef(internalRangeDate.endDate, endTimeText); // Validate range order with updated time if (dateTextInput.endDate && internalRangeDate.startDate) { const rangeError = getDateErrorMessage( dateTextInput.endDate, internalRangeDate.startDate, 'end', internalRangeDate.endDate ); if (rangeError) { endDateError.value = rangeError; emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); return; } } endDateError.value = null; if (startDateError.value === 'startDateMustPrecedeEndDate') { startDateError.value = null; } emit(DateRangePickerCalendarEvent.FORM_DATE, { startDate: internalRangeDate.startDate, endDate: internalRangeDate.endDate, startTime: dateTextInput.startTime ?? props.dateFormData.startTime, endTime: endTimeText, }); emit(DateRangePickerCalendarEvent.INPUT, { ...internalRangeDate, range: selectedRangeSelectorValue.value, }); }; const onStartTimeInput = (startTimeText: string) => { dateTextInput.startTime = startTimeText; }; const onEndTimeInput = (endTimeText: string) => { dateTextInput.endTime = endTimeText; }; const setInternalRange = ( startDate: Date, endDate: Date, granularity?: CalendarGranularityType, range?: string ) => { internalRangeDate.startDate = startDate; internalRangeDate.endDate = endDate; if (granularity) { internalRangeDate.granularity = granularity; } if (range) { internalRangeDate.range = range; } }; const onRangeSelectorInput = (newCustomRangeValue: string) => { const foundResult = props.quickSelectRanges?.find(({ value }) => value === newCustomRangeValue); if (foundResult) { resetSavedRanges(); const date = foundResult.data; const newEndDate = date.endDate || (date.isRelativeToNow ? new Date(Date.now()) : endOfDay(new Date(Date.now()))); // If the time difference between start and end dates is available, // calculating start date dynamically const newStartDate = date.isRelativeToNow && date.timeDifference ? new Date(newEndDate.getTime() - date.timeDifference) : date.startDate; // Set Form input Dates dateTextInput.endDate = formatDate(newEndDate); dateTextInput.startDate = formatDate(newStartDate); setInternalRange(newStartDate, newEndDate, null, newCustomRangeValue); if (props.granularities) { internalRangeDate.granularity = date.granularity || defaultGranularity.value; } // Set Form input time to quick select range time dateTextInput.startTime = dateToTimeInputString(newStartDate); dateTextInput.endTime = dateToTimeInputString(newEndDate); // Set calendar selection internalRangeDate.startDate = newStartDate; internalRangeDate.endDate = newEndDate; if (date.isRelativeToNow) { // Store this calculated start and end dates to be used in the selectedRangeSelectorValue logic calculatedIsRelativeToNowQuickSelectRanges.value = { ...calculatedIsRelativeToNowQuickSelectRanges.value, [newCustomRangeValue]: { startDate: newStartDate, endDate: newEndDate, }, }; } // Emit everytime the date range changes emit(DateRangePickerCalendarEvent.CUSTOM_RANGE, { ...foundResult.data, startDate: newStartDate, endDate: newEndDate, range: foundResult.value, }); } }; const adjustDate = { weekly: adjustDateForWeekly, monthly: adjustDateForMonthly, quarterly: adjustDateForQuarterly, }; const onGranularityInput = (value: CalendarGranularityType) => { const previousValue = internalRangeDate.granularity; internalRangeDate.granularity = value; // Save previously selected range if (previousValue) { saveRange(previousValue, structuredClone(toRaw(internalRangeDate))); } if (internalRangeDate.startDate && internalRangeDate.endDate) { // Adjust date range based on new granularity switch (value) { case 'quarterly': case 'monthly': case 'weekly': { if (savedRanges[value]) { setInternalRange(savedRanges[value].startDate, savedRanges[value].endDate, value); break; } const { startDate, endDate } = adjustDate[value]( internalRangeDate.startDate, internalRangeDate.endDate, computedMinMax.value.min, computedMinMax.value.max ); setInternalRange(startDate, endDate, value); break; } case 'daily': if (savedRanges.daily) { setInternalRange(savedRanges.daily.startDate, savedRanges.daily.endDate, value); } break; default: break; } dateTextInput.startDate = formatDate(internalRangeDate.startDate); dateTextInput.endDate = formatDate(internalRangeDate.endDate); emit(DateRangePickerCalendarEvent.INPUT, { ...internalRangeDate, range: selectedRangeSelectorValue.value, }); } }; const computedFormContainerClasses = computed(() => ({ 'b-date-range-picker-calendar__form-container--no-calendars': hasNoCalendars.value, })); const maxRangeMessage = computed(() => { const granularity = selectedGranularity.value; const count = granularity?.maxRange || props.maxRange; if (!count) { return null; } const key = maxRangeMessageKeyMap[granularity?.type ?? 'daily']; return tc(key, count, { count }); }); </script> <script lang="ts"> /** * Calendar for range dates. Contains the logic to handle the range forms. * * @example * import DatePickerCalendarRange from './date-range-picker-calendar.vue'; * * export default { * components: { DatePickerCalendarRange }, * template: ` * <date-range-picker-calendar * :value="{startDate: new Date(), endDate: new Date() }" * :first-day-of-week="BentoDatePickerFirstDayOfWeek.MONDAY" * :is-date-disabled="(date: Date) => boolean" * :number-of-months="2" * @input="({ startDate, endDate }) => void" * /> * ` * } */ export default { name: 'date-range-picker-calendar', i18n: { messages }, }; </script> <style lang="scss" scoped src="./date-range-picker-calendar.scss" />
|
|
1
|
+
<template> <div class="b-date-range-picker-calendar"> <!-- Form --> <div class="b-date-range-picker-calendar__form-container" :class="computedFormContainerClasses" data-testid="date-range-picker-calendar-form-container" > <div class="b-date-range-picker-calendar__form"> <template v-if="hasSlot('title')"> <slot name="title" /> </template> <bento-dropdown v-if="quickSelectRanges" :aria-label="t('customRange')" :items="quickSelectRangeDefaultItems" :model-value="selectedRangeSelectorValue" @update:model-value="onRangeSelectorInput" /> <bento-segmented-control v-if="granularities" :items="granularityItems" :model-value="internalRangeDate.granularity" full-width @update:model-value="onGranularityInput" > </bento-segmented-control> <bento-alert v-if="maxRangeMessage" variant="tip"> <template #description> {{ maxRangeMessage }} </template> </bento-alert> <div> <bento-typography stronger el="span" class="b-date-range-picker-calendar__label"> From </bento-typography> <date-time-input :date-model-value="startDateValue" :date-props="{ ariaLabel: t('dateFrom'), type: dateInputType, description: !isTouchDevice ? t(dateFormat) : undefined, errorMessage: startDateErrorText, }" :error-message="startSharedError" :show-time-input="allowTimeInput" :time-model-value="dateTextInput.startTime" :time-props="{ ariaLabel: t('timeFrom'), errorMessage: startSharedError, }" class="b-date-range-picker-calendar__form-input" @update:date-model-value="onStartDateInput" @date:keydown="onDateInputKeyDown" @update:time-model-value="onStartTimeInput" @time:error="onStartTimeError" @time:valid="onStartTimeValid" /> </div> <div> <bento-typography stronger el="span" class="b-date-range-picker-calendar__label"> To </bento-typography> <date-time-input :date-model-value="endDateValue" :date-props="{ ariaLabel: t('dateTo'), type: dateInputType, description: !isTouchDevice ? t(dateFormat) : undefined, errorMessage: endDateErrorText, }" :error-message="endSharedError" :show-time-input="allowTimeInput" :time-model-value="dateTextInput.endTime" :time-props="{ ariaLabel: t('timeTo'), errorMessage: endSharedError, }" class="b-date-range-picker-calendar__form-input" @update:date-model-value="onEndDateInput" @date:keydown="onDateInputKeyDown" @update:time-model-value="onEndTimeInput" @time:error="onEndTimeError" @time:valid="onEndTimeValid" /> </div> </div> <div v-if="hasSlot('actions')"> <slot name="actions" /> </div> </div> <!-- Calendar --> <div v-if="!hasNoCalendars && !isTouchDevice" class="b-date-range-picker-calendar__calendars-container" data-testid="date-range-picker-calendar-calendar-container" > <calendar :value="internalRangeDate" :first-day-of-week="firstDayOfWeek" :is-date-disabled="isDateDisabled" :max-range="maxRange" :min="computedMinMax.min" :max="computedMinMax.max" :number-of-months="numberOfMonths" :show-end-date-on-open="showEndDateOnOpen" :granularity="selectedGranularity" :variant="variant" :year-range="yearRange" is-range @input="onDateInput" @start-date-selected="onStartDateSelected" @end-date-selected="onEndDateSelected" /> </div> </div> </template> <script setup lang="ts"> import { computed, onMounted, type PropType, reactive, ref, toRaw, useSlots } from 'vue'; import { Calendar, type CalendarGranularityType } from '@/internal'; import { DateTimeInput } from '../date-time-input'; import { BentoAlert } from '@/components/alert'; import { BentoTypography } from '@/components/typography'; import BentoDropdown from '@/components/dropdown/dropdown.vue'; import { BentoSegmentedControl, type BentoSegmentedControlItem } from '@/components/segmented-control'; import { useI18n } from '@/utils/ts/i18n'; import { debounce } from '@/utils/ts/debounce'; import { useDateInputFormatter, useHasSlot, useTouchDevice } from '@/composables'; import { useGranularityAdjustments } from '@/components/internal/calendar/composables/use-granularity-adjustments'; import { useGranularMinMaxDate } from '@/components/internal/calendar/components/calendar-month/composables/granular-min-max-date'; import { useGranularityMemory } from '../../composables/use-granularity-memory'; import { DateRangePickerCalendarEvent, type DateRangePickerCalendarFormData, type DateRangePickerCalendarRangeSelectorItems, } from './date-range-picker-calendar.types'; import type { BentoDateRangePickerGranularityConfig, BentoDateRangePickerValue, } from '../../date-range-picker.types'; import { endOfDay } from 'date-fns/endOfDay'; import { startOfDay } from 'date-fns/startOfDay'; import { isSameSecond } from 'date-fns/isSameSecond'; import { isSameDay } from 'date-fns/isSameDay'; import { isEqual } from 'date-fns/isEqual'; import { isToday } from 'date-fns/isToday'; import { setMilliseconds } from 'date-fns/setMilliseconds'; import { dateToNativeDateString, dateToNativeMonthString, dateToTimeInputString, parseNativeMonthString, } from '@/utils/ts/format-date/format-date'; import { isValidTimeString } from '@/utils/ts/time-input'; import { setTimeInDateObject } from '@/utils/ts/format-date'; import messages from './messages.json'; const FORM_INPUT_DEBOUNCE_TIME = 300; const RANGE_SELECTOR_CUSTOM_RANGE_KEY = 'customRange'; type MessageSchema = (typeof messages)['en-US']; const maxRangeMessageKeyMap: Record<CalendarGranularityType, keyof MessageSchema> = { daily: 'maxRangeDays', weekly: 'maxRangeWeeks', monthly: 'maxRangeMonths', quarterly: 'maxRangeQuarters', }; const props = defineProps({ /** * Allows user to enter time values in the form */ allowTimeInput: { type: Boolean, default: false }, /** * Ranges form persistance data */ dateFormData: { type: Object as PropType<DateRangePickerCalendarFormData>, default: () => ({ startDate: undefined, endDate: undefined, startTime: undefined, endTime: undefined }), }, /** * Allows you to set the first day of the week. 0 is sunday, 1 is monday, 6 is saturday */ firstDayOfWeek: Calendar.props.firstDayOfWeek, /** * A list of available granularities. * If present, the date picker will display a segmented control to change granularity. */ granularities: { type: Array as PropType<Array<BentoDateRangePickerGranularityConfig>>, default: null, }, /** * Indicate if a date should be disabled or not */ isDateDisabled: Calendar.props.isDateDisabled, /** * Set a maximum number of dates to be selectable by the range. */ maxRange: Calendar.props.maxRange, /** * Sets a minimum limit for selectable dates - dates before `min` will not be selectable */ min: Calendar.props.min, /** * Sets a maximum limit for selectable dates - dates after `max` will not be selectable */ max: Calendar.props.max, /** * Number of months rendered on pane */ numberOfMonths: { type: Calendar.props.numberOfMonths.type, default: Calendar.props.numberOfMonths.default, validator: (n: number) => n >= 0, }, /** * Displays the end date's month when the calendar is opened. Defaults to false i.e. the start date's month is displayed. */ showEndDateOnOpen: { type: Boolean, default: false }, /** * Enables the custom range selector. * If provided, must be an array that sets the custom range dropdown items. Items are of the structure: * `label` - label of custom range item. * `value` - a unique key of the custom range item. * `data` - an object `{ startDate: Date; endDate: Date }` to set the date picker range to upon selecting. */ quickSelectRanges: { type: Array as PropType<DateRangePickerCalendarRangeSelectorItems>, default: undefined, }, /** * Selected date */ value: { type: Object as PropType<BentoDateRangePickerValue>, default: undefined }, /** * The type of calendar to display. Defaults to showing days. */ variant: Calendar.props.variant, /** * Range of years to be selectable from the year dropdown */ yearRange: Calendar.props.yearRange, }); const emit = defineEmits([ DateRangePickerCalendarEvent.CUSTOM_RANGE, DateRangePickerCalendarEvent.INPUT, DateRangePickerCalendarEvent.ERROR, DateRangePickerCalendarEvent.FORM_DATE, DateRangePickerCalendarEvent.START_DATE_SELECTED, ]); const slots = useSlots(); const hasSlot = useHasSlot(slots); const { t, tc } = useI18n<{ message: MessageSchema }>({ messages }); const { isTouchDevice } = useTouchDevice(); const { dateFormat, isFormatValid, parseDate, onKeyDown, autoFormat, formatDate: formatDateUtil, } = useDateInputFormatter(); const calculatedIsRelativeToNowQuickSelectRanges = ref({}); const defaultGranularity = computed(() => (props.variant === 'month' ? 'monthly' : 'daily')); const hasNoCalendars = computed(() => props.numberOfMonths === 0); const internalRangeDate = reactive<BentoDateRangePickerValue>({ startDate: props.value?.startDate, endDate: props.value?.endDate, range: props.value?.range, ...(props.granularities ? { granularity: props.value?.granularity || defaultGranularity.value, } : {}), }); /** * Currently range selector dropdown item that is selected. * The logic will try and find in each range item: * - If an endDate does not exist, then only check if the startDate day matches and if today's day matches. * - if time input has been enabled, then check if the seconds match. * - if an endDate does exist then just check if the startDate day and the endDate day match. * * Note: All date comparisons use local time consistently. Both sides of each comparison * originate from the same timezone context, so no UTC normalization is needed. */ const selectedRangeSelectorValue = computed(() => { return ( // eslint-disable-next-line consistent-return props?.quickSelectRanges?.find(({ value, data }) => { if (!data.endDate) { // Check if this quick select range is explicitly marked as "relative to now" if (data.isRelativeToNow) { // Retrieve previously calculated start/end dates for this relative range. // We use these if the user has already clicked on this time of quick range const newlyCalculatedQuickSelectDates = calculatedIsRelativeToNowQuickSelectRanges.value?.[value]; return newlyCalculatedQuickSelectDates ? isEqual(newlyCalculatedQuickSelectDates.startDate, internalRangeDate.startDate) && isEqual(newlyCalculatedQuickSelectDates.endDate, internalRangeDate.endDate) : // If not stored calculated dates, fall back to comparing the quick select item's value // with the internal range's stored range identifier. value === internalRangeDate.range; } // This block handles quick select ranges that do not have an endDate // AND are NOT explicitly marked as 'isRelativeToNow'. // This means the endDate will be today at the end of the day. // Uses isEqual for startDate to prevent false matches (e.g. same-day selection // accidentally matching a "This week" range). When time input is enabled, // also verify the end time is at end of day so that manual time changes // correctly fall back to "Custom range". return ( isEqual(data.startDate, internalRangeDate.startDate) && isToday(internalRangeDate.endDate) && (!props.allowTimeInput || isSameSecond(internalRangeDate.endDate, endOfDay(internalRangeDate.endDate))) && (props.granularities ? data.granularity === internalRangeDate.granularity : true) ); } if (props.allowTimeInput) { const res = isSameSecond(data.startDate, internalRangeDate.startDate) && isSameSecond(data.endDate, internalRangeDate.endDate) && (props.granularities ? data.granularity === internalRangeDate.granularity : true); return res; } if (data.endDate) { return ( isSameDay(data.startDate, internalRangeDate.startDate) && isSameDay(data.endDate, internalRangeDate.endDate) && (props.granularities ? data.granularity === internalRangeDate.granularity : true) ); } })?.value ?? RANGE_SELECTOR_CUSTOM_RANGE_KEY ); }); const quickSelectRangeDefaultItems = computed(() => [ { label: t('customRange'), value: RANGE_SELECTOR_CUSTOM_RANGE_KEY }, ...(props.quickSelectRanges ? props.quickSelectRanges : []), ]); const selectedGranularity = computed( () => props.granularities?.find(({ type }) => type === internalRangeDate.granularity) || null ); const granularityItems = computed<Array<BentoSegmentedControlItem>>( () => props.granularities?.map(({ type, disabled }) => ({ label: t(type), value: type, disabled, })) ?? [] ); const { getGranularCalendarLimits } = useGranularMinMaxDate(props); const computedMinMax = computed(() => props.granularities ? getGranularCalendarLimits(selectedGranularity.value) : { min: props.min ? startOfDay(props.min) : null, max: props.max ? endOfDay(props.max) : null } ); const { adjustDateForQuarterly, adjustDateForMonthly, adjustDateForWeekly, adjustStartDateInputForWeekly, adjustEndDateInputForWeekly, adjustStartDateInputForMonthly, adjustEndDateInputForMonthly, adjustStartDateInputForQuarterly, adjustEndDateInputForQuarterly, } = useGranularityAdjustments(props.firstDayOfWeek); const { savedRanges, saveRange, resetSavedRanges } = useGranularityMemory(); /** * Format date to a string value in current format * @param dateToFormat - Date to be formatted */ const formatDate = (dateToFormat?: Date) => { if (!dateToFormat) { return ''; } return formatDateUtil(dateToFormat); }; // Form error keys const startDateError = ref<keyof MessageSchema | null>(null); const endDateError = ref<keyof MessageSchema | null>(null); // Translated error messages for the template const startDateErrorText = computed(() => (startDateError.value ? t(startDateError.value) : null)); const endDateErrorText = computed(() => (endDateError.value ? t(endDateError.value) : null)); // Shared errors exclude format errors since those only concern the date input const startSharedError = computed(() => startDateError.value === 'invalidDateFormat' ? null : startDateErrorText.value ); const endSharedError = computed(() => (endDateError.value === 'invalidDateFormat' ? null : endDateErrorText.value)); /** * Returns a translated error message for a given date string, or null if valid. * @param dateText The date string to validate. * @param counterpartDate Optional date to compare against for range order validation. * @param role Whether the date being validated is the 'start' or 'end' of the range. */ const getDateErrorMessage = ( dateText: string, counterpartDate?: Date, role?: 'start' | 'end', resolvedDateTime?: Date ): keyof MessageSchema | null => { if (!dateText || !isFormatValid(dateText)) { return 'invalidDateFormat'; } const date = parseDate(dateText); const dayDate = startOfDay(date); if ((props.min && dayDate < startOfDay(props.min)) || (props.max && dayDate > startOfDay(props.max))) { return 'selectedDateIsNotAvailable'; } if (props.isDateDisabled && props.isDateDisabled(dayDate)) { return 'selectedDateIsNotAvailable'; } if (counterpartDate) { // When time input is enabled and we have a resolved datetime, compare full datetimes; // otherwise fall back to day-level comparison. const current = resolvedDateTime ? setMilliseconds(resolvedDateTime, 0).getTime() : dayDate.getTime(); const counterpart = resolvedDateTime ? setMilliseconds(counterpartDate, 0).getTime() : startOfDay(counterpartDate).getTime(); const isInvalid = role === 'start' ? current >= counterpart : current <= counterpart; if (isInvalid) { return 'startDateMustPrecedeEndDate'; } } return null; }; // Form input text used in range variant const dateTextInput = reactive({ startDate: props.dateFormData.startDate, endDate: props.dateFormData.endDate, startTime: props.dateFormData.startTime, endTime: props.dateFormData.endTime, }); /** * Checks if a time string is valid and within the min/max bounds if a date is provided. * @param timeText The time string to validate. * @param date The date string to check the time against. * @returns True if the time is valid, false otherwise. */ const isValidTime = (timeText: string, date?: string) => { const isTimeAllowed = () => { if (date && isValidDate(date)) { const dateToCheckWithTime = setTimeInDateObject(parseDate(date), timeText); // Check if date is between min/max bounds, if there are any if ( (props.min && dateToCheckWithTime < setMilliseconds(props.min, 0)) || (props.max && dateToCheckWithTime > setMilliseconds(props.max, 0)) ) { return false; } } return true; }; return isValidTimeString(timeText) && isTimeAllowed(); }; const isMonthVariant = computed(() => props.variant === 'month'); const formatNativeDate = (date: Date): string => { if (!date) { return ''; } return isMonthVariant.value ? dateToNativeMonthString(date) : dateToNativeDateString(date); }; const nativeStartDate = computed(() => formatNativeDate(internalRangeDate.startDate)); const nativeEndDate = computed(() => formatNativeDate(internalRangeDate.endDate)); const dateInputType = computed(() => (isTouchDevice.value ? (isMonthVariant.value ? 'month' : 'date') : 'text')); const startDateValue = computed(() => (isTouchDevice.value ? nativeStartDate.value : dateTextInput.startDate)); const endDateValue = computed(() => (isTouchDevice.value ? nativeEndDate.value : dateTextInput.endDate)); /** * Sets the time in a date object if the time is valid and the variant is not 'month'. * @param date The date object to modify. * @param time The time string to set. * @returns The modified date object or the original if time is not set. */ const setTimeInDateRef = (date: Date, time: string) => { // Do not set time if month variant if (!time || !isValidTimeString(time) || props.variant === 'month') { return date; } return setTimeInDateObject(new Date(date), time); }; const onDateInput = (selectedRange: BentoDateRangePickerValue) => { dateTextInput.startTime = dateToTimeInputString( props.min && props.allowTimeInput && isSameDay(props.min, selectedRange.startDate) ? props.min : startOfDay(selectedRange.startDate) ); dateTextInput.endTime = dateToTimeInputString( props.max && props.allowTimeInput && isSameDay(props.max, selectedRange.endDate) ? props.max : endOfDay(selectedRange.endDate) ); internalRangeDate.startDate = setTimeInDateRef(selectedRange.startDate, dateTextInput.startTime); internalRangeDate.endDate = setTimeInDateRef(selectedRange.endDate, dateTextInput.endTime); // Resetting internal range date when selecting a custom range through the calendar internalRangeDate.range = null; if (props.granularities) { // Reset saved ranges on input resetSavedRanges(); } emit(DateRangePickerCalendarEvent.INPUT, { ...internalRangeDate, range: selectedRangeSelectorValue.value, }); }; /** * Checks if a date string is in current format and if the date is enabled. * @param dateText The date string to validate. * @returns True if the date is valid and enabled, false otherwise. */ const isValidDate = (dateText: string): boolean => !!dateText && !getDateErrorMessage(dateText); /** * When mounted, set the input error states to true if the values are not valid */ onMounted(() => { // Start date validation startDateError.value = props.dateFormData.startDate ? getDateErrorMessage(props.dateFormData.startDate) : null; // Start time validation is delegated to bento-input-time via events // End date validation endDateError.value = props.dateFormData.endDate ? getDateErrorMessage(props.dateFormData.endDate) : null; // End time validation is delegated to bento-input-time via events }); const onStartDateSelected = (newStartDate: Date) => { dateTextInput.startDate = formatDate(newStartDate); if (!dateTextInput.startTime) { // Set the time to the beginning of the day dateTextInput.startTime = dateToTimeInputString(newStartDate); } startDateError.value = null; if (endDateError.value === 'startDateMustPrecedeEndDate') { endDateError.value = null; } emit(DateRangePickerCalendarEvent.START_DATE_SELECTED, newStartDate); }; const onEndDateSelected = (newEndDate: Date) => { dateTextInput.endDate = formatDate(newEndDate); if (!dateTextInput.endTime) { // Set the time to the end of the day dateTextInput.endTime = dateToTimeInputString(endOfDay(newEndDate)); } endDateError.value = null; if (startDateError.value === 'startDateMustPrecedeEndDate') { startDateError.value = null; } }; const getDate = (keyName: 'startDate' | 'endDate') => { const dateProp = props.value?.[keyName]; if (dateProp) { return dateProp; } if (props.dateFormData[keyName] && isValidDate(props.dateFormData[keyName])) { return parseDate(props.dateFormData[keyName]); } return undefined; }; const validateStartDate = (startDateText: string) => { if (isValidDate(startDateText)) { let dateText = startDateText; startDateError.value = null; switch (selectedGranularity.value?.type) { case 'weekly': dateText = adjustStartDateInputForWeekly( startDateText, formatDate, computedMinMax.value.min, computedMinMax.value.max ); dateTextInput.startDate = dateText; break; case 'monthly': dateText = adjustStartDateInputForMonthly( startDateText, formatDate, computedMinMax.value.min, computedMinMax.value.max ); dateTextInput.startDate = dateText; break; case 'quarterly': dateText = adjustStartDateInputForQuarterly( startDateText, formatDate, computedMinMax.value.min, computedMinMax.value.max ); dateTextInput.startDate = dateText; break; case 'daily': default: break; } const startDate = startOfDay(parseDate(dateText)); internalRangeDate.startDate = setTimeInDateRef(startDate, dateTextInput.startTime); if (isValidDate(dateTextInput.endDate)) { internalRangeDate.endDate = setTimeInDateRef( startOfDay(parseDate(dateTextInput.endDate)), dateTextInput.endTime ); } const endDate = getDate('endDate'); const rangeError = getDateErrorMessage( dateText, props.allowTimeInput ? internalRangeDate.endDate : endDate, 'start', props.allowTimeInput ? internalRangeDate.startDate : undefined ); if (rangeError) { startDateError.value = rangeError; emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); return; } if (endDateError.value === 'startDateMustPrecedeEndDate') { endDateError.value = null; } // Resetting internal range date when selecting a custom range through the calendar internalRangeDate.range = null; emit(DateRangePickerCalendarEvent.FORM_DATE, { startDate, endDate, }); emit(DateRangePickerCalendarEvent.INPUT, { ...internalRangeDate, range: selectedRangeSelectorValue.value, }); } else { // Indicate form error startDateError.value = getDateErrorMessage(startDateText); // Emit Date picker calendar error emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); } }; const validateStartDateDebounced = debounce(validateStartDate, FORM_INPUT_DEBOUNCE_TIME); const onStartDateInput = (startDateText: string) => { // Set the date as string when input through the form // Bypassing autoFormat for native month input since it doesn't support YYYY-MM dateTextInput.startDate = isTouchDevice.value && isMonthVariant.value ? startDateText : autoFormat(startDateText); if (isTouchDevice.value) { if (isMonthVariant.value && startDateText) { const startDate = parseNativeMonthString(startDateText); internalRangeDate.startDate = setTimeInDateRef(startDate, dateTextInput.startTime); internalRangeDate.range = null; emit(DateRangePickerCalendarEvent.INPUT, { ...internalRangeDate, range: selectedRangeSelectorValue.value, }); } else if (dateTextInput.startDate) { validateStartDate(dateTextInput.startDate); } else { // on touch devices, skip the any intermediate values e.g. pressing zero first startDateError.value = null; } } else { validateStartDateDebounced(dateTextInput.startDate); } }; const validateEndDate = (endDateText: string) => { if (isValidDate(endDateText)) { let dateText = endDateText; endDateError.value = null; switch (selectedGranularity.value?.type) { case 'weekly': dateText = adjustEndDateInputForWeekly( endDateText, formatDate, computedMinMax.value.min, computedMinMax.value.max ); dateTextInput.endDate = dateText; break; case 'monthly': dateText = adjustEndDateInputForMonthly( endDateText, formatDate, computedMinMax.value.min, computedMinMax.value.max ); dateTextInput.endDate = dateText; break; case 'quarterly': dateText = adjustEndDateInputForQuarterly( endDateText, formatDate, computedMinMax.value.min, computedMinMax.value.max ); dateTextInput.endDate = dateText; break; case 'daily': default: break; } // Format date string to Date object const endDate = startOfDay(parseDate(dateText)); internalRangeDate.endDate = setTimeInDateRef(endDate, dateTextInput.endTime); if (isValidDate(dateTextInput.startDate)) { internalRangeDate.startDate = setTimeInDateRef( startOfDay(parseDate(dateTextInput.startDate)), dateTextInput.startTime ); } const startDate = getDate('startDate'); const rangeError = getDateErrorMessage( dateText, props.allowTimeInput ? internalRangeDate.startDate : startDate, 'end', props.allowTimeInput ? internalRangeDate.endDate : undefined ); if (rangeError) { endDateError.value = rangeError; emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); return; } if (startDateError.value === 'startDateMustPrecedeEndDate') { startDateError.value = null; } // Resetting internal range date when selecting a custom range through the calendar internalRangeDate.range = null; emit(DateRangePickerCalendarEvent.FORM_DATE, { startDate, endDate, }); emit(DateRangePickerCalendarEvent.INPUT, { ...internalRangeDate, range: selectedRangeSelectorValue.value, }); } else { // Indicate form error endDateError.value = getDateErrorMessage(endDateText); // Emit Date picker calendar error emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); } }; const validateEndDateDebounced = debounce(validateEndDate, FORM_INPUT_DEBOUNCE_TIME); const onEndDateInput = (endDateText: string) => { // Set the date as string when input through the form // Bypassing autoFormat for native month input since it doesn't support YYYY-MM dateTextInput.endDate = isTouchDevice.value && isMonthVariant.value ? endDateText : autoFormat(endDateText); if (isTouchDevice.value) { if (isMonthVariant.value && endDateText) { const endDate = parseNativeMonthString(endDateText); internalRangeDate.endDate = setTimeInDateRef(endDate, dateTextInput.endTime); internalRangeDate.range = null; emit(DateRangePickerCalendarEvent.INPUT, { ...internalRangeDate, range: selectedRangeSelectorValue.value, }); } else if (dateTextInput.endDate) { validateEndDate(dateTextInput.endDate); } else { // on touch devices, skip the any intermediate values e.g. pressing zero first endDateError.value = null; } } else { validateEndDateDebounced(dateTextInput.endDate); } }; const onDateInputKeyDown = (event: KeyboardEvent) => { onKeyDown(event); }; const onStartTimeError = () => { emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); }; const onStartTimeValid = () => { const startTimeText = dateTextInput.startTime; if (!isValidTime(startTimeText, dateTextInput.startDate)) { emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); return; } internalRangeDate.startDate = setTimeInDateRef(internalRangeDate.startDate, startTimeText); // Validate range order with updated time if (dateTextInput.startDate && internalRangeDate.endDate) { const rangeError = getDateErrorMessage( dateTextInput.startDate, internalRangeDate.endDate, 'start', internalRangeDate.startDate ); if (rangeError) { startDateError.value = rangeError; emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); return; } } startDateError.value = null; if (endDateError.value === 'startDateMustPrecedeEndDate') { endDateError.value = null; } const endDate = getDate('endDate'); emit(DateRangePickerCalendarEvent.FORM_DATE, { startDate: internalRangeDate.startDate, endDate, startTime: startTimeText, endTime: dateTextInput.endTime ?? props.dateFormData.endTime, }); emit(DateRangePickerCalendarEvent.INPUT, { ...internalRangeDate, range: selectedRangeSelectorValue.value, }); }; const onEndTimeError = () => { emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); }; const onEndTimeValid = () => { const endTimeText = dateTextInput.endTime; if (!isValidTime(endTimeText, dateTextInput.endDate)) { emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); return; } internalRangeDate.endDate = setTimeInDateRef(internalRangeDate.endDate, endTimeText); // Validate range order with updated time if (dateTextInput.endDate && internalRangeDate.startDate) { const rangeError = getDateErrorMessage( dateTextInput.endDate, internalRangeDate.startDate, 'end', internalRangeDate.endDate ); if (rangeError) { endDateError.value = rangeError; emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); return; } } endDateError.value = null; if (startDateError.value === 'startDateMustPrecedeEndDate') { startDateError.value = null; } emit(DateRangePickerCalendarEvent.FORM_DATE, { startDate: internalRangeDate.startDate, endDate: internalRangeDate.endDate, startTime: dateTextInput.startTime ?? props.dateFormData.startTime, endTime: endTimeText, }); emit(DateRangePickerCalendarEvent.INPUT, { ...internalRangeDate, range: selectedRangeSelectorValue.value, }); }; const onStartTimeInput = (startTimeText: string) => { dateTextInput.startTime = startTimeText; }; const onEndTimeInput = (endTimeText: string) => { dateTextInput.endTime = endTimeText; }; const setInternalRange = ( startDate: Date, endDate: Date, granularity?: CalendarGranularityType, range?: string ) => { internalRangeDate.startDate = startDate; internalRangeDate.endDate = endDate; if (granularity) { internalRangeDate.granularity = granularity; } if (range) { internalRangeDate.range = range; } }; const onRangeSelectorInput = (newCustomRangeValue: string) => { const foundResult = props.quickSelectRanges?.find(({ value }) => value === newCustomRangeValue); if (foundResult) { resetSavedRanges(); const date = foundResult.data; const newEndDate = date.endDate || (date.isRelativeToNow ? new Date(Date.now()) : endOfDay(new Date(Date.now()))); // If the time difference between start and end dates is available, // calculating start date dynamically const newStartDate = date.isRelativeToNow && date.timeDifference ? new Date(newEndDate.getTime() - date.timeDifference) : date.startDate; // Set Form input Dates dateTextInput.endDate = formatDate(newEndDate); dateTextInput.startDate = formatDate(newStartDate); setInternalRange(newStartDate, newEndDate, null, newCustomRangeValue); if (props.granularities) { internalRangeDate.granularity = date.granularity || defaultGranularity.value; } // Set Form input time to quick select range time dateTextInput.startTime = dateToTimeInputString(newStartDate); dateTextInput.endTime = dateToTimeInputString(newEndDate); // Set calendar selection internalRangeDate.startDate = newStartDate; internalRangeDate.endDate = newEndDate; if (date.isRelativeToNow) { // Store this calculated start and end dates to be used in the selectedRangeSelectorValue logic calculatedIsRelativeToNowQuickSelectRanges.value = { ...calculatedIsRelativeToNowQuickSelectRanges.value, [newCustomRangeValue]: { startDate: newStartDate, endDate: newEndDate, }, }; } // Emit everytime the date range changes emit(DateRangePickerCalendarEvent.CUSTOM_RANGE, { ...foundResult.data, startDate: newStartDate, endDate: newEndDate, range: foundResult.value, }); } }; const adjustDate = { weekly: adjustDateForWeekly, monthly: adjustDateForMonthly, quarterly: adjustDateForQuarterly, }; const onGranularityInput = (value: CalendarGranularityType) => { const previousValue = internalRangeDate.granularity; internalRangeDate.granularity = value; // Save previously selected range if (previousValue) { saveRange(previousValue, structuredClone(toRaw(internalRangeDate))); } if (internalRangeDate.startDate && internalRangeDate.endDate) { // Adjust date range based on new granularity switch (value) { case 'quarterly': case 'monthly': case 'weekly': { if (savedRanges[value]) { setInternalRange(savedRanges[value].startDate, savedRanges[value].endDate, value); break; } const { startDate, endDate } = adjustDate[value]( internalRangeDate.startDate, internalRangeDate.endDate, computedMinMax.value.min, computedMinMax.value.max ); setInternalRange(startDate, endDate, value); break; } case 'daily': if (savedRanges.daily) { setInternalRange(savedRanges.daily.startDate, savedRanges.daily.endDate, value); } break; default: break; } dateTextInput.startDate = formatDate(internalRangeDate.startDate); dateTextInput.endDate = formatDate(internalRangeDate.endDate); emit(DateRangePickerCalendarEvent.INPUT, { ...internalRangeDate, range: selectedRangeSelectorValue.value, }); } }; const computedFormContainerClasses = computed(() => ({ 'b-date-range-picker-calendar__form-container--no-calendars': hasNoCalendars.value, })); const maxRangeMessage = computed(() => { const granularity = selectedGranularity.value; const count = granularity?.maxRange || props.maxRange; if (!count) { return null; } const key = maxRangeMessageKeyMap[granularity?.type ?? 'daily']; return tc(key, count, { count }); }); </script> <script lang="ts"> /** * Calendar for range dates. Contains the logic to handle the range forms. * * @example * import DatePickerCalendarRange from './date-range-picker-calendar.vue'; * * export default { * components: { DatePickerCalendarRange }, * template: ` * <date-range-picker-calendar * :value="{startDate: new Date(), endDate: new Date() }" * :first-day-of-week="BentoDatePickerFirstDayOfWeek.MONDAY" * :is-date-disabled="(date: Date) => boolean" * :number-of-months="2" * @input="({ startDate, endDate }) => void" * /> * ` * } */ export default { name: 'date-range-picker-calendar', i18n: { messages }, }; </script> <style lang="scss" scoped src="./date-range-picker-calendar.scss" />
|
|
@@ -1 +1 @@
|
|
|
1
|
-
<!-- TODO: Remove aria props as these are part of JSX elements /vue/types/jsx.d.ts --> <!-- eslint-disable vue/attribute-hyphenation --> <template> <div v-bento-click-outside-directive="closeDateRangePicker" class="b-date-range-picker"> <!-- Label --> <field-label v-if="label" :id="labelId" :label="label" :tooltip-text="tooltipText" :optional="optional" :required="required" /> <!-- Input --> <div @keydown.enter="onEnterPressedOverInput"> <dropdown-input-default ref="inputContainerRef" always-combobox-is-input :ariaControls="datePickerContainerId" :ariaExpanded="isDateRangePickerOpen" :aria-labelledby="dropdownInputAriaLabelledBy" :aria-describedby="ariaDescribedBy" :aria-required="required" :disabled="disabled" :is-invalid="!!errorMessage || shouldDisplayError" :open="isDateRangePickerOpen" :display-value="displayedDateValue" :readonly="isReadOnly" @open="openDateRangePicker" @clear="clearDateRangePickerValue" @close="closeDateRangePicker" /> </div> <!-- Error Messages --> <error-message v-if="shouldDisplayError" :id="dateErrorId" :error-message="t('invalidDateFormat')" class="b-date-range-picker__error-message" /> <error-message v-if="!!errorMessage" :id="errorId" :error-message="errorMessage" class="b-date-range-picker__error-message" /> <!-- Description --> <bento-typography v-if="description" :id="descriptionId" class="b-date-range-picker__description" :class="descriptionConditionalClasses" el="span" > {{ description }} </bento-typography> <!-- Calendar container --> <bento-popover v-if="inputContainerRef" :id="datePickerContainerId" class="b-date-range-picker__container" :open="isDateRangePickerOpen" :target-element="inputContainerRef" :aria-label="popoverAriaLabel" position="bottom-start" without-space fit-content trap-all overflow-visible @keydown.esc.native="closeDateRangePicker" > <date-range-picker-calendar :allow-time-input="withTimeInput" :quick-select-ranges="adjustedQuickSelectRanges" :value="internalModelValue" :first-day-of-week="resolvedFirstDayOfWeek" :granularities="granularities" :is-date-disabled="isDateDisabled" :max-range="maxRange" :min="min" :max="max" :number-of-months="computedNumberOfMonths" :show-end-date-on-open="showEndDateOnOpen" :date-form-data="displayedTextInputValue" :variant="variant" :year-range="yearRange" @custom-range="onRangeSelectorInput" @input="onDateRangePickerInput" @error="onDateRangePickerError" @form-date="onDateRangePickerFormDateInputUpdate" > <template v-if="hasActions" #actions> <bento-divider /> <div class="b-date-range-picker__footer"> <bento-button-actions :actions="rangePickerButtonActions" :layout="BentoButtonActionsLayout.SPACE_BETWEEN" /> </div> </template> </date-range-picker-calendar> </bento-popover> </div> </template> <script setup lang="ts"> import { computed, type HTMLAttributes, onMounted, ref, toRaw, toRef, useAttrs, watch } from 'vue'; import { useBreakpoints } from '@vueuse/core'; // Components import { BentoButtonActions, BentoButtonActionsLayout } from '../button'; import { BentoDivider } from '@/components/divider'; import { BentoPopover } from '@/components/popover'; import { BentoTypography } from '../typography'; import { DateRangePickerCalendar } from './components/date-range-picker-calendar'; import { DropdownInputDefault } from '@/components/dropdown/components'; import { ErrorMessage, FieldLabel } from '@/internal'; // Utils import { generateUid } from '@/core/utils/ts'; import { useI18n } from '@/utils/ts/i18n'; import { dateToTimeInputString } from '@/utils/ts/format-date'; import { useDateInputFormatter } from '@/composables/use-date-input-formatter/use-date-input-formatter'; // Composables import { useDateRangePickerCalendarText } from './composables/use-date-range-picker-calendar-text'; import { useFirstDayOfWeek, useFormLayoutFieldLoading } from '@/composables'; // Directives import { BentoClickOutsideDirective as vBentoClickOutsideDirective } from '@/directives'; // Types import { type BentoDateRangePickerEmits, type BentoDateRangePickerProps, type BentoDateRangePickerValue, } from './date-range-picker.types'; import { type DateRangePickerCalendarFormData, type DateRangePickerCalendarRangeSelectorItem, } from './components/date-range-picker-calendar/date-range-picker-calendar.types'; import { deprecate } from '@/utils/ts/deprecate'; import messages from './messages.json'; import { useFormFieldEmits } from '@/utils/ts/form-field/form-field'; type MessageSchema = (typeof messages)['en-US']; const emit = defineEmits<BentoDateRangePickerEmits>(); const props = withDefaults(defineProps<BentoDateRangePickerProps>(), { description: undefined, disabled: false, errorMessage: null, firstDayOfWeek: undefined, hasActions: false, isDateDisabled: undefined, label: undefined, max: null, maxRange: null, min: null, modelValue: null, numberOfMonths: undefined, optional: false, placeholder: null, quickSelectRanges: undefined, readonly: false, required: false, showEndDateOnOpen: false, tooltipText: null, value: null, variant: undefined, // Default will be set by @/internal/calendar yearRange: undefined, }); const attrs: HTMLAttributes = useAttrs(); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const { dateToInputDateString } = useDateInputFormatter(); const datePickerContainerId = generateUid('bento-date-range-picker-container'); const labelId = generateUid('bento-date-range-picker-label'); const descriptionId = generateUid('bento-date-range-picker-description'); const errorId = generateUid('bento-date-range-picker-error'); const dateErrorId = generateUid('bento-date-range-picker-date-error'); const popoverAriaLabel = computed(() => props.label || t('dateRangePicker', { name: datePickerContainerId })); const { resolvedFirstDayOfWeek } = useFirstDayOfWeek(toRef(props, 'firstDayOfWeek')); const breakpoints = useBreakpoints({ none: 0, one: 710, // width of date range picker with one panes two: 1030, // width of date range picker with two panes three: 1290, // width of date range picker with three panes }); const computedNumberOfMonths = computed(() => { const activeBreakpoint = breakpoints.active().value; const breakpointToCalendarPanesMap = { none: 0, one: 1, two: 2, }; if (activeBreakpoint in breakpointToCalendarPanesMap) { const maxPanes = breakpointToCalendarPanesMap[activeBreakpoint as keyof typeof breakpointToCalendarPanesMap]; // If numberOfMonths is not provided, use the max panes for the breakpoint. // Otherwise, use the smaller of the two values. return props.numberOfMonths === undefined ? maxPanes : Math.min(props.numberOfMonths, maxPanes); } // Default case for 'three' and larger breakpoints, respect user-provided numberOfMonths. return props.numberOfMonths; }); const adjustedQuickSelectRanges = computed<Array<DateRangePickerCalendarRangeSelectorItem>>(() => { return ( props?.quickSelectRanges && props.quickSelectRanges.map((range: DateRangePickerCalendarRangeSelectorItem) => { const timeDiff = !range.data.endDate ? new Date(Date.now()).getTime() - range.data.startDate.getTime() : undefined; return { ...range, data: { ...range.data, // Adding timeDifference to calculate an up-to-date endDate on range selection timeDifference: timeDiff, }, }; }) ); }); const dropdownInputAriaLabelledBy = computed(() => (props.label ? labelId : (attrs?.['aria-labelledby'] ?? null))); const isDateIncorrect = ref(false); const inputContainerRef = ref(null); const isDateRangePickerOpen = ref(false); const { isReadOnly } = useFormLayoutFieldLoading(toRef(props, 'readonly')); const { emitValue } = useFormFieldEmits(emit); watch( () => props.disabled, () => { // Close date picker popover if it's opened when it's disabled if (props.disabled && isDateRangePickerOpen.value) { isDateRangePickerOpen.value = false; } } ); const internalModelValue = computed(() => props.modelValue || props.value); const shouldDisplayError = computed(() => { if (props.hasActions) { return !isApplyDisabled.value && isDateIncorrect.value; } return isDateIncorrect.value; }); const descriptionConditionalClasses = computed(() => ({ 'b-date-range-picker__description--error': !!props.errorMessage || shouldDisplayError.value, })); const isMonthVariant = computed(() => props.variant === 'month'); const withTimeInput = computed(() => props.allowTimeInput && props.variant !== 'month'); const { dateDisplayValue, textInputDisplayValue, formatTextInputDisplayValue } = useDateRangePickerCalendarText({ datePickerValue: internalModelValue, isDateIncorrect, quickSelectRanges: adjustedQuickSelectRanges, isMonthVariant, withTimeInput, placeholder: toRef(props, 'placeholder'), }); const ariaDescribedBy = computed( () => `${props.description ? descriptionId : ''} ${props.errorMessage ? errorId : ''} ${ isDateIncorrect.value ? dateErrorId : '' }`.trim() || null ); const closeDateRangePicker = () => { isDateRangePickerOpen.value = false; }; const openDateRangePicker = () => { isDateRangePickerOpen.value = true; formatTextInputDisplayValue(); }; /* * Used when `hasActions` is set to true */ const localDateDisplayValue = ref(dateDisplayValue.value); const localTextInputDisplayValue = ref<DateRangePickerCalendarFormData>( // textInputDisplayValue is a reactive object structuredClone(toRaw(textInputDisplayValue)) ); const selectedDate = ref<BentoDateRangePickerValue>(props.modelValue ?? props.value); const isApplyDisabled = ref(true); const displayedTextInputValue = computed(() => props.hasActions ? localTextInputDisplayValue.value : textInputDisplayValue ); const displayedDateValue = computed(() => props.hasActions ? localDateDisplayValue.value : dateDisplayValue.value ); onMounted(() => { if (!props.hasActions) { deprecate( 'BentoDateRangePicker "hasActions" property', `Set the BentoDateRangePicker "hasActions" property true which will display the "Apply" and "Cancel" buttons. This will be the default behavior in the v2 and "hasActions" will be removed.`, '2.0.0' ); } if (props.value) { deprecate( 'BentoDateRangePicker "value" property', 'The use of "value" prop in "BentoDateRangePicker" is no longer supported. Use the "v-model" or "model-value" property instead.', '2.0.0' ); } if ( props.granularities && props.variant !== 'month' && props.granularities.find(({ type }) => type === 'quarterly') ) { throw new Error('Quarterly granularity is only supported by BentoDateRangePicker with variant: "month"'); } }); // On update for the value we need to update all the locally stored data watch([() => props.value, () => props.modelValue], () => { localDateDisplayValue.value = dateDisplayValue.value; localTextInputDisplayValue.value = structuredClone(toRaw(textInputDisplayValue)); }); const rangePickerButtonActions = computed(() => [ { title: t('apply'), event: () => { emitValue(selectedDate.value); closeDateRangePicker(); isApplyDisabled.value = true; }, disabled: isApplyDisabled.value, }, { title: t('cancel'), event: closeDateRangePicker, }, ]); watch(selectedDate, date => { // skip emit until Apply button is clicked if (props.hasActions) { isApplyDisabled.value = isDateIncorrect.value; return; } emitValue(date); }); const onDateRangePickerInput = (selectedRange: BentoDateRangePickerValue) => { // Reset the errors when the date is correct isDateIncorrect.value = false; textInputDisplayValue.startDate = dateToInputDateString(selectedRange.startDate); textInputDisplayValue.endDate = dateToInputDateString(selectedRange.endDate); textInputDisplayValue.startTime = dateToTimeInputString(selectedRange.startDate); textInputDisplayValue.endTime = dateToTimeInputString(selectedRange.endDate); selectedDate.value = selectedRange; }; const clearDateRangePickerValue = async () => { if (isDateIncorrect.value) { isDateIncorrect.value = false; } selectedDate.value = null; }; const onEnterPressedOverInput = () => { // open the datepicker if (!isDateRangePickerOpen.value) { isDateRangePickerOpen.value = true; return; } // Close the date picker if the date is correct and it is open if (!isDateIncorrect.value) { isDateRangePickerOpen.value = false; } }; const onDateRangePickerFormDateInputUpdate = (dateFormDate: DateRangePickerCalendarFormData) => { isDateIncorrect.value = false; if (dateFormDate.startTime) { textInputDisplayValue.startTime = dateFormDate.startTime; } if (dateFormDate.endTime) { textInputDisplayValue.endTime = dateFormDate.endTime; } }; const onDateRangePickerError = dateTextInput => { isDateIncorrect.value = true; textInputDisplayValue.startDate = dateTextInput.startDate; textInputDisplayValue.endDate = dateTextInput.endDate; // Remove current date range selection selectedDate.value = { startDate: null, endDate: null }; }; const onRangeSelectorInput = (quickSelectRanges?: BentoDateRangePickerValue) => { if (quickSelectRanges) { textInputDisplayValue.startDate = dateToInputDateString(quickSelectRanges.startDate); textInputDisplayValue.endDate = dateToInputDateString(quickSelectRanges.endDate); textInputDisplayValue.startTime = dateToTimeInputString(quickSelectRanges.startDate); textInputDisplayValue.endTime = dateToTimeInputString(quickSelectRanges.endDate); selectedDate.value = quickSelectRanges; } }; </script> <script lang="ts"> /** * Date range picker selector. * * @example * import { BentoDateRangePicker } from '@adyen/bento-vue2'; * * export default { * components: { BentoDateRangePicker }, * template: ` * <bento-date-range-picker * label="Label" * description="Supporting text" * required * optional * disabled * @input="onDateChanged" * v-model="selectedDate" * /> * `, * setup() { * const selectedDate = ref(new Date()); // reactive({ startDate: new Date(), endDate: new Date() }) * return { * selectedDate, * } * } * } */ export default { i18n: { messages }, name: 'bento-date-range-picker', model: { prop: 'modelValue' }, }; </script> <style lang="scss" scoped src="./date-range-picker.scss" />
|
|
1
|
+
<!-- TODO: Remove aria props as these are part of JSX elements /vue/types/jsx.d.ts --> <!-- eslint-disable vue/attribute-hyphenation --> <template> <div v-bento-click-outside-directive="closeDateRangePicker" class="b-date-range-picker"> <!-- Label --> <field-label v-if="label" :id="labelId" :label="label" :tooltip-text="tooltipText" :optional="optional" :required="required" /> <!-- Input --> <div @keydown.enter="onEnterPressedOverInput"> <dropdown-input-default ref="inputContainerRef" always-combobox-is-input :ariaControls="datePickerContainerId" :ariaExpanded="isDateRangePickerOpen" :aria-labelledby="dropdownInputAriaLabelledBy" :aria-describedby="ariaDescribedBy" :aria-required="required" :disabled="disabled" :is-invalid="!!errorMessage || shouldDisplayError" :open="isDateRangePickerOpen" :display-value="displayedDateValue" :readonly="isReadOnly" @open="openDateRangePicker" @clear="clearDateRangePickerValue" @close="closeDateRangePicker" /> </div> <!-- Error Messages --> <error-message v-if="shouldDisplayError" :id="dateErrorId" :error-message="t('invalidDateFormat')" class="b-date-range-picker__error-message" /> <error-message v-if="!!errorMessage" :id="errorId" :error-message="errorMessage" class="b-date-range-picker__error-message" /> <!-- Description --> <bento-typography v-if="description" :id="descriptionId" class="b-date-range-picker__description" :class="descriptionConditionalClasses" el="span" > {{ description }} </bento-typography> <!-- Calendar container --> <bento-popover v-if="inputContainerRef" :id="datePickerContainerId" class="b-date-range-picker__container" :open="isDateRangePickerOpen" :target-element="inputContainerRef" :aria-label="popoverAriaLabel" position="bottom-start" without-space fit-content trap-all overflow-visible :trap-all-options="trapAllOptions" @keydown.esc.native="closeDateRangePicker" > <date-range-picker-calendar :allow-time-input="withTimeInput" :quick-select-ranges="adjustedQuickSelectRanges" :value="dateRangePickerCalendarValue" :first-day-of-week="resolvedFirstDayOfWeek" :granularities="granularities" :is-date-disabled="isDateDisabled" :max-range="maxRange" :min="min" :max="max" :number-of-months="computedNumberOfMonths" :show-end-date-on-open="showEndDateOnOpen" :date-form-data="displayedTextInputValue" :variant="variant" :year-range="yearRange" @custom-range="onRangeSelectorInput" @input="onDateRangePickerInput" @error="onDateRangePickerError" @form-date="onDateRangePickerFormDateInputUpdate" > <template v-if="hasActions" #actions> <bento-divider /> <div class="b-date-range-picker__footer"> <bento-button-actions :actions="rangePickerButtonActions" :layout="BentoButtonActionsLayout.SPACE_BETWEEN" /> </div> </template> </date-range-picker-calendar> </bento-popover> </div> </template> <script setup lang="ts"> import { computed, type HTMLAttributes, onMounted, ref, toRaw, toRef, useAttrs, watch } from 'vue'; import { useBreakpoints } from '@vueuse/core'; // Components import { BentoButtonActions, BentoButtonActionsLayout } from '../button'; import { BentoDivider } from '@/components/divider'; import { BentoPopover } from '@/components/popover'; import { BentoTypography } from '../typography'; import { DateRangePickerCalendar } from './components/date-range-picker-calendar'; import { DropdownInputDefault } from '@/components/dropdown/components'; import { ErrorMessage, FieldLabel } from '@/internal'; // Utils import { generateUid } from '@/core/utils/ts'; import { useI18n } from '@/utils/ts/i18n'; import { dateToTimeInputString } from '@/utils/ts/format-date'; import { useDateInputFormatter } from '@/composables/use-date-input-formatter/use-date-input-formatter'; // Composables import { useDateRangePickerCalendarText } from './composables/use-date-range-picker-calendar-text'; import { useFirstDayOfWeek, useFormLayoutFieldLoading } from '@/composables'; // Directives import { BentoClickOutsideDirective as vBentoClickOutsideDirective } from '@/directives'; // Types import { type BentoDateRangePickerEmits, type BentoDateRangePickerProps, type BentoDateRangePickerValue, } from './date-range-picker.types'; import { type DateRangePickerCalendarFormData, type DateRangePickerCalendarRangeSelectorItem, } from './components/date-range-picker-calendar/date-range-picker-calendar.types'; import { deprecate } from '@/utils/ts/deprecate'; import messages from './messages.json'; import { useFormFieldEmits } from '@/utils/ts/form-field/form-field'; type MessageSchema = (typeof messages)['en-US']; const emit = defineEmits<BentoDateRangePickerEmits>(); const props = withDefaults(defineProps<BentoDateRangePickerProps>(), { description: undefined, disabled: false, errorMessage: null, firstDayOfWeek: undefined, hasActions: false, isDateDisabled: undefined, label: undefined, max: null, maxRange: null, min: null, modelValue: null, numberOfMonths: undefined, optional: false, placeholder: null, quickSelectRanges: undefined, readonly: false, required: false, showEndDateOnOpen: false, tooltipText: null, value: null, variant: undefined, // Default will be set by @/internal/calendar yearRange: undefined, }); const attrs: HTMLAttributes = useAttrs(); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const { dateToInputDateString } = useDateInputFormatter(); const datePickerContainerId = generateUid('bento-date-range-picker-container'); const labelId = generateUid('bento-date-range-picker-label'); const descriptionId = generateUid('bento-date-range-picker-description'); const errorId = generateUid('bento-date-range-picker-error'); const dateErrorId = generateUid('bento-date-range-picker-date-error'); const popoverAriaLabel = computed(() => props.label || t('dateRangePicker', { name: datePickerContainerId })); const { resolvedFirstDayOfWeek } = useFirstDayOfWeek(toRef(props, 'firstDayOfWeek')); const breakpoints = useBreakpoints({ none: 0, one: 710, // width of date range picker with one panes two: 1030, // width of date range picker with two panes three: 1290, // width of date range picker with three panes }); const computedNumberOfMonths = computed(() => { const activeBreakpoint = breakpoints.active().value; const breakpointToCalendarPanesMap = { none: 0, one: 1, two: 2, }; if (activeBreakpoint in breakpointToCalendarPanesMap) { const maxPanes = breakpointToCalendarPanesMap[activeBreakpoint as keyof typeof breakpointToCalendarPanesMap]; // If numberOfMonths is not provided, use the max panes for the breakpoint. // Otherwise, use the smaller of the two values. return props.numberOfMonths === undefined ? maxPanes : Math.min(props.numberOfMonths, maxPanes); } // Default case for 'three' and larger breakpoints, respect user-provided numberOfMonths. return props.numberOfMonths; }); const adjustedQuickSelectRanges = computed<Array<DateRangePickerCalendarRangeSelectorItem>>(() => { return ( props?.quickSelectRanges && props.quickSelectRanges.map((range: DateRangePickerCalendarRangeSelectorItem) => { const timeDiff = !range.data.endDate ? new Date(Date.now()).getTime() - range.data.startDate.getTime() : undefined; return { ...range, data: { ...range.data, // Adding timeDifference to calculate an up-to-date endDate on range selection timeDifference: timeDiff, }, }; }) ); }); const dropdownInputAriaLabelledBy = computed(() => (props.label ? labelId : (attrs?.['aria-labelledby'] ?? null))); const isDateIncorrect = ref(false); const inputContainerRef = ref(null); const isDateRangePickerOpen = ref(false); const { isReadOnly } = useFormLayoutFieldLoading(toRef(props, 'readonly')); const { emitValue } = useFormFieldEmits(emit); watch( () => props.disabled, () => { // Close date picker popover if it's opened when it's disabled if (props.disabled && isDateRangePickerOpen.value) { isDateRangePickerOpen.value = false; } } ); const internalModelValue = computed(() => props.modelValue || props.value); const shouldDisplayError = computed(() => { if (props.hasActions) { return !isApplyDisabled.value && isDateIncorrect.value; } return isDateIncorrect.value; }); const descriptionConditionalClasses = computed(() => ({ 'b-date-range-picker__description--error': !!props.errorMessage || shouldDisplayError.value, })); const isMonthVariant = computed(() => props.variant === 'month'); const withTimeInput = computed(() => props.allowTimeInput && props.variant !== 'month'); const { dateDisplayValue, textInputDisplayValue, formatTextInputDisplayValue } = useDateRangePickerCalendarText({ datePickerValue: internalModelValue, isDateIncorrect, quickSelectRanges: adjustedQuickSelectRanges, isMonthVariant, withTimeInput, placeholder: toRef(props, 'placeholder'), }); const ariaDescribedBy = computed( () => `${props.description ? descriptionId : ''} ${props.errorMessage ? errorId : ''} ${ isDateIncorrect.value ? dateErrorId : '' }`.trim() || null ); const closeDateRangePicker = () => { isDateRangePickerOpen.value = false; }; const openDateRangePicker = () => { isDateRangePickerOpen.value = true; formatTextInputDisplayValue(); }; /* * Used when `hasActions` is set to true */ const localDateDisplayValue = ref(dateDisplayValue.value); const localTextInputDisplayValue = ref<DateRangePickerCalendarFormData>( // textInputDisplayValue is a reactive object structuredClone(toRaw(textInputDisplayValue)) ); const selectedDate = ref<BentoDateRangePickerValue>(props.modelValue ?? props.value); const isApplyDisabled = ref(true); const dateRangePickerCalendarValue = computed(() => props.hasActions ? selectedDate.value : internalModelValue.value ); const displayedTextInputValue = ref<DateRangePickerCalendarFormData>( props.hasActions ? localTextInputDisplayValue.value : textInputDisplayValue ); const displayedDateValue = computed(() => props.hasActions ? localDateDisplayValue.value : dateDisplayValue.value ); const trapAllOptions = { allowOutsideClick: true, }; onMounted(() => { if (!props.hasActions) { deprecate( 'BentoDateRangePicker "hasActions" property', `Set the BentoDateRangePicker "hasActions" property true which will display the "Apply" and "Cancel" buttons. This will be the default behavior in the v2 and "hasActions" will be removed.`, '2.0.0' ); } if (props.value) { deprecate( 'BentoDateRangePicker "value" property', 'The use of "value" prop in "BentoDateRangePicker" is no longer supported. Use the "v-model" or "model-value" property instead.', '2.0.0' ); } if ( props.granularities && props.variant !== 'month' && props.granularities.find(({ type }) => type === 'quarterly') ) { throw new Error('Quarterly granularity is only supported by BentoDateRangePicker with variant: "month"'); } }); // Sync the local draft display state back to the committed model value. // Since `displayedTextInputValue` is a mutable ref, it must point to the new cloned draft object. const syncLocalDisplayStateFromCommittedValue = () => { localDateDisplayValue.value = dateDisplayValue.value; localTextInputDisplayValue.value = structuredClone(toRaw(textInputDisplayValue)); displayedTextInputValue.value = props.hasActions ? localTextInputDisplayValue.value : textInputDisplayValue; selectedDate.value = internalModelValue.value; }; // On update for the value we need to update all the locally stored data watch([() => props.value, () => props.modelValue], () => { syncLocalDisplayStateFromCommittedValue(); }); const cancelDateRangePicker = () => { // Reset local date range picker state syncLocalDisplayStateFromCommittedValue(); isDateIncorrect.value = false; isApplyDisabled.value = true; closeDateRangePicker(); }; const rangePickerButtonActions = computed(() => [ { title: t('apply'), event: () => { emitValue(selectedDate.value); closeDateRangePicker(); isApplyDisabled.value = true; }, disabled: isApplyDisabled.value, }, { title: t('cancel'), event: cancelDateRangePicker, }, ]); watch(selectedDate, date => { // skip emit until Apply button is clicked if (props.hasActions) { isApplyDisabled.value = isDateIncorrect.value || date === internalModelValue.value; return; } emitValue(date); }); const onDateRangePickerInput = (selectedRange: BentoDateRangePickerValue) => { // Reset the errors when the date is correct isDateIncorrect.value = false; displayedTextInputValue.value.startDate = dateToInputDateString(selectedRange.startDate); displayedTextInputValue.value.endDate = dateToInputDateString(selectedRange.endDate); displayedTextInputValue.value.startTime = dateToTimeInputString(selectedRange.startDate); displayedTextInputValue.value.endTime = dateToTimeInputString(selectedRange.endDate); selectedDate.value = selectedRange; }; const clearDateRangePickerValue = async () => { if (isDateIncorrect.value) { isDateIncorrect.value = false; } selectedDate.value = null; }; const onEnterPressedOverInput = () => { // open the datepicker if (!isDateRangePickerOpen.value) { isDateRangePickerOpen.value = true; return; } // Close the date picker if the date is correct and it is open if (!isDateIncorrect.value) { isDateRangePickerOpen.value = false; } }; const onDateRangePickerFormDateInputUpdate = (dateFormDate: DateRangePickerCalendarFormData) => { isDateIncorrect.value = false; if (dateFormDate.startTime) { displayedTextInputValue.value.startTime = dateFormDate.startTime; } if (dateFormDate.endTime) { displayedTextInputValue.value.endTime = dateFormDate.endTime; } }; const onDateRangePickerError = dateTextInput => { isDateIncorrect.value = true; displayedTextInputValue.value.startDate = dateTextInput.startDate; displayedTextInputValue.value.endDate = dateTextInput.endDate; // Remove current date range selection selectedDate.value = { startDate: null, endDate: null }; }; const onRangeSelectorInput = (quickSelectRanges?: BentoDateRangePickerValue) => { if (quickSelectRanges) { displayedTextInputValue.value.startDate = dateToInputDateString(quickSelectRanges.startDate); displayedTextInputValue.value.endDate = dateToInputDateString(quickSelectRanges.endDate); displayedTextInputValue.value.startTime = dateToTimeInputString(quickSelectRanges.startDate); displayedTextInputValue.value.endTime = dateToTimeInputString(quickSelectRanges.endDate); selectedDate.value = quickSelectRanges; } }; </script> <script lang="ts"> /** * Date range picker selector. * * @example * import { BentoDateRangePicker } from '@adyen/bento-vue2'; * * export default { * components: { BentoDateRangePicker }, * template: ` * <bento-date-range-picker * label="Label" * description="Supporting text" * required * optional * disabled * @input="onDateChanged" * v-model="selectedDate" * /> * `, * setup() { * const selectedDate = ref(new Date()); // reactive({ startDate: new Date(), endDate: new Date() }) * return { * selectedDate, * } * } * } */ export default { i18n: { messages }, name: 'bento-date-range-picker', model: { prop: 'modelValue' }, }; </script> <style lang="scss" scoped src="./date-range-picker.scss" />
|
|
@@ -1 +1 @@
|
|
|
1
|
-
<template> <bento-menu class="b-draggable-handle" :class="computedClasses" :data="menuData" :menu-width="250" :button="buttonComputedProps" > <template #iconLeft> <span class="b-draggable-handle__icon"> <grab-icon aria-hidden="true" /> </span> </template> </bento-menu> </template> <script setup lang="ts"> import { computed } from 'vue'; import { BentoMenu, type BentoMenuItem, type BentoMenuProps } from '@/components/menu'; import { type BentoDraggableHandleProps } from './draggable-handle.types'; import { useI18n } from '@/utils/ts/i18n'; import GrabIcon from '@adyen/ui-assets-icons-16/vue/grab'; import ArrowTopIcon from '@adyen/ui-assets-icons-16/vue/arrow-top'; import ArrowLeftIcon from '@adyen/ui-assets-icons-16/vue/arrow-left'; import ArrowRightIcon from '@adyen/ui-assets-icons-16/vue/arrow-right'; import ArrowBottomIcon from '@adyen/ui-assets-icons-16/vue/arrow-bottom'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const { t } = useI18n<{ message: MessageSchema }>({ messages }); const props = withDefaults(defineProps<BentoDraggableHandleProps>(), { condensed: false, disabled: false, disabledMenus: () => ({}), variant: 'up-and-down', }); const emit = defineEmits<{ /** * Emitted when the "Move to bottom" action is fired. */ (e: 'move-bottom'): void; /** * Emitted when the "Move down" action is fired. */ (e: 'move-down'): void; /** * Emitted when the "Move left" action is fired. (Only in `all-directions` variant) */ (e: 'move-left'): void; /** * Emitted when the "Move right" action is fired. (Only in `all-directions` variant) */ (e: 'move-right'): void; /** * Emitted when the "Move to top" action is fired. */ (e: 'move-top'): void; /** * Emitted when the "Move up" action is fired. */ (e: 'move-up'): void; }>(); const buttonComputedProps = computed( () => ({ 'aria-label': t('reorderLabel', { label: props.label }), condensed: props.condensed, disabled: props.disabled, variant: 'tertiary', }) as BentoMenuProps['button'] ); const computedClasses = computed(() => ({ /** * Disables the handle and prevents the entire * element from being dragged. * Used by `useBentoDraggable` */ 'b-draggable-handle--disabled': props.disabled, })); /** * Renders the menus for the draggable handle. */ const menuData = computed(() => { const MENUS: { [key: string]: BentoMenuItem; } = { up: { text: t('moveUp'), handler: () => emit('move-up'), disabled: props.disabledMenus.up, icon: ArrowTopIcon, }, down: { text: t('moveDown'), handler: () => emit('move-down'), disabled: props.disabledMenus.down, icon: ArrowBottomIcon, }, left: { text: t('moveLeft'), handler: () => emit('move-left'), disabled: props.disabledMenus.left, icon: ArrowLeftIcon, }, right: { text: t('moveRight'), handler: () => emit('move-right'), disabled: props.disabledMenus.right, icon: ArrowRightIcon, }, top: { text: t('moveToTheTop'), handler: () => emit('move-top'), disabled: props.disabledMenus.top, icon: ArrowTopIcon, }, bottom: { text: t('moveToTheBottom'), handler: () => emit('move-bottom'), disabled: props.disabledMenus.bottom, icon: ArrowBottomIcon, }, }; // Render the menus and dividers according to the variant return props.variant === 'up-and-down' ? [ MENUS.up, // Divider added after "Move down" { ...MENUS.down, addDivider: true, }, MENUS.top, MENUS.bottom, ] : [ MENUS.up, MENUS.down, MENUS.left, // Divider added after "Move right" { ...MENUS.right, addDivider: true, }, MENUS.top, MENUS.bottom, ]; }); </script> <script lang="ts"> /** * Bento drag and drop handle component. * * @example * import { BentoDraggableHandle } from '@adyen/bento-vue2'; * * export default { * components: { BentoDraggableHandle }, * template: ` * <bento-draggable-handle * bottom * condensed * down * left * right * top * up * \@move-bottom="moveBottom(index)" * \@move-down="moveDown(index)" * \@move-left="moveLeft(index)" * \@move-right="moveRight(index)" * \@move-top="moveTop(index)" * \@move-up="moveUp(index)" * /> * `, * setup() { * const moveBottom = () => {} * const moveDown = () => {} * const moveLeft = () => {} * const moveRight = () => {} * const moveTop = () => {} * const moveUp = () => {} * } * } */ export default { i18n: { messages }, name: 'bento-draggable-handle', }; </script> <style lang="scss" scoped src="./draggable-handle.scss" />
|
|
1
|
+
<template> <bento-menu ref="menuRef" class="b-draggable-handle" :class="computedClasses" :data="menuData" :menu-width="250" :button="buttonComputedProps" > <template #iconLeft> <span class="b-draggable-handle__icon"> <grab-icon aria-hidden="true" /> </span> </template> </bento-menu> </template> <script setup lang="ts"> import { computed, ref } from 'vue'; import { BentoMenu, type BentoMenuItem, type BentoMenuProps } from '@/components/menu'; import { useEventListener } from '@vueuse/core'; import { type BentoDraggableHandleProps } from './draggable-handle.types'; import { useI18n } from '@/utils/ts/i18n'; import GrabIcon from '@adyen/ui-assets-icons-16/vue/grab'; import ArrowTopIcon from '@adyen/ui-assets-icons-16/vue/arrow-top'; import ArrowLeftIcon from '@adyen/ui-assets-icons-16/vue/arrow-left'; import ArrowRightIcon from '@adyen/ui-assets-icons-16/vue/arrow-right'; import ArrowBottomIcon from '@adyen/ui-assets-icons-16/vue/arrow-bottom'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const { t } = useI18n<{ message: MessageSchema }>({ messages }); const props = withDefaults(defineProps<BentoDraggableHandleProps>(), { condensed: false, disabled: false, disabledMenus: () => ({}), variant: 'up-and-down', }); const emit = defineEmits<{ /** * Emitted when the "Move to bottom" action is fired. */ (e: 'move-bottom'): void; /** * Emitted when the "Move down" action is fired. */ (e: 'move-down'): void; /** * Emitted when the "Move left" action is fired. (Only in `all-directions` variant) */ (e: 'move-left'): void; /** * Emitted when the "Move right" action is fired. (Only in `all-directions` variant) */ (e: 'move-right'): void; /** * Emitted when the "Move to top" action is fired. */ (e: 'move-top'): void; /** * Emitted when the "Move up" action is fired. */ (e: 'move-up'): void; }>(); const menuRef = ref<InstanceType<typeof BentoMenu> | null>(null); const buttonComputedProps = computed( () => ({ 'aria-label': t('reorderLabel', { label: props.label }), condensed: props.condensed, disabled: props.disabled, variant: 'tertiary', }) as BentoMenuProps['button'] ); const computedClasses = computed(() => ({ /** * Disables the handle and prevents the entire * element from being dragged. * Used by `useBentoDraggable` */ 'b-draggable-handle--disabled': props.disabled, })); /** * Renders the menus for the draggable handle. */ const menuData = computed(() => { const MENUS: { [key: string]: BentoMenuItem; } = { up: { text: t('moveUp'), handler: () => emit('move-up'), disabled: props.disabledMenus.up, icon: ArrowTopIcon, }, down: { text: t('moveDown'), handler: () => emit('move-down'), disabled: props.disabledMenus.down, icon: ArrowBottomIcon, }, left: { text: t('moveLeft'), handler: () => emit('move-left'), disabled: props.disabledMenus.left, icon: ArrowLeftIcon, }, right: { text: t('moveRight'), handler: () => emit('move-right'), disabled: props.disabledMenus.right, icon: ArrowRightIcon, }, top: { text: t('moveToTheTop'), handler: () => emit('move-top'), disabled: props.disabledMenus.top, icon: ArrowTopIcon, }, bottom: { text: t('moveToTheBottom'), handler: () => emit('move-bottom'), disabled: props.disabledMenus.bottom, icon: ArrowBottomIcon, }, }; // Render the menus and dividers according to the variant return props.variant === 'up-and-down' ? [ MENUS.up, // Divider added after "Move down" { ...MENUS.down, addDivider: true, }, MENUS.top, MENUS.bottom, ] : [ MENUS.up, MENUS.down, MENUS.left, // Divider added after "Move right" { ...MENUS.right, addDivider: true, }, MENUS.top, MENUS.bottom, ]; }); /** * Closes the menu when a drag is about to start. * * SortableJS clones the dragged element synchronously during `dragstart` to create * the drag ghost. Because Vue batches DOM updates to the next tick, calling * `closeMenu()` alone would leave the popover visible in the cloned ghost. * * To prevent this, the popover DOM element is hidden directly via `style.display` * before SortableJS can capture it. The subsequent `closeMenu(false)` call then * synchronises the Vue reactive state. The `false` argument skips reverting focus * to the menu button, which would interfere with the ongoing drag. */ useEventListener(document, 'dragstart', () => { const ref = menuRef.value?.$refs?.popoverRef; if (ref && !Array.isArray(ref) && '$el' in ref && ref.$el instanceof HTMLElement) { ref.$el.style.display = 'none'; } menuRef.value?.closeMenu(false); }); </script> <script lang="ts"> /** * Bento drag and drop handle component. * * @example * import { BentoDraggableHandle } from '@adyen/bento-vue2'; * * export default { * components: { BentoDraggableHandle }, * template: ` * <bento-draggable-handle * bottom * condensed * down * left * right * top * up * \@move-bottom="moveBottom(index)" * \@move-down="moveDown(index)" * \@move-left="moveLeft(index)" * \@move-right="moveRight(index)" * \@move-top="moveTop(index)" * \@move-up="moveUp(index)" * /> * `, * setup() { * const moveBottom = () => {} * const moveDown = () => {} * const moveLeft = () => {} * const moveRight = () => {} * const moveTop = () => {} * const moveUp = () => {} * } * } */ export default { i18n: { messages }, name: 'bento-draggable-handle', }; </script> <style lang="scss" scoped src="./draggable-handle.scss" />
|