@adyen/bento-mcp 0.9.0 → 0.11.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 +16 -0
- package/README.md +38 -12
- package/dist/assets/components/avatar/avatar.stories.ts +1 -1
- package/dist/assets/components/avatar/avatar.types.ts +1 -0
- package/dist/assets/components/avatar/avatar.vue +1 -1
- package/dist/assets/components/avatar/components/avatar-image/avatar-image.vue +1 -1
- package/dist/assets/components/data-grid/components/data-grid-cell-actions/data-grid-cell-actions.vue +1 -1
- package/dist/assets/components/date-picker/date-picker.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.stories.ts +1 -1
- package/dist/assets/components/date-range-picker/date-range-picker.vue +1 -1
- package/dist/assets/components/dropdown/components/dropdown-base-textbox/dropdown-base-textbox.vue +1 -1
- package/dist/assets/components/dropdown/components/dropdown-default-textbox/dropdown-default-textbox.vue +1 -1
- package/dist/assets/components/dropdown/components/dropdown-options-container/dropdown-options-container.vue +1 -1
- package/dist/assets/components/dropdown/components/dropdown-small-textbox/dropdown-small-textbox.vue +1 -1
- package/dist/assets/components/dropdown/composables/use-keyboard-navigation.types.ts +1 -1
- package/dist/assets/components/dropdown/dropdown.vue +1 -1
- package/dist/assets/components/header-with-views/components/header-with-views-all-views/header-with-views-all-views.vue +1 -1
- package/dist/assets/components/header-with-views/header-with-views.vue +1 -1
- package/dist/assets/components/internal/calendar/composables/use-granularity-adjustments.types.ts +1 -1
- package/dist/assets/components/internal/listbox/components/listbox-option/listbox-option.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/components/listbox-single-select-option/listbox-single-select-option.vue +1 -1
- package/dist/assets/components/internal/listbox/listbox.vue +1 -1
- package/dist/assets/components/menu/menu.docs.mdx +8 -7
- package/dist/assets/components/menu/menu.vue +1 -1
- package/dist/assets/components/popover/popover.types.ts +1 -1
- package/dist/assets/components/popover/popover.vue +1 -1
- package/dist/assets/components/popper-container/popper-container.types.ts +1 -1
- package/dist/assets/components/popper-container/popper-container.vue +1 -1
- package/dist/assets/components/progress-bar/progress-bar.docs.mdx +102 -0
- package/dist/assets/components/progress-bar/progress-bar.stories.ts +1 -0
- package/dist/assets/components/progress-bar/progress-bar.types.ts +1 -0
- package/dist/assets/components/progress-bar/progress-bar.vue +1 -0
- package/dist/assets/components.json +1 -0
- package/dist/assets/deprecations.md +157 -157
- package/dist/assets/index.ts +1 -1
- package/dist/assets/usage.json +80 -79
- package/dist/assets/variables.css +42 -41
- package/dist/main.js +1 -1
- package/package.json +1 -1
- package/dist/assets/components/avatar/components/avatar-image/avatar-image.types.ts +0 -1
- package/dist/assets/components/internal/calendar/composables/use-range-pane.types.ts +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.11.0 (2026-07-29)
|
|
4
|
+
|
|
5
|
+
This was a version bump only for mcp to align it with other projects, there were no code changes.
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
## 0.10.0 (2026-07-21)
|
|
9
|
+
|
|
10
|
+
### Documentation
|
|
11
|
+
|
|
12
|
+
- added devin to mcp docs & CLI commands & requirements ([86b978488](https://github.com/Adyen/bento/commit/86b978488))
|
|
13
|
+
|
|
14
|
+
### ❤️ Thank You
|
|
15
|
+
|
|
16
|
+
- gerald
|
|
17
|
+
|
|
18
|
+
|
|
3
19
|
## 0.9.0 (2026-07-15)
|
|
4
20
|
|
|
5
21
|
### Miscellaneous Chores
|
package/README.md
CHANGED
|
@@ -11,13 +11,47 @@ models (LLMs) to interact with Bento's codebase, documentation, and components.
|
|
|
11
11
|
- **Deprecation Registry**: Query deprecated props and their alternatives.
|
|
12
12
|
- **Bento AI Skill**: A strict implementation protocol for AI agents.
|
|
13
13
|
|
|
14
|
-
##
|
|
14
|
+
## Requirements
|
|
15
|
+
|
|
16
|
+
- Node.js must be installed in order to install the Bento MCP server and component writing skill.
|
|
17
|
+
|
|
18
|
+
## Quick Installation
|
|
19
|
+
|
|
20
|
+
After installing Node.js, copy and paste this page's contents into your AI agent and ask it to configure Bento MCP and
|
|
21
|
+
add the Bento AI Skill for your client.
|
|
22
|
+
|
|
23
|
+
## Step-by-Step Installation
|
|
15
24
|
|
|
16
25
|
### 1. Register the MCP Server
|
|
17
26
|
|
|
18
27
|
You can use this server as a tool with various AI assistant clients that support the Model Context Protocol.
|
|
19
28
|
|
|
20
|
-
|
|
29
|
+
Choose either option 1.1 or 1.2 to register the MCP server.
|
|
30
|
+
|
|
31
|
+
#### 1.1 Add the server with a command
|
|
32
|
+
|
|
33
|
+
Run the command for your AI assistant client:
|
|
34
|
+
|
|
35
|
+
| Client | Command |
|
|
36
|
+
| :---------------- | :------------------------------------------------------------------------- |
|
|
37
|
+
| **Claude** | `claude mcp add --transport stdio bento -- npx -y @adyen/bento-mcp@latest` |
|
|
38
|
+
| **Droid/Factory** | `droid mcp add bento "npx -y @adyen/bento-mcp@latest"` |
|
|
39
|
+
| **Gemini** | `gemini mcp add bento npx -y @adyen/bento-mcp@latest` |
|
|
40
|
+
| **Windsurf** | — |
|
|
41
|
+
| **Devin** | `devin mcp add bento -- npx -y @adyen/bento-mcp@latest` |
|
|
42
|
+
|
|
43
|
+
#### 1.2 Add the server manually
|
|
44
|
+
|
|
45
|
+
If your client does not support adding MCP servers with a command, add the configuration below to your client's settings
|
|
46
|
+
file:
|
|
47
|
+
|
|
48
|
+
| Client | Local Path | Global Path |
|
|
49
|
+
| :---------------- | :---------------------------- | :------------------------------------ |
|
|
50
|
+
| **Claude** | `.claude/settings.local.json` | `~/.claude/settings.json` |
|
|
51
|
+
| **Droid/Factory** | `.factory/mcp.json` | `~/.factory/mcp.json` |
|
|
52
|
+
| **Gemini** | `.gemini/settings.json` | `~/.gemini/settings.json` |
|
|
53
|
+
| **Windsurf** | `-` | `~/.codeium/windsurf/mcp_config.json` |
|
|
54
|
+
| **Devin** | `.devin/config.json` | `~/.config/devin/config.json` |
|
|
21
55
|
|
|
22
56
|
```json
|
|
23
57
|
{
|
|
@@ -31,16 +65,6 @@ To configure the MCP server, add the following to your client's settings file:
|
|
|
31
65
|
}
|
|
32
66
|
```
|
|
33
67
|
|
|
34
|
-
The location of this file may vary depending on your client and setup. For project-level and global configuration, you
|
|
35
|
-
can typically use:
|
|
36
|
-
|
|
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` |
|
|
43
|
-
|
|
44
68
|
Once configured, you can invoke the tool by its name (`bento`).
|
|
45
69
|
|
|
46
70
|
### 2. Add the Bento AI Skill
|
|
@@ -55,6 +79,8 @@ Run the following command in your project root:
|
|
|
55
79
|
npm install -D @adyen/bento-mcp && npx bento-add-skill
|
|
56
80
|
```
|
|
57
81
|
|
|
82
|
+
After this you can invoke the skill with `/writing-bento-components`
|
|
83
|
+
|
|
58
84
|
#### For Other Agents (Claude, Cursor, etc.)
|
|
59
85
|
|
|
60
86
|
We recommend using [@codemcp/agentskills](https://github.com/codemcp/agentskills):
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import { type StoryObj } from '@storybook/vue'; import BentoAvatar from './avatar.vue'; import { storybookDocsParameter } from '@/utils/ts/storybook'; import { isVue2 } from 'vue-demi'; const meta = { title: 'Avatar', component: BentoAvatar, argTypes: { username: { name: 'username', description: 'Name of user', table: { type: { summary: 'VNode[]' }, }, control: { type: 'text', }, }, src: { name: 'src', description: 'Image src of user profile picture', table: { type: { summary: 'VNode[]' }, }, control: { type: 'text', }, }, }, }; export default meta; type Story = StoryObj<typeof BentoAvatar>; const
|
|
1
|
+
import { type StoryObj } from '@storybook/vue'; import BentoAvatar from './avatar.vue'; import { storybookDocsParameter } from '@/utils/ts/storybook'; import { isVue2 } from 'vue-demi'; import { BentoAvatarSizeOptions } from './avatar.types'; const meta = { title: 'Avatar', component: BentoAvatar, argTypes: { username: { name: 'username', description: 'Name of user', table: { type: { summary: 'VNode[]' }, }, control: { type: 'text', }, }, src: { name: 'src', description: 'Image src of user profile picture', table: { type: { summary: 'VNode[]' }, }, control: { type: 'text', }, }, size: { options: BentoAvatarSizeOptions, control: { type: 'select', }, }, }, }; export default meta; type Story = StoryObj<typeof BentoAvatar>; const defaultCode = ` <bento-avatar username="John" /> `; export const Default: Story = { render: (_args, { argTypes }) => ({ components: { BentoAvatar }, props: Object.keys(argTypes), setup: props => ({ args: isVue2 ? props : _args }), template: ` <bento-avatar v-bind="args"/> `, }), args: { username: 'John', iconOnly: false, size: 'small', }, parameters: storybookDocsParameter(defaultCode), };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/** * Public */ export type BentoAvatarSize = (typeof BentoAvatarSizeOptions)[number]; /** * Private */ export const BentoAvatarSizeOptions = ['small', 'medium', 'large', 'xlarge'] as const;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
<template> <div class="b-avatar" :role="iconOnly ? 'img' : null" :aria-label="computedAriaLabel"> <avatar-image :src="src" :username="computedUsername" :
|
|
1
|
+
<template> <div class="b-avatar" :role="iconOnly ? 'img' : null" :aria-label="computedAriaLabel"> <avatar-image :src="src" :username="computedUsername" :size="size" /> <bento-typography v-if="!iconOnly" class="b-avatar__label" el="span" variant="body"> {{ username }} </bento-typography> </div> </template> <script setup lang="ts"> import { computed } from 'vue'; import { AvatarImage } from './components/avatar-image'; import { BentoTypography } from '@/components/typography'; import { type BentoAvatarSize } from './avatar.types'; const props = withDefaults( defineProps<{ /** * Displays the user profile without the label. */ iconOnly?: boolean; /** * Image src to load the profile picture. */ src?: string; /** * Size of the profile image. */ size?: BentoAvatarSize; /** * Name of the user that will be used both in the label and to extract initials to display default value. * John => J * John Smith => JS * John Smith Doe => JD * John Smith Doe Holly => JH */ username: string; }>(), { iconOnly: false, size: 'small', src: null, } ); const computedAriaLabel = computed(() => (props.iconOnly ? props.username : null)); const computedUsername = computed(() => (!props.src ? props.username : null)); </script> <script lang="ts"> /** * Displays an user profile including an avatar and optionally a label. * * @example * import { BentoAvatar } from '@adyen/bento-vue2'; * * export default { * components: { BentoAvatar }, * template: `<bento-avatar :src="userAvatarSrc" username="Jane" />` * } */ export default { name: 'bento-avatar', }; </script> <style lang="scss" scoped src="./avatar.scss" />
|
|
@@ -1 +1 @@
|
|
|
1
|
-
<template> <div class="b-avatar-image" :class="conditionalClasses" aria-hidden="true" data-testid="avatar"> <img v-if="src" :src="src" class="b-avatar-image__image" alt="" data-testid="avatar-content" /> <div v-else class="b-avatar-image__default" data-testid="avatar-content"> <bento-typography el="span"
|
|
1
|
+
<template> <div class="b-avatar-image" :class="conditionalClasses" aria-hidden="true" data-testid="avatar"> <img v-if="src" :src="src" class="b-avatar-image__image" alt="" data-testid="avatar-content" /> <div v-else class="b-avatar-image__default" data-testid="avatar-content"> <bento-typography el="span" v-bind="iconInitialsTypographyProps"> {{ initials }} </bento-typography> </div> </div> </template> <script setup lang="ts"> import { computed, toRefs } from 'vue'; import { BentoTypography, type BentoTypographyProps } from '@/components/typography'; import { useInitials } from '@/composables'; import { type BentoAvatarSize } from '../../avatar.types'; const props = withDefaults( defineProps<{ /** * Image src to load the profile picture. */ src?: string; /** * Name of the user that will be used to extract initials to display default value. * John => J * John Smith => JS * John Smith Doe => JD * John Smith Doe Holly => JH */ username?: string; /** * Size of the profile image. */ size?: BentoAvatarSize; }>(), { size: 'medium', src: null, username: null, } ); const { username } = toRefs(props); const initials = useInitials(username); const conditionalClasses = computed(() => ({ [`b-avatar-image--${props.size}`]: props.size, })); const iconInitialsTypographyProps = computed<BentoTypographyProps>(() => ({ variant: props.size === 'large' || props.size === 'xlarge' ? 'title' : 'body', strongest: props.size === 'small' || props.size === 'medium', medium: props.size === 'large', large: props.size === 'xlarge', })); </script> <script lang="ts"> /** * Image used as user profile inside bento-avatar. * * @example * import { AvatarImage } from './components/avatar-image'; * * export default { * components: { AvatarImage }, * template: `<avatar-image :src="userAvatarSrc" username="Jane" />` * } */ export default {}; </script> <style lang="scss" scoped src="./avatar-image.scss" />
|
|
@@ -1 +1 @@
|
|
|
1
|
-
<template> <Transition name="b-data-grid-cell-actions__animation" appear> <div class="b-data-grid-cell-actions"> <div v-if="showGradientElement && !isTouchDevice" class="b-data-grid-cell-actions__background-gradient" :class="conditionalCellActionsBackgroundGradientClasses" ></div> <div class="b-data-grid-cell-actions__actions" :class="conditionalCellActionsClasses" role="group"> <div v-for="(button, index) in computedCellActions" :key="index" class="b-data-grid-cell-actions__action" > <bento-menu v-if="button.menuData" v-bento-tooltip-directive:[button.tooltipPosition]="button.tooltipText" :data="transformMenuEvents(button.menuData)" :button="{ condensed: true, variant: 'secondary', disabled: button.disabled, }" :disable-arrow-down-opening-menu="disableArrowDownOpeningMenu" :teleport="teleportMenu" @open="emit('menu-open')" @close="emit('menu-close')" @menu-item-click="emit('menu-item-click')" > <template v-if="shouldShowMenuIconLeft(button)" #iconLeft> <options-vertical-icon v-if="button?.variant === 'more'" :svg-title="getButtonTitle(button, true)" /> <component :is="button.iconLeft" v-else :svg-title="getButtonTitle(button, true)" /> </template> <template v-if="!button?.isShort" #default> {{ getButtonTitle(button) }} </template> </bento-menu> <copy v-else-if="button.variant === 'copy'" :text="getCopyText(button)" :button-text="getButtonTitle(button)" :button="{ condensed: true, variant: 'secondary', }" /> <bento-button v-else v-bento-tooltip-directive:[button.tooltipPosition]="button.tooltipText" condensed variant="secondary" :critical="button.critical" :disabled="button.disabled" :link="button.link" v-on="generateButtonEvents(button)" > <template v-if="button.iconLeft || button.variant === 'edit'" #iconLeft> <edit1-icon v-if="button?.variant === 'edit'" :svg-title="getButtonTitle(button, true)" /> <component :is="button.iconLeft" v-else :svg-title="button.title" /> </template> <template v-if="!button?.isShort" #default> {{ getButtonTitle(button) }} </template> <template v-if="button.iconRight || button.variant === 'open'" #iconRight> <external-link-icon v-if="button?.variant === 'open'" :svg-title="getButtonTitle(button, true)" /> <component :is="button.iconRight" v-else :svg-title="button.title" /> </template> </bento-button> </div> </div> </div> </Transition> </template> <script setup lang="ts"> import { computed, inject, type PropType, ref, type Ref } from 'vue'; import { BentoButton } from '@/components/button'; import { BentoMenu, type BentoMenuItem } from '@/components/menu'; import { Copy } from '@/internal/copy'; import { BentoTooltipDirective as vBentoTooltipDirective } from '@/directives/tooltip'; import { printDevelopmentWarning } from '@/utils/ts/print-development-warning'; import { type BentoDatagridDataItem } from '@/components/data-grid/data-grid.types'; import { DATA_GRID_TOUCH_DEVICE } from '@/components/data-grid/data-grid.keys'; import Edit1Icon from '@adyen/ui-assets-icons-16/vue/edit-1'; import ExternalLinkIcon from '@adyen/ui-assets-icons-16/vue/external-link'; import OptionsVerticalIcon from '@adyen/ui-assets-icons-16/vue/options-vertical'; // Types import type { BentoColumnCellAction, BentoColumnCellActionMenuItem, BentoColumnCellActions, BentoColumnCellActionsByCellDataFn, BentoColumnCellActionsProp, BentoDataGridRowActionsProp, } from './data-grid-cell-actions.types'; // Translations import messages from './messages.json'; import { useI18n } from '@/utils/ts/i18n'; type MessageSchema = (typeof messages)['en-US']; const { t } = useI18n<{ message: MessageSchema }>({ messages }); const props = defineProps({ /** * Column definitions using 'key' to relate to data object. */ cellActions: { type: [Array, Function] as PropType<BentoColumnCellActionsProp | BentoDataGridRowActionsProp>, required: true, validator: (value: BentoColumnCellActions) => value?.length <= 2, }, /** * The column field key. */ columnKey: { type: String, default: undefined, }, /** * Disables any opening of the cell actions menus using the down arrow key. */ disableArrowDownOpeningMenu: { type: Boolean, default: false, }, /** * Set to true if the datagrid has been scrolled and the right side has overflowing space. */ hasRightOverflow: { type: Boolean, default: false, }, /** * Set to true if the row has a darker background when zebra rows is enabled. */ hasZebraBackground: { type: Boolean, default: false, }, /** * Set to true if the row is selected. */ isSelected: { type: Boolean, default: false, }, /** * Set to true if the row is highlighted. */ isHighlighted: { type: Boolean, default: false, }, /** * Set if set to true will teleport the action menu component */ teleportMenu: { type: Boolean, default: false, }, /** * The row data item object. */ rowDataItem: { type: Object as PropType<BentoDatagridDataItem>, required: true, }, }); const emit = defineEmits<{ /** * Emits when a cell actions menu is opened */ (e: 'menu-open'): void; /** * Emits when a cell actions menu is closed */ (e: 'menu-close'): void; /** * Emits when a cell actions menu button closes due to a menu item click */ (e: 'menu-item-click'): void; }>(); const isTouchDevice: Ref<boolean> = inject(DATA_GRID_TOUCH_DEVICE, ref(false)); // Helper to get text for menu items, always returning a string const getMenuItemText = (action: BentoColumnCellAction | BentoColumnCellActionMenuItem) => { // If it's already a menu item with a text property, use it if ('text' in action && action.text !== undefined) { return action.text; } // Otherwise, derive from variant or title switch ((action as BentoColumnCellAction).variant) { case 'copy': return t('copy'); case 'edit': return t('edit'); case 'open': return t('open'); case 'more': return t('more'); default: return (action as BentoColumnCellAction).title; } }; const getMenuItemIcon = (action: BentoColumnCellAction) => { switch (action.variant) { case 'copy': return Copy; case 'edit': return Edit1Icon; case 'open': return ExternalLinkIcon; default: return undefined; } }; const isRowActions = computed(() => props.columnKey === undefined); const conditionalCellActionsBackgroundGradientClasses = computed(() => ({ 'b-data-grid-cell-actions__background-gradient--with-row-actions-and-overflow': isRowActions.value && props.hasRightOverflow, 'b-data-grid-cell-actions__background-gradient--zebra': props.hasZebraBackground, 'b-data-grid-cell-actions__background-gradient--highlighted': props.isHighlighted, 'b-data-grid-cell-actions__background-gradient--selected': props.isSelected, })); const conditionalCellActionsClasses = computed(() => ({ 'b-data-grid-cell-actions__actions--with-row-actions': isRowActions.value, 'b-data-grid-cell-actions__actions--zebra': props.hasZebraBackground, 'b-data-grid-cell-actions__actions--highlighted': props.isHighlighted, 'b-data-grid-cell-actions__actions--selected': props.isSelected, 'b-data-grid-cell-actions__actions--zebra-hover': !isRowActions.value && props.hasZebraBackground, 'b-data-grid-cell-actions__actions--highlighted-hover': !isRowActions.value && props.isHighlighted, 'b-data-grid-cell-actions__actions--selected-hover': !isRowActions.value && props.isSelected, })); const showGradientElement = computed(() => !isRowActions.value || (isRowActions.value && props.hasRightOverflow)); /** * Checks if the passed object is a cell actions function that returns an cell actions array. * @param cellActions - a cell actions array or function */ function isBentoColumnCellActionsByCellValueDataFn( cellActions: BentoColumnCellActionsProp ): cellActions is BentoColumnCellActionsByCellDataFn { return typeof cellActions === 'function'; } const computedCellActions = computed(() => { const cellActions = isBentoColumnCellActionsByCellValueDataFn(props.cellActions) ? props.cellActions?.(props.rowDataItem, props.columnKey) : props.cellActions; if (isTouchDevice.value && !isRowActions.value && cellActions) { const flattenedMenuItems: Array<BentoColumnCellActionMenuItem> = []; cellActions.forEach(action => { if (action.menuData) { action.menuData.forEach(subMenuItem => { flattenedMenuItems.push(subMenuItem); }); } else { flattenedMenuItems.push({ text: getMenuItemText(action), event: action.event, critical: action.critical, disabled: action.disabled, icon: getMenuItemIcon(action), }); } }); return [ { variant: 'more', isShort: true, iconLeft: OptionsVerticalIcon, menuData: transformMenuEvents(flattenedMenuItems), }, ] as BentoColumnCellActions; } // Do not allow users to provide more than 2 cell action buttons if (!isTouchDevice.value && cellActions?.length > 2) { printDevelopmentWarning( 'WARNING: BentoDataGrid Cell actions can only have a maximum of 2 actions, if you need more consider using the menuData property instead.' ); } return cellActions?.slice(0, 2); }); const getButtonTitle = computed(() => (button: BentoColumnCellAction, isForSvgTitle?: boolean) => { if ((!isForSvgTitle && button.isShort) || (isForSvgTitle && !button.isShort)) { return undefined; } switch (button.variant) { case 'copy': return t('copy'); case 'edit': return t('edit'); case 'open': return t('open'); case 'more': return t('more'); default: return button.title; } }); const generateButtonEvents = (button: BentoColumnCellAction) => button?.event !== undefined ? { click: (event: MouseEvent) => { event.stopPropagation(); button.event(props.rowDataItem, props.columnKey); }, } : {}; const generateMenuEvents = (menuDataItem: BentoColumnCellActionMenuItem): BentoMenuItem => ({ ...menuDataItem, handler: () => { if (menuDataItem?.handler && typeof menuDataItem.handler === 'function') { menuDataItem?.handler(); } if (menuDataItem?.event && typeof menuDataItem.event === 'function') { menuDataItem?.event(props.rowDataItem, props.columnKey); } }, }); const transformMenuEvents = (menuData: Array<BentoColumnCellActionMenuItem>) => menuData.map(menuDataItem => { const transformedMenuData = menuDataItem; if (transformedMenuData?.items) { transformedMenuData.items = transformMenuEvents(transformedMenuData.items); } return { ...generateMenuEvents(transformedMenuData) }; }); const getCopyText = (button: BentoColumnCellAction) => button?.getCopyData !== undefined ? button.getCopyData(props.rowDataItem, props.columnKey) : props.columnKey ? (props.rowDataItem?.[props.columnKey]?.toString() as string) : ''; const shouldShowMenuIconLeft = (button: BentoColumnCellAction) => button.iconLeft || button?.variant === 'more'; </script> <script lang="ts"> /** * Internal component to display the data-grid actions. * There are 4 different pre-defined buttons that can be used by using the `variant` property. * * The component renders buttons or a menu depending on the actions provided. * * @example * <data-grid-cell-actions * :cell-actions="[ * { variant: 'edit', event: () => {} }, * { * variant: 'more', * isShort: true, * menuData: [ * { * text: 'Cancel payment', * handler: () => {}), * critical: true, * }, * { * text: 'Transfer payment', * handler: () => {}, * }, * { * text: 'Delete payment', * handler: () => {}, * disabled: true, * }, * ], * }, * ]" * column-key="payment" * :row-data-item="rowData" * /> */ export default { i18n: { messages }, name: 'data-grid-cell-actions', }; </script> <style lang="scss" scoped src="./data-grid-cell-actions.scss" />
|
|
1
|
+
<template> <Transition name="b-data-grid-cell-actions__animation" appear> <div class="b-data-grid-cell-actions"> <div v-if="showGradientElement && !isTouchDevice" class="b-data-grid-cell-actions__background-gradient" :class="conditionalCellActionsBackgroundGradientClasses" ></div> <div class="b-data-grid-cell-actions__actions" :class="conditionalCellActionsClasses" role="group"> <div v-for="(button, index) in computedCellActions" :key="index" class="b-data-grid-cell-actions__action" > <bento-menu v-if="button.menuData" v-bento-tooltip-directive:[button.tooltipPosition]="button.tooltipText" :data="transformMenuEvents(button.menuData)" :button="{ condensed: true, variant: 'secondary', disabled: button.disabled, 'aria-label': getButtonTitle(button, true), }" :disable-arrow-down-opening-menu="disableArrowDownOpeningMenu" :teleport="teleportMenu" @open="emit('menu-open')" @close="emit('menu-close')" @menu-item-click="emit('menu-item-click')" > <template v-if="shouldShowMenuIconLeft(button)" #iconLeft> <options-vertical-icon v-if="button?.variant === 'more'" :aria-hidden="true" /> <component :is="button.iconLeft" v-else :aria-hidden="true" /> </template> <template v-if="!button?.isShort" #default> {{ getButtonTitle(button) }} </template> </bento-menu> <copy v-else-if="button.variant === 'copy'" :text="getCopyText(button)" :button-text="getButtonTitle(button)" :button="{ condensed: true, variant: 'secondary', }" /> <bento-button v-else v-bento-tooltip-directive:[button.tooltipPosition]="button.tooltipText" condensed variant="secondary" :critical="button.critical" :disabled="button.disabled" :link="button.link" v-on="generateButtonEvents(button)" > <template v-if="button.iconLeft || button.variant === 'edit'" #iconLeft> <edit1-icon v-if="button?.variant === 'edit'" :svg-title="getButtonTitle(button, true)" /> <component :is="button.iconLeft" v-else :svg-title="button.title" /> </template> <template v-if="!button?.isShort" #default> {{ getButtonTitle(button) }} </template> <template v-if="button.iconRight || button.variant === 'open'" #iconRight> <external-link-icon v-if="button?.variant === 'open'" :svg-title="getButtonTitle(button, true)" /> <component :is="button.iconRight" v-else :svg-title="button.title" /> </template> </bento-button> </div> </div> </div> </Transition> </template> <script setup lang="ts"> import { computed, inject, type PropType, ref, type Ref } from 'vue'; import { BentoButton } from '@/components/button'; import { BentoMenu, type BentoMenuItem } from '@/components/menu'; import { Copy } from '@/internal/copy'; import { BentoTooltipDirective as vBentoTooltipDirective } from '@/directives/tooltip'; import { printDevelopmentWarning } from '@/utils/ts/print-development-warning'; import { type BentoDatagridDataItem } from '@/components/data-grid/data-grid.types'; import { DATA_GRID_TOUCH_DEVICE } from '@/components/data-grid/data-grid.keys'; import Edit1Icon from '@adyen/ui-assets-icons-16/vue/edit-1'; import ExternalLinkIcon from '@adyen/ui-assets-icons-16/vue/external-link'; import OptionsVerticalIcon from '@adyen/ui-assets-icons-16/vue/options-vertical'; // Types import type { BentoColumnCellAction, BentoColumnCellActionMenuItem, BentoColumnCellActions, BentoColumnCellActionsByCellDataFn, BentoColumnCellActionsProp, BentoDataGridRowActionsProp, } from './data-grid-cell-actions.types'; // Translations import messages from './messages.json'; import { useI18n } from '@/utils/ts/i18n'; type MessageSchema = (typeof messages)['en-US']; const { t } = useI18n<{ message: MessageSchema }>({ messages }); const props = defineProps({ /** * Column definitions using 'key' to relate to data object. */ cellActions: { type: [Array, Function] as PropType<BentoColumnCellActionsProp | BentoDataGridRowActionsProp>, required: true, validator: (value: BentoColumnCellActions) => value?.length <= 2, }, /** * The column field key. */ columnKey: { type: String, default: undefined, }, /** * Disables any opening of the cell actions menus using the down arrow key. */ disableArrowDownOpeningMenu: { type: Boolean, default: false, }, /** * Set to true if the datagrid has been scrolled and the right side has overflowing space. */ hasRightOverflow: { type: Boolean, default: false, }, /** * Set to true if the row has a darker background when zebra rows is enabled. */ hasZebraBackground: { type: Boolean, default: false, }, /** * Set to true if the row is selected. */ isSelected: { type: Boolean, default: false, }, /** * Set to true if the row is highlighted. */ isHighlighted: { type: Boolean, default: false, }, /** * Set if set to true will teleport the action menu component */ teleportMenu: { type: Boolean, default: false, }, /** * The row data item object. */ rowDataItem: { type: Object as PropType<BentoDatagridDataItem>, required: true, }, }); const emit = defineEmits<{ /** * Emits when a cell actions menu is opened */ (e: 'menu-open'): void; /** * Emits when a cell actions menu is closed */ (e: 'menu-close'): void; /** * Emits when a cell actions menu button closes due to a menu item click */ (e: 'menu-item-click'): void; }>(); const isTouchDevice: Ref<boolean> = inject(DATA_GRID_TOUCH_DEVICE, ref(false)); // Helper to get text for menu items, always returning a string const getMenuItemText = (action: BentoColumnCellAction | BentoColumnCellActionMenuItem) => { // If it's already a menu item with a text property, use it if ('text' in action && action.text !== undefined) { return action.text; } // Otherwise, derive from variant or title switch ((action as BentoColumnCellAction).variant) { case 'copy': return t('copy'); case 'edit': return t('edit'); case 'open': return t('open'); case 'more': return t('more'); default: return (action as BentoColumnCellAction).title; } }; const getMenuItemIcon = (action: BentoColumnCellAction) => { switch (action.variant) { case 'copy': return Copy; case 'edit': return Edit1Icon; case 'open': return ExternalLinkIcon; default: return undefined; } }; const isRowActions = computed(() => props.columnKey === undefined); const conditionalCellActionsBackgroundGradientClasses = computed(() => ({ 'b-data-grid-cell-actions__background-gradient--with-row-actions-and-overflow': isRowActions.value && props.hasRightOverflow, 'b-data-grid-cell-actions__background-gradient--zebra': props.hasZebraBackground, 'b-data-grid-cell-actions__background-gradient--highlighted': props.isHighlighted, 'b-data-grid-cell-actions__background-gradient--selected': props.isSelected, })); const conditionalCellActionsClasses = computed(() => ({ 'b-data-grid-cell-actions__actions--with-row-actions': isRowActions.value, 'b-data-grid-cell-actions__actions--zebra': props.hasZebraBackground, 'b-data-grid-cell-actions__actions--highlighted': props.isHighlighted, 'b-data-grid-cell-actions__actions--selected': props.isSelected, 'b-data-grid-cell-actions__actions--zebra-hover': !isRowActions.value && props.hasZebraBackground, 'b-data-grid-cell-actions__actions--highlighted-hover': !isRowActions.value && props.isHighlighted, 'b-data-grid-cell-actions__actions--selected-hover': !isRowActions.value && props.isSelected, })); const showGradientElement = computed(() => !isRowActions.value || (isRowActions.value && props.hasRightOverflow)); /** * Checks if the passed object is a cell actions function that returns an cell actions array. * @param cellActions - a cell actions array or function */ function isBentoColumnCellActionsByCellValueDataFn( cellActions: BentoColumnCellActionsProp ): cellActions is BentoColumnCellActionsByCellDataFn { return typeof cellActions === 'function'; } const computedCellActions = computed(() => { const cellActions = isBentoColumnCellActionsByCellValueDataFn(props.cellActions) ? props.cellActions?.(props.rowDataItem, props.columnKey) : props.cellActions; if (isTouchDevice.value && !isRowActions.value && cellActions) { const flattenedMenuItems: Array<BentoColumnCellActionMenuItem> = []; cellActions.forEach(action => { if (action.menuData) { action.menuData.forEach(subMenuItem => { flattenedMenuItems.push(subMenuItem); }); } else { flattenedMenuItems.push({ text: getMenuItemText(action), event: action.event, critical: action.critical, disabled: action.disabled, icon: getMenuItemIcon(action), }); } }); return [ { variant: 'more', isShort: true, iconLeft: OptionsVerticalIcon, menuData: transformMenuEvents(flattenedMenuItems), }, ] as BentoColumnCellActions; } // Do not allow users to provide more than 2 cell action buttons if (!isTouchDevice.value && cellActions?.length > 2) { printDevelopmentWarning( 'WARNING: BentoDataGrid Cell actions can only have a maximum of 2 actions, if you need more consider using the menuData property instead.' ); } return cellActions?.slice(0, 2); }); const getButtonTitle = computed(() => (button: BentoColumnCellAction, isForSvgTitle?: boolean) => { if ((!isForSvgTitle && button.isShort) || (isForSvgTitle && !button.isShort)) { return undefined; } switch (button.variant) { case 'copy': return t('copy'); case 'edit': return t('edit'); case 'open': return t('open'); case 'more': return t('more'); default: return button.title; } }); const generateButtonEvents = (button: BentoColumnCellAction) => button?.event !== undefined ? { click: (event: MouseEvent) => { event.stopPropagation(); button.event(props.rowDataItem, props.columnKey); }, } : {}; const generateMenuEvents = (menuDataItem: BentoColumnCellActionMenuItem): BentoMenuItem => ({ ...menuDataItem, handler: () => { if (menuDataItem?.handler && typeof menuDataItem.handler === 'function') { menuDataItem?.handler(); } if (menuDataItem?.event && typeof menuDataItem.event === 'function') { menuDataItem?.event(props.rowDataItem, props.columnKey); } }, }); const transformMenuEvents = (menuData: Array<BentoColumnCellActionMenuItem>) => menuData.map(menuDataItem => { const transformedMenuData = menuDataItem; if (transformedMenuData?.items) { transformedMenuData.items = transformMenuEvents(transformedMenuData.items); } return { ...generateMenuEvents(transformedMenuData) }; }); const getCopyText = (button: BentoColumnCellAction) => button?.getCopyData !== undefined ? button.getCopyData(props.rowDataItem, props.columnKey) : props.columnKey ? (props.rowDataItem?.[props.columnKey]?.toString() as string) : ''; const shouldShowMenuIconLeft = (button: BentoColumnCellAction) => button.iconLeft || button?.variant === 'more'; </script> <script lang="ts"> /** * Internal component to display the data-grid actions. * There are 4 different pre-defined buttons that can be used by using the `variant` property. * * The component renders buttons or a menu depending on the actions provided. * * @example * <data-grid-cell-actions * :cell-actions="[ * { variant: 'edit', event: () => {} }, * { * variant: 'more', * isShort: true, * menuData: [ * { * text: 'Cancel payment', * handler: () => {}), * critical: true, * }, * { * text: 'Transfer payment', * handler: () => {}, * }, * { * text: 'Delete payment', * handler: () => {}, * disabled: true, * }, * ], * }, * ]" * column-key="payment" * :row-data-item="rowData" * /> */ export default { i18n: { messages }, name: 'data-grid-cell-actions', }; </script> <style lang="scss" scoped src="./data-grid-cell-actions.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="closeDatePicker" class="b-date-picker" @keydown.space.capture="onSpaceBarOrEnter" @keydown.enter.capture="onSpaceBarOrEnter" > <!-- Label --> <field-label v-if="label" :id="labelId" :label="label" :tooltip-text="tooltipText" :optional="optional" :required="required" /> <!-- Input --> <div> <bento-input-field v-if="isTouchDevice" ref="inputContainerRef" :type="isMonthVariant ? 'month' : 'date'" :aria-labelledby="label ? labelId : $attrs?.['aria-labelledby']" :aria-label="label || $attrs?.['aria-labelledby'] ? null : popoverAriaLabel" :aria-describedby="ariaDescribedBy" :aria-required="required" :disabled="disabled" :error="!!errorMessage || !!dateErrorText" :model-value="nativeValue" :readonly="isReadOnly" :min="formattedMin" :max="formattedMax" @update:model-value="onNativeDateInput" /> <dropdown-input-default v-else ref="inputContainerRef" always-combobox-is-input :ariaControls="datePickerContainerId" :ariaExpanded="isDatePickerOpen" :aria-labelledby="label ? labelId : $attrs?.['aria-labelledby']" :aria-label="label ? null : popoverAriaLabel" :aria-describedby="ariaDescribedBy" :aria-required="required" :disabled="disabled" dynamic-filtering :is-invalid="!!errorMessage || !!dateErrorText" :open="isDatePickerOpen" :value="textInputValue" :display-value="dateDisplayValue" :debounce-time="0" :readonly="isReadOnly" @input="onDateInputChange" @open="openDatePicker" @keydown="onDateKeyDown" @clear="clearDatePickerValue" @close="closeDatePicker" /> </div> <!-- Error Messages --> <error-message v-if="dateErrorText && !errorMessage" :id="dateErrorId" :error-message="dateErrorText" class="b-date-picker__error-message" /> <error-message v-if="errorMessage" :id="errorId" :error-message="errorMessage" class="b-date-picker__error-message" /> <!-- Description --> <bento-typography v-if="description" class="b-date-picker__description" :class="descriptionConditionalClasses" el="span" > {{ description }} </bento-typography> <!-- Calendar container --> <bento-popover v-if="inputContainerRef && !isTouchDevice" :id="datePickerContainerId" class="b-date-picker__container" role="dialog" :open="isDatePickerOpen" :target-element="inputContainerRef" :aria-label="popoverAriaLabel" :aria-modal="true" position="bottom-start" fit-content trap-all overflow-visible :trap-all-options="computedTrapAllOptions" @keydown.esc.native="closeDatePicker" > <calendar ref="calendarContainerRef" :default-display-month="defaultDisplayMonth" :first-day-of-week="resolvedFirstDayOfWeek" :is-date-disabled="isDateDisabled" :number-of-months="1" :value="internalModelValue" :min="min" :max="max" :variant="variant" :year-range="yearRange" @input="onDateSelected" /> </bento-popover> </div> </template> <script setup lang="ts"> import { computed, nextTick, ref, toRef, watch } from 'vue'; import { startOfMonth } from 'date-fns/startOfMonth'; // Components import { BentoPopover } from '@/components/popover'; import { BentoTypography } from '../typography'; import { Calendar, type CalendarSingleDateValue, ErrorMessage, FieldLabel } from '@/internal'; import { DropdownInputDefault } from '@/components/dropdown/components'; import { BentoInputField } from '@/components/input-field'; // Utils import { debounce } from '@/utils/ts/debounce'; import { generateUid } from '@/core/utils/ts'; import { useI18n } from '@/utils/ts/i18n'; import { dateToNativeDateString, dateToNativeMonthString, parseNativeDateString } from '@/utils/ts/format-date'; import { useFormFieldEmits } from '@/utils/ts/form-field/form-field'; import { deprecate } from '@/utils/ts/deprecate'; // Composables import { useDatePickerSingleCalendarText } from './composables/use-date-picker-single-calendar-text'; import { useDateInputFormatter, useFirstDayOfWeek, useFormLayoutFieldLoading, useTouchDevice } from '@/composables'; // Directive import { BentoClickOutsideDirective as vBentoClickOutsideDirective } from '@/directives'; // Types import { type BentoDatePickerEmits, type BentoDatePickerProps } from './date-picker.types'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const DEBOUNCE_TIME = 1000; const props = withDefaults(defineProps<BentoDatePickerProps>(), { defaultDisplayMonth: null, description: undefined, disabled: false, errorMessage: null, firstDayOfWeek: undefined, isDateDisabled: undefined, label: undefined, max: null, min: null, modelValue: null, optional: false, placeholder: null, readonly: false, required: false, tooltipText: null, value: null, variant: undefined, // Default will be set by @/internal/calendar yearRange: undefined, }); const emit = defineEmits<BentoDatePickerEmits>(); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const datePickerContainerId = generateUid('bento-date-picker-container'); const labelId = generateUid('bento-date-picker-label'); const descriptionId = generateUid('bento-date-picker-description'); const errorId = generateUid('bento-date-picker-error'); const dateErrorId = generateUid('bento-date-picker-date-error'); const popoverAriaLabel = computed(() => props.label || t('datePicker', { name: datePickerContainerId })); // Refs const calendarContainerRef = ref(null); const inputContainerRef = ref(null); // Values const dateError = ref<keyof MessageSchema | null>(null); const isDatePickerOpen = ref(false); const initialFocusOnDatePickerOpen = ref(undefined); const internalModelValue = ref<CalendarSingleDateValue | null>(props.modelValue); // to be removed when `props.value` is removed const { isReadOnly } = useFormLayoutFieldLoading(toRef(props, 'readonly')); const { emitValue } = useFormFieldEmits<CalendarSingleDateValue>(emit); const { resolvedFirstDayOfWeek } = useFirstDayOfWeek(toRef(props, 'firstDayOfWeek')); const { isFormatValid, parseDate, onKeyDown, autoFormat } = useDateInputFormatter(); const { isTouchDevice } = useTouchDevice(); const computedTrapAllOptions = computed(() => ({ initialFocus: initialFocusOnDatePickerOpen.value, additionalContainers: [inputContainerRef.value.$el], })); watch( () => props.disabled, disabled => { // Close date picker popover if it's opened when it's disabled if (disabled && isDatePickerOpen.value) { isDatePickerOpen.value = false; } } ); watch( [() => props.value, () => props.modelValue], ([newValue, newModelValue]) => { if (newValue) { deprecate( 'BentoDatePicker "value" property', `The use of "value" prop in "BentoDatePicker" is no longer supported. Use the "v-model" or "model-value" property instead.`, '2.0.0' ); } internalModelValue.value = newModelValue ?? newValue; }, { immediate: true } ); const isMonthVariant = computed(() => props.variant === 'month'); const { dateDisplayValue, textInputValue, formatTextInputDisplayValue } = useDatePickerSingleCalendarText({ datePickerValue: internalModelValue, dateError, isMonthVariant, placeholder: toRef(props, 'placeholder'), }); const formatNativeDate = (date: Date): string => { if (!date) { return ''; } return isMonthVariant.value ? dateToNativeMonthString(date) : dateToNativeDateString(date); }; const nativeValue = computed(() => formatNativeDate(internalModelValue.value)); const onNativeDateInput = (value: string) => { if (!value) { dateError.value = null; emitValue(null); return; } const date = parseNativeDateString(value, isMonthVariant.value ? 'month' : 'date'); dateError.value = getDateErrorReason(date); if (dateError.value) { // Emit null to match the non-touch validation behavior. // Note: on repeated invalid selections, the native input may still display // the selected date because Vue skips re-rendering when the model stays null. emitValue(null); return; } emitValue(date); }; const formattedMin = computed(() => formatNativeDate(props.min)); const formattedMax = computed(() => formatNativeDate(props.max)); const getDateErrorReason = (date: Date | null): keyof MessageSchema | null => { if (!date || isNaN(date.getTime())) { return 'invalidDateFormat'; } // Check if date is between min/max bounds, if there are any (inclusive) if ((props.min && date < props.min) || (props.max && date > props.max)) { return 'invalidDateFormat'; } // Check if function is set, otherwise all dates are enabled if (props.isDateDisabled && props.isDateDisabled(date)) { return 'selectedDateIsNotAvailable'; } return null; }; const dateErrorText = computed(() => (dateError.value ? t(dateError.value) : null)); watch( [isDatePickerOpen, textInputValue, () => props.isDateDisabled, () => props.min, () => props.max], ([isOpen, textValue]) => { // Re-validate the current input when the picker closes, validation props change or text input change if (!isOpen && textValue && isFormatValid(textValue)) { dateError.value = getDateErrorReason(parseDate(textValue)); } } ); const descriptionConditionalClasses = computed(() => ({ 'b-date-picker__description--error': props.errorMessage || dateErrorText.value, })); const ariaDescribedBy = computed( () => `${props.description ? descriptionId : ''} ${props.errorMessage ? errorId : ''} ${ dateErrorText.value ? dateErrorId : '' }`.trim() || null ); const closeDatePicker = () => { isDatePickerOpen.value = false; }; const openDatePicker = () => { isDatePickerOpen.value = true; formatTextInputDisplayValue(); }; const onDateSelected = (selectedDate: Date) => { // Reset the error state when a valid date is selected dateError.value = null; closeDatePicker(); emitValue(selectedDate); }; const clearDatePickerValue = async () => { if (dateError.value) { dateError.value = null; textInputValue.value = null; } emitValue(null); }; const onDateKeyDown = (event: KeyboardEvent) => { onKeyDown(event); }; const validateDate = (stringDate: string) => { // Validate only if the date is complete if (stringDate.length < 10) { return; } const parsedDate = isFormatValid(stringDate) ? parseDate(stringDate) : null; const errorKey = getDateErrorReason(parsedDate); if (!errorKey && parsedDate) { dateError.value = null; emitValue(parsedDate); } else if (stringDate) { dateError.value = errorKey || 'invalidDateFormat'; emitValue(null); } }; const validateDateDebounced = debounce(validateDate, DEBOUNCE_TIME); const onDateInputChange = (stringDate: string) => { dateError.value = null; textInputValue.value = autoFormat(stringDate); // If we are closing the date picker, we should validate (and emit date inputs) immediately so the user doesn't have to wait for the debounce time. // On touch devices with native input, we also want immediate feedback. if (isDatePickerOpen.value || isTouchDevice.value) { validateDate(textInputValue.value); } else { validateDateDebounced(textInputValue.value); } }; /** * Space bar and Enter keyDown event handler. * * * Opens the date picker dialog on the first keydown. * * Move focus to selected date, i.e., the date displayed in the date input text field. If no date has been selected, places focus on the current date. * @param {KeyboardEvent} event Event being triggered */ const onSpaceBarOrEnter = async (event: KeyboardEvent) => { if (isDatePickerOpen.value) { return; } event.preventDefault(); openDatePicker(); // Wait for the date picker dialog to open await nextTick(); // Set current date (today) if no date is selected if (!internalModelValue.value) { // Format the opened date picker date to YYYY-MM-DD formatTextInputDisplayValue(new Date()); emitValue(new Date()); } // Set initial focus initialFocusOnDatePickerOpen.value = calendarContainerRef.value?.$refs.calendarRef[0].focusOnDate( props.variant === 'month' ? startOfMonth(internalModelValue.value) : internalModelValue.value, true ) ?? undefined; }; </script> <script lang="ts"> /** * Date picker selector. * * @example * import { BentoDatePicker } from '@adyen/bento-vue2'; * * export default { * components: { BentoDatePicker }, * template: ` * <bento-date-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-picker', model: { prop: 'modelValue' }, }; </script> <style lang="scss" scoped src="./date-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="closeDatePicker" class="b-date-picker" @keydown.space.capture="onSpaceBarOrEnter" @keydown.enter.capture="onSpaceBarOrEnter" > <!-- Label --> <field-label v-if="label" :id="labelId" :label="label" :tooltip-text="tooltipText" :optional="optional" :required="required" /> <!-- Input --> <div> <bento-input-field v-if="isTouchDevice" ref="inputContainerRef" :type="isMonthVariant ? 'month' : 'date'" :aria-labelledby="label ? labelId : $attrs?.['aria-labelledby']" :aria-label="label || $attrs?.['aria-labelledby'] ? null : popoverAriaLabel" :aria-describedby="ariaDescribedBy" :aria-required="required" :disabled="disabled" :error="!!errorMessage || !!dateErrorText" :model-value="nativeValue" :readonly="isReadOnly" :min="formattedMin" :max="formattedMax" @update:model-value="onNativeDateInput" /> <dropdown-input-default v-else ref="inputContainerRef" always-combobox-is-input :ariaControls="datePickerContainerId" :ariaExpanded="isDatePickerOpen" :aria-labelledby="label ? labelId : $attrs?.['aria-labelledby']" :aria-label="label ? null : popoverAriaLabel" :aria-describedby="ariaDescribedBy" :aria-required="required" :disabled="disabled" dynamic-filtering :is-invalid="!!errorMessage || !!dateErrorText" :open="isDatePickerOpen" :value="textInputValue" :display-value="dateDisplayValue" :debounce-time="0" :readonly="isReadOnly" @input="onDateInputChange" @open="openDatePicker" @keydown="onDateKeyDown" @clear="clearDatePickerValue" @close="closeDatePicker" /> </div> <!-- Error Messages --> <error-message v-if="dateErrorText && !errorMessage" :id="dateErrorId" :error-message="dateErrorText" class="b-date-picker__error-message" /> <error-message v-if="errorMessage" :id="errorId" :error-message="errorMessage" class="b-date-picker__error-message" /> <!-- Description --> <bento-typography v-if="description" class="b-date-picker__description" :class="descriptionConditionalClasses" el="span" > {{ description }} </bento-typography> <!-- Calendar container --> <bento-popover v-if="inputContainerRef && !isTouchDevice" :id="datePickerContainerId" class="b-date-picker__container" role="dialog" :open="isDatePickerOpen" :target-element="inputContainerRef" :aria-label="popoverAriaLabel" :aria-modal="true" position="bottom-start" fallback-strategy="initialPlacement" fit-content trap-all overflow-visible :trap-all-options="computedTrapAllOptions" @keydown.esc.native="closeDatePicker" > <calendar ref="calendarContainerRef" :default-display-month="defaultDisplayMonth" :first-day-of-week="resolvedFirstDayOfWeek" :is-date-disabled="isDateDisabled" :number-of-months="1" :value="internalModelValue" :min="min" :max="max" :variant="variant" :year-range="yearRange" @input="onDateSelected" /> </bento-popover> </div> </template> <script setup lang="ts"> import { computed, nextTick, ref, toRef, watch } from 'vue'; import { startOfMonth } from 'date-fns/startOfMonth'; // Components import { BentoPopover } from '@/components/popover'; import { BentoTypography } from '../typography'; import { Calendar, type CalendarSingleDateValue, ErrorMessage, FieldLabel } from '@/internal'; import { DropdownInputDefault } from '@/components/dropdown/components'; import { BentoInputField } from '@/components/input-field'; // Utils import { debounce } from '@/utils/ts/debounce'; import { generateUid } from '@/core/utils/ts'; import { useI18n } from '@/utils/ts/i18n'; import { dateToNativeDateString, dateToNativeMonthString, parseNativeDateString } from '@/utils/ts/format-date'; import { useFormFieldEmits } from '@/utils/ts/form-field/form-field'; import { deprecate } from '@/utils/ts/deprecate'; // Composables import { useDatePickerSingleCalendarText } from './composables/use-date-picker-single-calendar-text'; import { useDateInputFormatter, useFirstDayOfWeek, useFormLayoutFieldLoading, useTouchDevice } from '@/composables'; // Directive import { BentoClickOutsideDirective as vBentoClickOutsideDirective } from '@/directives'; // Types import { type BentoDatePickerEmits, type BentoDatePickerProps } from './date-picker.types'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const DEBOUNCE_TIME = 1000; const props = withDefaults(defineProps<BentoDatePickerProps>(), { defaultDisplayMonth: null, description: undefined, disabled: false, errorMessage: null, firstDayOfWeek: undefined, isDateDisabled: undefined, label: undefined, max: null, min: null, modelValue: null, optional: false, placeholder: null, readonly: false, required: false, tooltipText: null, value: null, variant: undefined, // Default will be set by @/internal/calendar yearRange: undefined, }); const emit = defineEmits<BentoDatePickerEmits>(); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const datePickerContainerId = generateUid('bento-date-picker-container'); const labelId = generateUid('bento-date-picker-label'); const descriptionId = generateUid('bento-date-picker-description'); const errorId = generateUid('bento-date-picker-error'); const dateErrorId = generateUid('bento-date-picker-date-error'); const popoverAriaLabel = computed(() => props.label || t('datePicker', { name: datePickerContainerId })); // Refs const calendarContainerRef = ref(null); const inputContainerRef = ref(null); // Values const dateError = ref<keyof MessageSchema | null>(null); const isDatePickerOpen = ref(false); const initialFocusOnDatePickerOpen = ref(undefined); const internalModelValue = ref<CalendarSingleDateValue | null>(props.modelValue); // to be removed when `props.value` is removed const { isReadOnly } = useFormLayoutFieldLoading(toRef(props, 'readonly')); const { emitValue } = useFormFieldEmits<CalendarSingleDateValue>(emit); const { resolvedFirstDayOfWeek } = useFirstDayOfWeek(toRef(props, 'firstDayOfWeek')); const { isFormatValid, parseDate, onKeyDown, autoFormat } = useDateInputFormatter(); const { isTouchDevice } = useTouchDevice(); const computedTrapAllOptions = computed(() => ({ initialFocus: initialFocusOnDatePickerOpen.value, additionalContainers: [inputContainerRef.value.$el], })); watch( () => props.disabled, disabled => { // Close date picker popover if it's opened when it's disabled if (disabled && isDatePickerOpen.value) { isDatePickerOpen.value = false; } } ); watch( [() => props.value, () => props.modelValue], ([newValue, newModelValue]) => { if (newValue) { deprecate( 'BentoDatePicker "value" property', `The use of "value" prop in "BentoDatePicker" is no longer supported. Use the "v-model" or "model-value" property instead.`, '2.0.0' ); } internalModelValue.value = newModelValue ?? newValue; }, { immediate: true } ); const isMonthVariant = computed(() => props.variant === 'month'); const { dateDisplayValue, textInputValue, formatTextInputDisplayValue } = useDatePickerSingleCalendarText({ datePickerValue: internalModelValue, dateError, isMonthVariant, placeholder: toRef(props, 'placeholder'), }); const formatNativeDate = (date: Date): string => { if (!date) { return ''; } return isMonthVariant.value ? dateToNativeMonthString(date) : dateToNativeDateString(date); }; const nativeValue = computed(() => formatNativeDate(internalModelValue.value)); const onNativeDateInput = (value: string) => { if (!value) { dateError.value = null; emitValue(null); return; } const date = parseNativeDateString(value, isMonthVariant.value ? 'month' : 'date'); dateError.value = getDateErrorReason(date); if (dateError.value) { // Emit null to match the non-touch validation behavior. // Note: on repeated invalid selections, the native input may still display // the selected date because Vue skips re-rendering when the model stays null. emitValue(null); return; } emitValue(date); }; const formattedMin = computed(() => formatNativeDate(props.min)); const formattedMax = computed(() => formatNativeDate(props.max)); const getDateErrorReason = (date: Date | null): keyof MessageSchema | null => { if (!date || isNaN(date.getTime())) { return 'invalidDateFormat'; } // Check if date is between min/max bounds, if there are any (inclusive) if ((props.min && date < props.min) || (props.max && date > props.max)) { return 'invalidDateFormat'; } // Check if function is set, otherwise all dates are enabled if (props.isDateDisabled && props.isDateDisabled(date)) { return 'selectedDateIsNotAvailable'; } return null; }; const dateErrorText = computed(() => (dateError.value ? t(dateError.value) : null)); watch( [isDatePickerOpen, textInputValue, () => props.isDateDisabled, () => props.min, () => props.max], ([isOpen, textValue]) => { // Re-validate the current input when the picker closes, validation props change or text input change if (!isOpen && textValue && isFormatValid(textValue)) { dateError.value = getDateErrorReason(parseDate(textValue)); } } ); const descriptionConditionalClasses = computed(() => ({ 'b-date-picker__description--error': props.errorMessage || dateErrorText.value, })); const ariaDescribedBy = computed( () => `${props.description ? descriptionId : ''} ${props.errorMessage ? errorId : ''} ${ dateErrorText.value ? dateErrorId : '' }`.trim() || null ); const closeDatePicker = () => { isDatePickerOpen.value = false; }; const openDatePicker = () => { isDatePickerOpen.value = true; formatTextInputDisplayValue(); }; const onDateSelected = (selectedDate: Date) => { // Reset the error state when a valid date is selected dateError.value = null; closeDatePicker(); emitValue(selectedDate); }; const clearDatePickerValue = async () => { if (dateError.value) { dateError.value = null; textInputValue.value = null; } emitValue(null); }; const onDateKeyDown = (event: KeyboardEvent) => { onKeyDown(event); }; const validateDate = (stringDate: string) => { // Validate only if the date is complete if (stringDate.length < 10) { return; } const parsedDate = isFormatValid(stringDate) ? parseDate(stringDate) : null; const errorKey = getDateErrorReason(parsedDate); if (!errorKey && parsedDate) { dateError.value = null; emitValue(parsedDate); } else if (stringDate) { dateError.value = errorKey || 'invalidDateFormat'; emitValue(null); } }; const validateDateDebounced = debounce(validateDate, DEBOUNCE_TIME); const onDateInputChange = (stringDate: string) => { dateError.value = null; textInputValue.value = autoFormat(stringDate); // If we are closing the date picker, we should validate (and emit date inputs) immediately so the user doesn't have to wait for the debounce time. // On touch devices with native input, we also want immediate feedback. if (isDatePickerOpen.value || isTouchDevice.value) { validateDate(textInputValue.value); } else { validateDateDebounced(textInputValue.value); } }; /** * Space bar and Enter keyDown event handler. * * * Opens the date picker dialog on the first keydown. * * Move focus to selected date, i.e., the date displayed in the date input text field. If no date has been selected, places focus on the current date. * @param {KeyboardEvent} event Event being triggered */ const onSpaceBarOrEnter = async (event: KeyboardEvent) => { if (isDatePickerOpen.value) { return; } event.preventDefault(); openDatePicker(); // Wait for the date picker dialog to open await nextTick(); // Set current date (today) if no date is selected if (!internalModelValue.value) { // Format the opened date picker date to YYYY-MM-DD formatTextInputDisplayValue(new Date()); emitValue(new Date()); } // Set initial focus initialFocusOnDatePickerOpen.value = calendarContainerRef.value?.$refs.calendarRef[0].focusOnDate( props.variant === 'month' ? startOfMonth(internalModelValue.value) : internalModelValue.value, true ) ?? undefined; }; </script> <script lang="ts"> /** * Date picker selector. * * @example * import { BentoDatePicker } from '@adyen/bento-vue2'; * * export default { * components: { BentoDatePicker }, * template: ` * <bento-date-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-picker', model: { prop: 'modelValue' }, }; </script> <style lang="scss" scoped src="./date-picker.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 type { BentoRangeEndpoint } from '@/components/internal/calendar/composables/use-granularity-adjustments.types'; 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 { normalizeRange } = 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); }; /** * Applies all active range constraints to the current value. * @param fixedEndpoint - Endpoint to keep unchanged when maxRange shortens the range. * @param alignToGranularity - Whether to expand dates to complete granularity units. */ const normalizeInternalRange = (fixedEndpoint: BentoRangeEndpoint, alignToGranularity = true) => { if (!internalRangeDate.startDate || !internalRangeDate.endDate) { return; } const normalizedRange = normalizeRange({ startDate: internalRangeDate.startDate, endDate: internalRangeDate.endDate, fixedEndpoint, granularity: selectedGranularity.value, alignToGranularity, fallbackMaxRange: props.maxRange, min: computedMinMax.value.min, max: computedMinMax.value.max, }); if ( isEqual(normalizedRange.startDate, internalRangeDate.startDate) && isEqual(normalizedRange.endDate, internalRangeDate.endDate) ) { return; } internalRangeDate.startDate = normalizedRange.startDate; internalRangeDate.endDate = normalizedRange.endDate; dateTextInput.startDate = formatDate(normalizedRange.startDate); dateTextInput.endDate = formatDate(normalizedRange.endDate); }; // 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, alignToGranularity = false) => { if (isValidDate(startDateText)) { const dateText = startDateText; startDateError.value = null; dateTextInput.startDate = dateText; 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; } // The user changed the start date, so preserve the existing end date. normalizeInternalRange('end', alignToGranularity); // Resetting internal range date when selecting a custom range through the calendar internalRangeDate.range = null; emit(DateRangePickerCalendarEvent.FORM_DATE, { startDate: internalRangeDate.startDate, endDate: internalRangeDate.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) { validateStartDate(formatDate(parseNativeMonthString(startDateText)), true); } 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, alignToGranularity = false) => { if (isValidDate(endDateText)) { const dateText = endDateText; endDateError.value = null; dateTextInput.endDate = dateText; // 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; } // The user changed the end date, so preserve the existing start date. normalizeInternalRange('start', alignToGranularity); // Resetting internal range date when selecting a custom range through the calendar internalRangeDate.range = null; emit(DateRangePickerCalendarEvent.FORM_DATE, { startDate: internalRangeDate.startDate, endDate: internalRangeDate.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) { validateEndDate(formatDate(parseNativeMonthString(endDateText)), true); } 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 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) { if (savedRanges[value]) { setInternalRange(savedRanges[value].startDate, savedRanges[value].endDate, value); } // Keep the most recent endpoint when the new granularity shortens the range. normalizeInternalRange('end'); 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
|
-
import { type Meta, type StoryObj } from '@storybook/vue'; import { action } from '@storybook/addon-actions'; import { ref } from 'vue'; import { isVue2 } from 'vue-demi'; import { format } from 'date-fns/format'; import { addMonths } from 'date-fns/addMonths'; import { endOfMonth } from 'date-fns/endOfMonth'; import { endOfWeek } from 'date-fns/endOfWeek'; import { lastDayOfQuarter } from 'date-fns/lastDayOfQuarter'; import { startOfQuarter } from 'date-fns/startOfQuarter'; import { startOfWeek } from 'date-fns/startOfWeek'; import { subDays } from 'date-fns/subDays'; import { subMonths } from 'date-fns/subMonths'; import { subQuarters } from 'date-fns/subQuarters'; import { subWeeks } from 'date-fns/subWeeks'; import { BentoDateRangePickerFirstDayOfWeek } from './date-range-picker.types'; import { storybookDocsParameter } from '@/utils/ts/storybook'; import BentoDateRangePicker from './date-range-picker.vue'; import BentoDateRangePickerDefaultExample from './__tests__/date-range-picker-default-example.vue?raw'; import { startOfDay, subHours, subMinutes } from 'date-fns'; const meta: Meta = { title: 'Date picker/Range dates', component: BentoDateRangePicker, argTypes: { // Properties maxRange: { name: 'maxRange', defaultValue: undefined, description: 'Set a maximum number of dates to be selectable by the range.', table: { type: { summary: 'number' } }, control: { type: 'number', }, }, min: { control: { type: 'date' } }, max: { control: { type: 'date' } }, // Funcs isDateDisabled: { name: 'isDateDisabled', defaultValue: '', description: 'Disables the dates that meet the criteria defined in the function.', table: { type: { summary: '(currentDate: Date) => boolean' } }, control: { disable: true }, }, // Enum props firstDayOfWeek: { options: Object.keys(BentoDateRangePickerFirstDayOfWeek) .map(key => BentoDateRangePickerFirstDayOfWeek[key]) .filter(value => typeof value !== 'string') as Array<number>, control: { type: 'select', }, }, // Events input: { name: 'input', defaultValue: '', description: 'Triggered when a date is selected', table: { type: { summary: '(selectedDate: Date) => void' } }, control: { disable: true }, }, value: { control: { type: 'text' }, table: { category: 'Deprecated' }, description: 'Deprecated for usage with properties since version 2.0. Use `v-model` or `model-value` property instead', }, }, }; export default meta; type Story = StoryObj<typeof BentoDateRangePicker>; // To be used by as the current time across all date filters const CURRENT_DATE_AND_TIME = new Date(); export const Default: Story = { render: (_args, { argTypes }) => ({ components: { BentoDateRangePicker }, props: Object.keys(argTypes), template: ` <div style="max-width: 380px"> <bento-date-range-picker v-bind="args" :model-value="selectedDate" @update:model-value="onDateChanged" /> </div> `, setup(props) { const selectedDate = ref((isVue2 ? props : _args).modelValue); const onDateChanged = (newSelectedDate: Date) => { selectedDate.value = newSelectedDate; action('input')(selectedDate); action('update:model-value')(selectedDate); }; return { // Values selectedDate, args: isVue2 ? props : _args, // Events onDateChanged, }; }, }), args: { yearRange: { minYear: undefined, maxYear: undefined }, allowTimeInput: true, description: 'Supporting text', label: 'Label', firstDayOfWeek: 1, numberOfMonths: 2, maxRange: 0, optional: true, required: true, isDateDisabled: (date: Date) => date.valueOf() > Date.now(), modelValue: { startDate: subDays(new Date(), 3), endDate: new Date(), range: 'lastFourDays' }, hasActions: true, }, parameters: storybookDocsParameter(BentoDateRangePickerDefaultExample), }; export const RangeSelector = { ...Default, args: { yearRange: { minYear: undefined, maxYear: undefined }, ...Default.args, modelValue: { startDate: subDays(startOfDay(CURRENT_DATE_AND_TIME), 1), endDate: CURRENT_DATE_AND_TIME, range: 'lastTwoDays', }, quickSelectRanges: [ { label: 'Last 15 min', value: 'lastFifteenMin', data: { startDate: subMinutes(CURRENT_DATE_AND_TIME, 15), isRelativeToNow: true, }, }, { label: 'Last 24 hours', value: 'lastTwentyFourHours', data: { startDate: subHours(CURRENT_DATE_AND_TIME, 24), isRelativeToNow: true, }, }, { label: 'Last 2 days', value: 'lastTwoDays', data: { startDate: subDays(startOfDay(CURRENT_DATE_AND_TIME), 1) }, }, { label: 'Last 3 days', value: 'lastThreeDays', data: { startDate: subDays(startOfDay(CURRENT_DATE_AND_TIME), 2) }, }, { label: 'Last 4 days', value: 'lastFourDays', data: { startDate: subDays(startOfDay(CURRENT_DATE_AND_TIME), 3) }, }, { label: 'Last 24 hours', value: 'lastTwentyFourHours', data: { startDate: subHours(CURRENT_DATE_AND_TIME, 24), isRelativeToNow: true, }, }, { label: 'Last 2 days', value: 'lastTwoDays', data: { startDate: subDays(startOfDay(CURRENT_DATE_AND_TIME), 1) }, }, { label: 'Last 3 days', value: 'lastThreeDays', data: { startDate: subDays(startOfDay(CURRENT_DATE_AND_TIME), 2) }, }, { label: 'Last 4 days', value: 'lastFourDays', data: { startDate: subDays(startOfDay(CURRENT_DATE_AND_TIME), 3) }, }, ], }, parameters: storybookDocsParameter(` <bento-date-range-picker v-model="selectedDate" label="Label" description="Supporting text" :first-day-of-week="BentoDateRangePickerFirstDayOfWeek.SUNDAY" isDateDisabled="(currentDate: Date) => boolean" maxRange required optional disabled hasActions :quick-select-ranges="[ { label: 'Last 2 days', value: 'lastTwoDays', data: { startDate: subDays(new Date(), 1) }, }, { label: 'Last 3 days', value: 'lastThreeDays', data: { startDate: subDays(new Date(), 2), endDate: new Date() }, }, { label: 'Last 4 days', value: 'lastFourDays', data: { startDate: subDays(new Date(), 3), endDate: new Date() }, }, ]" /> `), }; const GRANULARITIES_VALUE = { startDate: startOfWeek(subWeeks(new Date(), 1), { weekStartsOn: Default.args.firstDayOfWeek }), endDate: endOfWeek(subWeeks(new Date(), 1), { weekStartsOn: Default.args.firstDayOfWeek }), granularity: 'weekly', }; const granularitiesCode = ` <template> <bento-date-range-picker v-model="selectedDate" label="Label" description="Supporting text" :allow-time-input="false" :first-day-of-week="BentoDateRangePickerFirstDayOfWeek.MONDAY" :granularities="granularities" :max="new Date('${format(subDays(endOfMonth(new Date()), 2), 'yyyy-MM-dd')}')" hasActions /> </template> <script setup lang="ts"> import { BentoDateRangePicker, BentoDateRangePickerFirstDayOfWeek, type BentoDateRangePickerGranularityConfig, type BentoDateRangePickerValue, } from "@adyen/bento-vue2" const selectedDate = ref<BentoDateRangePickerValue>({ startDate: new Date('${format(GRANULARITIES_VALUE.startDate, 'yyyy-MM-dd')}'), endDate: new Date('${format(GRANULARITIES_VALUE.endDate, 'yyyy-MM-dd')}'), granularity: 'monthly', }); const granularities: Array<BentoDateRangePickerGranularityConfig> = [ { type: 'daily' }, { type: 'weekly' }, { type: 'monthly' }, }] </script> `; export const Granularities = { ...Default, args: { yearRange: { minYear: undefined, maxYear: undefined }, allowTimeInput: false, description: 'Supporting text', label: 'Label', numberOfMonths: 2, min: subMonths(endOfMonth(new Date()), 6), max: subDays(endOfMonth(new Date()), 2), granularities: [ { type: 'daily', }, { type: 'weekly', }, { type: 'monthly', }, ], modelValue: GRANULARITIES_VALUE, quickSelectRanges: [ { label: 'Last 2 days', value: 'lastTwoDays', data: { startDate: subDays(new Date(), 1), endDate: new Date(), granularity: 'daily' }, }, { label: 'Last quarter', value: 'lastQuarter', data: { startDate: startOfQuarter(subQuarters(new Date(), 1)), endDate: lastDayOfQuarter(subQuarters(new Date(), 1)), granularity: 'monthly', }, }, ], hasActions: true, }, parameters: storybookDocsParameter(granularitiesCode), }; export const MonthVariant = { ...Default, args: { yearRange: { minYear: undefined, maxYear: undefined }, ...Default.args, allowTimeInput: false, variant: 'month', modelValue: { startDate: subMonths(new Date(), 2), endDate: new Date(), granularity: 'monthly' }, isDateDisabled: null, max: addMonths(new Date(), 6), granularities: [{ type: 'monthly' }, { type: 'quarterly' }], quickSelectRanges: [ { label: 'Last 3 months', value: 'lastTwoMonths', data: { startDate: subMonths(new Date(), 3), endDate: subMonths(new Date(), 1), granularity: 'monthly', }, }, { label: 'Last quarter', value: 'lastQuarter', data: { startDate: startOfQuarter(subQuarters(new Date(), 1)), endDate: lastDayOfQuarter(subQuarters(new Date(), 1)), granularity: 'quarterly', }, }, ], }, parameters: storybookDocsParameter(` <bento-date-range-picker v-model="selectedDate" label="Label" description="Supporting text" isDateDisabled="(currentDate: Date) => boolean" maxRange required optional disabled hasActions variant="month" /> `), };
|
|
1
|
+
import { type Meta, type StoryObj } from '@storybook/vue'; import { action } from '@storybook/addon-actions'; import { ref } from 'vue'; import { isVue2 } from 'vue-demi'; import { format } from 'date-fns/format'; import { addMonths } from 'date-fns/addMonths'; import { endOfMonth } from 'date-fns/endOfMonth'; import { endOfWeek } from 'date-fns/endOfWeek'; import { lastDayOfQuarter } from 'date-fns/lastDayOfQuarter'; import { startOfQuarter } from 'date-fns/startOfQuarter'; import { startOfWeek } from 'date-fns/startOfWeek'; import { subDays } from 'date-fns/subDays'; import { subMonths } from 'date-fns/subMonths'; import { subQuarters } from 'date-fns/subQuarters'; import { subWeeks } from 'date-fns/subWeeks'; import { BentoDateRangePickerFirstDayOfWeek } from './date-range-picker.types'; import { storybookDocsParameter } from '@/utils/ts/storybook'; import BentoDateRangePicker from './date-range-picker.vue'; import BentoDateRangePickerDefaultExample from './__tests__/date-range-picker-default-example.vue?raw'; import { startOfDay, subHours, subMinutes } from 'date-fns'; const meta: Meta = { title: 'Date picker/Range dates', component: BentoDateRangePicker, argTypes: { // Properties maxRange: { name: 'maxRange', defaultValue: undefined, description: 'Set a maximum number of dates to be selectable by the range.', table: { type: { summary: 'number' } }, control: { type: 'number', }, }, min: { control: { type: 'date' } }, max: { control: { type: 'date' } }, // Funcs isDateDisabled: { name: 'isDateDisabled', defaultValue: '', description: 'Disables the dates that meet the criteria defined in the function.', table: { type: { summary: '(currentDate: Date) => boolean' } }, control: { disable: true }, }, // Enum props firstDayOfWeek: { options: Object.keys(BentoDateRangePickerFirstDayOfWeek) .map(key => BentoDateRangePickerFirstDayOfWeek[key]) .filter(value => typeof value !== 'string') as Array<number>, control: { type: 'select', }, }, // Events input: { name: 'input', defaultValue: '', description: 'Triggered when a date is selected', table: { type: { summary: '(selectedDate: Date) => void' } }, control: { disable: true }, }, value: { control: { type: 'text' }, table: { category: 'Deprecated' }, description: 'Deprecated for usage with properties since version 2.0. Use `v-model` or `model-value` property instead', }, }, }; export default meta; type Story = StoryObj<typeof BentoDateRangePicker>; // To be used by as the current time across all date filters const CURRENT_DATE_AND_TIME = new Date(); export const Default: Story = { render: (_args, { argTypes }) => ({ components: { BentoDateRangePicker }, props: Object.keys(argTypes), template: ` <div style="max-width: 380px"> <bento-date-range-picker v-bind="args" :model-value="selectedDate" @update:model-value="onDateChanged" /> </div> `, setup(props) { const selectedDate = ref((isVue2 ? props : _args).modelValue); const onDateChanged = (newSelectedDate: Date) => { selectedDate.value = newSelectedDate; action('input')(selectedDate); action('update:model-value')(selectedDate); }; return { // Values selectedDate, args: isVue2 ? props : _args, // Events onDateChanged, }; }, }), args: { yearRange: { minYear: undefined, maxYear: undefined }, allowTimeInput: true, description: 'Supporting text', label: 'Label', firstDayOfWeek: 1, numberOfMonths: 2, maxRange: 0, optional: true, required: true, isDateDisabled: (date: Date) => date.valueOf() > Date.now(), modelValue: { startDate: subDays(new Date(), 3), endDate: new Date(), range: 'lastFourDays' }, hasActions: true, }, parameters: storybookDocsParameter(BentoDateRangePickerDefaultExample), }; export const RangeSelector = { ...Default, args: { yearRange: { minYear: undefined, maxYear: undefined }, ...Default.args, modelValue: { startDate: subDays(startOfDay(CURRENT_DATE_AND_TIME), 1), endDate: CURRENT_DATE_AND_TIME, range: 'lastTwoDays', }, quickSelectRanges: [ { label: 'Last 15 min', value: 'lastFifteenMin', data: { startDate: subMinutes(CURRENT_DATE_AND_TIME, 15), isRelativeToNow: true, }, }, { label: 'Last 24 hours', value: 'lastTwentyFourHours', data: { startDate: subHours(CURRENT_DATE_AND_TIME, 24), isRelativeToNow: true, }, }, { label: 'Last 2 days', value: 'lastTwoDays', data: { startDate: subDays(startOfDay(CURRENT_DATE_AND_TIME), 1) }, }, { label: 'Last 3 days', value: 'lastThreeDays', data: { startDate: subDays(startOfDay(CURRENT_DATE_AND_TIME), 2) }, }, { label: 'Last 4 days', value: 'lastFourDays', data: { startDate: subDays(startOfDay(CURRENT_DATE_AND_TIME), 3) }, }, { label: 'Last 24 hours', value: 'lastTwentyFourHours', data: { startDate: subHours(CURRENT_DATE_AND_TIME, 24), isRelativeToNow: true, }, }, { label: 'Last 2 days', value: 'lastTwoDays', data: { startDate: subDays(startOfDay(CURRENT_DATE_AND_TIME), 1) }, }, { label: 'Last 3 days', value: 'lastThreeDays', data: { startDate: subDays(startOfDay(CURRENT_DATE_AND_TIME), 2) }, }, { label: 'Last 4 days', value: 'lastFourDays', data: { startDate: subDays(startOfDay(CURRENT_DATE_AND_TIME), 3) }, }, ], }, parameters: storybookDocsParameter(` <bento-date-range-picker v-model="selectedDate" label="Label" description="Supporting text" :first-day-of-week="BentoDateRangePickerFirstDayOfWeek.SUNDAY" isDateDisabled="(currentDate: Date) => boolean" maxRange required optional disabled hasActions :quick-select-ranges="[ { label: 'Last 2 days', value: 'lastTwoDays', data: { startDate: subDays(new Date(), 1) }, }, { label: 'Last 3 days', value: 'lastThreeDays', data: { startDate: subDays(new Date(), 2), endDate: new Date() }, }, { label: 'Last 4 days', value: 'lastFourDays', data: { startDate: subDays(new Date(), 3), endDate: new Date() }, }, ]" /> `), }; const GRANULARITIES_VALUE = { startDate: startOfWeek(subWeeks(new Date(), 1), { weekStartsOn: Default.args.firstDayOfWeek }), endDate: endOfWeek(subWeeks(new Date(), 1), { weekStartsOn: Default.args.firstDayOfWeek }), granularity: 'weekly', }; const granularitiesCode = ` <template> <bento-date-range-picker v-model="selectedDate" label="Label" description="Supporting text" :allow-time-input="false" :first-day-of-week="BentoDateRangePickerFirstDayOfWeek.MONDAY" :granularities="granularities" :max="new Date('${format(subDays(endOfMonth(new Date()), 2), 'yyyy-MM-dd')}')" hasActions /> </template> <script setup lang="ts"> import { BentoDateRangePicker, BentoDateRangePickerFirstDayOfWeek, type BentoDateRangePickerGranularityConfig, type BentoDateRangePickerValue, } from "@adyen/bento-vue2" const selectedDate = ref<BentoDateRangePickerValue>({ startDate: new Date('${format(GRANULARITIES_VALUE.startDate, 'yyyy-MM-dd')}'), endDate: new Date('${format(GRANULARITIES_VALUE.endDate, 'yyyy-MM-dd')}'), granularity: 'monthly', }); const granularities: Array<BentoDateRangePickerGranularityConfig> = [ { type: 'daily' }, { type: 'weekly' }, { type: 'monthly' }, }] </script> `; export const Granularities = { ...Default, args: { yearRange: { minYear: undefined, maxYear: undefined }, allowTimeInput: false, description: 'Supporting text', label: 'Label', numberOfMonths: 2, min: subMonths(endOfMonth(new Date()), 6), max: subDays(endOfMonth(new Date()), 2), granularities: [ { type: 'daily', }, { type: 'weekly', maxRange: 2, }, { type: 'monthly', maxRange: 2, }, ], modelValue: GRANULARITIES_VALUE, quickSelectRanges: [ { label: 'Last 2 days', value: 'lastTwoDays', data: { startDate: subDays(new Date(), 1), endDate: new Date(), granularity: 'daily' }, }, { label: 'Last quarter', value: 'lastQuarter', data: { startDate: startOfQuarter(subQuarters(new Date(), 1)), endDate: lastDayOfQuarter(subQuarters(new Date(), 1)), granularity: 'monthly', }, }, ], hasActions: true, }, parameters: storybookDocsParameter(granularitiesCode), }; export const MonthVariant = { ...Default, args: { yearRange: { minYear: undefined, maxYear: undefined }, ...Default.args, allowTimeInput: false, variant: 'month', modelValue: { startDate: subMonths(new Date(), 2), endDate: new Date(), granularity: 'monthly' }, isDateDisabled: null, max: addMonths(new Date(), 6), granularities: [{ type: 'monthly' }, { type: 'quarterly' }], quickSelectRanges: [ { label: 'Last 3 months', value: 'lastTwoMonths', data: { startDate: subMonths(new Date(), 3), endDate: subMonths(new Date(), 1), granularity: 'monthly', }, }, { label: 'Last quarter', value: 'lastQuarter', data: { startDate: startOfQuarter(subQuarters(new Date(), 1)), endDate: lastDayOfQuarter(subQuarters(new Date(), 1)), granularity: 'quarterly', }, }, ], }, parameters: storybookDocsParameter(` <bento-date-range-picker v-model="selectedDate" label="Label" description="Supporting text" isDateDisabled="(currentDate: Date) => boolean" maxRange required optional disabled hasActions variant="month" /> `), };
|
|
@@ -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 :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
|
+
<!-- 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" fallback-strategy="initialPlacement" 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" />
|