@adyen/bento-mcp 0.3.0 → 0.4.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/dist/assets/components/data-grid/components/data-grid-cell-actions/data-grid-cell-actions.vue +1 -1
- package/dist/assets/components/data-grid/components/data-grid-row/data-grid-row.vue +1 -1
- package/dist/assets/components/data-grid/data-grid.docs.mdx +1 -1
- package/dist/assets/components/data-grid/data-grid.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/draggable/components/draggable-handle/draggable-handle.stories.ts +1 -0
- package/dist/assets/components/draggable/components/draggable-handle/draggable-handle.types.ts +1 -0
- package/dist/assets/components/draggable/components/draggable-handle/draggable-handle.vue +1 -0
- package/dist/assets/components/draggable/draggable.docs.mdx +178 -0
- package/dist/assets/components/draggable/draggable.stories.ts +1 -0
- package/dist/assets/components/draggable/draggable.types.ts +1 -0
- package/dist/assets/components/draggable/draggable.vue +1 -0
- package/dist/assets/components/filter-bar/components/date-filter/date-filter.vue +1 -1
- package/dist/assets/components/filter-bar/filter-bar.docs.mdx +1 -0
- package/dist/assets/components/filter-bar/filter-bar.stories.ts +1 -1
- package/dist/assets/components/filter-bar/filter-bar.types.ts +1 -1
- package/dist/assets/components/filter-bar/filter-bar.vue +1 -1
- package/dist/assets/components/form-layout/components/form-layout-group/form-layout-group.vue +1 -1
- package/dist/assets/components/form-layout/form-layout.docs.mdx +33 -20
- package/dist/assets/components/form-layout/form-layout.stories.ts +1 -1
- package/dist/assets/components/input-field/input-field.vue +1 -1
- package/dist/assets/components/input-time/input-time.vue +1 -1
- package/dist/assets/components/internal/listbox/components/listbox-multi-select/listbox-multi-select.vue +1 -1
- package/dist/assets/components.json +101 -100
- package/dist/assets/deprecations.md +196 -196
- package/dist/assets/index.ts +1 -1
- package/dist/assets/tailwind/README.md +45 -5
- package/dist/main.js +2 -3
- package/package.json +2 -3
- package/dist/assets/components/draggable-handle/draggable-handle.stories.ts +0 -1
- package/dist/assets/components/draggable-handle/draggable-handle.types.ts +0 -1
- package/dist/assets/components/draggable-handle/draggable-handle.vue +0 -1
- /package/dist/assets/components/{draggable-handle → draggable/components/draggable-handle}/draggable-handle.docs.mdx +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0 (2026-04-15)
|
|
4
|
+
|
|
5
|
+
### Bug Fixes
|
|
6
|
+
|
|
7
|
+
- fixed `bento-mcp` components registry ([1656acaf8](https://github.com/Adyen/bento/commit/1656acaf8))
|
|
8
|
+
|
|
9
|
+
### Miscellaneous Chores
|
|
10
|
+
|
|
11
|
+
- removed `@swc/cli` from `mcp` package to fix `CVE-2026-31808` and `CVE-2026-32630` ([3c2114a97](https://github.com/Adyen/bento/commit/3c2114a97))
|
|
12
|
+
|
|
13
|
+
### ❤️ Thank You
|
|
14
|
+
|
|
15
|
+
- daver
|
|
16
|
+
- erice
|
|
17
|
+
|
|
18
|
+
|
|
3
19
|
## 0.3.0 (2026-04-08)
|
|
4
20
|
|
|
5
21
|
### Features
|
|
@@ -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, })); 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, }" :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 +1 @@
|
|
|
1
|
-
<template> <div class="b-data-grid-row-container" role="presentation"> <div v-bind="ariaRowAttributes" :id="rowId?.toString()" :key="rowIndex" class="b-data-grid-row-container__row" :class="conditionalRowClasses(rowData, rowIndex)" role="row" :aria-selected="getRowAriaSelectedValue(rowData)" @click="onRowClick" @keyup.enter="onRowClick" @keyup.space="onRowClick" > <template v-for="({ columnSet, id, isFrozenColumns }, columnSetIndex) of columnSets"> <div v-if="columnSet?.length" :key="`data-grid-rows-${id}`" class="b-data-grid-row-container__cells" :class="conditionalRowSetClasses(isFrozenColumns, isSmallContainer)" :data-testid="`data-grid-${id}-rows`" > <data-grid-cell v-for="(column, colIndex) in columnSet" :key="`cell-${column.field}`" :ref="el => registerRowCellRef(el, column.field)" class="b-data-grid-row-container__cell" :column="column" :column-set-index="columnSetIndex" :col-index="colIndex" :column-sets="columnSets" :condensed="condensed" :dragging-columns="draggingColumns" :dragging-events-for-row-cell="draggingEventsForRowCell" :get-data-item-id="getDataItemId" :has-zebra-background="isRowEven" :is-highlighted="isHighlightedRow(rowData)" :is-selected="isSelectedRow(rowData)" :hovered-over-column-state="hoveredOverColumnState" :is-data-item-disabled="isDataItemDisabled" :is-measuring-column-widths="isMeasuringColumnWidths" :is-nested-content-expanded="isNestedContentShown" :nested-content="shouldShowNestedContentToggle" :nested-rows-element-ids="allNestedRowIds" :row-data="rowData" :row-index="rowIndex" :selection-is-indeterminate="hasUnSelectedNestedRows" :selectable="selectable" :selected-items="selectedItems" @cell-hovered="emit('cell-hovered', $event)" @cell-focus="emit('cell-focus', $event)" @on-frozen-columns-key-press="emit('on-frozen-columns-key-press', $event)" @select-row="onRowSelect" @nested-content-toggled="nestedContentToggled" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </data-grid-cell> </div> <div v-if="!isFrozenColumns && rowActions" :key="`data-grid-row-actions-${id}`" ref="rowActionsRef" :aria-colindex="sumColumnAmountsInColumnSets().toString()" class="b-data-grid-row-container__row-actions-container" :class="conditionalRowActionsContainerClasses" :style="computedRowActionsStyles" :data-cellindex="getRowActionsCellIndex()" tabindex="-1" role="gridcell" @focus="onRowActionsCellFocus" @focusOut="onRowActionsCellFocusOut" @keydown="onRowActionsCellOnKeyDown" > <data-grid-cell-actions class="b-data-grid-row-container__row-actions" disable-arrow-down-opening-menu :cell-actions="rowActions" :has-right-overflow="hasRightOverflow" :row-data-item="rowData" teleport-menu @menu-open="onRowActionMenuOpen" @menu-close="onRowActionMenuClose" /> </div> </template> </div> <Transition v-if=" (hasNestedContent && hasSlot('nested-slot') && shouldShowNestedContent(rowData)) || (hasNestedRows && shouldShowNestedRowsBooleanResult) " :key="`${rowIndex}-nest-content`" name="b-data-grid-row-container__animation--nested-content" > <div v-if="isNestedContentShown && shouldShowRenderNestedRows" :class="conditionalNestedRowClasses" data-testid="nested-content-container" :style="nestedContentStyles" role="presentation" > <div ref="nestedContentRef"> <div v-if="isLoadingNestedRows" class="b-data-grid-row-container__row-nested-rows-loading-container" > <bento-loading-indicator /> </div> <data-grid-row v-for="(childRowData, childRowIndex) in rowData.children" v-bind="props" :id="getRowId(childRowIndex)" :key="`child-${childRowIndex}`" :ref="el => registerNestedRowRef(el, childRowIndex)" :row-data="childRowData" :row-index="childRowIndex" :row-set-size="rowData.children.length" is-nested-row @cell-hovered="emit('cell-hovered', $event)" @cell-focus="emit('cell-focus', $event)" @row-click="emitRowClick" @on-frozen-columns-key-press="emit('on-frozen-columns-key-press', $event)" @select-row="onNestedRowSelect(childRowData, $event)" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </data-grid-row> </div> </div> <div v-else-if="isNestedContentShown" v-bind="nestedContentContainerDivAriaAttributes" :class="conditionalNestedRowClasses" data-testid="nested-content-container" :style="nestedContentStyles" role="row" tabindex="-1" > <div ref="nestedContentRef" role="gridcell"> <div v-if="isLoadingNestedRows" class="b-data-grid-row-container__row-nested-rows-loading-container" > <bento-loading-indicator /> </div> <div v-else-if="hasSlot('nested-slot') && shouldShowNestedContent(rowData)"> <slot :item="rowData" name="nested-slot" /> </div> </div> </div> </Transition> </div> </template> <script setup lang="ts"> import { type ComponentPublicInstance, computed, type ComputedRef, inject, onMounted, type PropType, ref, useSlots, watch, } from 'vue'; import { BentoLoadingIndicator } from '@/components/loading-indicator'; import { focusOnInteractiveChild, makeAllInteractiveChildrenFocusable, makeAllInteractiveChildrenUnFocusable, } from '@/components/data-grid/utils'; import DataGridRow from '@/components/data-grid/components/data-grid-row/data-grid-row.vue'; import DataGridCell from '@/components/data-grid/components/data-grid-cell/data-grid-cell.vue'; import DataGridCellActions from '@/components/data-grid/components/data-grid-cell-actions/data-grid-cell-actions.vue'; // Types and enums import { type BentoColumn, type BentoDatagridDataItem, type BentoDatagridDataItemId, type BentoDatagridGetDataItemIdFn, type BentoDatagridIsDataItemDisabledFn, type BentoDatagridShouldShowNestedContentFn, type BentoDatagridShouldShowNestedRowsFn, BentoDatagridStyleState, } from '@/components/data-grid/data-grid.types'; import type { DataGridCellColumnSet, DataGridCellDragEventsForColumn, } from '../data-grid-cell/data-grid-cell.types'; import type { BentoDatagridDraggingColumnState } from '../data-grid-columns/data-grid-columns.types'; import type { DatagridColumnHoveringState } from '@/components/data-grid/composables/use-column-hovering'; // Translations import { type Booleanish } from '@/types/prop-types'; import { useHasSlot } from '@/composables/use-has-slot/use-has-slot'; import { useExpandableContentHeight } from '@/composables/use-expandable-content-height/use-expandable-content-height'; import { observeSizeOfElement } from '@/utils/ts/resize'; import { DATAGRID_ACTIONS_COLUMN_FIELD_NAME, DATAGRID_CELL_OFFSET_PADDING, } from '@/components/data-grid/composables/use-calculate-column-width/use-calculate-column-width'; import { generateUid } from '@/core/utils/ts'; import { type BentoDataGridRowActionsProp } from '@/components/data-grid/components/data-grid-cell-actions/data-grid-cell-actions.types'; import type { HTMLAttributes } from 'vue/types/jsx'; import { EventKey } from '@/types/utils-events'; import { DATA_GRID_SMALL_CONTAINER } from '@/components/data-grid/data-grid.keys'; const slots = useSlots(); const hasSlot = useHasSlot(slots); const props = defineProps({ /** * Row actions property that generates which button to show in row actions area */ rowActions: { type: [Function, Array] as PropType<BentoDataGridRowActionsProp>, default: undefined, }, /** * When true, will emit a 'rowClick' event when a row is clicked. Will add cursor: pointer to rows. */ allowRowClicks: { type: Boolean, default: false }, /** * The columns sets including */ columnSets: { type: Array as PropType<Array<DataGridCellColumnSet>>, required: true, }, /** * Sets the row cells to the condensed padding state. */ condensed: { type: Boolean, default: false, }, /** * Columns being dragged, contains dragging column and a target column */ draggingColumns: { type: Object as PropType<BentoDatagridDraggingColumnState>, default: undefined, }, /** * Dragging event handlers for each row cell */ draggingEventsForRowCell: { type: Function as PropType<DataGridCellDragEventsForColumn>, required: true, }, /** * A customisable method to retrieve a unique identifier for a data item in the array of data supplied. * This is used selecting and performance reasons and must exist in each data item. */ getDataItemId: { type: Function as PropType<BentoDatagridGetDataItemIdFn>, default: (item: BentoDatagridDataItem) => item.id, }, /** * Set to true if the datagrid has been scrolled and the left side has overflowing space. */ hasLeftOverflow: { type: Boolean, default: false, }, /** * Enables the nested content slot functionality. */ hasNestedContent: { type: Boolean, default: false, }, /** * Enables the nested content slot functionality. */ hasNestedRows: { 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, }, /** * Highlighted row IDs. */ highlightedRows: { type: Array as PropType<Array<BentoDatagridDataItemId>>, default: () => [], }, /** * The row index of a currently hovered row. */ hoveredRowIndex: { type: Number, default: -1, }, /** * The column hovered over state and whether the hovered over column should show a border right or left. */ hoveredOverColumnState: { type: Object as PropType<DatagridColumnHoveringState>, required: true, }, /** * A customisable method to determine if a row is not selectable. * The default is that every row is selectable. */ isDataItemDisabled: { type: Function as PropType<BentoDatagridIsDataItemDisabledFn>, default: () => false, }, /** * If the row is of an even number. * Rows are 0th based index e.g. index 0 is an odd-numbered row. */ isRowEven: { type: Boolean, default: false, }, /** * If the row is the last child in it's parent container. */ isRowLastChild: { type: Boolean, default: false, }, /** * If columns are being measured for the autoWidth functionality. */ isMeasuringColumnWidths: { type: Boolean, default: false, }, /** * Determines if this row is nested inside another row. */ isNestedRow: { type: Boolean, default: false, }, /** * The maximum width of the row actions in all rows */ rowActionsMaximumWidth: { type: Number, default: 0, }, /** * The row data object */ rowData: { type: Object as PropType<BentoDatagridDataItem>, required: true, }, /** * The row index. */ rowIndex: { type: Number, required: true, }, /** * The row ID to be using for the ID attribute. */ rowId: { type: Number, default: undefined, }, /** * The number of rows that this row is within. */ rowSetSize: { type: Number, default: 0, }, /** * Determines whether to render frozen seperator on the row. */ showFrozenColumnsSeparator: { type: Boolean, default: false, }, /** * Determines if the row selection is enabled. */ selectable: { type: Boolean, default: false, }, /** * The array of IDs for the selected rows. */ selectedItems: { type: Array as PropType<Array<BentoDatagridDataItemId>>, required: true, }, /** * Enables / Disables the nested-content slot */ shouldShowNestedContent: { type: Function as PropType<BentoDatagridShouldShowNestedContentFn>, default: (_item: BentoDatagridDataItem) => true, }, /** * Returns the nested rows for a given BentoDatagridDataItem. */ shouldShowNestedRows: { type: Function as PropType<BentoDatagridShouldShowNestedRowsFn>, default: (_item: BentoDatagridDataItem) => true, }, }); const emit = defineEmits<{ /** * Emitted when a cell has been hovered over. This is used to keep the row in a hovered state when hovering over a menu. */ (e: 'cell-hovered', value: number); /** * Emitted when an a frozen column cell fires a key up or down event. */ (e: 'on-frozen-columns-key-press', event: Event); /** * Emitted when a row has been selected. */ (e: 'select-row', value: Array<BentoDatagridDataItemId>, rowData: BentoDatagridDataItem); /** * Emitted when a nested row has been selected. */ ( e: 'select-nested-row', value: Array<BentoDatagridDataItemId>, parentRowData: BentoDatagridDataItem, rowData: BentoDatagridDataItem ); /** * Emitted when a row has been clicked. */ (e: 'row-click', rowData: BentoDatagridDataItem, event: MouseEvent | KeyboardEvent); /** * Emitted when a row with nested content is toggled */ (e: 'nested-content-toggled', rowData: BentoDatagridDataItem); /** * Emitted when a row cell is focused. */ ( e: 'cell-focus', value: { rowData: BentoDatagridDataItem; column: BentoColumn; rowIndex: number; colIndex: number } ); }>(); /** * Checks if the relatedTarget element appears after the reference element in DOM order. * Used to detect backward navigation (Shift+Tab). */ const isRelatedTargetAfterInDOM = (relatedTarget: HTMLElement | null, referenceEl: HTMLElement): boolean => { if (!relatedTarget || !referenceEl) { return false; } return !!(referenceEl.compareDocumentPosition(relatedTarget) & Node.DOCUMENT_POSITION_FOLLOWING); }; // Refs const gridcellRefs = ref<{ [key: string]: HTMLDivElement }>({}); const isNestedContentShown = ref(false); const nestedContentRef = ref(); const rowActionsRef = ref(); const nestedRowRefs = ref<Array<InstanceType<typeof DataGridRow>>>([]); const nestedContentSlotId = generateUid('nested-content-slot'); const isRowActionMenuOpen = ref(false); const isExitingRowActionInteraction = ref(false); const focusableItems = ref<Array<{ node: HTMLElement }>>(); /** * Returns the row-action container element, handling the case where Vue 2 * may wrap refs inside a v-for as an array. */ const getRowActionsElement = (): HTMLElement | undefined => { const val = rowActionsRef.value; return Array.isArray(val) ? val[0] : val; }; onMounted(() => { const container = getRowActionsElement(); if (container) { focusableItems.value = makeAllInteractiveChildrenUnFocusable(container); } }); const shouldShowNestedRowsBooleanResult = computed(() => props.shouldShowNestedRows(props.rowData)); const shouldShowRenderNestedRows = computed( () => !props.isNestedRow && props.hasNestedRows && shouldShowNestedRowsBooleanResult.value && props.rowData?.children?.length > 0 ); const isLoadingNestedRows = computed( () => !props.isNestedRow && props.hasNestedRows && shouldShowNestedRowsBooleanResult.value && (props.rowData?.children === undefined || props?.rowData?.children.length === 0) ); const shouldShowNestedContentToggle = computed( () => (props.hasNestedContent && props.shouldShowNestedContent(props.rowData)) || (props.hasNestedRows && shouldShowNestedRowsBooleanResult.value && !props.isNestedRow) ); const ariaRowAttributes = computed<HTMLAttributes>(() => { if (props.hasNestedContent || props.hasNestedRows) { const isNestedContentShownBooleanishVal = isNestedContentShown.value.toString() as Booleanish; return { 'aria-expanded': shouldShowNestedContentToggle.value ? isNestedContentShownBooleanishVal : undefined, 'aria-level': props.isNestedRow ? '2' : '1', 'aria-posinset': props.rowIndex + 1, 'aria-setsize': props.rowSetSize, }; } return { 'aria-rowindex': props.rowIndex + 1 }; }); const nestedContentContainerDivAriaAttributes = computed<HTMLAttributes>(() => { if (isLoadingNestedRows.value) { return { 'aria-busy': 'true', 'aria-live': 'polite', }; } if ( shouldShowRenderNestedRows.value || !props.hasNestedContent || !props.shouldShowNestedContent(props.rowData) ) { return undefined; } return { 'aria-level': props.isNestedRow ? '3' : '2', 'aria-posinset': '1', 'aria-setsize': '1', id: nestedContentSlotId, role: 'row', tabindex: '-1', }; }); const nestedRowIds = computed(() => { if (shouldShowRenderNestedRows.value) { return props.rowData?.children?.reduce((acc, _val, index) => { acc[index] = generateUid('nested-row'); return acc; }, {}); } return []; }); const allNestedRowIds = computed(() => { if (!isNestedContentShown.value) { return undefined; } if (props.hasNestedContent && props.shouldShowNestedContent(props.rowData)) { return [nestedContentSlotId]; } if (shouldShowRenderNestedRows.value && Object.keys(nestedRowIds.value)?.length > 0) { const generatedRowIds = Object.values(nestedRowIds.value); if (generatedRowIds?.length > 0) { return generatedRowIds; } } return []; }); const hasUnSelectedNestedRows = computed(() => { const childrendDataItemIds = props.rowData?.children?.map(nestedItem => props.getDataItemId(nestedItem)); if (childrendDataItemIds?.length > 0) { const everyChildIsSelected = childrendDataItemIds.every(childId => props?.selectedItems?.includes(childId)); const someItemsAreSelected = childrendDataItemIds.some(childId => props?.selectedItems?.includes(childId)); return !everyChildIsSelected && someItemsAreSelected; } return false; }); // Methods const registerRowCellRef = (el: Element | ComponentPublicInstance, field: string) => { // We always know this will be used on a DataGridCell component so we can cast it const gridCellComponent = el as InstanceType<typeof DataGridCell>; if (el !== null && gridCellComponent?.gridcellRef) { gridcellRefs.value[field] = gridCellComponent.gridcellRef as HTMLDivElement; } }; const registerNestedRowRef = (el: Element | ComponentPublicInstance, childRowIndex: number) => { if (el !== null) { nestedRowRefs.value[childRowIndex] = el as InstanceType<typeof DataGridRow>; } }; const getRowId = (childRowIndex: number) => { return nestedRowIds.value[childRowIndex]; }; const getRowAriaSelectedValue = (row: BentoDatagridDataItem): Booleanish => { if (!props.selectable) { return null; } const id = props.getDataItemId(row); return props.selectedItems.includes(id).toString() as Booleanish; }; const emitRowClick = (rowData: BentoDatagridDataItem, event: MouseEvent | KeyboardEvent) => { emit('row-click', rowData, event); }; /** * Handles row clicks when the `allowRowClicks` prop is true. * * This method also prevents the `row-click` event from firing if the click * originated from an interactive element (like a button, input, or link) * within the row. This is to avoid unintended behavior when a user * interacts with controls inside a row. * * @param event The mouse or keyboard event from the click or key press. */ const onRowClick = (event: MouseEvent | KeyboardEvent) => { // Only proceed if allowRowClicks is true if (!props.allowRowClicks) { return; } let isEventTargetInteractiveElement = false; const dataGridRowInteractiveTypes = ['button', 'input', 'a']; for (let i = 0; i < dataGridRowInteractiveTypes.length; i++) { if ((event?.target as HTMLElement)?.closest(dataGridRowInteractiveTypes[i])) { isEventTargetInteractiveElement = true; break; } } // Only trigger if element is NOT interactive if (!isEventTargetInteractiveElement) { emitRowClick(props.rowData, event); } }; const onRowSelect = (value: Array<BentoDatagridDataItemId>) => { emit('select-row', value, props.rowData); }; const onNestedRowSelect = (rowData: BentoDatagridDataItem, value: Array<BentoDatagridDataItemId>) => { emit('select-nested-row', value, props.rowData, rowData); }; /** * Sets the state to indicate that a row action menu is open. This is used to apply a higher * z-index to the row's action container, ensuring the menu displays correctly above * subsequent rows. */ const onRowActionMenuOpen = () => { isRowActionMenuOpen.value = true; }; /** * Resets the state when a row action menu is closed, removing the elevated z-index from the * row's action container. */ const onRowActionMenuClose = () => { isRowActionMenuOpen.value = false; }; /** * When the row actions cell is focused, determine navigation direction and focus * the appropriate interactive child. Backward navigation (Shift+Tab) focuses the * last child; forward navigation focuses the first. */ const onRowActionsCellFocus = (e: FocusEvent) => { if (isExitingRowActionInteraction.value) { isExitingRowActionInteraction.value = false; return; } const container = getRowActionsElement(); if (!container || !focusableItems.value?.length) { return; } const isBackwardNavigation = isRelatedTargetAfterInDOM(e.relatedTarget as HTMLElement, container); const lastIndex = focusableItems.value.length - 1; const targetItem = isBackwardNavigation ? focusableItems.value[lastIndex] : focusableItems.value[0]; makeAllInteractiveChildrenFocusable([targetItem]); focusOnInteractiveChild(container, { last: isBackwardNavigation }); }; /** * When focus leaves the row actions cell, make all interactive children unfocusable. * This ensures that tabbing through the grid skips over the individual actions within the cell. */ const onRowActionsCellFocusOut = () => { const container = getRowActionsElement(); if (container) { makeAllInteractiveChildrenUnFocusable(container); } }; /** * Handles keyboard navigation within the row actions cell. * Allows using left and right arrow keys to move between interactive elements (e.g., buttons) in the cell. * @param e The keyboard event. */ const onRowActionsCellOnKeyDown = (e: KeyboardEvent) => { const container = getRowActionsElement(); // Escape returns focus to the gridcell container if (e.key === EventKey.ESCAPE) { if (container) { makeAllInteractiveChildrenUnFocusable(container); } isExitingRowActionInteraction.value = true; container?.focus(); e.stopPropagation(); e.preventDefault(); return; } if (e.key !== EventKey.TAB && container) { makeAllInteractiveChildrenUnFocusable(container); } if ( e.key === EventKey.ARROW_RIGHT && focusableItems.value?.length > 1 && focusableItems.value[0].node === document.activeElement ) { if (container) { makeAllInteractiveChildrenUnFocusable(container); } focusableItems.value[1].node.setAttribute('tabindex', '0'); focusableItems.value[1].node.focus(); e.stopPropagation(); e.preventDefault(); } else if ( e.key === EventKey.ARROW_LEFT && focusableItems.value?.length > 1 && focusableItems.value[1].node === document.activeElement ) { if (container) { makeAllInteractiveChildrenUnFocusable(container); } focusableItems.value[0].node.setAttribute('tabindex', '0'); focusableItems.value[0].node.focus(); e.stopPropagation(); e.preventDefault(); } }; // Cell and Columns index calculation const sumColumnAmountsInColumnSets = () => props.columnSets.reduce((total, { columnSet }) => total + columnSet.length, 0); const getRowActionsCellIndex = () => sumColumnAmountsInColumnSets() * props.rowIndex + sumColumnAmountsInColumnSets(); // Nested content expanding logic const { updateContentHeight, contentHeight } = useExpandableContentHeight(nestedContentRef, isNestedContentShown); const nestedContentStyles = computed(() => { if (props.hasNestedContent || props.hasNestedRows) { // calculate the padding by getting the action column width and the first column left padding: const paddingLeft = shouldShowRenderNestedRows.value || isLoadingNestedRows.value ? 0 : gridcellRefs.value[DATAGRID_ACTIONS_COLUMN_FIELD_NAME].offsetWidth + DATAGRID_CELL_OFFSET_PADDING; return { paddingLeft: `${paddingLeft}px`, 'max-height': (props.hasNestedContent || props.hasNestedRows) && contentHeight.value && !contentHeight?.value?.includes('null') ? contentHeight.value : 0, }; } return undefined; }); watch( () => nestedContentRef.value, () => { let observer: ResizeObserver | null = null; if (!observer && nestedContentRef.value) { observer = observeSizeOfElement(nestedContentRef.value, () => { if (nestedContentRef.value) { updateContentHeight(); } }); } } ); const nestedContentToggled = () => { isNestedContentShown.value = !isNestedContentShown.value; updateContentHeight(); emit('nested-content-toggled', props.rowData); }; watch( () => props.rowData?.children, (_newChildren, oldChildren) => { if (props?.rowData?.children?.length > 0 && oldChildren === undefined && !isNestedContentShown.value) { updateContentHeight(); } } ); const isSmallContainer: ComputedRef<boolean> = inject( DATA_GRID_SMALL_CONTAINER, computed(() => false) ); const isHighlightedRow = row => props.highlightedRows.includes(props.getDataItemId(row)); const isSelectedRow = row => props.selectedItems.includes(props.getDataItemId(row)); // Styling const conditionalRowClasses = (row: BentoDatagridDataItem, rowIndex: number) => ({ 'b-data-grid-row-container__row--disabled': props.isDataItemDisabled(row), 'b-data-grid-row-container__row--row-clicks-enabled': props.allowRowClicks, 'b-data-grid-row-container__row--highlighted': isHighlightedRow(row), 'b-data-grid-row-container__row--selected': isSelectedRow(row), 'b-data-grid-row-container__row--hovered': props.hoveredRowIndex === rowIndex, 'b-data-grid-row-container__row--is-last-child': props.isRowLastChild && !isNestedContentShown.value, 'b-data-grid-row-container__row--nested-row': props.isNestedRow, 'b-data-grid-row-container__row--zebra': props.isRowEven, }); const conditionalRowSetClasses = (isFrozenRowSet: boolean, isSmallContainer: boolean) => ({ 'b-data-grid-row-container__row-cell-set--frozen': isFrozenRowSet && !isSmallContainer, [`b-data-grid-row-container__row-cell-set--${BentoDatagridStyleState.OVERFLOW_SHADOW_LEFT}`]: !isSmallContainer && isFrozenRowSet && props.hasLeftOverflow, 'b-data-grid-row-container__row-cell-set--frozen-separator': !isSmallContainer && isFrozenRowSet && props.showFrozenColumnsSeparator, 'b-data-grid-row-container__row-cell-set--unfrozen': !isFrozenRowSet || isSmallContainer, 'b-data-grid-row-container__row-cell-set--is-measuring': props.isMeasuringColumnWidths, }); const conditionalNestedRowClasses = computed(() => ({ 'b-data-grid-row-container__row-nested-slot--is-last-child': props.isRowLastChild, 'b-data-grid-row-container__row-nested-slot': !shouldShowRenderNestedRows.value && props.shouldShowNestedContent(props.rowData), })); const conditionalRowActionsContainerClasses = computed(() => ({ 'b-data-grid-row-container__row-actions-container--row-actions-menu-open': isRowActionMenuOpen.value, })); const computedRowActionsStyles = computed(() => ({ minWidth: `${props.rowActionsMaximumWidth}px`, })); // Exposed refs defineExpose({ gridcellRefs, isNestedContentShown, nestedRowRefs, rowActionsRef, }); </script> <script lang="ts"> /** * Internal component to display the BentoDataGrid data rows containig the data row cells. * * @example * * export default { * components: { DataGridRow }, * template: ` * <data-grid-row * :ref="el => registerRowCellRefs(el, rowIndex)" * :style="gridColumnWidthStyle" * :allow-row-clicks="allowRowClicks" * :column-sets="columnSets" * :condensed="condensed" * :dragging-columns="draggingColumns" * :dragging-events-for-row-cell="draggingEventsForRowCell" * :get-data-item-id="getDataItemId" * :has-left-overflow="hasLeftOverflow" * :highlighted-rows="highlightedRows" * :hovered-row-index="hoveredRowIndex" * :hovered-over-column-state="hoveredOverColumnState" * :is-data-item-disabled="isDataItemDisabled" * :is-measuring-column-widths="isMeasuringColumnWidths" * :row-data="rowData" * :row-index="rowIndex" * :show-frozen-columns-separator="showFrozenColumnsSeparator" * :selectable="selectable" * :selected-items="selectedItems" * @cell-hovered="onCellHover" * @row-click="onRowClick" * @on-frozen-columns-key-press="updateScrollPositionOnFrozenColumnsNavigation" * @select-row="onRowSelect" * > * <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> * <slot :name="innerSlot" v-bind="scope" /> * </template> * </data-grid-row> * ` */ export default { name: 'data-grid-row', }; </script> <style lang="scss" scoped src="./data-grid-row.scss" />
|
|
1
|
+
<template> <div class="b-data-grid-row-container" role="presentation"> <div v-bind="ariaRowAttributes" :id="rowId?.toString()" :key="rowIndex" class="b-data-grid-row-container__row" :class="conditionalRowClasses(rowData, rowIndex)" role="row" :aria-selected="getRowAriaSelectedValue(rowData)" @click="onRowClick" @keyup.enter="onRowClick" @keyup.space="onRowClick" > <template v-for="({ columnSet, id, isFrozenColumns }, columnSetIndex) of columnSets"> <div v-if="columnSet?.length" :key="`data-grid-rows-${id}`" class="b-data-grid-row-container__cells" :class="conditionalRowSetClasses(isFrozenColumns, isSmallContainer)" :data-testid="`data-grid-${id}-rows`" > <data-grid-cell v-for="(column, colIndex) in columnSet" :key="`cell-${column.field}`" :ref="el => registerRowCellRef(el, column.field)" class="b-data-grid-row-container__cell" :column="column" :column-set-index="columnSetIndex" :col-index="colIndex" :column-sets="columnSets" :condensed="condensed" :dragging-columns="draggingColumns" :dragging-events-for-row-cell="draggingEventsForRowCell" :get-data-item-id="getDataItemId" :has-zebra-background="isRowEven" :is-highlighted="isHighlightedRow(rowData)" :is-selected="isSelectedRow(rowData)" :hovered-over-column-state="hoveredOverColumnState" :is-data-item-disabled="isDataItemDisabled" :is-measuring-column-widths="isMeasuringColumnWidths" :is-nested-content-expanded="isNestedContentShown" :nested-content="shouldShowNestedContentToggle" :nested-rows-element-ids="allNestedRowIds" :row-data="rowData" :row-index="rowIndex" :selection-is-indeterminate="hasUnSelectedNestedRows" :selectable="selectable" :selected-items="selectedItems" @cell-hovered="emit('cell-hovered', $event)" @cell-focus="emit('cell-focus', $event)" @on-frozen-columns-key-press="emit('on-frozen-columns-key-press', $event)" @select-row="onRowSelect" @nested-content-toggled="nestedContentToggled" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </data-grid-cell> </div> <div v-if="!isFrozenColumns && rowActions" :key="`data-grid-row-actions-${id}`" ref="rowActionsRef" :aria-colindex="sumColumnAmountsInColumnSets().toString()" class="b-data-grid-row-container__row-actions-container" :class="conditionalRowActionsContainerClasses" :style="computedRowActionsStyles" :data-cellindex="getRowActionsCellIndex()" tabindex="-1" role="gridcell" @focus="onRowActionsCellFocus" @focusOut="onRowActionsCellFocusOut" @keydown="onRowActionsCellOnKeyDown" > <data-grid-cell-actions class="b-data-grid-row-container__row-actions" disable-arrow-down-opening-menu :cell-actions="rowActions" :has-right-overflow="hasRightOverflow" :row-data-item="rowData" :has-zebra-background="isRowEven" :is-highlighted="isHighlightedRow(rowData)" :is-selected="isSelectedRow(rowData)" teleport-menu @menu-open="onRowActionMenuOpen" @menu-close="onRowActionMenuClose" /> </div> </template> </div> <Transition v-if=" (hasNestedContent && hasSlot('nested-slot') && shouldShowNestedContent(rowData)) || (hasNestedRows && shouldShowNestedRowsBooleanResult) " :key="`${rowIndex}-nest-content`" name="b-data-grid-row-container__animation--nested-content" > <div v-if="isNestedContentShown && shouldShowRenderNestedRows" :class="conditionalNestedRowClasses" data-testid="nested-content-container" :style="nestedContentStyles" role="presentation" > <div ref="nestedContentRef"> <div v-if="isLoadingNestedRows" class="b-data-grid-row-container__row-nested-rows-loading-container" > <bento-loading-indicator /> </div> <data-grid-row v-for="(childRowData, childRowIndex) in rowData.children" v-bind="props" :id="getRowId(childRowIndex)" :key="`child-${childRowIndex}`" :ref="el => registerNestedRowRef(el, childRowIndex)" :row-data="childRowData" :row-index="childRowIndex" :row-set-size="rowData.children.length" is-nested-row @cell-hovered="emit('cell-hovered', $event)" @cell-focus="emit('cell-focus', $event)" @row-click="emitRowClick" @on-frozen-columns-key-press="emit('on-frozen-columns-key-press', $event)" @select-row="onNestedRowSelect(childRowData, $event)" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </data-grid-row> </div> </div> <div v-else-if="isNestedContentShown" v-bind="nestedContentContainerDivAriaAttributes" :class="conditionalNestedRowClasses" data-testid="nested-content-container" :style="nestedContentStyles" role="row" tabindex="-1" > <div ref="nestedContentRef" role="gridcell"> <div v-if="isLoadingNestedRows" class="b-data-grid-row-container__row-nested-rows-loading-container" > <bento-loading-indicator /> </div> <div v-else-if="hasSlot('nested-slot') && shouldShowNestedContent(rowData)"> <slot :item="rowData" name="nested-slot" /> </div> </div> </div> </Transition> </div> </template> <script setup lang="ts"> import { type ComponentPublicInstance, computed, type ComputedRef, inject, onMounted, type PropType, ref, useSlots, watch, } from 'vue'; import { BentoLoadingIndicator } from '@/components/loading-indicator'; import { focusOnInteractiveChild, makeAllInteractiveChildrenFocusable, makeAllInteractiveChildrenUnFocusable, } from '@/components/data-grid/utils'; import DataGridRow from '@/components/data-grid/components/data-grid-row/data-grid-row.vue'; import DataGridCell from '@/components/data-grid/components/data-grid-cell/data-grid-cell.vue'; import DataGridCellActions from '@/components/data-grid/components/data-grid-cell-actions/data-grid-cell-actions.vue'; // Types and enums import { type BentoColumn, type BentoDatagridDataItem, type BentoDatagridDataItemId, type BentoDatagridGetDataItemIdFn, type BentoDatagridIsDataItemDisabledFn, type BentoDatagridShouldShowNestedContentFn, type BentoDatagridShouldShowNestedRowsFn, BentoDatagridStyleState, } from '@/components/data-grid/data-grid.types'; import type { DataGridCellColumnSet, DataGridCellDragEventsForColumn, } from '../data-grid-cell/data-grid-cell.types'; import type { BentoDatagridDraggingColumnState } from '../data-grid-columns/data-grid-columns.types'; import type { DatagridColumnHoveringState } from '@/components/data-grid/composables/use-column-hovering'; // Translations import { type Booleanish } from '@/types/prop-types'; import { useHasSlot } from '@/composables/use-has-slot/use-has-slot'; import { useExpandableContentHeight } from '@/composables/use-expandable-content-height/use-expandable-content-height'; import { observeSizeOfElement } from '@/utils/ts/resize'; import { DATAGRID_ACTIONS_COLUMN_FIELD_NAME, DATAGRID_CELL_OFFSET_PADDING, } from '@/components/data-grid/composables/use-calculate-column-width/use-calculate-column-width'; import { generateUid } from '@/core/utils/ts'; import { type BentoDataGridRowActionsProp } from '@/components/data-grid/components/data-grid-cell-actions/data-grid-cell-actions.types'; import type { HTMLAttributes } from 'vue/types/jsx'; import { EventKey } from '@/types/utils-events'; import { DATA_GRID_SMALL_CONTAINER } from '@/components/data-grid/data-grid.keys'; const slots = useSlots(); const hasSlot = useHasSlot(slots); const props = defineProps({ /** * Row actions property that generates which button to show in row actions area */ rowActions: { type: [Function, Array] as PropType<BentoDataGridRowActionsProp>, default: undefined, }, /** * When true, will emit a 'rowClick' event when a row is clicked. Will add cursor: pointer to rows. */ allowRowClicks: { type: Boolean, default: false }, /** * The columns sets including */ columnSets: { type: Array as PropType<Array<DataGridCellColumnSet>>, required: true, }, /** * Sets the row cells to the condensed padding state. */ condensed: { type: Boolean, default: false, }, /** * Columns being dragged, contains dragging column and a target column */ draggingColumns: { type: Object as PropType<BentoDatagridDraggingColumnState>, default: undefined, }, /** * Dragging event handlers for each row cell */ draggingEventsForRowCell: { type: Function as PropType<DataGridCellDragEventsForColumn>, required: true, }, /** * A customisable method to retrieve a unique identifier for a data item in the array of data supplied. * This is used selecting and performance reasons and must exist in each data item. */ getDataItemId: { type: Function as PropType<BentoDatagridGetDataItemIdFn>, default: (item: BentoDatagridDataItem) => item.id, }, /** * Set to true if the datagrid has been scrolled and the left side has overflowing space. */ hasLeftOverflow: { type: Boolean, default: false, }, /** * Enables the nested content slot functionality. */ hasNestedContent: { type: Boolean, default: false, }, /** * Enables the nested content slot functionality. */ hasNestedRows: { 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, }, /** * Highlighted row IDs. */ highlightedRows: { type: Array as PropType<Array<BentoDatagridDataItemId>>, default: () => [], }, /** * The row index of a currently hovered row. */ hoveredRowIndex: { type: Number, default: -1, }, /** * The column hovered over state and whether the hovered over column should show a border right or left. */ hoveredOverColumnState: { type: Object as PropType<DatagridColumnHoveringState>, required: true, }, /** * A customisable method to determine if a row is not selectable. * The default is that every row is selectable. */ isDataItemDisabled: { type: Function as PropType<BentoDatagridIsDataItemDisabledFn>, default: () => false, }, /** * If the row is of an even number. * Rows are 0th based index e.g. index 0 is an odd-numbered row. */ isRowEven: { type: Boolean, default: false, }, /** * If the row is the last child in it's parent container. */ isRowLastChild: { type: Boolean, default: false, }, /** * If columns are being measured for the autoWidth functionality. */ isMeasuringColumnWidths: { type: Boolean, default: false, }, /** * Determines if this row is nested inside another row. */ isNestedRow: { type: Boolean, default: false, }, /** * The maximum width of the row actions in all rows */ rowActionsMaximumWidth: { type: Number, default: 0, }, /** * The row data object */ rowData: { type: Object as PropType<BentoDatagridDataItem>, required: true, }, /** * The row index. */ rowIndex: { type: Number, required: true, }, /** * The row ID to be using for the ID attribute. */ rowId: { type: Number, default: undefined, }, /** * The number of rows that this row is within. */ rowSetSize: { type: Number, default: 0, }, /** * Determines whether to render frozen seperator on the row. */ showFrozenColumnsSeparator: { type: Boolean, default: false, }, /** * Determines if the row selection is enabled. */ selectable: { type: Boolean, default: false, }, /** * The array of IDs for the selected rows. */ selectedItems: { type: Array as PropType<Array<BentoDatagridDataItemId>>, required: true, }, /** * Enables / Disables the nested-content slot */ shouldShowNestedContent: { type: Function as PropType<BentoDatagridShouldShowNestedContentFn>, default: (_item: BentoDatagridDataItem) => true, }, /** * Returns the nested rows for a given BentoDatagridDataItem. */ shouldShowNestedRows: { type: Function as PropType<BentoDatagridShouldShowNestedRowsFn>, default: (_item: BentoDatagridDataItem) => true, }, }); const emit = defineEmits<{ /** * Emitted when a cell has been hovered over. This is used to keep the row in a hovered state when hovering over a menu. */ (e: 'cell-hovered', value: number); /** * Emitted when an a frozen column cell fires a key up or down event. */ (e: 'on-frozen-columns-key-press', event: Event); /** * Emitted when a row has been selected. */ (e: 'select-row', value: Array<BentoDatagridDataItemId>, rowData: BentoDatagridDataItem); /** * Emitted when a nested row has been selected. */ ( e: 'select-nested-row', value: Array<BentoDatagridDataItemId>, parentRowData: BentoDatagridDataItem, rowData: BentoDatagridDataItem ); /** * Emitted when a row has been clicked. */ (e: 'row-click', rowData: BentoDatagridDataItem, event: MouseEvent | KeyboardEvent); /** * Emitted when a row with nested content is toggled */ (e: 'nested-content-toggled', rowData: BentoDatagridDataItem); /** * Emitted when a row cell is focused. */ ( e: 'cell-focus', value: { rowData: BentoDatagridDataItem; column: BentoColumn; rowIndex: number; colIndex: number } ); }>(); /** * Checks if the relatedTarget element appears after the reference element in DOM order. * Used to detect backward navigation (Shift+Tab). */ const isRelatedTargetAfterInDOM = (relatedTarget: HTMLElement | null, referenceEl: HTMLElement): boolean => { if (!relatedTarget || !referenceEl) { return false; } return !!(referenceEl.compareDocumentPosition(relatedTarget) & Node.DOCUMENT_POSITION_FOLLOWING); }; // Refs const gridcellRefs = ref<{ [key: string]: HTMLDivElement }>({}); const isNestedContentShown = ref(false); const nestedContentRef = ref(); const rowActionsRef = ref(); const nestedRowRefs = ref<Array<InstanceType<typeof DataGridRow>>>([]); const nestedContentSlotId = generateUid('nested-content-slot'); const isRowActionMenuOpen = ref(false); const isExitingRowActionInteraction = ref(false); const focusableItems = ref<Array<{ node: HTMLElement }>>(); /** * Returns the row-action container element, handling the case where Vue 2 * may wrap refs inside a v-for as an array. */ const getRowActionsElement = (): HTMLElement | undefined => { const val = rowActionsRef.value; return Array.isArray(val) ? val[0] : val; }; onMounted(() => { const container = getRowActionsElement(); if (container) { focusableItems.value = makeAllInteractiveChildrenUnFocusable(container); } }); const shouldShowNestedRowsBooleanResult = computed(() => props.shouldShowNestedRows(props.rowData)); const shouldShowRenderNestedRows = computed( () => !props.isNestedRow && props.hasNestedRows && shouldShowNestedRowsBooleanResult.value && props.rowData?.children?.length > 0 ); const isLoadingNestedRows = computed( () => !props.isNestedRow && props.hasNestedRows && shouldShowNestedRowsBooleanResult.value && (props.rowData?.children === undefined || props?.rowData?.children.length === 0) ); const shouldShowNestedContentToggle = computed( () => (props.hasNestedContent && props.shouldShowNestedContent(props.rowData)) || (props.hasNestedRows && shouldShowNestedRowsBooleanResult.value && !props.isNestedRow) ); const ariaRowAttributes = computed<HTMLAttributes>(() => { if (props.hasNestedContent || props.hasNestedRows) { const isNestedContentShownBooleanishVal = isNestedContentShown.value.toString() as Booleanish; return { 'aria-expanded': shouldShowNestedContentToggle.value ? isNestedContentShownBooleanishVal : undefined, 'aria-level': props.isNestedRow ? '2' : '1', 'aria-posinset': props.rowIndex + 1, 'aria-setsize': props.rowSetSize, }; } return { 'aria-rowindex': props.rowIndex + 1 }; }); const nestedContentContainerDivAriaAttributes = computed<HTMLAttributes>(() => { if (isLoadingNestedRows.value) { return { 'aria-busy': 'true', 'aria-live': 'polite', }; } if ( shouldShowRenderNestedRows.value || !props.hasNestedContent || !props.shouldShowNestedContent(props.rowData) ) { return undefined; } return { 'aria-level': props.isNestedRow ? '3' : '2', 'aria-posinset': '1', 'aria-setsize': '1', id: nestedContentSlotId, role: 'row', tabindex: '-1', }; }); const nestedRowIds = computed(() => { if (shouldShowRenderNestedRows.value) { return props.rowData?.children?.reduce((acc, _val, index) => { acc[index] = generateUid('nested-row'); return acc; }, {}); } return []; }); const allNestedRowIds = computed(() => { if (!isNestedContentShown.value) { return undefined; } if (props.hasNestedContent && props.shouldShowNestedContent(props.rowData)) { return [nestedContentSlotId]; } if (shouldShowRenderNestedRows.value && Object.keys(nestedRowIds.value)?.length > 0) { const generatedRowIds = Object.values(nestedRowIds.value); if (generatedRowIds?.length > 0) { return generatedRowIds; } } return []; }); const hasUnSelectedNestedRows = computed(() => { const childrendDataItemIds = props.rowData?.children?.map(nestedItem => props.getDataItemId(nestedItem)); if (childrendDataItemIds?.length > 0) { const everyChildIsSelected = childrendDataItemIds.every(childId => props?.selectedItems?.includes(childId)); const someItemsAreSelected = childrendDataItemIds.some(childId => props?.selectedItems?.includes(childId)); return !everyChildIsSelected && someItemsAreSelected; } return false; }); // Methods const registerRowCellRef = (el: Element | ComponentPublicInstance, field: string) => { // We always know this will be used on a DataGridCell component so we can cast it const gridCellComponent = el as InstanceType<typeof DataGridCell>; if (el !== null && gridCellComponent?.gridcellRef) { gridcellRefs.value[field] = gridCellComponent.gridcellRef as HTMLDivElement; } }; const registerNestedRowRef = (el: Element | ComponentPublicInstance, childRowIndex: number) => { if (el !== null) { nestedRowRefs.value[childRowIndex] = el as InstanceType<typeof DataGridRow>; } }; const getRowId = (childRowIndex: number) => { return nestedRowIds.value[childRowIndex]; }; const getRowAriaSelectedValue = (row: BentoDatagridDataItem): Booleanish => { if (!props.selectable) { return null; } const id = props.getDataItemId(row); return props.selectedItems.includes(id).toString() as Booleanish; }; const emitRowClick = (rowData: BentoDatagridDataItem, event: MouseEvent | KeyboardEvent) => { emit('row-click', rowData, event); }; /** * Handles row clicks when the `allowRowClicks` prop is true. * * This method also prevents the `row-click` event from firing if the click * originated from an interactive element (like a button, input, or link) * within the row. This is to avoid unintended behavior when a user * interacts with controls inside a row. * * @param event The mouse or keyboard event from the click or key press. */ const onRowClick = (event: MouseEvent | KeyboardEvent) => { // Only proceed if allowRowClicks is true if (!props.allowRowClicks) { return; } let isEventTargetInteractiveElement = false; const dataGridRowInteractiveTypes = ['button', 'input', 'a']; for (let i = 0; i < dataGridRowInteractiveTypes.length; i++) { if ((event?.target as HTMLElement)?.closest(dataGridRowInteractiveTypes[i])) { isEventTargetInteractiveElement = true; break; } } // Only trigger if element is NOT interactive if (!isEventTargetInteractiveElement) { emitRowClick(props.rowData, event); } }; const onRowSelect = (value: Array<BentoDatagridDataItemId>) => { emit('select-row', value, props.rowData); }; const onNestedRowSelect = (rowData: BentoDatagridDataItem, value: Array<BentoDatagridDataItemId>) => { emit('select-nested-row', value, props.rowData, rowData); }; /** * Sets the state to indicate that a row action menu is open. This is used to apply a higher * z-index to the row's action container, ensuring the menu displays correctly above * subsequent rows. */ const onRowActionMenuOpen = () => { isRowActionMenuOpen.value = true; }; /** * Resets the state when a row action menu is closed, removing the elevated z-index from the * row's action container. */ const onRowActionMenuClose = () => { isRowActionMenuOpen.value = false; }; /** * When the row actions cell is focused, determine navigation direction and focus * the appropriate interactive child. Backward navigation (Shift+Tab) focuses the * last child; forward navigation focuses the first. */ const onRowActionsCellFocus = (e: FocusEvent) => { if (isExitingRowActionInteraction.value) { isExitingRowActionInteraction.value = false; return; } const container = getRowActionsElement(); if (!container || !focusableItems.value?.length) { return; } const isBackwardNavigation = isRelatedTargetAfterInDOM(e.relatedTarget as HTMLElement, container); const lastIndex = focusableItems.value.length - 1; const targetItem = isBackwardNavigation ? focusableItems.value[lastIndex] : focusableItems.value[0]; makeAllInteractiveChildrenFocusable([targetItem]); focusOnInteractiveChild(container, { last: isBackwardNavigation }); }; /** * When focus leaves the row actions cell, make all interactive children unfocusable. * This ensures that tabbing through the grid skips over the individual actions within the cell. */ const onRowActionsCellFocusOut = () => { const container = getRowActionsElement(); if (container) { makeAllInteractiveChildrenUnFocusable(container); } }; /** * Handles keyboard navigation within the row actions cell. * Allows using left and right arrow keys to move between interactive elements (e.g., buttons) in the cell. * @param e The keyboard event. */ const onRowActionsCellOnKeyDown = (e: KeyboardEvent) => { const container = getRowActionsElement(); // Escape returns focus to the gridcell container if (e.key === EventKey.ESCAPE) { if (container) { makeAllInteractiveChildrenUnFocusable(container); } isExitingRowActionInteraction.value = true; container?.focus(); e.stopPropagation(); e.preventDefault(); return; } if (e.key !== EventKey.TAB && container) { makeAllInteractiveChildrenUnFocusable(container); } if ( e.key === EventKey.ARROW_RIGHT && focusableItems.value?.length > 1 && focusableItems.value[0].node === document.activeElement ) { if (container) { makeAllInteractiveChildrenUnFocusable(container); } focusableItems.value[1].node.setAttribute('tabindex', '0'); focusableItems.value[1].node.focus(); e.stopPropagation(); e.preventDefault(); } else if ( e.key === EventKey.ARROW_LEFT && focusableItems.value?.length > 1 && focusableItems.value[1].node === document.activeElement ) { if (container) { makeAllInteractiveChildrenUnFocusable(container); } focusableItems.value[0].node.setAttribute('tabindex', '0'); focusableItems.value[0].node.focus(); e.stopPropagation(); e.preventDefault(); } }; // Cell and Columns index calculation const sumColumnAmountsInColumnSets = () => props.columnSets.reduce((total, { columnSet }) => total + columnSet.length, 0); const getRowActionsCellIndex = () => sumColumnAmountsInColumnSets() * props.rowIndex + sumColumnAmountsInColumnSets(); // Nested content expanding logic const { updateContentHeight, contentHeight } = useExpandableContentHeight(nestedContentRef, isNestedContentShown); const nestedContentStyles = computed(() => { if (props.hasNestedContent || props.hasNestedRows) { // calculate the padding by getting the action column width and the first column left padding: const paddingLeft = shouldShowRenderNestedRows.value || isLoadingNestedRows.value ? 0 : gridcellRefs.value[DATAGRID_ACTIONS_COLUMN_FIELD_NAME].offsetWidth + DATAGRID_CELL_OFFSET_PADDING; return { paddingLeft: `${paddingLeft}px`, 'max-height': (props.hasNestedContent || props.hasNestedRows) && contentHeight.value && !contentHeight?.value?.includes('null') ? contentHeight.value : 0, }; } return undefined; }); watch( () => nestedContentRef.value, () => { let observer: ResizeObserver | null = null; if (!observer && nestedContentRef.value) { observer = observeSizeOfElement(nestedContentRef.value, () => { if (nestedContentRef.value) { updateContentHeight(); } }); } } ); const nestedContentToggled = () => { isNestedContentShown.value = !isNestedContentShown.value; updateContentHeight(); emit('nested-content-toggled', props.rowData); }; watch( () => props.rowData?.children, (_newChildren, oldChildren) => { if (props?.rowData?.children?.length > 0 && oldChildren === undefined && !isNestedContentShown.value) { updateContentHeight(); } } ); const isSmallContainer: ComputedRef<boolean> = inject( DATA_GRID_SMALL_CONTAINER, computed(() => false) ); const isHighlightedRow = row => props.highlightedRows.includes(props.getDataItemId(row)); const isSelectedRow = row => props.selectedItems.includes(props.getDataItemId(row)); // Styling const conditionalRowClasses = (row: BentoDatagridDataItem, rowIndex: number) => ({ 'b-data-grid-row-container__row--disabled': props.isDataItemDisabled(row), 'b-data-grid-row-container__row--row-clicks-enabled': props.allowRowClicks, 'b-data-grid-row-container__row--highlighted': isHighlightedRow(row), 'b-data-grid-row-container__row--selected': isSelectedRow(row), 'b-data-grid-row-container__row--hovered': props.hoveredRowIndex === rowIndex, 'b-data-grid-row-container__row--is-last-child': props.isRowLastChild && !isNestedContentShown.value, 'b-data-grid-row-container__row--nested-row': props.isNestedRow, 'b-data-grid-row-container__row--zebra': props.isRowEven, }); const conditionalRowSetClasses = (isFrozenRowSet: boolean, isSmallContainer: boolean) => ({ 'b-data-grid-row-container__row-cell-set--frozen': isFrozenRowSet && !isSmallContainer, [`b-data-grid-row-container__row-cell-set--${BentoDatagridStyleState.OVERFLOW_SHADOW_LEFT}`]: !isSmallContainer && isFrozenRowSet && props.hasLeftOverflow, 'b-data-grid-row-container__row-cell-set--frozen-separator': !isSmallContainer && isFrozenRowSet && props.showFrozenColumnsSeparator, 'b-data-grid-row-container__row-cell-set--unfrozen': !isFrozenRowSet || isSmallContainer, 'b-data-grid-row-container__row-cell-set--is-measuring': props.isMeasuringColumnWidths, }); const conditionalNestedRowClasses = computed(() => ({ 'b-data-grid-row-container__row-nested-slot--is-last-child': props.isRowLastChild, 'b-data-grid-row-container__row-nested-slot': !shouldShowRenderNestedRows.value && props.shouldShowNestedContent(props.rowData), })); const conditionalRowActionsContainerClasses = computed(() => ({ 'b-data-grid-row-container__row-actions-container--row-actions-menu-open': isRowActionMenuOpen.value, })); const computedRowActionsStyles = computed(() => ({ minWidth: `${props.rowActionsMaximumWidth}px`, })); // Exposed refs defineExpose({ gridcellRefs, isNestedContentShown, nestedRowRefs, rowActionsRef, }); </script> <script lang="ts"> /** * Internal component to display the BentoDataGrid data rows containig the data row cells. * * @example * * export default { * components: { DataGridRow }, * template: ` * <data-grid-row * :ref="el => registerRowCellRefs(el, rowIndex)" * :style="gridColumnWidthStyle" * :allow-row-clicks="allowRowClicks" * :column-sets="columnSets" * :condensed="condensed" * :dragging-columns="draggingColumns" * :dragging-events-for-row-cell="draggingEventsForRowCell" * :get-data-item-id="getDataItemId" * :has-left-overflow="hasLeftOverflow" * :highlighted-rows="highlightedRows" * :hovered-row-index="hoveredRowIndex" * :hovered-over-column-state="hoveredOverColumnState" * :is-data-item-disabled="isDataItemDisabled" * :is-measuring-column-widths="isMeasuringColumnWidths" * :row-data="rowData" * :row-index="rowIndex" * :show-frozen-columns-separator="showFrozenColumnsSeparator" * :selectable="selectable" * :selected-items="selectedItems" * @cell-hovered="onCellHover" * @row-click="onRowClick" * @on-frozen-columns-key-press="updateScrollPositionOnFrozenColumnsNavigation" * @select-row="onRowSelect" * > * <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> * <slot :name="innerSlot" v-bind="scope" /> * </template> * </data-grid-row> * ` */ export default { name: 'data-grid-row', }; </script> <style lang="scss" scoped src="./data-grid-row.scss" />
|
|
@@ -305,7 +305,7 @@ You can automatically update `filter-values` using the
|
|
|
305
305
|
| `filters` | Array of filter objects. Each one of the elements in the array corresponds to a filter that will be created and rendered in the FilterBar. |
|
|
306
306
|
| `filterValues` | Array of filter values. Each one of the elements in the array corresponds to a filter's value to be set in the filter bar. |
|
|
307
307
|
| `filterSearchTerm` | String that contains the search term used to filter the data with, it is displayed in the search input of the filter bar. |
|
|
308
|
-
| `filterSearchConfig` | Sets the configuration options (`hint`, `placeholder`, `debounceTime` and `
|
|
308
|
+
| `filterSearchConfig` | Sets the configuration options (`hint`, `placeholder`, `debounceTime`, `inputFieldAriaLabel` and `width`) for the filter's search bar. |
|
|
309
309
|
|
|
310
310
|
<Canvas of={ComponentStories.Filtering} />
|
|
311
311
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
<template> <div ref="dataGridRef" class="b-data-grid" data-testid="data-grid" :class="conditionalGridClasses" aria-live="polite" :aria-busy="loading" @copy="handleClipboard" > <div v-if="filters" ref="filtersRef" class="b-data-grid__filters" :class="conditionalFilterClasses" :style="{ top: computedStickyFiltersTopPosition }" :aria-controls="dataGridUid" :aria-label="t('filterBar')" role="region" > <bento-filter-bar v-if="filters" ref="filterBarRef" :config="filters" :container-size-layout="filtersContainerSizeLayout" :filter-values="filterValues" :filter-values-object="filterValuesObject" :search="filterSearchConfig" :search-term="filterSearchTerm" @search-input="onSearchTermInput" @search-clear="onSearchTermCleared" @input="filters => $emit(BentoDatagridEvent.UPDATE_FILTERS, filters)" @update:filter-values="filterValues => $emit(BentoDatagridEvent.UPDATE_FILTER_VALUES, filterValues)" @update:filter-values-object=" filterValueObject => $emit(BentoDatagridEvent.UPDATE_FILTER_VALUES_OBJECT, filterValueObject) " /> </div> <div v-if="$slots.header" class="b-data-grid__header"> <slot name="header"></slot> </div> <div :id="dataGridUid" :role="ariaGridRole" :aria-rowcount="data.length" :aria-colcount="columns.length" :aria-label="emptyState ? t('noResults') : null" class="b-data-grid__wrapper" :style="{ top: computedColumnHeadersTopPosition }" > <div class="b-data-grid__column-headers" :style="{ top: computedColumnHeadersTopPosition }"> <div ref="columnsRef" role="rowgroup" class="b-data-grid__columns-row" @scroll="updateScrollPosition" @keydown="navigationInput" > <div v-if="columnPanel" role="row"> <bento-data-grid-config-settings class="b-data-grid__config-settings" role="gridcell" :columns="localColumnsState" :condensed="condensed" :zebra-rows="internalZebraRows" @update:condensed="onConfigSettingsCondensedUpdate" @update:zebra-rows="onConfigSettingsZebraRowsUpdate" @update:columns="onConfigSettingsColumnsUpdate" /> </div> <div :style="gridRowWidthStyle" class="b-data-grid__columns" role="row"> <template v-for="({ columnSet, id, isFrozenColumns }, index) of columnSets"> <data-grid-columns v-if="columnSet?.length" :key="`data-grid-columns-${id}`" :ref="el => registerColumnHeaderRefs(el)" :columns="columnSet" :has-resizable-columns="hasResizableColumns" :sort-by="sortBy" :all-items-selected-state="allItemsSelectedState" :is-frozen-columns="isFrozenColumns" :show-frozen-columns-separator="showFrozenColumnsSeparator" :dragging-columns="draggingColumns" :has-draggable-columns="allowColumnDragAndDrop" :has-column-panel="columnPanel" :has-left-overflow="hasLeftOverflow" :has-first-column-panel-right-aligned="index === computedColumnPanelAlignment" :hovered-over-column-state="hoveredOverColumnState" :is-measuring-column-widths="isMeasuringColumnWidths" :is-only-one-visible-column="isOnlyOneVisibleColumn" :selectable="data?.length > 0 && selectable" :selectable-across-pages="selectableAcrossPagesComputed" :selected-items="selectedItems" :visible-rows-state="visibleRowsState" :selection-state="selectionState" :truncate-column-headers="truncateColumnHeaders" :row-actions-maximum-width="rowActionsMaxWidth" v-on="draggingEventsForColumns" @column-hover="hoveredOverColumn = $event" @column-move="onMoveColumnByOffset" @column-update="onConfigSettingsColumnUpdate" @column-resize="onColumnResized" @column-resizing="onColumnWidthOverride" @select-all-rows="onAllRowSelect" @select-all-across-pages="emit(BentoDatagridEvent.SELECT_ALL_ACROSS_PAGES)" @select-all-current-results="emit(BentoDatagridEvent.SELECT_ALL_CURRENT_RESULTS)" @deselect-all-current-results="emit(BentoDatagridEvent.DESELECT_ALL_CURRENT_RESULTS)" @deselect-all="onDeselectAll" @sort="onSort" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </data-grid-columns> </template> </div> </div> </div> <div ref="body" data-testid="data-grid-body" class="b-data-grid__body" :class="conditionalGridBodyClasses" @scroll="updateScrollPosition" > <div v-if="loading && !isLazyLoad" class="b-data-grid__loading"> <bento-loading-indicator class="b-data-grid__loading-indicator" large /> <bento-typography el="span" variant="caption">{{ t('gettingYourData') }}</bento-typography> </div> <div v-else role="rowgroup" @keydown="navigationInput"> <template v-if="data?.length === 0 && emptyState"> <div role="row" tabindex="-1"> <div role="gridcell" :aria-colspan="columns.length" tabindex="-1"> <bento-empty-state class="b-data-grid__empty-state" v-bind="limitedEmptyStateProps" /> </div> </div> </template> <data-grid-row v-for="(rowData, rowIndex) in data" v-else :key="rowIndex" :ref="el => registerRowCellRefs(el, rowIndex)" :style="gridRowWidthStyle" :allow-row-clicks="allowRowClicks" :column-sets="columnSets" :condensed="condensed" :dragging-columns="draggingColumns" :dragging-events-for-row-cell="draggingEventsForRowCell" :get-data-item-id="getDataItemId" :has-nested-content="hasNestedContent" :has-nested-rows="hasNestedRows" :has-left-overflow="hasLeftOverflow" :has-right-overflow="hasRightOverflow" :highlighted-rows="highlightedRows" :hovered-row-index="hoveredRowIndex" :hovered-over-column-state="hoveredOverColumnState" :is-data-item-disabled="isDataItemDisabled" :is-row-even="internalZebraRows ? rowIndex % 2 !== 0 : false" :is-row-last-child="rowIndex + 1 === data?.length" :is-measuring-column-widths="isMeasuringColumnWidths" :row-actions-maximum-width="rowActionsMaxWidth" :row-actions="rowActions" :row-data="rowData" :row-index="rowIndex" :row-set-size="data?.length" :show-frozen-columns-separator="showFrozenColumnsSeparator" :should-show-nested-content="shouldShowNestedContent" :should-show-nested-rows="shouldShowNestedRows" :selectable="selectable" :selected-items="selectedItems" @cell-hovered="onCellHover" @cell-focus="emit(BentoDatagridEvent.CELL_FOCUS, $event)" @nested-content-toggled="onNestedContentToggled($event)" @row-click="onRowClick" @on-frozen-columns-key-press="updateScrollPositionOnFrozenColumnsNavigation" @select-row="onRowSelect" @select-nested-row="onNestedRowSelect" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </data-grid-row> </div> </div> </div> <data-grid-lazy-load v-if="isLazyLoad" :loading="loading" :is-infinite-scroll="isInfiniteScroll" @update:show-more="emitLazyLoadEvent" /> <div v-if="stickyHorizontalScrollbar" ref="virtualHorizontalScrollbarRef" class="b-data-grid__horizontal-scrollbar" aria-hidden="true" data-testid="sticky-horizontal-scrollbar" :style="horizontalScrollbarConditionalStyles" @scroll="syncVirtualScrollerWithBody" > <div :style="{ width: `${totalWidthOfAllColumns}px`, height: '15px' }"></div> </div> <div v-if="pagination && !isLazyLoad" ref="paginationContainerRef" data-testid="pagination-container" class="b-data-grid__pagination" :class="conditionalPaginationClasses" > <bento-pagination v-bind="pagination" :aria-label="$t('pagination')" :aria-controls="dataGridUid" @navigate="onPaginationNavigate" @items-page="onItemsPerPageChange" > <slot v-if="hasSlot('pagination-slot')" name="pagination-slot"></slot> </bento-pagination> </div> <div v-if="$slots.footer" class="b-data-grid__footer"><slot name="footer"></slot></div> <bento-action-bar v-if="showActionBar" :actions="bulkActions" :item-counter="itemsSelectedCounter" :extra-bottom-spacing="stickyPagination" @close="onDeselectAll" /> </div> </template> <script lang="ts" setup> import { type ComponentPublicInstance, computed, inject, nextTick, onMounted, type PropType, provide, ref, toRef, toRefs, useSlots, } from 'vue'; // Components import { BentoActionBar } from '@/components/action-bar'; import { BentoPagination } from '@/components/pagination'; import { BentoLoadingIndicator } from '@/components/loading-indicator'; import { BentoTypography } from '@/components/typography'; import { BentoDataGridConfigSettings, DataGridColumns, DataGridLazyLoad, DataGridRow } from './components'; // Composables import { useCalculateColumnWidth, useColumnHovering, useColumnReorderer, useConfigSettings, useKeyboardNavigator, useLazyLoading, useScroller, useSelector, } from './composables'; import { useRowCellRefs } from './composables/use-row-cell-refs/use-row-cell-refs'; import { useCalculateStickyElementHeight, useHasSlot } from '@/composables'; // Utils import { generateUid } from '@/core/utils/ts/generate-uid'; import { useI18n } from '@/utils/ts/i18n'; import { printDevelopmentWarning } from '@/utils/ts/print-development-warning'; import { handleClipboard } from '@/components/data-grid/utils/handle-clipboard/handle-clipboard'; // Types import { type BentoColumn, type BentoDatagridData, type BentoDatagridDataItem, type BentoDatagridDataItemId, type BentoDatagridEmptyStateProps, BentoDatagridEvent, type BentoDatagridGetDataItemIdFn, type BentoDatagridIsDataItemDisabledFn, type BentoDatagridPaginationProps, type BentoDatagridShouldShowNestedContentFn, type BentoDatagridShouldShowNestedRowsFn, type BentoDatagridSortByColumn, BentoDatagridStyleState, type BentoDataGridVisibleRowsState, } from './data-grid.types'; import { type BentoTabProvidedTitle } from '../tabs/components/tab.types'; import { type BentoDataGridTemplateDataGrid } from '@/templates/data-grid-template/data-grid-template.types'; import { type BentoLazyLoadType } from '@/types/lazy-load'; import type { BentoButtonActionsList } from '@/components/button'; import BentoFilterBar from '@/components/filter-bar/filter-bar.vue'; import { type BentoFilterBarContainerSizeLayout, type BentoFilterBarModel, type BentoFilterBarSearchBarProps, type BentoFilterBarSearchTerm, type BentoFilterValueObject, type BentoFilterValues, } from '@/components/filter-bar'; import { BentoEmptyState } from '@/components/empty-state'; import type { DatagridColumnsHeaderReferences } from '@/components/data-grid/components/data-grid-columns/data-grid-columns.types'; import type { BentoDataGridRowActionsProp } from '@/components/data-grid/components/data-grid-cell-actions/data-grid-cell-actions.types'; // Keys import { EXTERNAL_TITLE_REGISTER_ENTRY_INJECTION_KEY } from '@/components/tabs/tabs.keys'; import { DATA_GRID_REGISTER_TEMPLATE_INJECTION_KEY } from '@/templates/data-grid-template/data-grid-template.keys'; import { DATA_GRID_SLASHED_ZERO, DATA_GRID_SMALL_CONTAINER, DATA_GRID_TOUCH_DEVICE } from './data-grid.keys'; // Translations import messages from './messages.json'; import { useElementSize } from '@vueuse/core'; type MessageSchema = (typeof messages)['en-US']; const props = defineProps({ /** * When true, will emit a 'rowClick' event when a row is clicked. Will add cursor: pointer to rows. */ allowRowClicks: { type: Boolean, default: false }, /** * When true, column headers will become draggable, allowing users to rearrange columns. */ allowColumnDragAndDrop: { type: Boolean, default: true }, /** * List of actions that will be rendered in the action bar when user selects items. * * Each object in the list should have a `title`, an `event` and, * optionally, an `icon`. * * The component will display 4 actions and the hide the rest inside a menu. * * @see BentoButton for a list of all the other props supported by each button action. */ bulkActions: { type: Array as PropType<BentoButtonActionsList>, default: null, }, /** * Sets the row cells to the condensed padding state. */ condensed: { type: Boolean, default: false, }, /** * Column definitions using 'key' to relate to data object. */ columns: { type: Array as PropType<Array<BentoColumn>>, required: true, }, /** * Enables / hides the display options panel and the column contextual menu functionality. */ columnPanel: { type: Boolean, default: false }, /** * An array of data objects to display. */ data: { type: Array as PropType<BentoDatagridData>, required: true, }, /** * Empty state props that will be passed down to inner empty-state component */ emptyState: { type: Object as PropType<BentoDatagridEmptyStateProps>, default: undefined, }, /** * filter-bar value (i.e. filters) that will be passed down to inner filter-bar component * {@see BentoFilterBarModel} */ filters: { // TODO: Change this to BentoFilterBarProps['value'] when the interface is available type: Array as PropType<BentoFilterBarModel>, default: undefined, }, /** * Defines the layout (multi-line or one-line) to be used in the filter bar for different * container sizes: * - Large (>=700px): Always multi-line * - Medium (>=430px & <700px) * - Small (<430px) * * @default * { * medium: 'multi-line', * small: 'one-line' * } */ filtersContainerSizeLayout: { type: Object as PropType<BentoFilterBarContainerSizeLayout>, default: undefined, }, /** * An array of the values for each filter set in `value`. * {@see BentoFilterValues} */ filterValues: { type: Array as PropType<BentoFilterValues>, default: () => [], }, /** * An object of the values for each filter in the filter bar where key is a the field name and the value is the BentoFilterBarValue. * * @example * { * 'company': '200', * 'name': 'some text' * }, */ filterValuesObject: { type: Object as PropType<BentoFilterValueObject>, default: () => undefined, }, // TODO: Move this prop as a part of "config" BentoFilterBarModel = Array<BentoFilterModel | BentoFilterSearchModel> in Bento 2.0. // It was not done now because it's a breaking change for the types. /** * Filter's search bar properties with which the placeholder, hint and other * "bento-search-bar" properties can be set. @see {@link BentoFilterBarSearchBarProps} */ filterSearchConfig: { type: Object as PropType<BentoFilterBarSearchBarProps>, default: undefined, }, /** * Filter bar search term useful to filter all the data by text */ filterSearchTerm: { type: String as PropType<BentoFilterBarSearchTerm>, default: undefined, }, /** * Allows the grid to be rendered inside a confined space. * This makes the data-grid expand to fill the height of its parent container, with the grid body becoming scrollable. */ fitContent: { type: Boolean, default: false }, /** * A customisable method to retrieve a unique identifier for a data item in the array of data supplied. * This is used selecting and performance reasons and must exist in each data item. */ getDataItemId: { type: Function as PropType<BentoDatagridGetDataItemIdFn>, default: (item: BentoDatagridDataItem) => item.id, }, /** * Row actions propery that generates which button to show in row actions area */ rowActions: { type: [Function, Array] as PropType<BentoDataGridRowActionsProp>, default: undefined, }, /** * If resize is enabled */ hasResizableColumns: { type: Boolean, default: true, }, /** * Enables the nested content slot functionality. */ hasNestedContent: { type: Boolean, default: false, }, /** * Enables the nested content slot functionality. */ hasNestedRows: { type: Boolean, default: false, }, /** * Highlighted items */ highlightedRows: { type: Array as PropType<Array<BentoDatagridDataItemId>>, default: () => [], }, /** * A customisable method to determine if a row is not selectable. * The default is that every row is selectable. */ isDataItemDisabled: { type: Function as PropType<BentoDatagridIsDataItemDisabledFn>, default: () => false, }, /** * The type of `Lazy Load`. * Type "automatic" will enable infinite scrolling * Type "button" will enable lazy loading with "Show more" button * @values automatic, button, none */ lazyLoadType: { type: String as PropType<BentoLazyLoadType>, default: 'none', validator: (value: BentoLazyLoadType) => ['automatic', 'button', 'none'].includes(value), }, /** * If the loading indicator should be shown. */ loading: { type: Boolean, default: false, }, /** * Enables / Disables the table's surrounding border. */ outline: { type: Boolean, default: false, }, /** * Renders the data grid with no top spacing, to render inside containers that already have spacing */ noSpacingTop: { type: Boolean, default: false, }, /** * Pagination props that will be passed down to inner pagination component */ pagination: { type: Object as PropType<BentoDatagridPaginationProps>, default: undefined, }, /** * Adds an extra option when selecting all items that allows user to select items in other pages too. * Needs pagination.totalCount to be set. */ selectableAcrossPages: { type: Boolean, default: false, }, /** * Renders a font numeric style with slashed zeros instead of normal ones. */ slashedZero: { type: Boolean, default: false }, /** * Sticks the horizontal scrollbar to the bottom of the screen if the data-grid is larger than the screen height. */ stickyHorizontalScrollbar: { type: Boolean, default: false, }, /** * Make pagination stick to the bottom of the page when the data-grid is bigger than the screen. * Because styles have to be applied to pagination container element, this has to be a data-grid prop (rather than a pagination prop). */ stickyPagination: { type: Boolean, default: false, }, /** * Make data-grid filters stick to the top of the page along with the data-grid headers. * Defaults to true. */ stickyFilters: { type: Boolean, default: true, }, /** * Enables / Disables rows to be selected via a checkbox. On select the "select" event will be emitted. */ selectable: { type: Boolean, default: false, }, /** * Selected items */ selection: { type: Array as PropType<Array<BentoDatagridDataItemId>>, default: () => [], }, /** * Enables / Disables the nested-content slot */ shouldShowNestedContent: { type: Function as PropType<BentoDatagridShouldShowNestedContentFn>, default: (_item: BentoDatagridDataItem) => true, }, /** * Returns the nested rows for a given BentoDatagridDataItem. */ shouldShowNestedRows: { type: Function as PropType<BentoDatagridShouldShowNestedRowsFn>, default: (_item: BentoDatagridDataItem) => true, }, /** * Sortby handles proper rendering of sort icon in header. Sorting currently is only supported server side. * It does make sure the currently sorted column has a more visible sort icon, and the sort icon points in the right direction. */ sortBy: { type: Array as PropType<Array<BentoDatagridSortByColumn>>, // NOTE: is an array to allow multi-sorting in the future default: () => [], }, /** * Title of the data grid to be rendered as tab title when a data grid is passed in a tab layout. */ tabTitle: { type: String, default: null, }, /** * Truncates all data grid column headers */ truncateColumnHeaders: { type: Boolean, default: false, }, /** * Toggles the alternating background color of rows. */ zebraRows: { type: Boolean, default: false, }, }); const slots = useSlots(); const hasSlot = useHasSlot(slots); /* == Variables and references == */ // Create refs for elements const body = ref<HTMLDivElement>(null); const columnsRef = ref<HTMLDivElement>(); const columnHeaderRefs = ref<DatagridColumnsHeaderReferences>({}); const dataGridUid = ref<string>(generateUid('data-grid')); const gridcellRefs = ref<{ [key: string]: Array<HTMLDivElement> }>(null); const filtersRef = ref<HTMLDivElement>(null); const filterBarRef = ref<InstanceType<typeof BentoFilterBar>>(null); const paginationContainerRef = ref<HTMLDivElement>(null); const verticalScrollPositionRef = ref(null); const virtualHorizontalScrollbarRef = ref<HTMLDivElement>(null); const { allowColumnDragAndDrop, columns, columnPanel, data, lazyLoadType, loading, hasNestedContent, hasNestedRows, selectable, selection, selectableAcrossPages, stickyPagination, outline, pagination, zebraRows, } = toRefs(props); const hoveredRowIndex = ref(-1); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const frozenColumnsLength = computed(() => columnDefs.value.filter(column => column.frozen).length); /* == Responsive behavior == */ // Identifies if the data-grid is in a container smaller than 550px const MOBILE_BREAKPOINT = 550; const dataGridRef = ref(); const { width } = useElementSize(dataGridRef); const isSmallContainer = computed(() => width.value <= MOBILE_BREAKPOINT); provide(DATA_GRID_SMALL_CONTAINER, isSmallContainer); /* == Touch device behavior == */ // Identifies if the data-grid is rendered on a touch device const isTouchDevice = ref(false); onMounted(() => { if (typeof window !== 'undefined') { isTouchDevice.value = 'ontouchstart' in window || navigator?.maxTouchPoints > 0; } }); provide(DATA_GRID_TOUCH_DEVICE, isTouchDevice); /* == Data-grid template == */ const registerDatagridInDataGridTemplate: BentoDataGridTemplateDataGrid = inject( DATA_GRID_REGISTER_TEMPLATE_INJECTION_KEY, () => ({ additionalContentSlotRef: ref(null), isDataGridTemplate: false, stickyAditionalContent: ref(false), stickyTabs: ref(false), tabsRef: ref(null), }) ); const { additionalContentSlotRef, isDataGridTemplate, stickyTabs, tabsRef, stickyAdditionalContent } = registerDatagridInDataGridTemplate(); /* == Composable logic == */ // Column width calculation const { columnDefs, onColumnWidthOverride, gridRowWidthStyle, isMeasuringColumnWidths, isTotalWidthOfColumnsTakingUpAllAvailableGridWidth, columns: localColumnsState, totalWidthOfAllColumns, rowActionsMaxWidth, } = useCalculateColumnWidth(columns, data, body, columnHeaderRefs, gridcellRefs, { hasColumnPanel: columnPanel, hasSelectableColumn: selectable, hasSelectableAcrossPages: selectableAcrossPages, hasNestedContent, hasNestedRows, allowColumnDragAndDrop, isDataGridTemplate: isDataGridTemplate, }); const hasRowActions = computed(() => props.rowActions !== undefined); const { registerRowCellRefs, rebuildGridcellRefs } = useRowCellRefs( gridcellRefs, rowActionsMaxWidth, hasRowActions ); /* == Emitting logic == */ const definedEmitsFunction = defineEmits([ BentoDatagridEvent.COLUMNS, BentoDatagridEvent.COLUMN_RESIZE, BentoDatagridEvent.ITEMS_PAGE, BentoDatagridEvent.FILTER_SEARCH_INPUT, BentoDatagridEvent.FILTER_SEARCH_CLEAR, BentoDatagridEvent.NAVIGATE, BentoDatagridEvent.NESTED_CONTENT_TOGGLED, BentoDatagridEvent.UPDATE_COLUMNS, BentoDatagridEvent.UPDATE_CONDENSED, BentoDatagridEvent.UPDATE_FILTER_VALUES, BentoDatagridEvent.UPDATE_FILTER_VALUES_OBJECT, BentoDatagridEvent.UPDATE_FILTERS, BentoDatagridEvent.UPDATE_FILTER_SEARCH_TERM, BentoDatagridEvent.UPDATE_SHOW_MORE, BentoDatagridEvent.UPDATE_PAGINATION, BentoDatagridEvent.UPDATE_SELECTION, BentoDatagridEvent.UPDATE_SORT_BY, BentoDatagridEvent.ROW_CLICK, BentoDatagridEvent.SELECT, BentoDatagridEvent.SELECT_ALL_ACROSS_PAGES, BentoDatagridEvent.SELECT_ALL_CURRENT_RESULTS, BentoDatagridEvent.DESELECT_ALL_CURRENT_RESULTS, BentoDatagridEvent.SORT, BentoDatagridEvent.CELL_FOCUS, ]); /** * Hooks into the emit function and updates the local columns state if we're updating columns. * This is so if users use a computed ref with the columns property the ordering / toggling visibility will still function correctly. */ const emit: typeof definedEmitsFunction = (event, ...args) => { // If we updating columns update the local columns state if (event === BentoDatagridEvent.COLUMNS || event === BentoDatagridEvent.UPDATE_COLUMNS) { [localColumnsState.value] = args; } definedEmitsFunction(event, ...args); }; // Calculate dynamic top position of sticky filters const stickyFiltersHeight = useCalculateStickyElementHeight( tabsRef, stickyTabs, stickyAdditionalContent, additionalContentSlotRef ); const computedStickyFiltersTopPosition = computed(() => props.stickyFilters ? `${stickyFiltersHeight.value}px` : null ); // Calculate dynamic top position of column headers const columnHeadersHeight = useCalculateStickyElementHeight(filtersRef, toRef(props, 'stickyFilters')); const computedColumnHeadersTopPosition = computed( () => `${columnHeadersHeight.value + stickyFiltersHeight.value}px` ); // Config Settings (condensed, visbility and freezing settings) const { sortedColumns, columnSets, isOnlyOneVisibleColumn, internalZebraRows, onConfigSettingsColumnsUpdate, onConfigSettingsColumnUpdate, onConfigSettingsCondensedUpdate, onConfigSettingsZebraRowsUpdate, } = useConfigSettings(columnDefs, zebraRows, emit); // Column hovering const { hoveredOverColumn, hoveredOverColumnState } = useColumnHovering( columnSets, selectable, outline, isTotalWidthOfColumnsTakingUpAllAvailableGridWidth ); // Re-ordering column (dragging) const { draggingColumns, draggingEventsForColumns, draggingEventsForRowCell, onMoveColumnByOffset } = useColumnReorderer(sortedColumns, allowColumnDragAndDrop, emit); // Selecting const selectableAcrossPagesComputed = computed( () => props.data?.length > 0 && !!props.selectableAcrossPages && !!props.pagination && props.pagination.totalCount > 0 ); const { onAllRowSelect, onRowSelect, onNestedRowSelect, onDeselectAll, allItemsSelectedState, selectedItems, selectionState, } = useSelector( selection, emit, data, props.getDataItemId, props.isDataItemDisabled, selectableAcrossPagesComputed, pagination ); const visibleRowsState = computed<BentoDataGridVisibleRowsState>(() => ({ filtered: filterBarRef.value?.isClearAllVisible || filterBarRef.value?.isResetAllVisible || !!props.filterSearchTerm, allRowsVisible: pagination.value?.size >= pagination.value?.totalCount, })); // Keyboard navigation const { navigationInput } = useKeyboardNavigator( gridcellRefs, columnHeaderRefs, sortedColumns, selectable, hasRowActions, columnPanel ); // Lazy loading const { isLazyLoad, isInfiniteScroll, verticalScrollPosition, emitLazyLoadEvent } = useLazyLoading( lazyLoadType, verticalScrollPositionRef, body, loading, emit ); // Scrolling const { updateScrollPosition, updateScrollPositionOnFrozenColumnsNavigation, syncVirtualScrollerWithBody, hasLeftOverflow, hasRightOverflow, horizontalScrollbarConditionalStyles, } = useScroller( body, columnsRef, verticalScrollPosition, virtualHorizontalScrollbarRef, paginationContainerRef, totalWidthOfAllColumns, { isLazyLoad, pagination, stickyPagination, isDataGridTemplate, } ); /* == Empty State == */ const onEmptyStateClearResetButtonClick = () => { if (filterBarRef.value.isClearAllVisible) { filterBarRef.value.clearAllFilters(); } else if (filterBarRef.value.isResetAllVisible) { filterBarRef.value.resetAllFilters(); } }; const limitedEmptyStateProps = computed<BentoDatagridEmptyStateProps>(() => ({ ...props.emptyState, variant: props.emptyState?.variant === 'embedded' || props.emptyState?.variant === 'basic' ? props.emptyState.variant : 'basic', })); /* == Styles == */ // Conditional grid classes const conditionalGridClasses = computed(() => ({ [`b-data-grid--${BentoDatagridStyleState.OUTLINE}`]: props.outline, [`b-data-grid--${BentoDatagridStyleState.SCROLL}`]: props.fitContent || isLazyLoad.value, [`b-data-grid--${BentoDatagridStyleState.STICKY_FILTERS}`]: props.filters && props.stickyFilters, [`b-data-grid--${BentoDatagridStyleState.OVERFLOW_SHADOW_LEFT}`]: hasLeftOverflow.value && !frozenColumnsLength.value, [`b-data-grid--${BentoDatagridStyleState.OVERFLOW_SHADOW_RIGHT}`]: hasRightOverflow.value && props.rowActions === undefined, [`b-data-grid--${BentoDatagridStyleState.NO_SPACING_TOP}`]: props.noSpacingTop, [`b-data-grid--${BentoDatagridStyleState.FIT_CONTENT}`]: props.fitContent, [`b-data-grid--${BentoDatagridStyleState.ZEBRA_ROWS}`]: internalZebraRows.value, 'b-data-grid--lazy-load': isLazyLoad.value, })); // Conditional grid body classes const conditionalGridBodyClasses = computed(() => ({ 'b-data-grid__body--sticky-horizontal-scrollbar': props.stickyHorizontalScrollbar, })); const computedColumnPanelAlignment = computed(() => columnSets.value.findIndex(el => el.columnSet.length)); const showFrozenColumnsSeparator = computed(() => { const isSelectionOnlyFrozenColumn = (props.selectable || props.hasNestedContent || props.hasNestedRows) && frozenColumnsLength.value === 1; return !isSelectionOnlyFrozenColumn || hasLeftOverflow.value; }); // Conditional filter classes const conditionalFilterClasses = computed(() => ({ 'b-data-grid__filters--sticky': !!props.stickyFilters || isDataGridTemplate, })); // Conditional pagination classes const conditionalPaginationClasses = computed(() => ({ 'b-data-grid__pagination--padding-left-unset': props.columns?.[0]?.padding?.left === false, 'b-data-grid__pagination--padding-right-unset': props.columns?.[props.columns.length - 1]?.padding?.right === false, 'b-data-grid__pagination--sticky': !!props.stickyPagination || isDataGridTemplate, })); /* == Ref registration methods == */ const registerColumnHeaderRefs = (el: Element | ComponentPublicInstance) => { // We always know this will be used on a DataGridColumns component so we can cast it const datagridColumnsComponent = el as InstanceType<typeof DataGridColumns>; if (el !== null && datagridColumnsComponent?.columnHeaderRefs) { Object.entries(datagridColumnsComponent?.columnHeaderRefs).forEach(([fieldName, colHeaderEl]) => { columnHeaderRefs.value[fieldName] = colHeaderEl as HTMLDivElement; }); } }; /* == Row methods == */ // Row selecting const onRowClick = (row: BentoDatagridDataItem, event: MouseEvent) => { if (props.allowRowClicks) { emit(BentoDatagridEvent.ROW_CLICK, row, event); } }; const onNestedContentToggled = (rowData: BentoDatagridDataItem) => { emit(BentoDatagridEvent.NESTED_CONTENT_TOGGLED, rowData); nextTick(() => rebuildGridcellRefs()); }; // Programmatically set the hover state on a row const onCellHover = (rowIndex: number) => { hoveredRowIndex.value = rowIndex; }; /* == a11y == */ const ariaGridRole = computed(() => (props.hasNestedContent || props.hasNestedRows ? 'treegrid' : 'grid')); /* == Action bar == */ const itemsSelectedCounter = computed(() => selectedItems.value?.length ?? 0); const showActionBar = computed(() => props.bulkActions && itemsSelectedCounter.value > 0); /* == Emits & Events == */ // Resizing const onColumnResized = (resizeEvent: { field: string; width: number }) => emit(BentoDatagridEvent.COLUMN_RESIZE, resizeEvent); // Sorting const onSort = (updatedSortBy: Array<BentoDatagridSortByColumn>) => { emit(BentoDatagridEvent.UPDATE_SORT_BY, updatedSortBy); emit(BentoDatagridEvent.SORT, updatedSortBy); }; // Pagination const onPaginationNavigate = (page: number, size: number) => { emit(BentoDatagridEvent.NAVIGATE, page); emit(BentoDatagridEvent.UPDATE_PAGINATION, { ...props.pagination, size, page, }); }; const onItemsPerPageChange = (itemsPerPage: number) => { emit(BentoDatagridEvent.ITEMS_PAGE, itemsPerPage); }; // Filter bar const onSearchTermCleared = () => { emit(BentoDatagridEvent.FILTER_SEARCH_CLEAR); }; const onSearchTermInput = newSearchTerm => { emit(BentoDatagridEvent.UPDATE_FILTER_SEARCH_TERM, newSearchTerm); emit(BentoDatagridEvent.FILTER_SEARCH_INPUT, newSearchTerm); }; /* == Warnings == */ const hasMandatoryColumn = computed(() => columns.value.some(column => column.mandatory)); if (!hasMandatoryColumn.value) { printDevelopmentWarning( 'WARNING: At least one BentoDataGrid column should be set as mandatory to ensure data is always displayed correctly.' ); } /* == Provide/inject == */ const setTitle: BentoTabProvidedTitle = inject(EXTERNAL_TITLE_REGISTER_ENTRY_INJECTION_KEY, () => null); setTitle(props.tabTitle); provide(DATA_GRID_SLASHED_ZERO, toRef(props, 'slashedZero')); /* == Expose methods == */ defineExpose({ onEmptyStateClearResetButtonClick }); </script> <script lang="ts"> /** * A component to display data in columns and row format with a whole array of configuration for visualization and interaction. * * @example * import { BentoDataGrid } from '@adyen/bento-vue2'; * * export default { * components: { BentoDataGrid }, * template: ` * <bento-data-grid * :data="[ * { id: '123', name: 'John', company: 'TestCompany', 'payment': 200 } * { id: '321', name: 'Jane', company: 'Adyen', 'payment': 300 } * ]" * :columns="[ * { field: 'name', label: 'Name', minWidth: 300, mandatory: true }, * { field: 'company', label: 'Reference' }, * { field: 'payment', label: 'Payment', width: 200, minWidth: 150 }, * ]" * /> * ` * } */ export default { i18n: { messages }, name: 'bento-data-grid', }; </script> <style lang="scss" scoped src="./data-grid.scss" />
|
|
1
|
+
<template> <div ref="dataGridRef" class="b-data-grid" data-testid="data-grid" :class="conditionalGridClasses" aria-live="polite" :aria-busy="loading" @copy="handleClipboard" > <div v-if="filters" ref="filtersRef" class="b-data-grid__filters" :class="conditionalFilterClasses" :style="{ top: computedStickyFiltersTopPosition }" :aria-controls="dataGridUid" :aria-label="t('filterBar')" role="region" > <bento-filter-bar v-if="filters" ref="filterBarRef" :config="filters" :container-size-layout="filtersContainerSizeLayout" :filter-values="filterValues" :filter-values-object="filterValuesObject" :search="filterSearchConfig" :search-term="filterSearchTerm" @search-input="onSearchTermInput" @search-clear="onSearchTermCleared" @input="filters => $emit(BentoDatagridEvent.UPDATE_FILTERS, filters)" @update:filter-values="filterValues => $emit(BentoDatagridEvent.UPDATE_FILTER_VALUES, filterValues)" @update:filter-values-object=" filterValueObject => $emit(BentoDatagridEvent.UPDATE_FILTER_VALUES_OBJECT, filterValueObject) " /> </div> <div v-if="$slots.header" class="b-data-grid__header"> <slot name="header"></slot> </div> <div :id="dataGridUid" :role="ariaGridRole" :aria-rowcount="data.length" :aria-colcount="columns.length" :aria-label="emptyState ? t('noResults') : null" class="b-data-grid__wrapper" :style="{ top: computedColumnHeadersTopPosition }" > <div class="b-data-grid__column-headers" :style="{ top: computedColumnHeadersTopPosition }"> <div ref="columnsRef" role="rowgroup" class="b-data-grid__columns-row" @scroll="updateScrollPosition" @keydown="navigationInput" > <div v-if="columnPanel" role="row"> <bento-data-grid-config-settings class="b-data-grid__config-settings" role="gridcell" :columns="localColumnsState" :condensed="condensed" :zebra-rows="internalZebraRows" @update:condensed="onConfigSettingsCondensedUpdate" @update:zebra-rows="onConfigSettingsZebraRowsUpdate" @update:columns="onConfigSettingsColumnsUpdate" /> </div> <div :style="gridRowWidthStyle" class="b-data-grid__columns" role="row"> <template v-for="({ columnSet, id, isFrozenColumns }, index) of columnSets"> <data-grid-columns v-if="columnSet?.length" :key="`data-grid-columns-${id}`" :ref="el => registerColumnHeaderRefs(el)" :columns="columnSet" :has-resizable-columns="hasResizableColumns" :sort-by="sortBy" :all-items-selected-state="allItemsSelectedState" :is-frozen-columns="isFrozenColumns" :show-frozen-columns-separator="showFrozenColumnsSeparator" :dragging-columns="draggingColumns" :has-draggable-columns="allowColumnDragAndDrop" :has-column-panel="columnPanel" :has-left-overflow="hasLeftOverflow" :has-first-column-panel-right-aligned="index === computedColumnPanelAlignment" :hovered-over-column-state="hoveredOverColumnState" :is-measuring-column-widths="isMeasuringColumnWidths" :is-only-one-visible-column="isOnlyOneVisibleColumn" :selectable="data?.length > 0 && selectable" :selectable-across-pages="selectableAcrossPagesComputed" :selected-items="selectedItems" :visible-rows-state="visibleRowsState" :selection-state="selectionState" :truncate-column-headers="truncateColumnHeaders" :row-actions-maximum-width="rowActionsMaxWidth" v-on="draggingEventsForColumns" @column-hover="hoveredOverColumn = $event" @column-move="onMoveColumnByOffset" @column-update="onConfigSettingsColumnUpdate" @column-resize="onColumnResized" @column-resizing="onColumnWidthOverride" @select-all-rows="onAllRowSelect" @select-all-across-pages="emit(BentoDatagridEvent.SELECT_ALL_ACROSS_PAGES)" @select-all-current-results="emit(BentoDatagridEvent.SELECT_ALL_CURRENT_RESULTS)" @deselect-all-current-results="emit(BentoDatagridEvent.DESELECT_ALL_CURRENT_RESULTS)" @deselect-all="onDeselectAll" @sort="onSort" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </data-grid-columns> </template> </div> </div> </div> <div ref="body" data-testid="data-grid-body" class="b-data-grid__body" :class="conditionalGridBodyClasses" @scroll="updateScrollPosition" > <div v-if="loading && !isLazyLoad" class="b-data-grid__loading"> <bento-loading-indicator class="b-data-grid__loading-indicator" large /> <bento-typography el="span" variant="caption">{{ t('gettingYourData') }}</bento-typography> </div> <div v-else role="rowgroup" @keydown="navigationInput"> <template v-if="data?.length === 0 && emptyState"> <div role="row" tabindex="-1"> <div role="gridcell" :aria-colspan="columns.length" tabindex="-1"> <bento-empty-state class="b-data-grid__empty-state" v-bind="limitedEmptyStateProps" /> </div> </div> </template> <data-grid-row v-for="(rowData, rowIndex) in data" v-else :key="rowIndex" :ref="el => registerRowCellRefs(el, rowIndex)" :style="gridRowWidthStyle" :allow-row-clicks="allowRowClicks" :column-sets="columnSets" :condensed="condensed" :dragging-columns="draggingColumns" :dragging-events-for-row-cell="draggingEventsForRowCell" :get-data-item-id="getDataItemId" :has-nested-content="hasNestedContent" :has-nested-rows="hasNestedRows" :has-left-overflow="hasLeftOverflow" :has-right-overflow="hasRightOverflow" :highlighted-rows="highlightedRows" :hovered-row-index="hoveredRowIndex" :hovered-over-column-state="hoveredOverColumnState" :is-data-item-disabled="isDataItemDisabled" :is-row-even="internalZebraRows ? rowIndex % 2 !== 0 : false" :is-row-last-child="rowIndex + 1 === data?.length" :is-measuring-column-widths="isMeasuringColumnWidths" :row-actions-maximum-width="rowActionsMaxWidth" :row-actions="rowActions" :row-data="rowData" :row-index="rowIndex" :row-set-size="data?.length" :show-frozen-columns-separator="showFrozenColumnsSeparator" :should-show-nested-content="shouldShowNestedContent" :should-show-nested-rows="shouldShowNestedRows" :selectable="selectable" :selected-items="selectedItems" @cell-hovered="onCellHover" @cell-focus="emit(BentoDatagridEvent.CELL_FOCUS, $event)" @nested-content-toggled="onNestedContentToggled($event)" @row-click="onRowClick" @on-frozen-columns-key-press="updateScrollPositionOnFrozenColumnsNavigation" @select-row="onRowSelect" @select-nested-row="onNestedRowSelect" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </data-grid-row> </div> </div> </div> <data-grid-lazy-load v-if="isLazyLoad" :loading="loading" :is-infinite-scroll="isInfiniteScroll" @update:show-more="emitLazyLoadEvent" /> <div v-if="stickyHorizontalScrollbar" ref="virtualHorizontalScrollbarRef" class="b-data-grid__horizontal-scrollbar" aria-hidden="true" data-testid="sticky-horizontal-scrollbar" :style="horizontalScrollbarConditionalStyles" @scroll="syncVirtualScrollerWithBody" > <div :style="{ width: `${totalWidthOfAllColumns}px`, height: '15px' }"></div> </div> <div v-if="pagination && !isLazyLoad" ref="paginationContainerRef" data-testid="pagination-container" class="b-data-grid__pagination" :class="conditionalPaginationClasses" > <bento-pagination v-bind="pagination" :aria-label="$t('pagination')" :aria-controls="dataGridUid" @navigate="onPaginationNavigate" @items-page="onItemsPerPageChange" > <slot v-if="hasSlot('pagination-slot')" name="pagination-slot"></slot> </bento-pagination> </div> <div v-if="$slots.footer" class="b-data-grid__footer"><slot name="footer"></slot></div> <bento-action-bar v-if="showActionBar" :actions="bulkActions" :item-counter="itemsSelectedCounter" :extra-bottom-spacing="stickyPagination" @close="onDeselectAll" /> </div> </template> <script lang="ts" setup> import { type ComponentPublicInstance, computed, inject, nextTick, type PropType, provide, ref, toRef, toRefs, useSlots, } from 'vue'; // Components import { BentoActionBar } from '@/components/action-bar'; import { BentoPagination } from '@/components/pagination'; import { BentoLoadingIndicator } from '@/components/loading-indicator'; import { BentoTypography } from '@/components/typography'; import { BentoDataGridConfigSettings, DataGridColumns, DataGridLazyLoad, DataGridRow } from './components'; // Composables import { useCalculateColumnWidth, useColumnHovering, useColumnReorderer, useConfigSettings, useKeyboardNavigator, useLazyLoading, useScroller, useSelector, } from './composables'; import { useRowCellRefs } from './composables/use-row-cell-refs/use-row-cell-refs'; import { useCalculateStickyElementHeight, useHasSlot, useTouchDevice } from '@/composables'; // Utils import { generateUid } from '@/core/utils/ts/generate-uid'; import { useI18n } from '@/utils/ts/i18n'; import { printDevelopmentWarning } from '@/utils/ts/print-development-warning'; import { handleClipboard } from '@/components/data-grid/utils/handle-clipboard/handle-clipboard'; // Types import { type BentoColumn, type BentoDatagridData, type BentoDatagridDataItem, type BentoDatagridDataItemId, type BentoDatagridEmptyStateProps, BentoDatagridEvent, type BentoDatagridGetDataItemIdFn, type BentoDatagridIsDataItemDisabledFn, type BentoDatagridPaginationProps, type BentoDatagridShouldShowNestedContentFn, type BentoDatagridShouldShowNestedRowsFn, type BentoDatagridSortByColumn, BentoDatagridStyleState, type BentoDataGridVisibleRowsState, } from './data-grid.types'; import { type BentoTabProvidedTitle } from '../tabs/components/tab.types'; import { type BentoDataGridTemplateDataGrid } from '@/templates/data-grid-template/data-grid-template.types'; import { type BentoLazyLoadType } from '@/types/lazy-load'; import type { BentoButtonActionsList } from '@/components/button'; import BentoFilterBar from '@/components/filter-bar/filter-bar.vue'; import { type BentoFilterBarContainerSizeLayout, type BentoFilterBarModel, type BentoFilterBarSearchBarProps, type BentoFilterBarSearchTerm, type BentoFilterValueObject, type BentoFilterValues, } from '@/components/filter-bar'; import { BentoEmptyState } from '@/components/empty-state'; import type { DatagridColumnsHeaderReferences } from '@/components/data-grid/components/data-grid-columns/data-grid-columns.types'; import type { BentoDataGridRowActionsProp } from '@/components/data-grid/components/data-grid-cell-actions/data-grid-cell-actions.types'; // Keys import { EXTERNAL_TITLE_REGISTER_ENTRY_INJECTION_KEY } from '@/components/tabs/tabs.keys'; import { DATA_GRID_REGISTER_TEMPLATE_INJECTION_KEY } from '@/templates/data-grid-template/data-grid-template.keys'; import { DATA_GRID_SLASHED_ZERO, DATA_GRID_SMALL_CONTAINER, DATA_GRID_TOUCH_DEVICE } from './data-grid.keys'; // Translations import messages from './messages.json'; import { useElementSize } from '@vueuse/core'; type MessageSchema = (typeof messages)['en-US']; const props = defineProps({ /** * When true, will emit a 'rowClick' event when a row is clicked. Will add cursor: pointer to rows. */ allowRowClicks: { type: Boolean, default: false }, /** * When true, column headers will become draggable, allowing users to rearrange columns. */ allowColumnDragAndDrop: { type: Boolean, default: true }, /** * List of actions that will be rendered in the action bar when user selects items. * * Each object in the list should have a `title`, an `event` and, * optionally, an `icon`. * * The component will display 4 actions and the hide the rest inside a menu. * * @see BentoButton for a list of all the other props supported by each button action. */ bulkActions: { type: Array as PropType<BentoButtonActionsList>, default: null, }, /** * Sets the row cells to the condensed padding state. */ condensed: { type: Boolean, default: false, }, /** * Column definitions using 'key' to relate to data object. */ columns: { type: Array as PropType<Array<BentoColumn>>, required: true, }, /** * Enables / hides the display options panel and the column contextual menu functionality. */ columnPanel: { type: Boolean, default: false }, /** * An array of data objects to display. */ data: { type: Array as PropType<BentoDatagridData>, required: true, }, /** * Empty state props that will be passed down to inner empty-state component */ emptyState: { type: Object as PropType<BentoDatagridEmptyStateProps>, default: undefined, }, /** * filter-bar value (i.e. filters) that will be passed down to inner filter-bar component * {@see BentoFilterBarModel} */ filters: { // TODO: Change this to BentoFilterBarProps['value'] when the interface is available type: Array as PropType<BentoFilterBarModel>, default: undefined, }, /** * Defines the layout (multi-line or one-line) to be used in the filter bar for different * container sizes: * - Large (>=700px): Always multi-line * - Medium (>=430px & <700px) * - Small (<430px) * * @default * { * medium: 'multi-line', * small: 'one-line' * } */ filtersContainerSizeLayout: { type: Object as PropType<BentoFilterBarContainerSizeLayout>, default: undefined, }, /** * An array of the values for each filter set in `value`. * {@see BentoFilterValues} */ filterValues: { type: Array as PropType<BentoFilterValues>, default: () => [], }, /** * An object of the values for each filter in the filter bar where key is a the field name and the value is the BentoFilterBarValue. * * @example * { * 'company': '200', * 'name': 'some text' * }, */ filterValuesObject: { type: Object as PropType<BentoFilterValueObject>, default: () => undefined, }, // TODO: Move this prop as a part of "config" BentoFilterBarModel = Array<BentoFilterModel | BentoFilterSearchModel> in Bento 2.0. // It was not done now because it's a breaking change for the types. /** * Filter's search bar properties with which the placeholder, hint and other * "bento-search-bar" properties can be set. @see {@link BentoFilterBarSearchBarProps} */ filterSearchConfig: { type: Object as PropType<BentoFilterBarSearchBarProps>, default: undefined, }, /** * Filter bar search term useful to filter all the data by text */ filterSearchTerm: { type: String as PropType<BentoFilterBarSearchTerm>, default: undefined, }, /** * Allows the grid to be rendered inside a confined space. * This makes the data-grid expand to fill the height of its parent container, with the grid body becoming scrollable. */ fitContent: { type: Boolean, default: false }, /** * A customisable method to retrieve a unique identifier for a data item in the array of data supplied. * This is used selecting and performance reasons and must exist in each data item. */ getDataItemId: { type: Function as PropType<BentoDatagridGetDataItemIdFn>, default: (item: BentoDatagridDataItem) => item.id, }, /** * Row actions propery that generates which button to show in row actions area */ rowActions: { type: [Function, Array] as PropType<BentoDataGridRowActionsProp>, default: undefined, }, /** * If resize is enabled */ hasResizableColumns: { type: Boolean, default: true, }, /** * Enables the nested content slot functionality. */ hasNestedContent: { type: Boolean, default: false, }, /** * Enables the nested content slot functionality. */ hasNestedRows: { type: Boolean, default: false, }, /** * Highlighted items */ highlightedRows: { type: Array as PropType<Array<BentoDatagridDataItemId>>, default: () => [], }, /** * A customisable method to determine if a row is not selectable. * The default is that every row is selectable. */ isDataItemDisabled: { type: Function as PropType<BentoDatagridIsDataItemDisabledFn>, default: () => false, }, /** * The type of `Lazy Load`. * Type "automatic" will enable infinite scrolling * Type "button" will enable lazy loading with "Show more" button * @values automatic, button, none */ lazyLoadType: { type: String as PropType<BentoLazyLoadType>, default: 'none', validator: (value: BentoLazyLoadType) => ['automatic', 'button', 'none'].includes(value), }, /** * If the loading indicator should be shown. */ loading: { type: Boolean, default: false, }, /** * Enables / Disables the table's surrounding border. */ outline: { type: Boolean, default: false, }, /** * Renders the data grid with no top spacing, to render inside containers that already have spacing */ noSpacingTop: { type: Boolean, default: false, }, /** * Pagination props that will be passed down to inner pagination component */ pagination: { type: Object as PropType<BentoDatagridPaginationProps>, default: undefined, }, /** * Adds an extra option when selecting all items that allows user to select items in other pages too. * Needs pagination.totalCount to be set. */ selectableAcrossPages: { type: Boolean, default: false, }, /** * Renders a font numeric style with slashed zeros instead of normal ones. */ slashedZero: { type: Boolean, default: false }, /** * Sticks the horizontal scrollbar to the bottom of the screen if the data-grid is larger than the screen height. */ stickyHorizontalScrollbar: { type: Boolean, default: false, }, /** * Make pagination stick to the bottom of the page when the data-grid is bigger than the screen. * Because styles have to be applied to pagination container element, this has to be a data-grid prop (rather than a pagination prop). */ stickyPagination: { type: Boolean, default: false, }, /** * Make data-grid filters stick to the top of the page along with the data-grid headers. * Defaults to true. */ stickyFilters: { type: Boolean, default: true, }, /** * Enables / Disables rows to be selected via a checkbox. On select the "select" event will be emitted. */ selectable: { type: Boolean, default: false, }, /** * Selected items */ selection: { type: Array as PropType<Array<BentoDatagridDataItemId>>, default: () => [], }, /** * Enables / Disables the nested-content slot */ shouldShowNestedContent: { type: Function as PropType<BentoDatagridShouldShowNestedContentFn>, default: (_item: BentoDatagridDataItem) => true, }, /** * Returns the nested rows for a given BentoDatagridDataItem. */ shouldShowNestedRows: { type: Function as PropType<BentoDatagridShouldShowNestedRowsFn>, default: (_item: BentoDatagridDataItem) => true, }, /** * Sortby handles proper rendering of sort icon in header. Sorting currently is only supported server side. * It does make sure the currently sorted column has a more visible sort icon, and the sort icon points in the right direction. */ sortBy: { type: Array as PropType<Array<BentoDatagridSortByColumn>>, // NOTE: is an array to allow multi-sorting in the future default: () => [], }, /** * Title of the data grid to be rendered as tab title when a data grid is passed in a tab layout. */ tabTitle: { type: String, default: null, }, /** * Truncates all data grid column headers */ truncateColumnHeaders: { type: Boolean, default: false, }, /** * Toggles the alternating background color of rows. */ zebraRows: { type: Boolean, default: false, }, }); const slots = useSlots(); const hasSlot = useHasSlot(slots); /* == Variables and references == */ // Create refs for elements const body = ref<HTMLDivElement>(null); const columnsRef = ref<HTMLDivElement>(); const columnHeaderRefs = ref<DatagridColumnsHeaderReferences>({}); const dataGridUid = ref<string>(generateUid('data-grid')); const gridcellRefs = ref<{ [key: string]: Array<HTMLDivElement> }>(null); const filtersRef = ref<HTMLDivElement>(null); const filterBarRef = ref<InstanceType<typeof BentoFilterBar>>(null); const paginationContainerRef = ref<HTMLDivElement>(null); const verticalScrollPositionRef = ref(null); const virtualHorizontalScrollbarRef = ref<HTMLDivElement>(null); const { allowColumnDragAndDrop, columns, columnPanel, data, lazyLoadType, loading, hasNestedContent, hasNestedRows, selectable, selection, selectableAcrossPages, stickyPagination, outline, pagination, zebraRows, } = toRefs(props); const hoveredRowIndex = ref(-1); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const frozenColumnsLength = computed(() => columnDefs.value.filter(column => column.frozen).length); /* == Responsive behavior == */ // Identifies if the data-grid is in a container smaller than 550px const MOBILE_BREAKPOINT = 550; const dataGridRef = ref(); const { width } = useElementSize(dataGridRef); const isSmallContainer = computed(() => width.value <= MOBILE_BREAKPOINT); provide(DATA_GRID_SMALL_CONTAINER, isSmallContainer); /* == Touch device behavior == */ const { isTouchDevice } = useTouchDevice(); provide(DATA_GRID_TOUCH_DEVICE, isTouchDevice); /* == Data-grid template == */ const registerDatagridInDataGridTemplate: BentoDataGridTemplateDataGrid = inject( DATA_GRID_REGISTER_TEMPLATE_INJECTION_KEY, () => ({ additionalContentSlotRef: ref(null), isDataGridTemplate: false, stickyAditionalContent: ref(false), stickyTabs: ref(false), tabsRef: ref(null), }) ); const { additionalContentSlotRef, isDataGridTemplate, stickyTabs, tabsRef, stickyAdditionalContent } = registerDatagridInDataGridTemplate(); /* == Composable logic == */ // Column width calculation const { columnDefs, onColumnWidthOverride, gridRowWidthStyle, isMeasuringColumnWidths, isTotalWidthOfColumnsTakingUpAllAvailableGridWidth, columns: localColumnsState, totalWidthOfAllColumns, rowActionsMaxWidth, } = useCalculateColumnWidth(columns, data, body, columnHeaderRefs, gridcellRefs, { hasColumnPanel: columnPanel, hasSelectableColumn: selectable, hasSelectableAcrossPages: selectableAcrossPages, hasNestedContent, hasNestedRows, allowColumnDragAndDrop, isDataGridTemplate: isDataGridTemplate, }); const hasRowActions = computed(() => props.rowActions !== undefined); const { registerRowCellRefs, rebuildGridcellRefs } = useRowCellRefs( gridcellRefs, rowActionsMaxWidth, hasRowActions ); /* == Emitting logic == */ const definedEmitsFunction = defineEmits([ BentoDatagridEvent.COLUMNS, BentoDatagridEvent.COLUMN_RESIZE, BentoDatagridEvent.ITEMS_PAGE, BentoDatagridEvent.FILTER_SEARCH_INPUT, BentoDatagridEvent.FILTER_SEARCH_CLEAR, BentoDatagridEvent.NAVIGATE, BentoDatagridEvent.NESTED_CONTENT_TOGGLED, BentoDatagridEvent.UPDATE_COLUMNS, BentoDatagridEvent.UPDATE_CONDENSED, BentoDatagridEvent.UPDATE_FILTER_VALUES, BentoDatagridEvent.UPDATE_FILTER_VALUES_OBJECT, BentoDatagridEvent.UPDATE_FILTERS, BentoDatagridEvent.UPDATE_FILTER_SEARCH_TERM, BentoDatagridEvent.UPDATE_SHOW_MORE, BentoDatagridEvent.UPDATE_PAGINATION, BentoDatagridEvent.UPDATE_SELECTION, BentoDatagridEvent.UPDATE_SORT_BY, BentoDatagridEvent.ROW_CLICK, BentoDatagridEvent.SELECT, BentoDatagridEvent.SELECT_ALL_ACROSS_PAGES, BentoDatagridEvent.SELECT_ALL_CURRENT_RESULTS, BentoDatagridEvent.DESELECT_ALL_CURRENT_RESULTS, BentoDatagridEvent.SORT, BentoDatagridEvent.CELL_FOCUS, ]); /** * Hooks into the emit function and updates the local columns state if we're updating columns. * This is so if users use a computed ref with the columns property the ordering / toggling visibility will still function correctly. */ const emit: typeof definedEmitsFunction = (event, ...args) => { // If we updating columns update the local columns state if (event === BentoDatagridEvent.COLUMNS || event === BentoDatagridEvent.UPDATE_COLUMNS) { [localColumnsState.value] = args; } definedEmitsFunction(event, ...args); }; // Calculate dynamic top position of sticky filters const stickyFiltersHeight = useCalculateStickyElementHeight( tabsRef, stickyTabs, stickyAdditionalContent, additionalContentSlotRef ); const computedStickyFiltersTopPosition = computed(() => props.stickyFilters ? `${stickyFiltersHeight.value}px` : null ); // Calculate dynamic top position of column headers const columnHeadersHeight = useCalculateStickyElementHeight(filtersRef, toRef(props, 'stickyFilters')); const computedColumnHeadersTopPosition = computed( () => `${columnHeadersHeight.value + stickyFiltersHeight.value}px` ); // Config Settings (condensed, visbility and freezing settings) const { sortedColumns, columnSets, isOnlyOneVisibleColumn, internalZebraRows, onConfigSettingsColumnsUpdate, onConfigSettingsColumnUpdate, onConfigSettingsCondensedUpdate, onConfigSettingsZebraRowsUpdate, } = useConfigSettings(columnDefs, zebraRows, emit); // Column hovering const { hoveredOverColumn, hoveredOverColumnState } = useColumnHovering( columnSets, selectable, outline, isTotalWidthOfColumnsTakingUpAllAvailableGridWidth ); // Re-ordering column (dragging) const { draggingColumns, draggingEventsForColumns, draggingEventsForRowCell, onMoveColumnByOffset } = useColumnReorderer(sortedColumns, allowColumnDragAndDrop, emit); // Selecting const selectableAcrossPagesComputed = computed( () => props.data?.length > 0 && !!props.selectableAcrossPages && !!props.pagination && props.pagination.totalCount > 0 ); const { onAllRowSelect, onRowSelect, onNestedRowSelect, onDeselectAll, allItemsSelectedState, selectedItems, selectionState, } = useSelector( selection, emit, data, props.getDataItemId, props.isDataItemDisabled, selectableAcrossPagesComputed, pagination ); const visibleRowsState = computed<BentoDataGridVisibleRowsState>(() => ({ filtered: filterBarRef.value?.isClearAllVisible || filterBarRef.value?.isResetAllVisible || !!props.filterSearchTerm, allRowsVisible: pagination.value?.size >= pagination.value?.totalCount, })); // Keyboard navigation const { navigationInput } = useKeyboardNavigator( gridcellRefs, columnHeaderRefs, sortedColumns, selectable, hasRowActions, columnPanel ); // Lazy loading const { isLazyLoad, isInfiniteScroll, verticalScrollPosition, emitLazyLoadEvent } = useLazyLoading( lazyLoadType, verticalScrollPositionRef, body, loading, emit ); // Scrolling const { updateScrollPosition, updateScrollPositionOnFrozenColumnsNavigation, syncVirtualScrollerWithBody, hasLeftOverflow, hasRightOverflow, horizontalScrollbarConditionalStyles, } = useScroller( body, columnsRef, verticalScrollPosition, virtualHorizontalScrollbarRef, paginationContainerRef, totalWidthOfAllColumns, { isLazyLoad, pagination, stickyPagination, isDataGridTemplate, } ); /* == Empty State == */ const onEmptyStateClearResetButtonClick = () => { if (filterBarRef.value.isClearAllVisible) { filterBarRef.value.clearAllFilters(); } else if (filterBarRef.value.isResetAllVisible) { filterBarRef.value.resetAllFilters(); } }; const limitedEmptyStateProps = computed<BentoDatagridEmptyStateProps>(() => ({ ...props.emptyState, variant: props.emptyState?.variant === 'embedded' || props.emptyState?.variant === 'basic' ? props.emptyState.variant : 'basic', })); /* == Styles == */ // Conditional grid classes const conditionalGridClasses = computed(() => ({ [`b-data-grid--${BentoDatagridStyleState.OUTLINE}`]: props.outline, [`b-data-grid--${BentoDatagridStyleState.SCROLL}`]: props.fitContent || isLazyLoad.value, [`b-data-grid--${BentoDatagridStyleState.STICKY_FILTERS}`]: props.filters && props.stickyFilters, [`b-data-grid--${BentoDatagridStyleState.OVERFLOW_SHADOW_LEFT}`]: hasLeftOverflow.value && !frozenColumnsLength.value, [`b-data-grid--${BentoDatagridStyleState.OVERFLOW_SHADOW_RIGHT}`]: hasRightOverflow.value && props.rowActions === undefined, [`b-data-grid--${BentoDatagridStyleState.NO_SPACING_TOP}`]: props.noSpacingTop, [`b-data-grid--${BentoDatagridStyleState.FIT_CONTENT}`]: props.fitContent, [`b-data-grid--${BentoDatagridStyleState.ZEBRA_ROWS}`]: internalZebraRows.value, 'b-data-grid--lazy-load': isLazyLoad.value, })); // Conditional grid body classes const conditionalGridBodyClasses = computed(() => ({ 'b-data-grid__body--sticky-horizontal-scrollbar': props.stickyHorizontalScrollbar, })); const computedColumnPanelAlignment = computed(() => columnSets.value.findIndex(el => el.columnSet.length)); const showFrozenColumnsSeparator = computed(() => { const isSelectionOnlyFrozenColumn = (props.selectable || props.hasNestedContent || props.hasNestedRows) && frozenColumnsLength.value === 1; return !isSelectionOnlyFrozenColumn || hasLeftOverflow.value; }); // Conditional filter classes const conditionalFilterClasses = computed(() => ({ 'b-data-grid__filters--sticky': !!props.stickyFilters || isDataGridTemplate, })); // Conditional pagination classes const conditionalPaginationClasses = computed(() => ({ 'b-data-grid__pagination--padding-left-unset': props.columns?.[0]?.padding?.left === false, 'b-data-grid__pagination--padding-right-unset': props.columns?.[props.columns.length - 1]?.padding?.right === false, 'b-data-grid__pagination--sticky': !!props.stickyPagination || isDataGridTemplate, })); /* == Ref registration methods == */ const registerColumnHeaderRefs = (el: Element | ComponentPublicInstance) => { // We always know this will be used on a DataGridColumns component so we can cast it const datagridColumnsComponent = el as InstanceType<typeof DataGridColumns>; if (el !== null && datagridColumnsComponent?.columnHeaderRefs) { Object.entries(datagridColumnsComponent?.columnHeaderRefs).forEach(([fieldName, colHeaderEl]) => { columnHeaderRefs.value[fieldName] = colHeaderEl as HTMLDivElement; }); } }; /* == Row methods == */ // Row selecting const onRowClick = (row: BentoDatagridDataItem, event: MouseEvent) => { if (props.allowRowClicks) { emit(BentoDatagridEvent.ROW_CLICK, row, event); } }; const onNestedContentToggled = (rowData: BentoDatagridDataItem) => { emit(BentoDatagridEvent.NESTED_CONTENT_TOGGLED, rowData); nextTick(() => rebuildGridcellRefs()); }; // Programmatically set the hover state on a row const onCellHover = (rowIndex: number) => { hoveredRowIndex.value = rowIndex; }; /* == a11y == */ const ariaGridRole = computed(() => (props.hasNestedContent || props.hasNestedRows ? 'treegrid' : 'grid')); /* == Action bar == */ const itemsSelectedCounter = computed(() => selectedItems.value?.length ?? 0); const showActionBar = computed(() => props.bulkActions && itemsSelectedCounter.value > 0); /* == Emits & Events == */ // Resizing const onColumnResized = (resizeEvent: { field: string; width: number }) => emit(BentoDatagridEvent.COLUMN_RESIZE, resizeEvent); // Sorting const onSort = (updatedSortBy: Array<BentoDatagridSortByColumn>) => { emit(BentoDatagridEvent.UPDATE_SORT_BY, updatedSortBy); emit(BentoDatagridEvent.SORT, updatedSortBy); }; // Pagination const onPaginationNavigate = (page: number, size: number) => { emit(BentoDatagridEvent.NAVIGATE, page); emit(BentoDatagridEvent.UPDATE_PAGINATION, { ...props.pagination, size, page, }); }; const onItemsPerPageChange = (itemsPerPage: number) => { emit(BentoDatagridEvent.ITEMS_PAGE, itemsPerPage); }; // Filter bar const onSearchTermCleared = () => { emit(BentoDatagridEvent.FILTER_SEARCH_CLEAR); }; const onSearchTermInput = newSearchTerm => { emit(BentoDatagridEvent.UPDATE_FILTER_SEARCH_TERM, newSearchTerm); emit(BentoDatagridEvent.FILTER_SEARCH_INPUT, newSearchTerm); }; /* == Warnings == */ const hasMandatoryColumn = computed(() => columns.value.some(column => column.mandatory)); if (!hasMandatoryColumn.value) { printDevelopmentWarning( 'WARNING: At least one BentoDataGrid column should be set as mandatory to ensure data is always displayed correctly.' ); } /* == Provide/inject == */ const setTitle: BentoTabProvidedTitle = inject(EXTERNAL_TITLE_REGISTER_ENTRY_INJECTION_KEY, () => null); setTitle(props.tabTitle); provide(DATA_GRID_SLASHED_ZERO, toRef(props, 'slashedZero')); /* == Expose methods == */ defineExpose({ onEmptyStateClearResetButtonClick }); </script> <script lang="ts"> /** * A component to display data in columns and row format with a whole array of configuration for visualization and interaction. * * @example * import { BentoDataGrid } from '@adyen/bento-vue2'; * * export default { * components: { BentoDataGrid }, * template: ` * <bento-data-grid * :data="[ * { id: '123', name: 'John', company: 'TestCompany', 'payment': 200 } * { id: '321', name: 'Jane', company: 'Adyen', 'payment': 300 } * ]" * :columns="[ * { field: 'name', label: 'Name', minWidth: 300, mandatory: true }, * { field: 'company', label: 'Reference' }, * { field: 'payment', label: 'Payment', width: 200, minWidth: 150 }, * ]" * /> * ` * } */ export default { i18n: { messages }, name: 'bento-data-grid', }; </script> <style lang="scss" scoped src="./data-grid.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> <dropdown-input-default ref="inputContainerRef" always-combobox-is-input :ariaControls="datePickerContainerId" :ariaExpanded="isDatePickerOpen" :aria-labelledby="label ? labelId : null" :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" :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" :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 :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" @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'; // Utils import { debounce } from '@/utils/ts/debounce'; import { generateUid } from '@/core/utils/ts'; import { useI18n } from '@/utils/ts/i18n'; 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 } 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 }); 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 isDateIncorrect = ref(false); 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 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, isDateIncorrect, isMonthVariant, placeholder: toRef(props, 'placeholder'), }); const dateErrorText = computed(() => { if ( props.isDateDisabled && textInputValue.value && isFormatValid(textInputValue.value) && props.isDateDisabled(parseDate(textInputValue.value)) && !isDatePickerOpen.value ) { return t('selectedDateIsNotAvailable'); } return isDateIncorrect.value ? t('provideDateInAFormat') : null; }); 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 isDateEnabled = (date: Date) => { // Check if date is between min/max bounds, if there are any if ((props.min && date <= props.min) || (props.max && date >= props.max)) { return false; } // Check if function is set, otherwise all dates are enabled if (props.isDateDisabled) { return !props.isDateDisabled(date); } return true; }; 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 isDateIncorrect.value = false; closeDatePicker(); emitValue(selectedDate); }; const clearDatePickerValue = async () => { if (isDateIncorrect.value) { isDateIncorrect.value = false; 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 isValidDate = isFormatValid(stringDate) && isDateEnabled(parseDate(stringDate)); if (isValidDate) { isDateIncorrect.value = false; emitValue(parseDate(stringDate)); } else if (stringDate) { // Only set to true if the value exists (not null/undefined) isDateIncorrect.value = true; emitValue(null); } }; const validateDateDebounced = debounce(validateDate, DEBOUNCE_TIME); const onDateInputChange = (stringDate: string) => { 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. if (isDatePickerOpen.value) { validateDateDebounced(textInputValue.value); } else { validateDate(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" :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 :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" @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, parseNativeMonthString } 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 }); 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 isDateIncorrect = ref(false); 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, isDateIncorrect, 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) { emitValue(null); return; } if (isMonthVariant.value) { emitValue(parseNativeMonthString(value)); return; } const date = parseDate(value); if (date) { emitValue(date); } }; const dateErrorText = computed(() => { if ( props.isDateDisabled && textInputValue.value && isFormatValid(textInputValue.value) && props.isDateDisabled(parseDate(textInputValue.value)) && !isDatePickerOpen.value ) { return t('selectedDateIsNotAvailable'); } return isDateIncorrect.value ? t('provideDateInAFormat') : null; }); 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 formattedMin = computed(() => formatNativeDate(props.min)); const formattedMax = computed(() => formatNativeDate(props.max)); const isDateEnabled = (date: Date) => { // Check if date is between min/max bounds, if there are any if ((props.min && date <= props.min) || (props.max && date >= props.max)) { return false; } // Check if function is set, otherwise all dates are enabled if (props.isDateDisabled) { return !props.isDateDisabled(date); } return true; }; 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 isDateIncorrect.value = false; closeDatePicker(); emitValue(selectedDate); }; const clearDatePickerValue = async () => { if (isDateIncorrect.value) { isDateIncorrect.value = false; 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 isValidDate = isFormatValid(stringDate) && isDateEnabled(parseDate(stringDate)); if (isValidDate) { isDateIncorrect.value = false; emitValue(parseDate(stringDate)); } else if (stringDate) { // Only set to true if the value exists (not null/undefined) isDateIncorrect.value = true; emitValue(null); } }; const validateDateDebounced = debounce(validateDate, DEBOUNCE_TIME); const onDateInputChange = (stringDate: string) => { 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" />
|