@adyen/bento-mcp 0.2.1 → 0.3.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 CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 (2026-04-08)
4
+
5
+ ### Features
6
+
7
+ - amended writing bento comps skill to batch `get_component` mcp tool calls when needed ([5b9100929](https://github.com/Adyen/bento/commit/5b9100929))
8
+
9
+ ### ❤️ Thank You
10
+
11
+ - gerald
12
+
13
+
3
14
  ## 0.2.1 (2026-04-01)
4
15
 
5
16
  This was a version bump only for mcp to align it with other projects, there were no code changes.
package/SKILL.md CHANGED
@@ -35,6 +35,21 @@ Once the component name is known:
35
35
  - Execute `get_component(['component-name'])`.
36
36
  - **If tools are missing:** Provide the user with the setup instructions in Section 2.
37
37
 
38
+ #### ⚠️ CRITICAL: Truncation & Completeness Rules
39
+
40
+ 1. **One-at-a-Time Rule:** If requesting 3+ components, call `get_component` for **each component individually**. Batch
41
+ requests for many components often exceed output limits and result in truncated/incomplete documentation.
42
+
43
+ 2. **Truncation = Failure:** If `get_component` output is truncated or shows "Output too large", treat it as a **failed
44
+ fetch**. You MUST re-run the tool for each missing component individually.
45
+
46
+ 3. **Completeness Gate:** You are **STRICTLY PROHIBITED** from writing any Bento component code until you have
47
+ successfully fetched and read the full documentation (`.docs.mdx`, `.stories.ts`, `.vue`, `.types.ts`) for **EVERY**
48
+ Bento component you intend to use in that file.
49
+
50
+ 4. **Pre-Write Checklist:** Before any `write_file` or code edit, mentally verify: _"Have I called `get_component` for
51
+ every Bento component in this file?"_ If the answer is no, **STOP** and fetch the missing components.
52
+
38
53
  ### Step 3: Constraints First (Read `.docs.mdx`)
39
54
 
40
55
  - **DO NOT SKIM.** Look specifically for **"Do not use"** and **"Guidelines"** sections.
@@ -48,7 +63,7 @@ Once the component name is known:
48
63
 
49
64
  ---
50
65
 
51
- ## 🚫 ANTI-PATTERNS (Forbidden Actions)
66
+ ## 🚫 2. ANTI-PATTERNS (Forbidden Actions)
52
67
 
53
68
  To prevent token waste and errors, the following actions are **STRICTLY FORBIDDEN**:
54
69
 
@@ -56,10 +71,41 @@ To prevent token waste and errors, the following actions are **STRICTLY FORBIDDE
56
71
  - ❌ **Type Inspection:** Trying to read `.d.ts` or `.js` files inside `node_modules`.
57
72
  - ❌ **Icon Guessing:** Searching the filesystem for icons instead of checking `.stories.ts`.
58
73
  - ❌ **Grep-Discovery:** Running `grep` on `node_modules` to find component exports.
74
+ - ❌ **Partial Implementation:** Writing code that uses Bento components you haven't fetched via `get_component`.
75
+ - ❌ **Scavenging Truncated Output:** Using partial information from a truncated `get_component` response instead of
76
+ re-fetching individually.
77
+
78
+ ---
79
+
80
+ ## 🎨 3. Figma MCP Integration (Name Authority)
81
+
82
+ When using Figma MCP alongside Bento MCP:
83
+
84
+ ### ⚠️ Component Name Sovereignty
85
+
86
+ Design-to-code tools (like Figma MCP) often provide **legacy or internal names** that do not match the public API:
87
+
88
+ | Figma MCP Output | Correct Bento Export |
89
+ | :--------------- | :------------------- |
90
+ | `BHeader` | `BentoHeader` |
91
+ | `BModal` | `BentoModal` |
92
+ | `BButton` | `BentoButton` |
93
+ | `BInput` | `BentoInputField` |
94
+
95
+ **Rule:** You MUST **ignore** component names from Figma MCP and use **ONLY** the names discovered via `get_component`
96
+ or `get_all_components`. The Bento MCP is the **sole authority** on component names and exports.
97
+
98
+ ### Figma + Bento Workflow
99
+
100
+ 1. **Figma MCP** identifies visual components in the design (e.g., header, modal, button).
101
+ 2. **Bento MCP** `get_all_components()` to find the correct Bento component names.
102
+ 3. **Bento MCP** `get_component(['component-name'])` for each component individually (following the **One-at-a-Time
103
+ Rule**).
104
+ 4. **Implementation:** Use the names, props, and patterns from Bento MCP, not Figma MCP.
59
105
 
60
106
  ---
61
107
 
62
- ## ⚙️ 2. Bento MCP Setup
108
+ ## ⚙️ 4. Bento MCP Setup
63
109
 
64
110
  If the Bento MCP tools are not available in your environment, add the following configuration to your client:
65
111
 
@@ -82,10 +128,11 @@ If the Bento MCP tools are not available in your environment, add the following
82
128
  - **Gemini:** `.gemini/settings.json` (Local) or `~/.gemini/settings.json` (Global)
83
129
  - **Windsurf:** `~/.codeium/windsurf/mcp_config.json`
84
130
  - **Claude Desktop:** `~/Library/Application Support/Claude/claude_desktop_config.json`
131
+ - **Factory Droid:** Run `droid mcp add bento -- npx @adyen/bento-mcp@latest`
85
132
 
86
133
  ---
87
134
 
88
- ## 📚 3. Immutable Documentation (Tailwind & Tokens)
135
+ ## 📚 5. Immutable Documentation (Tailwind & Tokens)
89
136
 
90
137
  The following rules are NOT available via the MCP and must be followed strictly:
91
138
 
@@ -112,7 +159,7 @@ The `global.css` MUST follow this exact `@layer` order:
112
159
 
113
160
  ---
114
161
 
115
- ## 🎯 4. Tool Prioritization
162
+ ## 🎯 6. Tool Prioritization
116
163
 
117
164
  1. **Primary Tool:** **Bento MCP** (`get_all_components`, `get_component`).
118
165
  2. **Verification Tool:** **Production Code** (e.g., `wrapper.ts`).
@@ -120,7 +167,7 @@ The `global.css` MUST follow this exact `@layer` order:
120
167
 
121
168
  ---
122
169
 
123
- ## 🩺 5. Bento Doctor (Troubleshooting)
170
+ ## 🩺 7. Bento Doctor (Troubleshooting)
124
171
 
125
172
  | Issue | Resolution |
126
173
  | :---------------------- | :------------------------------------------------------------------------------------------- |
@@ -130,9 +177,12 @@ The `global.css` MUST follow this exact `@layer` order:
130
177
 
131
178
  ---
132
179
 
133
- ## ✅ 6. Final Review Checklist
180
+ ## ✅ 8. Final Review Checklist
134
181
 
135
182
  - [ ] **Discovery Check:** Did I use `get_all_components()` instead of `ls node_modules`?
183
+ - [ ] **Completeness Check:** Did I call `get_component` for **EVERY** Bento component I'm using?
184
+ - [ ] **Truncation Check:** Was any `get_component` output truncated? If yes, did I re-fetch individually?
185
+ - [ ] **Name Authority Check:** Am I using component names from Bento MCP (not Figma MCP)?
136
186
  - [ ] **Constraint Check:** Did I verify the "Do not use" section in the `.docs.mdx`?
137
187
  - [ ] **Literal Check:** Are all props using **string literals**?
138
188
  - [ ] **Layer Check:** Is my `global.css` using the correct Tailwind 4 `@layer` order?
@@ -1 +1 @@
1
- <template> <div :key="`cell-${column.field}`" ref="gridcellRef" :aria-hidden="ariaHidden" :data-testid="`cell-${column.field}`" :data-cellindex="getCellIndex(rowIndex, colIndex, columnSetIndex)" class="b-data-grid-cell" :class="conditionalRowCellClasses(column)" role="gridcell" :tabindex="tabIndex" :aria-colindex="getColIndex(colIndex, columnSetIndex) + 1" :style="{ width: column.autoWidth && isMeasuringColumnWidths ? 'auto' : `${column.width}px`, }" v-on="getGridcellEvents(column)" > <template v-if="column.field === DATAGRID_ACTIONS_COLUMN_FIELD_NAME"> <template v-if="selectable"> <bento-checkbox class="b-data-grid-cell__select-cell" :aria-label="t('selectRow')" :disabled="isDataItemDisabled(rowData)" :indeterminate="selectionIsIndeterminate" :model-value="selectedItems" :value="getDataItemId(rowData)" :tabindex="isFocused ? 0 : -1" @update:model-value="$emit('select-row', $event)" /> </template> <template v-if="nestedContent"> <bento-toggle-button ref="menuButtonRef" aria-haspopup="true" :aria-controls="controlledNestedRowElementIds" condensed variant="tertiary" :toggled="isNestedContentExpanded" @click="toggleNestedContent" > <template #iconLeft> <chevron-up-icon v-if="isNestedContentExpanded" class="b-accordion-item__icon" :svg-title="t('collapseNestedContent')" /> <chevron-down-icon v-else class="b-accordion-item__icon" :svg-title="t('expandNestedContent')" /> </template> </bento-toggle-button> </template> </template> <template v-else> <bento-typography el="div" class="b-data-grid-cell__cell-content" :class="computedCellContentClasses" data-testid="cell-content" > <slot :item="rowData" :row-index="rowIndex" :name="`item-${column.field}`"> <span :title="conditionalTitleAttribute(column, rowData[column.field])">{{ getRowData(rowData[column.field]) }}</span> </slot> </bento-typography> <div v-if="column.cellActions?.length > 0" class="b-data-grid-cell__cell-actions"> <data-grid-cell-actions v-if="(shouldShowCellActions || isTouchDevice) && (hasRowData || hasSlot(`item-${column.field}`))" ref="cellActionsRef" :cell-actions="column.cellActions" :column-key="column.field" :row-data-item="rowData" :has-zebra-background="hasZebraBackground" :is-highlighted="isHighlighted" :is-selected="isSelected" teleport-menu @menu-item-click="onMenuClose" /> </div> </template> </div> </template> <script setup lang="ts"> import { computed, type ComputedRef, inject, nextTick, onMounted, type PropType, provide, type Ref, ref, type UnwrapRef, useSlots, watch, } from 'vue'; import { focusOnInteractiveChild, makeAllInteractiveChildrenFocusable, makeAllInteractiveChildrenUnFocusable, } from '../../utils'; import { DATAGRID_ACTIONS_COLUMN_FIELD_NAME } from '../../composables/use-calculate-column-width'; import { BentoCheckbox } from '@/components/checkbox'; import { BentoTypography } from '@/components/typography'; import { BentoToggleButton } from '@/components/button'; import DataGridCellActions from '../data-grid-cell-actions/data-grid-cell-actions.vue'; import { DATA_GRID_SLASHED_ZERO, DATA_GRID_SMALL_CONTAINER, DATA_GRID_TOUCH_DEVICE } from '../../data-grid.keys'; import ChevronDownIcon from '@adyen/ui-assets-icons-16/vue/chevron-down'; import ChevronUpIcon from '@adyen/ui-assets-icons-16/vue/chevron-up'; import { DATA_GRID_CELL_MENU_REGISTERING_INJECTION_KEY } from '@/components/data-grid/components/data-grid-cell/data-grid-cell.keys'; // Types and enums import { type BentoColumn, BentoColumnOverflow, type BentoDatagridDataItem, type BentoDatagridDataItemId, type BentoDatagridGetDataItemIdFn, type BentoDatagridIsDataItemDisabledFn, } from '@/components/data-grid/data-grid.types'; import { type DataGridCellColumnSet, type DataGridCellDragEventsForColumn, type DataGridCellEvents, type DataGridCellEventsForColumn, DataGridCellPropClass, } from './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/use-column-hovering.types'; // Utils import { useHasSlot } from '@/composables'; import { useI18n } from '@/utils/ts/i18n'; import { isFocusVisible } from '@/utils/ts/focus-visible'; import { EventKey } from '@/types/utils-events'; // Translations import messages from './messages.json'; const slots = useSlots(); const hasSlot = useHasSlot(slots); type MessageSchema = (typeof messages)['en-US']; const { t } = useI18n<{ message: MessageSchema }>({ messages }); const props = defineProps({ draggingEventsForRowCell: { type: Function as PropType<DataGridCellDragEventsForColumn>, required: true, }, /** * The columns sets including */ columnSets: { type: Array as PropType<Array<DataGridCellColumnSet>>, required: true, }, /** * The columns set we are iterating over */ column: { type: Object as PropType<BentoColumn>, required: true, }, colIndex: { type: Number, required: true, }, columnSetIndex: { type: Number, 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, }, /** * 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 cell 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, }, /** * 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, }, isMeasuringColumnWidths: { type: Boolean, default: false, }, /** * Determines if the nested content is visible. */ isNestedContentExpanded: { type: Boolean, default: false, }, /** * Determines if the nest content slot is enabled for this data row. */ nestedContent: { type: Boolean, default: false, }, /** * The IDs of the rows this cell controls to show / hide them. */ nestedRowsElementIds: { type: Array as PropType<Array<string>>, default: () => [], }, /** * The row data object */ rowData: { type: Object as PropType<BentoDatagridDataItem>, required: true, }, rowIndex: { type: Number, required: true, }, /** * The row selection checkbox indeterminate state. */ selectionIsIndeterminate: { type: Boolean, default: false, }, /** * Determines if the row selection is enabled. */ selectable: { type: Boolean, default: false, }, selectedItems: { type: Array as PropType<Array<BentoDatagridDataItemId>>, required: true, }, }); const emit = defineEmits<{ /** * 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>); /** * Emitted when a row has been selected. */ (e: 'register-row-cell-element', el: Element, field: string, rowIndex: number); /** * 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 nested content is being toggled. */ (e: 'nested-content-toggled'); /** * Emitted when a cell gains focus. */ ( e: 'cell-focus', value: { rowData: BentoDatagridDataItem; column: BentoColumn; rowIndex: number; colIndex: number } ); }>(); // References const gridcellRef = ref<HTMLDivElement | null>(null); const cellActionsRef = ref<InstanceType<typeof DataGridCellActions>>(null); const shouldShowCellActions = ref(false); const childrenMenuContainerElements = ref<Array<Ref<UnwrapRef<HTMLDivElement>>>>([]); const isFocused = ref(false); const focusableItems = ref<Array<{ node: HTMLElement }>>(); const hasEverBeenFocused = ref(false); // Computed const hasRowData = computed(() => !!props.rowData?.[props.column.field]); const controlledNestedRowElementIds = computed(() => props.column.field === DATAGRID_ACTIONS_COLUMN_FIELD_NAME && props.nestedRowsElementIds?.length > 0 ? props.nestedRowsElementIds.join(' ') : undefined ); const ariaHidden = computed( () => props.column.field === DATAGRID_ACTIONS_COLUMN_FIELD_NAME && !props.selectable && !props.nestedContent ); const tabIndex = computed(() => { if (!ariaHidden.value) { // The first cell of the grid should be tabbable on initial render to allow keyboard navigation into the grid. if (getCellIndex(props.rowIndex, props.colIndex, props.columnSetIndex) === 0 && !hasEverBeenFocused.value) { return '0'; } // A cell becomes part of the tab order only when it is focused. if (isFocused.value) { return '0'; } } // Action cells are not directly tabbable; they are accessed via cell navigation. if (props.column.field === DATAGRID_ACTIONS_COLUMN_FIELD_NAME) { return '-1'; } // By default, cells are not in the tab order. return '-1'; }); // Row data retrieval methods const conditionalTitleAttribute = (column: BentoColumn, content: unknown) => column?.overflow === BentoColumnOverflow.ELLIPSIS ? `${content}` : undefined; const getRowData = (rowData: unknown) => rowData ?? '-'; // Cell and Columns index calculation const sumColumnAmountsInColumnSets = (limitIndex?: number) => props.columnSets.slice(0, limitIndex).reduce((total, { columnSet }) => total + columnSet.length, 0); // Sums the amount of columns on every previous column set to find // the absolute column index (useful for frozen columns use case) const getColIndex = (colIndex: number, colSetIndex: number) => colIndex + sumColumnAmountsInColumnSets(colSetIndex); const getCellIndex = (rowIndex: number, colIndex: number, colSetIndex: number) => sumColumnAmountsInColumnSets() * rowIndex + getColIndex(colIndex, colSetIndex); /** * We check here if we are leaving the cell but mouse into a child menu * If this is the case we don't want to hide the cell actions. * @param relatedTarget */ const isLeavingCellFocus = (relatedTarget: EventTarget) => { if ( relatedTarget === null || (!gridcellRef.value.contains(relatedTarget as Node) && !childrenMenuContainerElements.value ?.map(registeredMenuRef => registeredMenuRef.value) .some(menuElement => menuElement === relatedTarget || menuElement?.contains(relatedTarget as Node))) ) { shouldShowCellActions.value = false; } }; /** * Hides the cell actions when a menu item is clicked. This is necessary as relying solely on * focus-out events is not reliable, especially when the menu is teleported or an action * causes focus to leave the window. */ const onMenuClose = () => { shouldShowCellActions.value = false; }; // Events const hasInteractiveElFocusedInside = ref(false); const getGridcellEvents: DataGridCellEventsForColumn = (column: BentoColumn) => { const defaultEvents: DataGridCellEvents = { ...props.draggingEventsForRowCell(column), // Focus on the first interactive child in the grid cell if one exists focus: async () => { if (!isFocusVisible()) { return; } isFocused.value = true; hasEverBeenFocused.value = true; emit('cell-focus', { rowData: props.rowData, column: props.column, rowIndex: props.rowIndex, colIndex: getColIndex(props.colIndex, props.columnSetIndex), }); makeAllInteractiveChildrenFocusable(focusableItems.value); await nextTick(); const hasFocusedOnInteractiveChild = focusOnInteractiveChild(gridcellRef.value); if (hasFocusedOnInteractiveChild) { hasInteractiveElFocusedInside.value = true; } }, focusout: () => { if (!isFocusVisible()) { return; } hasInteractiveElFocusedInside.value = false; }, keydown: (event: KeyboardEvent) => { if (event.key !== EventKey.TAB) { hasInteractiveElFocusedInside.value = false; isFocused.value = false; makeAllInteractiveChildrenUnFocusable(gridcellRef.value); } }, }; const gridCellEvents: DataGridCellEvents = { ...defaultEvents }; if (column.frozen) { gridCellEvents.keyup = (event: Event) => emit('on-frozen-columns-key-press', event); gridCellEvents.keydown = (event: KeyboardEvent) => { defaultEvents.keydown(event); emit('on-frozen-columns-key-press', event); }; } if (column.cellActions) { gridCellEvents.mouseenter = () => { shouldShowCellActions.value = true; }; gridCellEvents.mouseleave = (e: MouseEvent) => { isLeavingCellFocus(e?.relatedTarget); }; gridCellEvents.focus = async (e: FocusEvent) => { defaultEvents.focus(e); await nextTick(); shouldShowCellActions.value = true; }; gridCellEvents.focusout = (e: FocusEvent) => { defaultEvents.focusout(e); isLeavingCellFocus(e?.relatedTarget); }; } return gridCellEvents; }; onMounted(() => { focusableItems.value = makeAllInteractiveChildrenUnFocusable(gridcellRef.value); }); const toggleNestedContent = () => { emit('nested-content-toggled'); }; // Each cell knows it's on hover state but this state needs to be emitted up // in order for the DataGrid to know which row to set the hovered state on. watch( () => shouldShowCellActions.value, () => { if (shouldShowCellActions.value === true) { emit('cell-hovered', props.rowIndex); } else { emit('cell-hovered', -1); } } ); const registerMenuContainerElement = (menuContainerElement: Ref<UnwrapRef<HTMLDivElement>>) => { childrenMenuContainerElements.value.push(menuContainerElement); }; const menuMouseLeave = (e: MouseEvent) => { if (!gridcellRef.value.contains(e?.relatedTarget as Node)) { shouldShowCellActions.value = false; } }; provide<{ registerMenuContainerElement: (menuContainerElement: Ref<UnwrapRef<HTMLDivElement>>) => void; menuMouseLeave: (e: MouseEvent) => void; }>(DATA_GRID_CELL_MENU_REGISTERING_INJECTION_KEY, { registerMenuContainerElement, menuMouseLeave, }); const hasSlashedZero = inject(DATA_GRID_SLASHED_ZERO, ref(false)); const isSmallContainer: ComputedRef<boolean> = inject( DATA_GRID_SMALL_CONTAINER, computed(() => false) ); const isTouchDevice: Ref<boolean> = inject(DATA_GRID_TOUCH_DEVICE, ref(false)); // Styling const conditionalRowCellClasses = (column: BentoColumn) => ({ [`b-data-grid-cell--${DataGridCellPropClass.COLUMN_HOVERED}`]: column?.field === props.hoveredOverColumnState.hoveredOverColumnField, [`b-data-grid-cell--${DataGridCellPropClass.COLUMN_HOVERED_NO_BORDER_LEFT}`]: column?.field === props.hoveredOverColumnState.hoveredOverColumnField && props.hoveredOverColumnState.noBorderLeft, [`b-data-grid-cell--${DataGridCellPropClass.COLUMN_HOVERED_NO_BORDER_RIGHT}`]: column?.field === props.hoveredOverColumnState.hoveredOverColumnField && props.hoveredOverColumnState.noBorderRight, [`b-data-grid-cell--${DataGridCellPropClass.PADDING_LEFT_UNSET}`]: column?.padding?.left === false, [`b-data-grid-cell--${DataGridCellPropClass.PADDING_RIGHT_UNSET}`]: column?.padding?.right === false, [`b-data-grid-cell--${DataGridCellPropClass.NUMERIC}`]: column?.numeric, [`b-data-grid-cell--${DataGridCellPropClass.CONDENSED}`]: props.condensed, [`b-data-grid-cell--${DataGridCellPropClass.OVERFLOW_ELLIPSIS}`]: column?.overflow === BentoColumnOverflow.ELLIPSIS, [`b-data-grid-cell--${DataGridCellPropClass.IS_COLUMN_REORDERING}`]: props.draggingColumns.dragging?.field === column.field, [`b-data-grid-cell--${DataGridCellPropClass.IS_COLUMN_REORDERING_TARGET_LEFT}`]: props.draggingColumns.target?.field === column.field && props.draggingColumns?.dragToLeft, [`b-data-grid-cell--${DataGridCellPropClass.IS_COLUMN_REORDERING_TARGET_RIGHT}`]: props.draggingColumns.target?.field === column.field && !props.draggingColumns?.dragToLeft, [`b-data-grid-cell--${DataGridCellPropClass.FROZEN}`]: column?.frozen, [`b-data-grid-cell--${DataGridCellPropClass.NESTED_CONTENT_ENABLED}`]: props.nestedContent, [`b-data-grid-cell--${DataGridCellPropClass.ACTION_COL_NESTED_CONTENT_ENABLED}`]: props.nestedContent && !props.selectable && column.field === DATAGRID_ACTIONS_COLUMN_FIELD_NAME, [`b-data-grid-cell--${DataGridCellPropClass.ACTION_COL_NESTED_CONTENT_AND_SELECTION_ENABLED}`]: props.nestedContent && props.selectable && column.field === DATAGRID_ACTIONS_COLUMN_FIELD_NAME, [`b-data-grid-cell--${DataGridCellPropClass.ACTION_COL_SELECTION_ENABLED}`]: column.field === DATAGRID_ACTIONS_COLUMN_FIELD_NAME && props.selectable, [`b-data-grid-cell--${DataGridCellPropClass.SLASHED_ZERO}`]: hasSlashedZero.value, 'b-data-grid-cell--small-container': isSmallContainer.value, }); const computedCellContentClasses = computed(() => ({ 'b-data-grid-cell__cell-content--is-focused-on-inside-interactive-element': hasInteractiveElFocusedInside.value, })); // Exposed refs defineExpose({ gridcellRef, }); </script> <script lang="ts"> /** * Internal component to display the BentoDataGrid cells. * * The component can also render the selecting row checkbox as well as cell actions. * * @example * <data-grid-cell * * /> */ export default { i18n: { messages }, name: 'data-grid-cell', }; </script> <style lang="scss" scoped src="./data-grid-cell.scss" />
1
+ <template> <div :key="`cell-${column.field}`" ref="gridcellRef" :aria-hidden="ariaHidden" :data-testid="`cell-${column.field}`" :data-cellindex="getCellIndex(rowIndex, colIndex, columnSetIndex)" class="b-data-grid-cell" :class="conditionalRowCellClasses(column)" role="gridcell" :tabindex="tabIndex" :aria-colindex="getColIndex(colIndex, columnSetIndex) + 1" :style="{ width: column.autoWidth && isMeasuringColumnWidths ? 'auto' : `${column.width}px`, }" v-on="getGridcellEvents(column)" > <template v-if="column.field === DATAGRID_ACTIONS_COLUMN_FIELD_NAME"> <template v-if="selectable"> <bento-checkbox class="b-data-grid-cell__select-cell" :aria-label="t('selectRow')" :disabled="isDataItemDisabled(rowData)" :indeterminate="selectionIsIndeterminate" :model-value="selectedItems" :value="getDataItemId(rowData)" :tabindex="isFocused ? 0 : -1" @update:model-value="$emit('select-row', $event)" /> </template> <template v-if="nestedContent"> <bento-toggle-button ref="menuButtonRef" aria-haspopup="true" :aria-controls="controlledNestedRowElementIds" condensed variant="tertiary" :toggled="isNestedContentExpanded" @click="toggleNestedContent" > <template #iconLeft> <chevron-up-icon v-if="isNestedContentExpanded" class="b-accordion-item__icon" :svg-title="t('collapseNestedContent')" /> <chevron-down-icon v-else class="b-accordion-item__icon" :svg-title="t('expandNestedContent')" /> </template> </bento-toggle-button> </template> </template> <template v-else> <bento-typography el="div" class="b-data-grid-cell__cell-content" :class="computedCellContentClasses" data-testid="cell-content" > <slot :item="rowData" :row-index="rowIndex" :name="`item-${column.field}`"> <span :title="conditionalTitleAttribute(column, rowData[column.field])">{{ getRowData(rowData[column.field]) }}</span> </slot> </bento-typography> <div v-if="column.cellActions?.length > 0" class="b-data-grid-cell__cell-actions"> <data-grid-cell-actions v-if="(shouldShowCellActions || isTouchDevice) && (hasRowData || hasSlot(`item-${column.field}`))" ref="cellActionsRef" :cell-actions="column.cellActions" :column-key="column.field" :row-data-item="rowData" :has-zebra-background="hasZebraBackground" :is-highlighted="isHighlighted" :is-selected="isSelected" teleport-menu @menu-item-click="onMenuClose" /> </div> </template> </div> </template> <script setup lang="ts"> import { computed, type ComputedRef, inject, nextTick, onMounted, type PropType, provide, type Ref, ref, type UnwrapRef, useSlots, watch, } from 'vue'; import { makeAllInteractiveChildrenFocusable, makeAllInteractiveChildrenUnFocusable } from '../../utils'; import { useCellKeyboardNavigation } from './composables/use-cell-keyboard-navigation'; import { DATAGRID_ACTIONS_COLUMN_FIELD_NAME } from '../../composables/use-calculate-column-width'; import { BentoCheckbox } from '@/components/checkbox'; import { BentoTypography } from '@/components/typography'; import { BentoToggleButton } from '@/components/button'; import DataGridCellActions from '../data-grid-cell-actions/data-grid-cell-actions.vue'; import { DATA_GRID_SLASHED_ZERO, DATA_GRID_SMALL_CONTAINER, DATA_GRID_TOUCH_DEVICE } from '../../data-grid.keys'; import ChevronDownIcon from '@adyen/ui-assets-icons-16/vue/chevron-down'; import ChevronUpIcon from '@adyen/ui-assets-icons-16/vue/chevron-up'; import { DATA_GRID_CELL_MENU_REGISTERING_INJECTION_KEY } from '@/components/data-grid/components/data-grid-cell/data-grid-cell.keys'; // Types and enums import { type BentoColumn, BentoColumnOverflow, type BentoDatagridDataItem, type BentoDatagridDataItemId, type BentoDatagridGetDataItemIdFn, type BentoDatagridIsDataItemDisabledFn, } from '@/components/data-grid/data-grid.types'; import { type DataGridCellColumnSet, type DataGridCellDragEventsForColumn, type DataGridCellEvents, type DataGridCellEventsForColumn, DataGridCellPropClass, } from './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/use-column-hovering.types'; // Utils import { useHasSlot } from '@/composables'; import { useI18n } from '@/utils/ts/i18n'; // Translations import messages from './messages.json'; const slots = useSlots(); const hasSlot = useHasSlot(slots); type MessageSchema = (typeof messages)['en-US']; const { t } = useI18n<{ message: MessageSchema }>({ messages }); const props = defineProps({ draggingEventsForRowCell: { type: Function as PropType<DataGridCellDragEventsForColumn>, required: true, }, /** * The columns sets including */ columnSets: { type: Array as PropType<Array<DataGridCellColumnSet>>, required: true, }, /** * The columns set we are iterating over */ column: { type: Object as PropType<BentoColumn>, required: true, }, colIndex: { type: Number, required: true, }, columnSetIndex: { type: Number, 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, }, /** * 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 cell 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, }, /** * 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, }, isMeasuringColumnWidths: { type: Boolean, default: false, }, /** * Determines if the nested content is visible. */ isNestedContentExpanded: { type: Boolean, default: false, }, /** * Determines if the nest content slot is enabled for this data row. */ nestedContent: { type: Boolean, default: false, }, /** * The IDs of the rows this cell controls to show / hide them. */ nestedRowsElementIds: { type: Array as PropType<Array<string>>, default: () => [], }, /** * The row data object */ rowData: { type: Object as PropType<BentoDatagridDataItem>, required: true, }, rowIndex: { type: Number, required: true, }, /** * The row selection checkbox indeterminate state. */ selectionIsIndeterminate: { type: Boolean, default: false, }, /** * Determines if the row selection is enabled. */ selectable: { type: Boolean, default: false, }, selectedItems: { type: Array as PropType<Array<BentoDatagridDataItemId>>, required: true, }, }); const emit = defineEmits<{ /** * 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>); /** * Emitted when a row has been selected. */ (e: 'register-row-cell-element', el: Element, field: string, rowIndex: number); /** * 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 nested content is being toggled. */ (e: 'nested-content-toggled'); /** * Emitted when a cell gains focus. */ ( e: 'cell-focus', value: { rowData: BentoDatagridDataItem; column: BentoColumn; rowIndex: number; colIndex: number } ); }>(); // References const gridcellRef = ref<HTMLDivElement | null>(null); const cellActionsRef = ref<InstanceType<typeof DataGridCellActions>>(null); const hasEverBeenFocused = ref(false); const { shouldShowCellActions, isFocused, focusableItems, childrenMenuContainerElements, hasInteractiveElFocusedInside, onMenuClose, hideCellActionsIfLeft, handleFocus, handleFocusout, handleKeydown, handleCellActionsFocus, handleCellActionsFocusout, } = useCellKeyboardNavigation({ gridcellRef }); // Computed const hasRowData = computed(() => !!props.rowData?.[props.column.field]); const controlledNestedRowElementIds = computed(() => props.column.field === DATAGRID_ACTIONS_COLUMN_FIELD_NAME && props.nestedRowsElementIds?.length > 0 ? props.nestedRowsElementIds.join(' ') : undefined ); const ariaHidden = computed( () => props.column.field === DATAGRID_ACTIONS_COLUMN_FIELD_NAME && !props.selectable && !props.nestedContent ); const tabIndex = computed(() => { if (!ariaHidden.value) { // The first cell of the grid should be tabbable on initial render to allow keyboard navigation into the grid. if (getCellIndex(props.rowIndex, props.colIndex, props.columnSetIndex) === 0 && !hasEverBeenFocused.value) { return '0'; } // A cell becomes part of the tab order only when it is focused. if (isFocused.value) { return '0'; } } // Action cells are not directly tabbable; they are accessed via cell navigation. if (props.column.field === DATAGRID_ACTIONS_COLUMN_FIELD_NAME) { return '-1'; } // By default, cells are not in the tab order. return '-1'; }); // Row data retrieval methods const conditionalTitleAttribute = (column: BentoColumn, content: unknown) => column?.overflow === BentoColumnOverflow.ELLIPSIS ? `${content}` : undefined; const getRowData = (rowData: unknown) => rowData ?? '-'; // Cell and Columns index calculation const sumColumnAmountsInColumnSets = (limitIndex?: number) => props.columnSets.slice(0, limitIndex).reduce((total, { columnSet }) => total + columnSet.length, 0); // Sums the amount of columns on every previous column set to find // the absolute column index (useful for frozen columns use case) const getColIndex = (colIndex: number, colSetIndex: number) => colIndex + sumColumnAmountsInColumnSets(colSetIndex); const getCellIndex = (rowIndex: number, colIndex: number, colSetIndex: number) => sumColumnAmountsInColumnSets() * rowIndex + getColIndex(colIndex, colSetIndex); // Events const getGridcellEvents: DataGridCellEventsForColumn = (column: BentoColumn) => { const defaultEvents: DataGridCellEvents = { ...props.draggingEventsForRowCell(column), focus: async () => { hasEverBeenFocused.value = true; await handleFocus(() => { emit('cell-focus', { rowData: props.rowData, column: props.column, rowIndex: props.rowIndex, colIndex: getColIndex(props.colIndex, props.columnSetIndex), }); }); }, focusout: handleFocusout, keydown: handleKeydown, }; const gridCellEvents: DataGridCellEvents = { ...defaultEvents }; if (column.frozen) { gridCellEvents.keyup = (event: Event) => emit('on-frozen-columns-key-press', event); gridCellEvents.keydown = (event: KeyboardEvent) => { defaultEvents.keydown(event); emit('on-frozen-columns-key-press', event); }; } if (column.cellActions) { gridCellEvents.mouseenter = () => { shouldShowCellActions.value = true; }; gridCellEvents.mouseleave = (e: MouseEvent) => { hideCellActionsIfLeft(e?.relatedTarget); }; gridCellEvents.focus = async (e: FocusEvent) => { await handleCellActionsFocus(defaultEvents.focus, e); }; gridCellEvents.focusout = (e: FocusEvent) => { handleCellActionsFocusout(defaultEvents.focusout, e); }; } return gridCellEvents; }; onMounted(() => { focusableItems.value = makeAllInteractiveChildrenUnFocusable(gridcellRef.value); }); const toggleNestedContent = () => { emit('nested-content-toggled'); }; // Each cell knows it's on hover state but this state needs to be emitted up // in order for the DataGrid to know which row to set the hovered state on. watch( () => shouldShowCellActions.value, async () => { if (shouldShowCellActions.value === true) { emit('cell-hovered', props.rowIndex); // Re-scan for focusable items after cell-actions render into the DOM await nextTick(); focusableItems.value = makeAllInteractiveChildrenUnFocusable(gridcellRef.value); // When the cell has keyboard focus, make children tabbable so Tab // can reach cell-action buttons that just appeared. if (isFocused.value) { makeAllInteractiveChildrenFocusable(focusableItems.value); } } else { emit('cell-hovered', -1); } } ); const registerMenuContainerElement = (menuContainerElement: Ref<UnwrapRef<HTMLDivElement>>) => { childrenMenuContainerElements.value.push(menuContainerElement); }; const menuMouseLeave = (e: MouseEvent) => { if (!gridcellRef.value.contains(e?.relatedTarget as Node)) { shouldShowCellActions.value = false; } }; provide<{ registerMenuContainerElement: (menuContainerElement: Ref<UnwrapRef<HTMLDivElement>>) => void; menuMouseLeave: (e: MouseEvent) => void; }>(DATA_GRID_CELL_MENU_REGISTERING_INJECTION_KEY, { registerMenuContainerElement, menuMouseLeave, }); const hasSlashedZero = inject(DATA_GRID_SLASHED_ZERO, ref(false)); const isSmallContainer: ComputedRef<boolean> = inject( DATA_GRID_SMALL_CONTAINER, computed(() => false) ); const isTouchDevice: Ref<boolean> = inject(DATA_GRID_TOUCH_DEVICE, ref(false)); // Styling const conditionalRowCellClasses = (column: BentoColumn) => ({ [`b-data-grid-cell--${DataGridCellPropClass.COLUMN_HOVERED}`]: column?.field === props.hoveredOverColumnState.hoveredOverColumnField, [`b-data-grid-cell--${DataGridCellPropClass.COLUMN_HOVERED_NO_BORDER_LEFT}`]: column?.field === props.hoveredOverColumnState.hoveredOverColumnField && props.hoveredOverColumnState.noBorderLeft, [`b-data-grid-cell--${DataGridCellPropClass.COLUMN_HOVERED_NO_BORDER_RIGHT}`]: column?.field === props.hoveredOverColumnState.hoveredOverColumnField && props.hoveredOverColumnState.noBorderRight, [`b-data-grid-cell--${DataGridCellPropClass.PADDING_LEFT_UNSET}`]: column?.padding?.left === false, [`b-data-grid-cell--${DataGridCellPropClass.PADDING_RIGHT_UNSET}`]: column?.padding?.right === false, [`b-data-grid-cell--${DataGridCellPropClass.NUMERIC}`]: column?.numeric, [`b-data-grid-cell--${DataGridCellPropClass.CONDENSED}`]: props.condensed, [`b-data-grid-cell--${DataGridCellPropClass.OVERFLOW_ELLIPSIS}`]: column?.overflow === BentoColumnOverflow.ELLIPSIS, [`b-data-grid-cell--${DataGridCellPropClass.IS_COLUMN_REORDERING}`]: props.draggingColumns.dragging?.field === column.field, [`b-data-grid-cell--${DataGridCellPropClass.IS_COLUMN_REORDERING_TARGET_LEFT}`]: props.draggingColumns.target?.field === column.field && props.draggingColumns?.dragToLeft, [`b-data-grid-cell--${DataGridCellPropClass.IS_COLUMN_REORDERING_TARGET_RIGHT}`]: props.draggingColumns.target?.field === column.field && !props.draggingColumns?.dragToLeft, [`b-data-grid-cell--${DataGridCellPropClass.FROZEN}`]: column?.frozen, [`b-data-grid-cell--${DataGridCellPropClass.NESTED_CONTENT_ENABLED}`]: props.nestedContent, [`b-data-grid-cell--${DataGridCellPropClass.ACTION_COL_NESTED_CONTENT_ENABLED}`]: props.nestedContent && !props.selectable && column.field === DATAGRID_ACTIONS_COLUMN_FIELD_NAME, [`b-data-grid-cell--${DataGridCellPropClass.ACTION_COL_NESTED_CONTENT_AND_SELECTION_ENABLED}`]: props.nestedContent && props.selectable && column.field === DATAGRID_ACTIONS_COLUMN_FIELD_NAME, [`b-data-grid-cell--${DataGridCellPropClass.ACTION_COL_SELECTION_ENABLED}`]: column.field === DATAGRID_ACTIONS_COLUMN_FIELD_NAME && props.selectable, [`b-data-grid-cell--${DataGridCellPropClass.SLASHED_ZERO}`]: hasSlashedZero.value, 'b-data-grid-cell--small-container': isSmallContainer.value, }); const computedCellContentClasses = computed(() => ({ 'b-data-grid-cell__cell-content--is-focused-on-inside-interactive-element': hasInteractiveElFocusedInside.value, })); // Exposed refs defineExpose({ gridcellRef, }); </script> <script lang="ts"> /** * Internal component to display the BentoDataGrid cells. * * The component can also render the selecting row checkbox as well as cell actions. * * @example * <data-grid-cell * * /> */ export default { i18n: { messages }, name: 'data-grid-cell', }; </script> <style lang="scss" scoped src="./data-grid-cell.scss" />
@@ -1 +1 @@
1
- <template> <div ref="allColumnsRef" class="b-data-grid-columns" :class="conditionalColumnsClasses" data-testid="data-grid-columns" > <div v-for="(column, index) in columns" :key="column.field" :ref="el => registerColumnsRefs(el, column.field)" :aria-colindex="index + 1" :aria-label="column.label" :aria-sort="getSortingColumnDirection(column)" :class="conditionalColumnClasses(column, index)" :data-testid="`${column.field}-column-header`" :draggable="hasDraggableColumns && !isResizing && !isActionsColumn(column)" class="b-data-grid-columns__column" role="columnheader" :tabindex="column.sortable || hasColumnPanel || column.infoIconTooltip ? '0' : '-1'" :style="{ width: column.autoWidth && isMeasuringColumnWidths ? 'auto' : `${column.width}px` }" v-on="dataGridColumnEvents(column)" > <div v-if="isActionsColumn(column) && selectable" class="b-data-grid-columns__select-column" :class="conditionalSelectColumnClasses" > <bento-checkbox v-if="!selectableAcrossPages" :aria-label="t('selectAll')" :model-value="allItemsSelectedState.allItemsSelected" data-testid="checkbox-select-all" :indeterminate="allItemsSelectedState.isIndeterminate" value="data-grid-select-all" @update:model-value="onAllRowSelect" /> <template v-if="selectableAcrossPages"> <bento-menu :button="{ variant: 'tertiary', condensed: true, 'aria-label': t('selectAllMenu') }" :data="selectAllMenu" class="b-data-grid-columns__select-column-menu" disable-arrow-down-opening-menu menu-fixed-positioning menu-position="bottom-start" > <template #iconLeft> <div class="b-data-grid-columns__select-column-menu-button"> <decorative-checkbox :indeterminate="allItemsSelectedState.isIndeterminate" :checked="allItemsSelectedState.allItemsSelected" :all-selected="isAllSelectionState" class="b-data-grid-columns__select-column-menu-checkbox" data-testid="checkbox-select-all" /> <triangle-down :svg-title="t('selectionMenu')" aria-hidden="true" /> </div> </template> </bento-menu> </template> </div> <template v-else> <div :aria-label="t('columnActions', { name: column.label })" aria-live="polite" role="region" class="b-data-grid-columns__column-header-and-actions" :class="conditionalColumnHeaderAndActionsClasses(column)" > <bento-typography el="div" strongest class="b-data-grid-columns__column-header" :class="conditionalColumnHeaderClasses" > <slot :column="column" :name="`head-${column.field}`"> <span class="b-data-grid-columns__label">{{ column.label }}</span> </slot> </bento-typography> <div v-if="hasColumnActions(column)" class="b-data-grid-columns__column-actions" :class="conditionalColumnActionsClasses(column)" > <div v-if="column.infoIconTooltip" class="b-data-grid-columns__column-info-icon"> <bento-info-icon :tooltip-text="column.infoIconTooltip" /> </div> <div v-if="column.sortable" class="b-data-grid-columns__column-sort"> <bento-toggle-button condensed :toggled="isSorting(column.field)" variant="tertiary" @click="sortByColumnField(column.field)" > <template #iconLeft> <arrow-up-and-down-sort-icon v-if="!getSortingDirection(column.field)" :svg-title="t('sortColumn', { name: column.label })" /> <sort-ascending-icon v-else-if=" getSortingDirection(column.field) === BentoDatagridSortDirection.ASCENDING " :svg-title="t('sortColumnInDescendingOrder', { name: column.label })" /> <sort-descending-icon v-else-if=" getSortingDirection(column.field) === BentoDatagridSortDirection.DESCENDING " :svg-title="t('sortColumnInAscendingOrder', { name: column.label })" /> </template> </bento-toggle-button> </div> <bento-menu v-if="hasColumnPanel" :ref="el => (contextMenuRef[column.field] = el)" class="b-data-grid-columns__menu" :class="conditionalColumnMenuClasses(column)" disable-arrow-down-opening-menu :data="contextualMenuData(column)" :menu-position="computeMenuPosition(hasFirstColumnPanelRightAligned, index)" :menu-fixed-positioning="true" :button="{ variant: 'tertiary', condensed: true }" @open="$emit(DatagridColumnsEvent.COLUMN_MENU_OPEN)" @close="$emit(DatagridColumnsEvent.COLUMN_MENU_CLOSE)" /> </div> </div> <div :data-testid="`data-grid-column-${column.field}`" class="b-data-grid-columns__column-separator-container" :class="conditionalColumnSeparatorClasses(column)" role="presentation" @mousedown="startResize(hasResizableColumns, column.field, $event)" @click.stop > <div v-if="!isFrozenColumns || (isFrozenColumns && index !== columns.length - 1)" class="b-data-grid-columns__column-separator" ></div> </div> </template> </div> <template v-if="rowActionsMaximumWidth && !isFrozenColumns"> <div role="presentation" class="b-data-grid-columns__row-actions-filler"></div> <div :ref="el => registerColumnsRefs(el, DATAGRID_ROW_ACTIONS_COLUMN_FIELD_NAME)" :aria-label="t('rowActionsColumn')" :aria-colindex="columns.length + 1" class="b-data-grid-columns__column" role="columnheader" tabindex="0" :style="{ minWidth: `${rowActionsMaximumWidth}px` }" ></div> </template> </div> </template> <script lang="ts" setup> import { computed, type ComputedRef, inject, type PropType, type Ref, ref, toRefs } from 'vue'; import { BentoCheckbox } from '@/components/checkbox'; import { type BentoColumn, type BentoColumnWidthOverridesLookup, type BentoDatagridAllItemsSelectedState, BentoDatagridAriaSortValue, type BentoDatagridSortByColumn, BentoDatagridSortDirection, type BentoDataGridVisibleRowsState, } from '../../data-grid.types'; import { BentoTypography } from '@/components/typography'; import { DATAGRID_ACTIONS_COLUMN_FIELD_NAME, DATAGRID_ROW_ACTIONS_COLUMN_FIELD_NAME, useResizer, useSorter, } from '../../composables'; import { focusOnInteractiveChild } from '../../utils'; import { BentoInfoIcon } from '@/components/info-icon'; import { type BentoDatagridColumnSelectionState, type BentoDatagridDraggingColumnState, DatagridColumnsEvent, type DatagridColumnsHeaderReferences, } from './data-grid-columns.types'; import { BentoMenu } from '@/components/menu'; import { BentoToggleButton } from '@/components/button'; import { DecorativeCheckbox } from '@/internal/decorative-checkbox'; import { useI18n } from '@/utils/ts/i18n'; import type { BentoMenuItem } from '@/components/menu/components'; import type { DatagridColumnHoveringState } from '@/components/data-grid/composables/use-column-hovering/use-column-hovering.types'; import { BentoPopoverPositions } from '@/components/popover/popover.types'; import ArrowUpAndDownSortIcon from '@adyen/ui-assets-icons-16/vue/arrow-up-down-sort'; import ArrowLeftIcon from '@adyen/ui-assets-icons-16/vue/arrow-left'; import ArrowRightIcon from '@adyen/ui-assets-icons-16/vue/arrow-right'; import FreezeIcon from '@adyen/ui-assets-icons-16/vue/freeze'; import HideIcon from '@adyen/ui-assets-icons-16/vue/hide'; import SortAscendingIcon from '@adyen/ui-assets-icons-16/vue/sort-ascending'; import SortDescendingIcon from '@adyen/ui-assets-icons-16/vue/sort-descending'; import ShowIcon from '@adyen/ui-assets-icons-16/vue/show'; import TriangleDown from '@adyen/ui-assets-icons-16/vue/triangle-down'; import messages from './messages.json'; import { DATA_GRID_SMALL_CONTAINER, DATA_GRID_TOUCH_DEVICE } from '@/components/data-grid/data-grid.keys'; type MessageSchema = (typeof messages)['en-US']; const props = defineProps({ /** * Column definitions using 'key' to relate to data object. */ columns: { type: Array as PropType<Array<BentoColumn>>, required: true, }, /** * Columns being dragged, contains dragging column and a target column */ draggingColumns: { type: Object as PropType<BentoDatagridDraggingColumnState>, default: undefined, }, /** * Determines the state of the current results: { filtered: boolean, allItemsVisible: boolean } to render the correct select all menu options */ visibleRowsState: { type: Object as PropType<BentoDataGridVisibleRowsState>, default: undefined, }, /** * Toggle the columns draggable events. */ hasDraggableColumns: { type: Boolean, default: true, }, /** * If resize is enabled */ hasResizableColumns: { type: Boolean, default: true, }, /** * Signifies if the column panel is enabled. */ hasColumnPanel: { type: Boolean, default: false, }, /** * If set to true, aligns the first column panel to the right of its button. */ hasFirstColumnPanelRightAligned: { type: Boolean, default: false, }, /** * If the data grid has overflow on the left side */ hasLeftOverflow: { type: Boolean, default: false, }, /** * 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, }, /** * Toggled to true when the DataGrid has auto width columns and is measuring the */ isMeasuringColumnWidths: { type: Boolean, default: false, }, /** * Used to disable hiding the last visible column. */ isOnlyOneVisibleColumn: { type: Boolean, default: false, }, /** * If these columns are frozen (pinned) columns */ isFrozenColumns: { type: Boolean, default: false, }, /** * Show frozen columns separator */ showFrozenColumnsSeparator: { type: Boolean, default: false, }, /** * The column all items selected state */ allItemsSelectedState: { type: Object as PropType<BentoDatagridAllItemsSelectedState>, default: undefined, }, /** * Determines if the user has enabled row selection */ selectable: { type: Boolean, default: false, }, /** * Adds an extra option when selecting all, to select items in other pages too */ selectableAcrossPages: { type: Boolean, default: false, }, /** * Determines which state of row selection is currently active. Used to display the "Select all" menu options. */ selectionState: { type: String as PropType<BentoDatagridColumnSelectionState>, default: undefined, }, /** * 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: () => [], }, /** * Truncates all data grid column headers */ truncateColumnHeaders: { type: Boolean, default: false, }, /** * The maximum width of the row actions in all rows */ rowActionsMaximumWidth: { type: Number, default: 0, }, }); const emit = defineEmits<{ /** * Emitted when a user manually resizes a column width. */ ( e: DatagridColumnsEvent.COLUMN_RESIZE, column: { field: string; width: number; } ); /** * Triggered when a column is resized. */ (e: DatagridColumnsEvent.COLUMN_RESIZING, value: BentoColumnWidthOverridesLookup); /** * Triggered when a column is sorted. */ (e: 'sort', updatedSortBy: Array<BentoDatagridSortByColumn>); /** * Triggered when selecting all rows. */ (e: DatagridColumnsEvent.SELECT_ALL_ROWS, areAllItemsSelected: boolean); /** * Triggered when all rows across all pages are selected. */ (e: DatagridColumnsEvent.SELECT_ALL_ACROSS_PAGES); /** * Triggered when all filtered rows are selected. */ (e: DatagridColumnsEvent.SELECT_ALL_CURRENT_RESULTS); /** * Triggered when deselecting all rows. */ (e: DatagridColumnsEvent.DESELECT_ALL); /** * Triggered when all filtered rows are deselected. */ (e: DatagridColumnsEvent.DESELECT_ALL_CURRENT_RESULTS); /** * Triggered when a columns started to be dragged. */ (e: DatagridColumnsEvent.COLUMN_DRAG_START, column: BentoColumn, event: DragEvent & Event); /** * Triggered when a column is being dragged over another DOM element. */ (e: DatagridColumnsEvent.COLUMN_DRAG_ENTER, column: BentoColumn, event: DragEvent & Event); /** * Triggered when a column is being dragged and is dropped over another DOM element. */ (e: DatagridColumnsEvent.COLUMN_DROP, column: BentoColumn); /** * Triggered when a column drag operation has ended. */ (e: DatagridColumnsEvent.COLUMN_DRAG_END); /** * Triggered when a column is being moved by a certain offset. */ (e: DatagridColumnsEvent.COLUMN_MOVE, movingColumn: BentoColumn, offset: number); /** * Triggered when a column is updated. */ (e: DatagridColumnsEvent.COLUMN_UPDATE, column: BentoColumn); /** * Triggered when the column contextual menu is opened. */ (e: DatagridColumnsEvent.COLUMN_MENU_OPEN); /** * Triggered when the column contextual menu is closed. */ (e: DatagridColumnsEvent.COLUMN_MENU_CLOSE); /** * Emitted when a column header is hovered over. */ (e: 'column-hover', columnKey: BentoColumn); }>(); const CLOSING_CONTEXTUAL_MENU_DELAY_MS = 100; const { t } = useI18n<{ message: MessageSchema }>({ messages }); const { columns, sortBy } = toRefs(props); const allColumnsRef = ref<HTMLDivElement>(); const columnHeaderRefs = ref<DatagridColumnsHeaderReferences>({}); const contextMenuRef = ref({}); const hoveredColumnField = ref(); const closingTimeOutId = ref(); // Selecting const onAllRowSelect = (areAllItemsSelected: boolean) => emit(DatagridColumnsEvent.SELECT_ALL_ROWS, areAllItemsSelected); const isActionsColumn = (column: BentoColumn) => column.field === DATAGRID_ACTIONS_COLUMN_FIELD_NAME; const isAllSelectionState = computed(() => props.selectionState === 'all'); // Select all menu const selectAllMenu = computed(() => [ { text: t('selectPage') as string, handler: () => onAllRowSelect(true), disabled: props.selectionState === 'page' || props.selectionState === 'all', }, props.visibleRowsState?.filtered ? { text: t('selectAllCurrentResults') as string, handler: () => emit(DatagridColumnsEvent.SELECT_ALL_CURRENT_RESULTS), addDivider: props.selectionState !== 'none', disabled: props.selectionState === 'all', } : { text: t('selectAll') as string, handler: () => emit(DatagridColumnsEvent.SELECT_ALL_ACROSS_PAGES), addDivider: props.selectionState !== 'none', disabled: props.selectionState === 'all', }, props.selectionState !== 'none' && { text: t('deselectPage') as string, handler: () => onAllRowSelect(false), addDivider: !props.visibleRowsState?.filtered, // otherwise divider shown in deselect all in current results disabled: props.selectionState === 'unselected-page', }, props.visibleRowsState?.filtered && props.selectionState !== 'none' && { text: t('deselectAllCurrentResults') as string, handler: () => emit(DatagridColumnsEvent.DESELECT_ALL_CURRENT_RESULTS), addDivider: true, disabled: props.selectionState === 'unselected-page' && props.visibleRowsState.allRowsVisible, }, props.selectionState !== 'none' && { text: t('deselectAll') as string, handler: () => emit(DatagridColumnsEvent.DESELECT_ALL), }, ].filter(Boolean) ); // Resizing const { startResize, isResizing, columnHoverEventToEmitAfterResizing } = useResizer(columns, emit); // Sorting const { isSorting, getSortingDirection, sortByColumnField } = useSorter(sortBy, columns, emit); const getSortingColumnDirection = (column: BentoColumn): BentoDatagridAriaSortValue => { if (!column.sortable) { return null; } const sortingDirection = props.sortBy.find(({ field }) => field === column.field)?.direction; if (!sortingDirection) { return BentoDatagridAriaSortValue.NONE; } const ariaSortingDirectionMap: { [key in BentoDatagridSortDirection]: BentoDatagridAriaSortValue } = { [BentoDatagridSortDirection.ASCENDING]: BentoDatagridAriaSortValue.ASCENDING, [BentoDatagridSortDirection.DESCENDING]: BentoDatagridAriaSortValue.DESCENDING, }; return ariaSortingDirectionMap[sortingDirection]; }; // Dragging emit events and methods const draggingEventsForColumn = computed(() => (column: BentoColumn) => { if (!props.hasDraggableColumns) { return {}; } return { dragstart: (e: DragEvent & Event) => { emit(DatagridColumnsEvent.COLUMN_DRAG_START, column, e); }, dragover: (e: DragEvent) => { e.preventDefault(); }, dragenter: (e: DragEvent) => { e.preventDefault(); emit(DatagridColumnsEvent.COLUMN_DRAG_ENTER, column, e); }, drop: () => { emit(DatagridColumnsEvent.COLUMN_DROP, column); }, dragend: () => { emit(DatagridColumnsEvent.COLUMN_DRAG_END); }, }; }); // Contextual menu (moving, visiblity and freezing) const contextualMenuData = computed<(col: BentoColumn) => Array<BentoMenuItem>>( () => (column: BentoColumn) => [ { text: t('moveColumnLeft') as string, handler: () => emit(DatagridColumnsEvent.COLUMN_MOVE, column, -1), icon: ArrowLeftIcon, disabled: props.columns[0].field === column.field, }, { text: t('moveColumnRight') as string, handler: () => emit(DatagridColumnsEvent.COLUMN_MOVE, column, 1), icon: ArrowRightIcon, disabled: props.columns[props.columns.length - 1].field === column.field, }, !isSmallContainer.value && { text: (column?.frozen ? t('unfreezeColumn') : t('freezeColumn')) as string, handler: () => emit(DatagridColumnsEvent.COLUMN_UPDATE, { ...column, frozen: !column?.frozen }), icon: FreezeIcon, }, { text: (column?.visible !== false ? t('hideColumn') : t('showColumn')) as string, handler: () => { emit(DatagridColumnsEvent.COLUMN_UPDATE, { ...column, visible: !(column?.visible !== false), }); }, icon: column?.visible !== false ? HideIcon : ShowIcon, disabled: column?.mandatory || (column?.visible !== false && props.isOnlyOneVisibleColumn), }, ].filter(Boolean) ); const showContextualMenu = (column: BentoColumn) => { hoveredColumnField.value = column.field; if (closingTimeOutId.value) { clearTimeout(closingTimeOutId.value); } }; const hideContextualMenu = (column: BentoColumn) => { // We want to delay closing for a couple of seconds so // it doesn't flicker if a user hovers over the icon and then inside the popover. const timeOutId = setTimeout(() => { if (column.field === hoveredColumnField.value) { hoveredColumnField.value = ''; } }, CLOSING_CONTEXTUAL_MENU_DELAY_MS); closingTimeOutId.value = timeOutId; }; /** * Generates events for a Datagrid Column. * @param column - the BentoColumn we are attaching events onto. */ const dataGridColumnEvents = (column: BentoColumn) => { let events: { [eventName: string]: (e: Event) => void } = { mouseenter: () => { showContextualMenu(column); if (!isResizing.value) { emit('column-hover', column); } else { columnHoverEventToEmitAfterResizing.value = column; } }, mouseleave: () => { hideContextualMenu(column); if (!isResizing.value) { emit('column-hover', null); } else { columnHoverEventToEmitAfterResizing.value = null; } }, focus: () => { showContextualMenu(column); if (isActionsColumn(column)) { focusOnInteractiveChild(columnHeaderRefs.value[column.field]); } }, focusout: (e: FocusEvent) => { if (!allColumnsRef.value?.contains(e?.relatedTarget as Node)) { hideContextualMenu(column); } }, }; if (!isActionsColumn(column)) { events = { ...events, ...draggingEventsForColumn.value(column), }; } return events; }; const hasColumnActions = (column: BentoColumn) => column.infoIconTooltip || column.sortable || props.hasColumnPanel; const registerColumnsRefs = (el: unknown, field: string) => { if (columnHeaderRefs.value?.[field] === undefined) { columnHeaderRefs.value = { ...columnHeaderRefs.value, [field]: null }; } if (el !== null) { columnHeaderRefs.value[field] = el as HTMLDivElement; } }; const isSmallContainer: ComputedRef<boolean> = inject( DATA_GRID_SMALL_CONTAINER, computed(() => false) ); const isTouchDevice: Ref<boolean> = inject(DATA_GRID_TOUCH_DEVICE, ref(false)); // Conditional column styles // Conditional styles const conditionalColumnsClasses = computed(() => { const defaultConditionalClasses = { 'b-data-grid-columns--is-measuring': props.isMeasuringColumnWidths, }; return columns.value[0]?.frozen && !isSmallContainer.value ? { ...defaultConditionalClasses, 'b-data-grid-columns--frozen': true, 'b-data-grid-columns--frozen-separator': props.showFrozenColumnsSeparator, 'b-data-grid-columns--overflow-shadow-left': props.hasLeftOverflow, } : { ...defaultConditionalClasses, 'b-data-grid-columns--unfrozen': true, }; }); const conditionalSelectColumnClasses = computed(() => ({ 'b-data-grid-columns__select-column--all': props.selectableAcrossPages, })); const computeMenuPosition = (hasFirstColumnPanelRightAligned, index) => hasFirstColumnPanelRightAligned && index === 0 ? BentoPopoverPositions.BOTTOM_START : BentoPopoverPositions.BOTTOM_END; const conditionalColumnClasses = (column: BentoColumn, columnIndex: number) => ({ 'b-data-grid-columns__column--column-hovered': column.field === props.hoveredOverColumnState.hoveredOverColumnField, 'b-data-grid-columns__column--column-hovered-no-border-left': column.field === props.hoveredOverColumnState.hoveredOverColumnField && props.hoveredOverColumnState.noBorderLeft, 'b-data-grid-columns__column--column-hovered-no-border-right': column.field === props.hoveredOverColumnState.hoveredOverColumnField && props.hoveredOverColumnState.noBorderRight, 'b-data-grid-columns__column--last-frozen-frozen-column': props.isFrozenColumns && columnIndex === columns.value.length - 1, 'b-data-grid-columns__column--sortable': column.sortable, 'b-data-grid-columns__column--padding-left-unset': column?.padding?.left === false, 'b-data-grid-columns__column--padding-right-unset': column?.padding?.right === false, 'b-data-grid-columns__column--numeric': column?.numeric, 'b-data-grid-columns__column--frozen': column?.frozen, 'b-data-grid-columns__column--is-re-ordering': column?.field === props.draggingColumns?.dragging?.field, 'b-data-grid-columns__column--is-re-ordering-target-left': column?.field === props.draggingColumns?.target?.field && props.draggingColumns?.dragToLeft, 'b-data-grid-columns__column--is-re-ordering-target-right': column?.field === props.draggingColumns?.target?.field && !props.draggingColumns?.dragToLeft, 'b-data-grid-columns__column--with-sorting-or-column-panel': column.sortable || column.infoIconTooltip || props.hasColumnPanel || props.selectableAcrossPages, 'b-data-grid-columns--small-container': isSmallContainer.value, }); const conditionalColumnHeaderAndActionsClasses = (column: BentoColumn) => ({ 'b-data-grid-columns__column-header-and-actions--numeric': column?.numeric, }); const conditionalColumnActionsClasses = (column: BentoColumn) => ({ 'b-data-grid-columns__column-actions--numeric': column?.numeric, }); const conditionalColumnSeparatorClasses = (column: BentoColumn) => ({ 'b-data-grid-columns__column-separator-container--is-resizeable': props.hasResizableColumns, 'b-data-grid-columns__column-separator-container--is-offset': props.hoveredOverColumnState.hoveredOverColumnField === column.field && props.hoveredOverColumnState.isSeparatorOffset, }); const conditionalColumnHeaderClasses = computed(() => ({ 'b-data-grid-columns__column-header--truncated': props.truncateColumnHeaders, })); const conditionalColumnMenuClasses = (column: BentoColumn) => ({ 'b-data-grid-columns__menu--hovered': hoveredColumnField.value === column.field || isTouchDevice.value, }); // Exposed refs defineExpose({ columnHeaderRefs, }); </script> <script lang="ts"> /** * A component to display the BentoDataGrid columns * * @example * * export default { * components: { DataGridColumns }, * template: ` * <data-grid-columns * v-if="columnSet?.length" * :key="`data-grid-columns-${id}`" * :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" * :is-only-one-visible-column="isOnlyOneVisibleColumn" * :selectable="selectable" * :selectable-across-pages="selectableAcrossPagesComputed" * :truncate-column-headers="truncateColumnHeaders" * v-on="draggingEventsForColumns" * @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)" * @sort="onSort" * > * <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> * <slot :name="innerSlot" v-bind="scope" /> * </template> * </data-grid-columns> * ` * } */ export default { i18n: { messages }, name: 'data-grid-columns', }; </script> <style lang="scss" scoped src="./data-grid-columns.scss" />
1
+ <template> <div ref="allColumnsRef" class="b-data-grid-columns" :class="conditionalColumnsClasses" data-testid="data-grid-columns" > <div v-for="(column, index) in columns" :key="column.field" :ref="el => registerColumnsRefs(el, column.field)" :aria-colindex="index + 1" :aria-label="column.label" :aria-sort="getSortingColumnDirection(column)" :class="conditionalColumnClasses(column, index)" :data-testid="`${column.field}-column-header`" :draggable="hasDraggableColumns && !isResizing && !isActionsColumn(column)" class="b-data-grid-columns__column" role="columnheader" :tabindex="column.sortable || hasColumnPanel || column.infoIconTooltip ? '0' : '-1'" :style="{ width: column.autoWidth && isMeasuringColumnWidths ? 'auto' : `${column.width}px` }" v-on="dataGridColumnEvents(column)" > <div v-if="isActionsColumn(column) && selectable" class="b-data-grid-columns__select-column" :class="conditionalSelectColumnClasses" > <bento-checkbox v-if="!selectableAcrossPages" :aria-label="t('selectAll')" :model-value="allItemsSelectedState.allItemsSelected" data-testid="checkbox-select-all" :indeterminate="allItemsSelectedState.isIndeterminate" value="data-grid-select-all" @update:model-value="onAllRowSelect" /> <template v-if="selectableAcrossPages"> <bento-menu :button="{ variant: 'tertiary', condensed: true, 'aria-label': t('selectAllMenu') }" :data="selectAllMenu" class="b-data-grid-columns__select-column-menu" disable-arrow-down-opening-menu menu-fixed-positioning menu-position="bottom-start" > <template #iconLeft> <div class="b-data-grid-columns__select-column-menu-button"> <decorative-checkbox :indeterminate="allItemsSelectedState.isIndeterminate" :checked="allItemsSelectedState.allItemsSelected" :all-selected="isAllSelectionState" class="b-data-grid-columns__select-column-menu-checkbox" data-testid="checkbox-select-all" /> <triangle-down :svg-title="t('selectionMenu')" aria-hidden="true" /> </div> </template> </bento-menu> </template> </div> <template v-else> <div :aria-label="t('columnActions', { name: column.label })" aria-live="polite" role="region" class="b-data-grid-columns__column-header-and-actions" :class="conditionalColumnHeaderAndActionsClasses(column)" > <bento-typography el="div" strongest class="b-data-grid-columns__column-header" :class="conditionalColumnHeaderClasses" > <slot :column="column" :name="`head-${column.field}`"> <span class="b-data-grid-columns__label">{{ column.label }}</span> </slot> </bento-typography> <div v-if="hasColumnActions(column)" class="b-data-grid-columns__column-actions" :class="conditionalColumnActionsClasses(column)" > <div v-if="column.infoIconTooltip" class="b-data-grid-columns__column-info-icon"> <bento-info-icon :tooltip-text="column.infoIconTooltip" /> </div> <div v-if="column.sortable" class="b-data-grid-columns__column-sort"> <bento-toggle-button condensed :toggled="isSorting(column.field)" variant="tertiary" @click="sortByColumnField(column.field)" > <template #iconLeft> <arrow-up-and-down-sort-icon v-if="!getSortingDirection(column.field)" :svg-title="t('sortColumn', { name: column.label })" /> <sort-ascending-icon v-else-if=" getSortingDirection(column.field) === BentoDatagridSortDirection.ASCENDING " :svg-title="t('sortColumnInDescendingOrder', { name: column.label })" /> <sort-descending-icon v-else-if=" getSortingDirection(column.field) === BentoDatagridSortDirection.DESCENDING " :svg-title="t('sortColumnInAscendingOrder', { name: column.label })" /> </template> </bento-toggle-button> </div> <bento-menu v-if="hasColumnPanel" :ref="el => (contextMenuRef[column.field] = el)" class="b-data-grid-columns__menu" :class="conditionalColumnMenuClasses(column)" disable-arrow-down-opening-menu :data="contextualMenuData(column)" :menu-position="computeMenuPosition(hasFirstColumnPanelRightAligned, index)" :menu-fixed-positioning="true" :button="{ variant: 'tertiary', condensed: true }" @open="$emit(DatagridColumnsEvent.COLUMN_MENU_OPEN)" @close="$emit(DatagridColumnsEvent.COLUMN_MENU_CLOSE)" /> </div> </div> <div :data-testid="`data-grid-column-${column.field}`" class="b-data-grid-columns__column-separator-container" :class="conditionalColumnSeparatorClasses(column)" role="presentation" @mousedown="startResize(hasResizableColumns, column.field, $event)" @click.stop > <div v-if="!isFrozenColumns || (isFrozenColumns && index !== columns.length - 1)" class="b-data-grid-columns__column-separator" ></div> </div> </template> </div> <template v-if="rowActionsMaximumWidth && !isFrozenColumns"> <div role="presentation" class="b-data-grid-columns__row-actions-filler"></div> <div :ref="el => registerColumnsRefs(el, DATAGRID_ROW_ACTIONS_COLUMN_FIELD_NAME)" :aria-label="t('rowActionsColumn')" :aria-colindex="columns.length + 1" class="b-data-grid-columns__column" role="columnheader" :tabindex="areAnyColumnsFocusable ? '0' : '-1'" :style="{ minWidth: `${rowActionsMaximumWidth}px` }" ></div> </template> </div> </template> <script lang="ts" setup> import { computed, type ComputedRef, inject, type PropType, type Ref, ref, toRefs } from 'vue'; import { BentoCheckbox } from '@/components/checkbox'; import { type BentoColumn, type BentoColumnWidthOverridesLookup, type BentoDatagridAllItemsSelectedState, BentoDatagridAriaSortValue, type BentoDatagridSortByColumn, BentoDatagridSortDirection, type BentoDataGridVisibleRowsState, } from '../../data-grid.types'; import { BentoTypography } from '@/components/typography'; import { DATAGRID_ACTIONS_COLUMN_FIELD_NAME, DATAGRID_ROW_ACTIONS_COLUMN_FIELD_NAME, useResizer, useSorter, } from '../../composables'; import { focusOnInteractiveChild } from '../../utils'; import { BentoInfoIcon } from '@/components/info-icon'; import { type BentoDatagridColumnSelectionState, type BentoDatagridDraggingColumnState, DatagridColumnsEvent, type DatagridColumnsHeaderReferences, } from './data-grid-columns.types'; import { BentoMenu } from '@/components/menu'; import { BentoToggleButton } from '@/components/button'; import { DecorativeCheckbox } from '@/internal/decorative-checkbox'; import { useI18n } from '@/utils/ts/i18n'; import type { BentoMenuItem } from '@/components/menu/components'; import type { DatagridColumnHoveringState } from '@/components/data-grid/composables/use-column-hovering/use-column-hovering.types'; import { BentoPopoverPositions } from '@/components/popover/popover.types'; import ArrowUpAndDownSortIcon from '@adyen/ui-assets-icons-16/vue/arrow-up-down-sort'; import ArrowLeftIcon from '@adyen/ui-assets-icons-16/vue/arrow-left'; import ArrowRightIcon from '@adyen/ui-assets-icons-16/vue/arrow-right'; import FreezeIcon from '@adyen/ui-assets-icons-16/vue/freeze'; import HideIcon from '@adyen/ui-assets-icons-16/vue/hide'; import SortAscendingIcon from '@adyen/ui-assets-icons-16/vue/sort-ascending'; import SortDescendingIcon from '@adyen/ui-assets-icons-16/vue/sort-descending'; import ShowIcon from '@adyen/ui-assets-icons-16/vue/show'; import TriangleDown from '@adyen/ui-assets-icons-16/vue/triangle-down'; import messages from './messages.json'; import { DATA_GRID_SMALL_CONTAINER, DATA_GRID_TOUCH_DEVICE } from '@/components/data-grid/data-grid.keys'; type MessageSchema = (typeof messages)['en-US']; const props = defineProps({ /** * Column definitions using 'key' to relate to data object. */ columns: { type: Array as PropType<Array<BentoColumn>>, required: true, }, /** * Columns being dragged, contains dragging column and a target column */ draggingColumns: { type: Object as PropType<BentoDatagridDraggingColumnState>, default: undefined, }, /** * Determines the state of the current results: { filtered: boolean, allItemsVisible: boolean } to render the correct select all menu options */ visibleRowsState: { type: Object as PropType<BentoDataGridVisibleRowsState>, default: undefined, }, /** * Toggle the columns draggable events. */ hasDraggableColumns: { type: Boolean, default: true, }, /** * If resize is enabled */ hasResizableColumns: { type: Boolean, default: true, }, /** * Signifies if the column panel is enabled. */ hasColumnPanel: { type: Boolean, default: false, }, /** * If set to true, aligns the first column panel to the right of its button. */ hasFirstColumnPanelRightAligned: { type: Boolean, default: false, }, /** * If the data grid has overflow on the left side */ hasLeftOverflow: { type: Boolean, default: false, }, /** * 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, }, /** * Toggled to true when the DataGrid has auto width columns and is measuring the */ isMeasuringColumnWidths: { type: Boolean, default: false, }, /** * Used to disable hiding the last visible column. */ isOnlyOneVisibleColumn: { type: Boolean, default: false, }, /** * If these columns are frozen (pinned) columns */ isFrozenColumns: { type: Boolean, default: false, }, /** * Show frozen columns separator */ showFrozenColumnsSeparator: { type: Boolean, default: false, }, /** * The column all items selected state */ allItemsSelectedState: { type: Object as PropType<BentoDatagridAllItemsSelectedState>, default: undefined, }, /** * Determines if the user has enabled row selection */ selectable: { type: Boolean, default: false, }, /** * Adds an extra option when selecting all, to select items in other pages too */ selectableAcrossPages: { type: Boolean, default: false, }, /** * Determines which state of row selection is currently active. Used to display the "Select all" menu options. */ selectionState: { type: String as PropType<BentoDatagridColumnSelectionState>, default: undefined, }, /** * 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: () => [], }, /** * Truncates all data grid column headers */ truncateColumnHeaders: { type: Boolean, default: false, }, /** * The maximum width of the row actions in all rows */ rowActionsMaximumWidth: { type: Number, default: 0, }, }); const emit = defineEmits<{ /** * Emitted when a user manually resizes a column width. */ ( e: DatagridColumnsEvent.COLUMN_RESIZE, column: { field: string; width: number; } ); /** * Triggered when a column is resized. */ (e: DatagridColumnsEvent.COLUMN_RESIZING, value: BentoColumnWidthOverridesLookup); /** * Triggered when a column is sorted. */ (e: 'sort', updatedSortBy: Array<BentoDatagridSortByColumn>); /** * Triggered when selecting all rows. */ (e: DatagridColumnsEvent.SELECT_ALL_ROWS, areAllItemsSelected: boolean); /** * Triggered when all rows across all pages are selected. */ (e: DatagridColumnsEvent.SELECT_ALL_ACROSS_PAGES); /** * Triggered when all filtered rows are selected. */ (e: DatagridColumnsEvent.SELECT_ALL_CURRENT_RESULTS); /** * Triggered when deselecting all rows. */ (e: DatagridColumnsEvent.DESELECT_ALL); /** * Triggered when all filtered rows are deselected. */ (e: DatagridColumnsEvent.DESELECT_ALL_CURRENT_RESULTS); /** * Triggered when a columns started to be dragged. */ (e: DatagridColumnsEvent.COLUMN_DRAG_START, column: BentoColumn, event: DragEvent & Event); /** * Triggered when a column is being dragged over another DOM element. */ (e: DatagridColumnsEvent.COLUMN_DRAG_ENTER, column: BentoColumn, event: DragEvent & Event); /** * Triggered when a column is being dragged and is dropped over another DOM element. */ (e: DatagridColumnsEvent.COLUMN_DROP, column: BentoColumn); /** * Triggered when a column drag operation has ended. */ (e: DatagridColumnsEvent.COLUMN_DRAG_END); /** * Triggered when a column is being moved by a certain offset. */ (e: DatagridColumnsEvent.COLUMN_MOVE, movingColumn: BentoColumn, offset: number); /** * Triggered when a column is updated. */ (e: DatagridColumnsEvent.COLUMN_UPDATE, column: BentoColumn); /** * Triggered when the column contextual menu is opened. */ (e: DatagridColumnsEvent.COLUMN_MENU_OPEN); /** * Triggered when the column contextual menu is closed. */ (e: DatagridColumnsEvent.COLUMN_MENU_CLOSE); /** * Emitted when a column header is hovered over. */ (e: 'column-hover', columnKey: BentoColumn); }>(); const CLOSING_CONTEXTUAL_MENU_DELAY_MS = 100; const { t } = useI18n<{ message: MessageSchema }>({ messages }); const { columns, sortBy } = toRefs(props); const allColumnsRef = ref<HTMLDivElement>(); const columnHeaderRefs = ref<DatagridColumnsHeaderReferences>({}); const contextMenuRef = ref({}); const hoveredColumnField = ref(); const closingTimeOutId = ref(); // Focusing const areAnyColumnsFocusable = computed(() => props.columns.some(column => column.sortable || props.hasColumnPanel || column.infoIconTooltip) ); // Selecting const onAllRowSelect = (areAllItemsSelected: boolean) => emit(DatagridColumnsEvent.SELECT_ALL_ROWS, areAllItemsSelected); const isActionsColumn = (column: BentoColumn) => column.field === DATAGRID_ACTIONS_COLUMN_FIELD_NAME; const isAllSelectionState = computed(() => props.selectionState === 'all'); // Select all menu const selectAllMenu = computed(() => [ { text: t('selectPage') as string, handler: () => onAllRowSelect(true), disabled: props.selectionState === 'page' || props.selectionState === 'all', }, props.visibleRowsState?.filtered ? { text: t('selectAllCurrentResults') as string, handler: () => emit(DatagridColumnsEvent.SELECT_ALL_CURRENT_RESULTS), addDivider: props.selectionState !== 'none', disabled: props.selectionState === 'all', } : { text: t('selectAll') as string, handler: () => emit(DatagridColumnsEvent.SELECT_ALL_ACROSS_PAGES), addDivider: props.selectionState !== 'none', disabled: props.selectionState === 'all', }, props.selectionState !== 'none' && { text: t('deselectPage') as string, handler: () => onAllRowSelect(false), addDivider: !props.visibleRowsState?.filtered, // otherwise divider shown in deselect all in current results disabled: props.selectionState === 'unselected-page', }, props.visibleRowsState?.filtered && props.selectionState !== 'none' && { text: t('deselectAllCurrentResults') as string, handler: () => emit(DatagridColumnsEvent.DESELECT_ALL_CURRENT_RESULTS), addDivider: true, disabled: props.selectionState === 'unselected-page' && props.visibleRowsState.allRowsVisible, }, props.selectionState !== 'none' && { text: t('deselectAll') as string, handler: () => emit(DatagridColumnsEvent.DESELECT_ALL), }, ].filter(Boolean) ); // Resizing const { startResize, isResizing, columnHoverEventToEmitAfterResizing } = useResizer(columns, emit); // Sorting const { isSorting, getSortingDirection, sortByColumnField } = useSorter(sortBy, columns, emit); const getSortingColumnDirection = (column: BentoColumn): BentoDatagridAriaSortValue => { if (!column.sortable) { return null; } const sortingDirection = props.sortBy.find(({ field }) => field === column.field)?.direction; if (!sortingDirection) { return BentoDatagridAriaSortValue.NONE; } const ariaSortingDirectionMap: { [key in BentoDatagridSortDirection]: BentoDatagridAriaSortValue } = { [BentoDatagridSortDirection.ASCENDING]: BentoDatagridAriaSortValue.ASCENDING, [BentoDatagridSortDirection.DESCENDING]: BentoDatagridAriaSortValue.DESCENDING, }; return ariaSortingDirectionMap[sortingDirection]; }; // Dragging emit events and methods const draggingEventsForColumn = computed(() => (column: BentoColumn) => { if (!props.hasDraggableColumns) { return {}; } return { dragstart: (e: DragEvent & Event) => { emit(DatagridColumnsEvent.COLUMN_DRAG_START, column, e); }, dragover: (e: DragEvent) => { e.preventDefault(); }, dragenter: (e: DragEvent) => { e.preventDefault(); emit(DatagridColumnsEvent.COLUMN_DRAG_ENTER, column, e); }, drop: () => { emit(DatagridColumnsEvent.COLUMN_DROP, column); }, dragend: () => { emit(DatagridColumnsEvent.COLUMN_DRAG_END); }, }; }); // Contextual menu (moving, visiblity and freezing) const contextualMenuData = computed<(col: BentoColumn) => Array<BentoMenuItem>>( () => (column: BentoColumn) => [ { text: t('moveColumnLeft') as string, handler: () => emit(DatagridColumnsEvent.COLUMN_MOVE, column, -1), icon: ArrowLeftIcon, disabled: props.columns[0].field === column.field, }, { text: t('moveColumnRight') as string, handler: () => emit(DatagridColumnsEvent.COLUMN_MOVE, column, 1), icon: ArrowRightIcon, disabled: props.columns[props.columns.length - 1].field === column.field, }, !isSmallContainer.value && { text: (column?.frozen ? t('unfreezeColumn') : t('freezeColumn')) as string, handler: () => emit(DatagridColumnsEvent.COLUMN_UPDATE, { ...column, frozen: !column?.frozen }), icon: FreezeIcon, }, { text: (column?.visible !== false ? t('hideColumn') : t('showColumn')) as string, handler: () => { emit(DatagridColumnsEvent.COLUMN_UPDATE, { ...column, visible: !(column?.visible !== false), }); }, icon: column?.visible !== false ? HideIcon : ShowIcon, disabled: column?.mandatory || (column?.visible !== false && props.isOnlyOneVisibleColumn), }, ].filter(Boolean) ); const showContextualMenu = (column: BentoColumn) => { hoveredColumnField.value = column.field; if (closingTimeOutId.value) { clearTimeout(closingTimeOutId.value); } }; const hideContextualMenu = (column: BentoColumn) => { // We want to delay closing for a couple of seconds so // it doesn't flicker if a user hovers over the icon and then inside the popover. const timeOutId = setTimeout(() => { if (column.field === hoveredColumnField.value) { hoveredColumnField.value = ''; } }, CLOSING_CONTEXTUAL_MENU_DELAY_MS); closingTimeOutId.value = timeOutId; }; /** * Generates events for a Datagrid Column. * @param column - the BentoColumn we are attaching events onto. */ const dataGridColumnEvents = (column: BentoColumn) => { let events: { [eventName: string]: (e: Event) => void } = { mouseenter: () => { showContextualMenu(column); if (!isResizing.value) { emit('column-hover', column); } else { columnHoverEventToEmitAfterResizing.value = column; } }, mouseleave: () => { hideContextualMenu(column); if (!isResizing.value) { emit('column-hover', null); } else { columnHoverEventToEmitAfterResizing.value = null; } }, focus: () => { showContextualMenu(column); if (isActionsColumn(column)) { focusOnInteractiveChild(columnHeaderRefs.value[column.field]); } }, focusout: (e: FocusEvent) => { if (!allColumnsRef.value?.contains(e?.relatedTarget as Node)) { hideContextualMenu(column); } }, }; if (!isActionsColumn(column)) { events = { ...events, ...draggingEventsForColumn.value(column), }; } return events; }; const hasColumnActions = (column: BentoColumn) => column.infoIconTooltip || column.sortable || props.hasColumnPanel; const registerColumnsRefs = (el: unknown, field: string) => { if (columnHeaderRefs.value?.[field] === undefined) { columnHeaderRefs.value = { ...columnHeaderRefs.value, [field]: null }; } if (el !== null) { columnHeaderRefs.value[field] = el as HTMLDivElement; } }; const isSmallContainer: ComputedRef<boolean> = inject( DATA_GRID_SMALL_CONTAINER, computed(() => false) ); const isTouchDevice: Ref<boolean> = inject(DATA_GRID_TOUCH_DEVICE, ref(false)); // Conditional column styles // Conditional styles const conditionalColumnsClasses = computed(() => { const defaultConditionalClasses = { 'b-data-grid-columns--is-measuring': props.isMeasuringColumnWidths, }; return columns.value[0]?.frozen && !isSmallContainer.value ? { ...defaultConditionalClasses, 'b-data-grid-columns--frozen': true, 'b-data-grid-columns--frozen-separator': props.showFrozenColumnsSeparator, 'b-data-grid-columns--overflow-shadow-left': props.hasLeftOverflow, } : { ...defaultConditionalClasses, 'b-data-grid-columns--unfrozen': true, }; }); const conditionalSelectColumnClasses = computed(() => ({ 'b-data-grid-columns__select-column--all': props.selectableAcrossPages, })); const computeMenuPosition = (hasFirstColumnPanelRightAligned, index) => hasFirstColumnPanelRightAligned && index === 0 ? BentoPopoverPositions.BOTTOM_START : BentoPopoverPositions.BOTTOM_END; const conditionalColumnClasses = (column: BentoColumn, columnIndex: number) => ({ 'b-data-grid-columns__column--column-hovered': column.field === props.hoveredOverColumnState.hoveredOverColumnField, 'b-data-grid-columns__column--column-hovered-no-border-left': column.field === props.hoveredOverColumnState.hoveredOverColumnField && props.hoveredOverColumnState.noBorderLeft, 'b-data-grid-columns__column--column-hovered-no-border-right': column.field === props.hoveredOverColumnState.hoveredOverColumnField && props.hoveredOverColumnState.noBorderRight, 'b-data-grid-columns__column--last-frozen-frozen-column': props.isFrozenColumns && columnIndex === columns.value.length - 1, 'b-data-grid-columns__column--sortable': column.sortable, 'b-data-grid-columns__column--padding-left-unset': column?.padding?.left === false, 'b-data-grid-columns__column--padding-right-unset': column?.padding?.right === false, 'b-data-grid-columns__column--numeric': column?.numeric, 'b-data-grid-columns__column--frozen': column?.frozen, 'b-data-grid-columns__column--is-re-ordering': column?.field === props.draggingColumns?.dragging?.field, 'b-data-grid-columns__column--is-re-ordering-target-left': column?.field === props.draggingColumns?.target?.field && props.draggingColumns?.dragToLeft, 'b-data-grid-columns__column--is-re-ordering-target-right': column?.field === props.draggingColumns?.target?.field && !props.draggingColumns?.dragToLeft, 'b-data-grid-columns__column--with-sorting-or-column-panel': column.sortable || column.infoIconTooltip || props.hasColumnPanel || props.selectableAcrossPages, 'b-data-grid-columns--small-container': isSmallContainer.value, }); const conditionalColumnHeaderAndActionsClasses = (column: BentoColumn) => ({ 'b-data-grid-columns__column-header-and-actions--numeric': column?.numeric, }); const conditionalColumnActionsClasses = (column: BentoColumn) => ({ 'b-data-grid-columns__column-actions--numeric': column?.numeric, }); const conditionalColumnSeparatorClasses = (column: BentoColumn) => ({ 'b-data-grid-columns__column-separator-container--is-resizeable': props.hasResizableColumns, 'b-data-grid-columns__column-separator-container--is-offset': props.hoveredOverColumnState.hoveredOverColumnField === column.field && props.hoveredOverColumnState.isSeparatorOffset, }); const conditionalColumnHeaderClasses = computed(() => ({ 'b-data-grid-columns__column-header--truncated': props.truncateColumnHeaders, })); const conditionalColumnMenuClasses = (column: BentoColumn) => ({ 'b-data-grid-columns__menu--hovered': hoveredColumnField.value === column.field || isTouchDevice.value, }); // Exposed refs defineExpose({ columnHeaderRefs, }); </script> <script lang="ts"> /** * A component to display the BentoDataGrid columns * * @example * * export default { * components: { DataGridColumns }, * template: ` * <data-grid-columns * v-if="columnSet?.length" * :key="`data-grid-columns-${id}`" * :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" * :is-only-one-visible-column="isOnlyOneVisibleColumn" * :selectable="selectable" * :selectable-across-pages="selectableAcrossPagesComputed" * :truncate-column-headers="truncateColumnHeaders" * v-on="draggingEventsForColumns" * @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)" * @sort="onSort" * > * <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> * <slot :name="innerSlot" v-bind="scope" /> * </template> * </data-grid-columns> * ` * } */ export default { i18n: { messages }, name: 'data-grid-columns', }; </script> <style lang="scss" scoped src="./data-grid-columns.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}`" :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 } ); }>(); // Refs const gridcellRefs = ref<{ [key: string]: HTMLDivElement }>({}); const isNestedContentShown = ref(false); const nestedContentRef = ref(); const rowActionsRef = ref(); const nestedContentSlotId = generateUid('nested-content-slot'); const isRowActionMenuOpen = ref(false); const focusableItems = ref<Array<{ node: HTMLElement }>>(); onMounted(() => { if (rowActionsRef.value) { focusableItems.value = makeAllInteractiveChildrenUnFocusable(rowActionsRef.value); } }); 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 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, make the first interactive child focusable and focus it. * This allows for keyboard navigation into the cell's actions. */ const onRowActionsCellFocus = () => { makeAllInteractiveChildrenFocusable([focusableItems.value?.[0]]); focusOnInteractiveChild(rowActionsRef.value); }; /** * 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 = () => { makeAllInteractiveChildrenUnFocusable(rowActionsRef.value); }; /** * 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) => { if (e.key !== EventKey.TAB) { makeAllInteractiveChildrenUnFocusable(rowActionsRef.value); } if ( e.key === EventKey.ARROW_RIGHT && focusableItems.value?.length > 1 && focusableItems.value[0].node === document.activeElement ) { makeAllInteractiveChildrenUnFocusable(rowActionsRef.value); 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 ) { makeAllInteractiveChildrenUnFocusable(rowActionsRef.value); 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, 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" 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 +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="emit(BentoDatagridEvent.NESTED_CONTENT_TOGGLED, $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, 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 { DATAGRID_ROW_ACTIONS_COLUMN_FIELD_NAME, useCalculateColumnWidth, useColumnHovering, useColumnReorderer, useConfigSettings, useKeyboardNavigator, useLazyLoading, useScroller, useSelector, } from './composables'; 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, }); /* == 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, computed(() => props.rowActions !== undefined), 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; }); } }; const registerRowCellRefs = (el: Element | ComponentPublicInstance, rowIndex: number) => { // We always know this will be used on a DataGridCell component so we can cast it const gridRowComponent = el as InstanceType<typeof DataGridRow>; if (el !== null) { if (gridRowComponent?.gridcellRefs) { const areRowActions = props.rowActions !== undefined && gridRowComponent?.rowActionsRef; Object.entries(gridRowComponent?.gridcellRefs).map(([field, cell]) => { if (gridcellRefs.value?.[field] === undefined) { gridcellRefs.value = { ...gridcellRefs.value, [field]: [] }; } gridcellRefs.value[field][rowIndex] = cell as HTMLDivElement; }); // This calculates the maxium width of the row actions column so we can add the same padding to the columns keep everything aligned if (areRowActions) { if (gridcellRefs.value?.[DATAGRID_ROW_ACTIONS_COLUMN_FIELD_NAME] === undefined) { gridcellRefs.value = { ...gridcellRefs.value, [DATAGRID_ROW_ACTIONS_COLUMN_FIELD_NAME]: [] }; } gridcellRefs.value[DATAGRID_ROW_ACTIONS_COLUMN_FIELD_NAME][rowIndex] = gridRowComponent .rowActionsRef?.[0] as HTMLDivElement; rowActionsMaxWidth.value = Math.max( rowActionsMaxWidth.value, gridRowComponent.rowActionsRef?.[0]?.offsetWidth ?? 0 ); } } } }; /* == Row methods == */ // Row selecting const onRowClick = (row: BentoDatagridDataItem, event: MouseEvent) => { if (props.allowRowClicks) { emit(BentoDatagridEvent.ROW_CLICK, row, event); } }; // 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, 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 +1 @@
1
- <template> <div class="b-date-range-picker-calendar"> <!-- Form --> <div class="b-date-range-picker-calendar__form-container" :class="computedFormContainerClasses" data-testid="date-range-picker-calendar-form-container" > <div class="b-date-range-picker-calendar__form"> <template v-if="hasSlot('title')"> <slot name="title" /> </template> <bento-dropdown v-if="quickSelectRanges" :aria-label="t('customRange')" :items="quickSelectRangeDefaultItems" :model-value="selectedRangeSelectorValue" @update:model-value="onRangeSelectorInput" /> <bento-segmented-control v-if="granularities" :items="granularityItems" :model-value="internalRangeDate.granularity" full-width @update:model-value="onGranularityInput" > </bento-segmented-control> <div> <bento-typography stronger el="span" class="b-date-range-picker-calendar__label"> From </bento-typography> <div :class="formInputWrapperConditionalClasses"> <bento-input-field :aria-label="t('dateFrom')" :model-value="dateTextInput.startDate" :error="startDateError" class="b-date-range-picker-calendar__form-input" @update:model-value="onStartDateInput" @keydown="onDateInputKeyDown" > <template #description>{{ t(dateFormat) }}</template> </bento-input-field> <bento-input-field v-if="allowTimeInput" :aria-label="t('timeFrom')" :model-value="dateTextInput.startTime" :error="startTimeError" class="b-date-range-picker-calendar__form-input" @update:model-value="onStartTimeInput" @keydown="onTimeInputKeyDown" > <template #description>HH:MM:SS</template> </bento-input-field> </div> </div> <div> <bento-typography stronger el="span" class="b-date-range-picker-calendar__label"> To </bento-typography> <div :class="formInputWrapperConditionalClasses"> <bento-input-field class="b-date-range-picker-calendar__form-input" :aria-label="t('dateTo')" :model-value="dateTextInput.endDate" :error="endDateError" @update:model-value="onEndDateInput" @keydown="onDateInputKeyDown" > <template #description>{{ t(dateFormat) }}</template> </bento-input-field> <bento-input-field v-if="allowTimeInput" :aria-label="t('timeTo')" :model-value="dateTextInput.endTime" :error="endTimeError" class="b-date-range-picker-calendar__form-input" @update:model-value="onEndTimeInput" @keydown="onTimeInputKeyDown" > <template #description>HH:MM:SS</template> </bento-input-field> </div> </div> </div> <div v-if="hasSlot('actions')"> <slot name="actions" /> </div> </div> <!-- Calendar --> <div v-if="!hasNoCalendars" class="b-date-range-picker-calendar__calendars-container" data-testid="date-range-picker-calendar-calendar-container" > <calendar :value="internalRangeDate" :first-day-of-week="firstDayOfWeek" :is-date-disabled="isDateDisabled" :max-range="maxRange" :min="computedMinMax.min" :max="computedMinMax.max" :number-of-months="numberOfMonths" :show-end-date-on-open="showEndDateOnOpen" :granularity="selectedGranularity" :variant="variant" is-range @input="onDateInput" @start-date-selected="onStartDateSelected" @end-date-selected="onEndDateSelected" /> </div> </div> </template> <script setup lang="ts"> import { computed, onMounted, type PropType, reactive, ref, toRaw, useSlots } from 'vue'; import { Calendar, type CalendarGranularityType } from '@/internal'; import { BentoTypography } from '@/components/typography'; import { BentoInputField } from '@/components/input-field'; import BentoDropdown from '@/components/dropdown/dropdown.vue'; import { BentoSegmentedControl, type BentoSegmentedControlItem } from '@/components/segmented-control'; import { useI18n } from '@/utils/ts/i18n'; import { debounce } from '@/utils/ts/debounce'; import { useDateInputFormatter, useHasSlot } from '@/composables'; import { useGranularityAdjustments } from '@/components/internal/calendar/composables/use-granularity-adjustments'; import { useGranularMinMaxDate } from '@/components/internal/calendar/components/calendar-month/composables/granular-min-max-date'; import { useGranularityMemory } from '../../composables/use-granularity-memory'; import { DateRangePickerCalendarEvent, type DateRangePickerCalendarFormData, type DateRangePickerCalendarRangeSelectorItems, } from './date-range-picker-calendar.types'; import type { BentoDateRangePickerGranularityConfig, BentoDateRangePickerValue, } from '../../date-range-picker.types'; import { endOfDay } from 'date-fns/endOfDay'; import { setHours } from 'date-fns/setHours'; import { setMinutes } from 'date-fns/setMinutes'; import { setSeconds } from 'date-fns/setSeconds'; import { startOfDay } from 'date-fns/startOfDay'; import { isSameSecond } from 'date-fns/isSameSecond'; import { isSameDay } from 'date-fns/isSameDay'; import { isEqual } from 'date-fns/isEqual'; import { isToday } from 'date-fns/isToday'; import { setMilliseconds } from 'date-fns/setMilliseconds'; import { dateToTimeInputString } from '@/utils/ts/format-date/format-date'; import { formatTimeInputValue, validateTimeInput } from '@/utils/ts/time-input'; import messages from './messages.json'; const FORM_INPUT_DEBOUNCE_TIME = 300; const RANGE_SELECTOR_CUSTOM_RANGE_KEY = 'customRange'; type MessageSchema = (typeof messages)['en-US']; const props = defineProps({ /** * Allows user to enter time values in the form */ allowTimeInput: { type: Boolean, default: false }, /** * Ranges form persistance data */ dateFormData: { type: Object as PropType<DateRangePickerCalendarFormData>, default: () => ({ startDate: undefined, endDate: undefined, startTime: undefined, endTime: undefined }), }, /** * Allows you to set the first day of the week. 0 is sunday, 1 is monday, 6 is saturday */ firstDayOfWeek: Calendar.props.firstDayOfWeek, /** * A list of available granularities. * If present, the date picker will display a segmented control to change granularity. */ granularities: { type: Array as PropType<Array<BentoDateRangePickerGranularityConfig>>, default: null, }, /** * Indicate if a date should be disabled or not */ isDateDisabled: Calendar.props.isDateDisabled, /** * Set a maximum number of dates to be selectable by the range. */ maxRange: Calendar.props.maxRange, /** * Sets a minimum limit for selectable dates - dates before `min` will not be selectable */ min: Calendar.props.min, /** * Sets a maximum limit for selectable dates - dates after `max` will not be selectable */ max: Calendar.props.max, /** * Number of months rendered on pane */ numberOfMonths: { type: Calendar.props.numberOfMonths.type, default: Calendar.props.numberOfMonths.default, validator: (n: number) => n >= 0, }, /** * Displays the end date's month when the calendar is opened. Defaults to false i.e. the start date's month is displayed. */ showEndDateOnOpen: { type: Boolean, default: false }, /** * Enables the custom range selector. * If provided, must be an array that sets the custom range dropdown items. Items are of the structure: * `label` - label of custom range item. * `value` - a unique key of the custom range item. * `data` - an object `{ startDate: Date; endDate: Date }` to set the date picker range to upon selecting. */ quickSelectRanges: { type: Array as PropType<DateRangePickerCalendarRangeSelectorItems>, default: undefined, }, /** * Selected date */ value: { type: Object as PropType<BentoDateRangePickerValue>, default: undefined }, /** * The type of calendar to display. Defaults to showing days. */ variant: Calendar.props.variant, }); const emit = defineEmits([ DateRangePickerCalendarEvent.CUSTOM_RANGE, DateRangePickerCalendarEvent.INPUT, DateRangePickerCalendarEvent.ERROR, DateRangePickerCalendarEvent.FORM_DATE, DateRangePickerCalendarEvent.START_DATE_SELECTED, ]); const slots = useSlots(); const hasSlot = useHasSlot(slots); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const { dateFormat, isFormatValid, parseDate, onKeyDown, autoFormat, formatDate: formatDateUtil, } = useDateInputFormatter(); const calculatedIsRelativeToNowQuickSelectRanges = ref({}); const defaultGranularity = computed(() => (props.variant === 'month' ? 'monthly' : 'daily')); const hasNoCalendars = computed(() => props.numberOfMonths === 0); const internalRangeDate = reactive<BentoDateRangePickerValue>({ startDate: props.value?.startDate, endDate: props.value?.endDate, range: props.value?.range, ...(props.granularities ? { granularity: props.value?.granularity || defaultGranularity.value, } : {}), }); /** * Currently range selector dropdown item that is selected. * The logic will try and find in each range item: * - If an endDate does not exist, then only check if the startDate day matches and if today's day matches. * - if time input has been enabled, then check if the seconds match. * - if an endDate does exist then just check if the startDate day and the endDate day match. * * Note: All date comparisons use local time consistently. Both sides of each comparison * originate from the same timezone context, so no UTC normalization is needed. */ const selectedRangeSelectorValue = computed(() => { return ( // eslint-disable-next-line consistent-return props?.quickSelectRanges?.find(({ value, data }) => { if (!data.endDate) { // Check if this quick select range is explicitly marked as "relative to now" if (data.isRelativeToNow) { // Retrieve previously calculated start/end dates for this relative range. // We use these if the user has already clicked on this time of quick range const newlyCalculatedQuickSelectDates = calculatedIsRelativeToNowQuickSelectRanges.value?.[value]; return newlyCalculatedQuickSelectDates ? isEqual(newlyCalculatedQuickSelectDates.startDate, internalRangeDate.startDate) && isEqual(newlyCalculatedQuickSelectDates.endDate, internalRangeDate.endDate) : // If not stored calculated dates, fall back to comparing the quick select item's value // with the internal range's stored range identifier. value === internalRangeDate.range; } // This block handles quick select ranges that do not have an endDate // AND are NOT explicitly marked as 'isRelativeToNow'. // This means the endDate will be today at the end of the day. // Uses isEqual for startDate to prevent false matches (e.g. same-day selection // accidentally matching a "This week" range). When time input is enabled, // also verify the end time is at end of day so that manual time changes // correctly fall back to "Custom range". return ( isEqual(data.startDate, internalRangeDate.startDate) && isToday(internalRangeDate.endDate) && (!props.allowTimeInput || isSameSecond(internalRangeDate.endDate, endOfDay(internalRangeDate.endDate))) && (props.granularities ? data.granularity === internalRangeDate.granularity : true) ); } if (props.allowTimeInput) { const res = isSameSecond(data.startDate, internalRangeDate.startDate) && isSameSecond(data.endDate, internalRangeDate.endDate) && (props.granularities ? data.granularity === internalRangeDate.granularity : true); return res; } if (data.endDate) { return ( isSameDay(data.startDate, internalRangeDate.startDate) && isSameDay(data.endDate, internalRangeDate.endDate) && (props.granularities ? data.granularity === internalRangeDate.granularity : true) ); } })?.value ?? RANGE_SELECTOR_CUSTOM_RANGE_KEY ); }); const formInputWrapperConditionalClasses = computed(() => ({ 'b-date-range-picker-calendar__form-input-wrapper': props.allowTimeInput, })); const quickSelectRangeDefaultItems = computed(() => [ { label: t('customRange'), value: RANGE_SELECTOR_CUSTOM_RANGE_KEY }, ...(props.quickSelectRanges ? props.quickSelectRanges : []), ]); const selectedGranularity = computed( () => props.granularities?.find(({ type }) => type === internalRangeDate.granularity) || null ); const granularityItems = computed<Array<BentoSegmentedControlItem>>( () => props.granularities?.map(({ type, disabled }) => ({ label: t(type), value: type, disabled, })) ?? [] ); const { getGranularCalendarLimits } = useGranularMinMaxDate(props); const computedMinMax = computed(() => props.granularities ? getGranularCalendarLimits(selectedGranularity.value) : { min: props.min ? startOfDay(props.min) : null, max: props.max ? endOfDay(props.max) : null } ); const { adjustDateForQuarterly, adjustDateForMonthly, adjustDateForWeekly, adjustStartDateInputForWeekly, adjustEndDateInputForWeekly, adjustStartDateInputForMonthly, adjustEndDateInputForMonthly, adjustStartDateInputForQuarterly, adjustEndDateInputForQuarterly, } = useGranularityAdjustments(props.firstDayOfWeek); const { savedRanges, saveRange, resetSavedRanges } = useGranularityMemory(); /** * Format date to a string value in current format * @param dateToFormat - Date to be formatted */ const formatDate = (dateToFormat?: Date) => { if (!dateToFormat) { return ''; } return formatDateUtil(dateToFormat); }; // Form error flags const startDateError = ref(false); const endDateError = ref(false); const startTimeError = ref(false); const endTimeError = ref(false); // Form input text used in range variant const dateTextInput = reactive({ startDate: props.dateFormData.startDate, endDate: props.dateFormData.endDate, startTime: props.dateFormData.startTime, endTime: props.dateFormData.endTime, }); /** * Sets the time of a date object from a time string. * @param dateToSetTimeIn The date object to set the time in. * @param time The time string in HH:MM:SS format. * @returns A new Date object with the time set. */ const setTimeInDateObject = (dateToSetTimeIn: Date, time: string) => { let dateWithTime = new Date(dateToSetTimeIn); const [hours, minutes, seconds] = time.split(':'); dateWithTime = setHours(dateWithTime, parseInt(hours, 10)); dateWithTime = setMinutes(dateWithTime, parseInt(minutes, 10)); dateWithTime = setSeconds(dateWithTime, parseInt(seconds, 10)); return dateWithTime; }; const isValidTimeString = (timeText: string) => /^(?:[01]\d|2[01234]):(?:[012345]\d):(?:[012345]\d)$/.test(timeText); /** * Checks if a time string is valid and within the min/max bounds if a date is provided. * @param timeText The time string to validate. * @param date The date string to check the time against. * @returns True if the time is valid, false otherwise. */ const isValidTime = (timeText: string, date?: string) => { const isTimeAllowed = () => { if (date && isValidDate(date)) { const dateToCheckWithTime = setTimeInDateObject(parseDate(date), timeText); // Check if date is between min/max bounds, if there are any if ( (props.min && dateToCheckWithTime < setMilliseconds(props.min, 0)) || (props.max && dateToCheckWithTime > setMilliseconds(props.max, 0)) ) { return false; } } return true; }; return isValidTimeString(timeText) && isTimeAllowed(); }; /** * Sets the time in a date object if the time is valid and the variant is not 'month'. * @param date The date object to modify. * @param time The time string to set. * @returns The modified date object or the original if time is not set. */ const setTimeInDateRef = (date: Date, time: string) => { // Do not set time if month variant if (!time || !isValidTimeString(time) || props.variant === 'month') { return date; } return setTimeInDateObject(new Date(date), time); }; const onDateInput = (selectedRange: BentoDateRangePickerValue) => { dateTextInput.startTime = dateToTimeInputString( props.min && props.allowTimeInput && isSameDay(props.min, selectedRange.startDate) ? props.min : startOfDay(selectedRange.startDate) ); dateTextInput.endTime = dateToTimeInputString( props.max && props.allowTimeInput && isSameDay(props.max, selectedRange.endDate) ? props.max : endOfDay(selectedRange.endDate) ); internalRangeDate.startDate = setTimeInDateRef(selectedRange.startDate, dateTextInput.startTime); internalRangeDate.endDate = setTimeInDateRef(selectedRange.endDate, dateTextInput.endTime); // Resetting internal range date when selecting a custom range through the calendar internalRangeDate.range = null; if (props.granularities) { // Reset saved ranges on input resetSavedRanges(); } emit(DateRangePickerCalendarEvent.INPUT, { ...internalRangeDate, range: selectedRangeSelectorValue.value, }); }; /** * Checks if a date string is in current format and if the date is enabled. * @param dateText The date string to validate. * @returns True if the date is valid and enabled, false otherwise. */ const isValidDate = (dateText: string) => { if (!dateText) { return false; } // Is date selectable const isDateEnabled = (date: Date) => { // Check if date is between or equal to min/max bounds // Set the time to the start of the day to disregard time if ( (props.min && startOfDay(date) < startOfDay(props.min)) || (props.max && startOfDay(date) > startOfDay(props.max)) ) { return false; } // Check if function is set, otherwise all dates are enabled if (props.isDateDisabled) { return !props.isDateDisabled(startOfDay(date)); } return true; }; // Date matches text regex and is not disabled return isFormatValid(dateText) && isDateEnabled(parseDate(dateText)); }; /** * When mounted, set the input error states to true if the values are not valid */ onMounted(() => { // Start date validation startDateError.value = !!(props.dateFormData.startDate && !isValidDate(props.dateFormData.startDate)); // Start time validation if (props.allowTimeInput) { startTimeError.value = !!( props.dateFormData.startTime && !isValidTime(props.dateFormData.startTime, props.dateFormData.startDate) ); } else { startTimeError.value = false; } // End date validation endDateError.value = !!(props.dateFormData.endDate && !isValidDate(props.dateFormData.endDate)); // End time validation if (props.allowTimeInput) { endTimeError.value = !!( props.dateFormData.endTime && !isValidTime(props.dateFormData.endTime, props.dateFormData.endDate) ); } else { endTimeError.value = false; } }); const onStartDateSelected = (newStartDate: Date) => { dateTextInput.startDate = formatDate(newStartDate); if (!dateTextInput.startTime) { // Set the time to the beginning of the day dateTextInput.startTime = dateToTimeInputString(newStartDate); } else if (startTimeError.value) { // Set the form time if it was previously set dateTextInput.startTime = props.dateFormData?.startTime; } // Reset errors startDateError.value = false; startTimeError.value = false; emit(DateRangePickerCalendarEvent.START_DATE_SELECTED, newStartDate); }; const onEndDateSelected = (newEndDate: Date) => { dateTextInput.endDate = formatDate(newEndDate); if (!dateTextInput.endTime) { // Set the time to the end of the day dateTextInput.endTime = dateToTimeInputString(endOfDay(newEndDate)); } else if (endTimeError.value) { // Set the form time if it was previously set dateTextInput.endTime = props.dateFormData?.endTime; } // Reset errors endDateError.value = false; endTimeError.value = false; }; const getDate = (keyName: keyof BentoDateRangePickerValue) => { const dateProp = (props.value as BentoDateRangePickerValue)[keyName]; if (dateProp) { return dateProp; } if (props.dateFormData[keyName] && isValidDate(props.dateFormData[keyName])) { return parseDate(props.dateFormData[keyName]); } return undefined; }; const validateStartDate = debounce((startDateText: string) => { if (isValidDate(startDateText)) { let dateText = startDateText; startDateError.value = false; switch (selectedGranularity.value?.type) { case 'weekly': dateText = adjustStartDateInputForWeekly( startDateText, formatDate, computedMinMax.value.min, computedMinMax.value.max ); dateTextInput.startDate = dateText; break; case 'monthly': dateText = adjustStartDateInputForMonthly( startDateText, formatDate, computedMinMax.value.min, computedMinMax.value.max ); dateTextInput.startDate = dateText; break; case 'quarterly': dateText = adjustStartDateInputForQuarterly( startDateText, formatDate, computedMinMax.value.min, computedMinMax.value.max ); dateTextInput.startDate = dateText; break; case 'daily': default: break; } const startDate = startOfDay(parseDate(dateText)); internalRangeDate.startDate = setTimeInDateRef(startDate, dateTextInput.startTime); internalRangeDate.endDate = setTimeInDateRef( startOfDay(parseDate(dateTextInput.endDate)), dateTextInput.endTime ); if (isValidDate(dateTextInput.endDate)) { internalRangeDate.endDate = setTimeInDateRef( startOfDay(parseDate(dateTextInput.endDate)), dateTextInput.endTime ); } const endDate = getDate('endDate'); // Resetting internal range date when selecting a custom range through the calendar internalRangeDate.range = null; emit(DateRangePickerCalendarEvent.FORM_DATE, { startDate, endDate, }); emit(DateRangePickerCalendarEvent.INPUT, { ...internalRangeDate, range: selectedRangeSelectorValue.value, }); } else { // Indicate form error startDateError.value = true; // Emit Date picker calendar error emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); } }, FORM_INPUT_DEBOUNCE_TIME); const onStartDateInput = (startDateText: string) => { // Set the date as string when input through the form dateTextInput.startDate = autoFormat(startDateText); validateStartDate(dateTextInput.startDate); }; const validateEndDate = debounce((endDateText: string) => { if (isValidDate(endDateText)) { let dateText = endDateText; endDateError.value = false; switch (selectedGranularity.value?.type) { case 'weekly': dateText = adjustEndDateInputForWeekly( endDateText, formatDate, computedMinMax.value.min, computedMinMax.value.max ); dateTextInput.endDate = dateText; break; case 'monthly': dateText = adjustEndDateInputForMonthly( endDateText, formatDate, computedMinMax.value.min, computedMinMax.value.max ); dateTextInput.endDate = dateText; break; case 'quarterly': dateText = adjustEndDateInputForQuarterly( endDateText, formatDate, computedMinMax.value.min, computedMinMax.value.max ); dateTextInput.endDate = dateText; break; case 'daily': default: break; } // Format date string to Date object const endDate = startOfDay(parseDate(dateText)); internalRangeDate.endDate = setTimeInDateRef(endDate, dateTextInput.endTime); if (isValidDate(dateTextInput.startDate)) { internalRangeDate.startDate = setTimeInDateRef( startOfDay(parseDate(dateTextInput.startDate)), dateTextInput.startTime ); } const startDate = getDate('startDate'); // Resetting internal range date when selecting a custom range through the calendar internalRangeDate.range = null; emit(DateRangePickerCalendarEvent.FORM_DATE, { startDate, endDate, }); emit(DateRangePickerCalendarEvent.INPUT, { ...internalRangeDate, range: selectedRangeSelectorValue.value, }); } else { // Indicate form error endDateError.value = true; // Emit Date picker calendar error emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); } }, FORM_INPUT_DEBOUNCE_TIME); const onEndDateInput = (endDateText: string) => { // Set the date as string when input through the form dateTextInput.endDate = autoFormat(endDateText); validateEndDate(dateTextInput.endDate); }; const onDateInputKeyDown = (event: KeyboardEvent) => { onKeyDown(event); }; const validateStartTime = debounce((startTimeText: string) => { if (isValidTime(startTimeText, props.dateFormData.startDate)) { startTimeError.value = false; internalRangeDate.startDate = setTimeInDateRef(internalRangeDate.startDate, startTimeText); const endDate = getDate('endDate'); emit(DateRangePickerCalendarEvent.FORM_DATE, { startDate: internalRangeDate.startDate, endDate, startTime: startTimeText, endTime: dateTextInput.endTime ?? props.dateFormData.endTime, }); emit(DateRangePickerCalendarEvent.INPUT, { ...internalRangeDate, range: selectedRangeSelectorValue.value, }); } else { // Indicate form error startTimeError.value = true; // Emit Date picker calendar error emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); } }); const validateEndTime = debounce((endTimeText: string) => { if (isValidTime(endTimeText, props.dateFormData.endDate)) { endTimeError.value = false; internalRangeDate.endDate = setTimeInDateRef(internalRangeDate.endDate, endTimeText); emit(DateRangePickerCalendarEvent.FORM_DATE, { startDate: internalRangeDate.startDate, endDate: internalRangeDate.endDate, startTime: dateTextInput.startTime ?? props.dateFormData.startTime, endTime: endTimeText, }); emit(DateRangePickerCalendarEvent.INPUT, { ...internalRangeDate, range: selectedRangeSelectorValue.value, }); } else { // Indicate form error endTimeError.value = true; // Emit Date picker calendar error emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); } }); const onStartTimeInput = (startTimeText: string) => { dateTextInput.startTime = formatTimeInputValue(startTimeText); validateStartTime(dateTextInput.startTime); }; const onEndTimeInput = (endDateText: string) => { dateTextInput.endTime = formatTimeInputValue(endDateText); validateEndTime(dateTextInput.endTime); }; const onTimeInputKeyDown = (event: KeyboardEvent) => { validateTimeInput(event); }; const setInternalRange = ( startDate: Date, endDate: Date, granularity?: CalendarGranularityType, range?: string ) => { internalRangeDate.startDate = startDate; internalRangeDate.endDate = endDate; if (granularity) { internalRangeDate.granularity = granularity; } if (range) { internalRangeDate.range = range; } }; const onRangeSelectorInput = (newCustomRangeValue: string) => { const foundResult = props.quickSelectRanges?.find(({ value }) => value === newCustomRangeValue); if (foundResult) { resetSavedRanges(); const date = foundResult.data; const newEndDate = date.endDate || (date.isRelativeToNow ? new Date(Date.now()) : endOfDay(new Date(Date.now()))); // If the time difference between start and end dates is available, // calculating start date dynamically const newStartDate = date.isRelativeToNow && date.timeDifference ? new Date(newEndDate.getTime() - date.timeDifference) : date.startDate; // Set Form input Dates dateTextInput.endDate = formatDate(newEndDate); dateTextInput.startDate = formatDate(newStartDate); setInternalRange(newStartDate, newEndDate, null, newCustomRangeValue); if (props.granularities) { internalRangeDate.granularity = date.granularity || defaultGranularity.value; } // Set Form input time to quick select range time dateTextInput.startTime = dateToTimeInputString(newStartDate); dateTextInput.endTime = dateToTimeInputString(newEndDate); // Set calendar selection internalRangeDate.startDate = newStartDate; internalRangeDate.endDate = newEndDate; if (date.isRelativeToNow) { // Store this calculated start and end dates to be used in the selectedRangeSelectorValue logic calculatedIsRelativeToNowQuickSelectRanges.value = { ...calculatedIsRelativeToNowQuickSelectRanges.value, [newCustomRangeValue]: { startDate: newStartDate, endDate: newEndDate, }, }; } // Emit everytime the date range changes emit(DateRangePickerCalendarEvent.CUSTOM_RANGE, { ...foundResult.data, startDate: newStartDate, endDate: newEndDate, range: foundResult.value, }); } }; const adjustDate = { weekly: adjustDateForWeekly, monthly: adjustDateForMonthly, quarterly: adjustDateForQuarterly, }; const onGranularityInput = (value: CalendarGranularityType) => { const previousValue = internalRangeDate.granularity; internalRangeDate.granularity = value; // Save previously selected range if (previousValue) { saveRange(previousValue, structuredClone(toRaw(internalRangeDate))); } if (internalRangeDate.startDate && internalRangeDate.endDate) { // Adjust date range based on new granularity switch (value) { case 'quarterly': case 'monthly': case 'weekly': { if (savedRanges[value]) { setInternalRange(savedRanges[value].startDate, savedRanges[value].endDate, value); break; } const { startDate, endDate } = adjustDate[value]( internalRangeDate.startDate, internalRangeDate.endDate, computedMinMax.value.min, computedMinMax.value.max ); setInternalRange(startDate, endDate, value); break; } case 'daily': if (savedRanges.daily) { setInternalRange(savedRanges.daily.startDate, savedRanges.daily.endDate, value); } break; default: break; } dateTextInput.startDate = formatDate(internalRangeDate.startDate); dateTextInput.endDate = formatDate(internalRangeDate.endDate); emit(DateRangePickerCalendarEvent.INPUT, { ...internalRangeDate, range: selectedRangeSelectorValue.value, }); } }; const computedFormContainerClasses = computed(() => ({ 'b-date-range-picker-calendar__form-container--no-calendars': hasNoCalendars.value, })); </script> <script lang="ts"> /** * Calendar for range dates. Contains the logic to handle the range forms. * * @example * import DatePickerCalendarRange from './date-range-picker-calendar.vue'; * * export default { * components: { DatePickerCalendarRange }, * template: ` * <date-range-picker-calendar * :value="{startDate: new Date(), endDate: new Date() }" * :first-day-of-week="BentoDatePickerFirstDayOfWeek.MONDAY" * :is-date-disabled="(date: Date) => boolean" * :number-of-months="2" * @input="({ startDate, endDate }) => void" * /> * ` * } */ export default { name: 'date-range-picker-calendar', i18n: { messages }, }; </script> <style lang="scss" scoped src="./date-range-picker-calendar.scss" />
1
+ <template> <div class="b-date-range-picker-calendar"> <!-- Form --> <div class="b-date-range-picker-calendar__form-container" :class="computedFormContainerClasses" data-testid="date-range-picker-calendar-form-container" > <div class="b-date-range-picker-calendar__form"> <template v-if="hasSlot('title')"> <slot name="title" /> </template> <bento-dropdown v-if="quickSelectRanges" :aria-label="t('customRange')" :items="quickSelectRangeDefaultItems" :model-value="selectedRangeSelectorValue" @update:model-value="onRangeSelectorInput" /> <bento-segmented-control v-if="granularities" :items="granularityItems" :model-value="internalRangeDate.granularity" full-width @update:model-value="onGranularityInput" > </bento-segmented-control> <div> <bento-typography stronger el="span" class="b-date-range-picker-calendar__label"> From </bento-typography> <div :class="formInputWrapperConditionalClasses"> <bento-input-field :aria-label="t('dateFrom')" :model-value="dateTextInput.startDate" :error="startDateError" class="b-date-range-picker-calendar__form-input" @update:model-value="onStartDateInput" @keydown="onDateInputKeyDown" > <template #description>{{ t(dateFormat) }}</template> </bento-input-field> <bento-input-time v-if="allowTimeInput" :aria-label="t('timeFrom')" :model-value="dateTextInput.startTime" class="b-date-range-picker-calendar__form-input" @update:model-value="onStartTimeInput" @input:error="onStartTimeError" @input:valid="onStartTimeValid" /> </div> </div> <div> <bento-typography stronger el="span" class="b-date-range-picker-calendar__label"> To </bento-typography> <div :class="formInputWrapperConditionalClasses"> <bento-input-field class="b-date-range-picker-calendar__form-input" :aria-label="t('dateTo')" :model-value="dateTextInput.endDate" :error="endDateError" @update:model-value="onEndDateInput" @keydown="onDateInputKeyDown" > <template #description>{{ t(dateFormat) }}</template> </bento-input-field> <bento-input-time v-if="allowTimeInput" :aria-label="t('timeTo')" :model-value="dateTextInput.endTime" class="b-date-range-picker-calendar__form-input" @update:model-value="onEndTimeInput" @input:error="onEndTimeError" @input:valid="onEndTimeValid" /> </div> </div> </div> <div v-if="hasSlot('actions')"> <slot name="actions" /> </div> </div> <!-- Calendar --> <div v-if="!hasNoCalendars" class="b-date-range-picker-calendar__calendars-container" data-testid="date-range-picker-calendar-calendar-container" > <calendar :value="internalRangeDate" :first-day-of-week="firstDayOfWeek" :is-date-disabled="isDateDisabled" :max-range="maxRange" :min="computedMinMax.min" :max="computedMinMax.max" :number-of-months="numberOfMonths" :show-end-date-on-open="showEndDateOnOpen" :granularity="selectedGranularity" :variant="variant" is-range @input="onDateInput" @start-date-selected="onStartDateSelected" @end-date-selected="onEndDateSelected" /> </div> </div> </template> <script setup lang="ts"> import { computed, onMounted, type PropType, reactive, ref, toRaw, useSlots } from 'vue'; import { Calendar, type CalendarGranularityType } from '@/internal'; import { BentoTypography } from '@/components/typography'; import { BentoInputField } from '@/components/input-field'; import { BentoInputTime } from '@/components/input-time'; import BentoDropdown from '@/components/dropdown/dropdown.vue'; import { BentoSegmentedControl, type BentoSegmentedControlItem } from '@/components/segmented-control'; import { useI18n } from '@/utils/ts/i18n'; import { debounce } from '@/utils/ts/debounce'; import { useDateInputFormatter, useHasSlot } from '@/composables'; import { useGranularityAdjustments } from '@/components/internal/calendar/composables/use-granularity-adjustments'; import { useGranularMinMaxDate } from '@/components/internal/calendar/components/calendar-month/composables/granular-min-max-date'; import { useGranularityMemory } from '../../composables/use-granularity-memory'; import { DateRangePickerCalendarEvent, type DateRangePickerCalendarFormData, type DateRangePickerCalendarRangeSelectorItems, } from './date-range-picker-calendar.types'; import type { BentoDateRangePickerGranularityConfig, BentoDateRangePickerValue, } from '../../date-range-picker.types'; import { endOfDay } from 'date-fns/endOfDay'; import { setHours } from 'date-fns/setHours'; import { setMinutes } from 'date-fns/setMinutes'; import { setSeconds } from 'date-fns/setSeconds'; import { startOfDay } from 'date-fns/startOfDay'; import { isSameSecond } from 'date-fns/isSameSecond'; import { isSameDay } from 'date-fns/isSameDay'; import { isEqual } from 'date-fns/isEqual'; import { isToday } from 'date-fns/isToday'; import { setMilliseconds } from 'date-fns/setMilliseconds'; import { dateToTimeInputString } from '@/utils/ts/format-date/format-date'; import { isValidTimeString } from '@/utils/ts/time-input'; import messages from './messages.json'; const FORM_INPUT_DEBOUNCE_TIME = 300; const RANGE_SELECTOR_CUSTOM_RANGE_KEY = 'customRange'; type MessageSchema = (typeof messages)['en-US']; const props = defineProps({ /** * Allows user to enter time values in the form */ allowTimeInput: { type: Boolean, default: false }, /** * Ranges form persistance data */ dateFormData: { type: Object as PropType<DateRangePickerCalendarFormData>, default: () => ({ startDate: undefined, endDate: undefined, startTime: undefined, endTime: undefined }), }, /** * Allows you to set the first day of the week. 0 is sunday, 1 is monday, 6 is saturday */ firstDayOfWeek: Calendar.props.firstDayOfWeek, /** * A list of available granularities. * If present, the date picker will display a segmented control to change granularity. */ granularities: { type: Array as PropType<Array<BentoDateRangePickerGranularityConfig>>, default: null, }, /** * Indicate if a date should be disabled or not */ isDateDisabled: Calendar.props.isDateDisabled, /** * Set a maximum number of dates to be selectable by the range. */ maxRange: Calendar.props.maxRange, /** * Sets a minimum limit for selectable dates - dates before `min` will not be selectable */ min: Calendar.props.min, /** * Sets a maximum limit for selectable dates - dates after `max` will not be selectable */ max: Calendar.props.max, /** * Number of months rendered on pane */ numberOfMonths: { type: Calendar.props.numberOfMonths.type, default: Calendar.props.numberOfMonths.default, validator: (n: number) => n >= 0, }, /** * Displays the end date's month when the calendar is opened. Defaults to false i.e. the start date's month is displayed. */ showEndDateOnOpen: { type: Boolean, default: false }, /** * Enables the custom range selector. * If provided, must be an array that sets the custom range dropdown items. Items are of the structure: * `label` - label of custom range item. * `value` - a unique key of the custom range item. * `data` - an object `{ startDate: Date; endDate: Date }` to set the date picker range to upon selecting. */ quickSelectRanges: { type: Array as PropType<DateRangePickerCalendarRangeSelectorItems>, default: undefined, }, /** * Selected date */ value: { type: Object as PropType<BentoDateRangePickerValue>, default: undefined }, /** * The type of calendar to display. Defaults to showing days. */ variant: Calendar.props.variant, }); const emit = defineEmits([ DateRangePickerCalendarEvent.CUSTOM_RANGE, DateRangePickerCalendarEvent.INPUT, DateRangePickerCalendarEvent.ERROR, DateRangePickerCalendarEvent.FORM_DATE, DateRangePickerCalendarEvent.START_DATE_SELECTED, ]); const slots = useSlots(); const hasSlot = useHasSlot(slots); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const { dateFormat, isFormatValid, parseDate, onKeyDown, autoFormat, formatDate: formatDateUtil, } = useDateInputFormatter(); const calculatedIsRelativeToNowQuickSelectRanges = ref({}); const defaultGranularity = computed(() => (props.variant === 'month' ? 'monthly' : 'daily')); const hasNoCalendars = computed(() => props.numberOfMonths === 0); const internalRangeDate = reactive<BentoDateRangePickerValue>({ startDate: props.value?.startDate, endDate: props.value?.endDate, range: props.value?.range, ...(props.granularities ? { granularity: props.value?.granularity || defaultGranularity.value, } : {}), }); /** * Currently range selector dropdown item that is selected. * The logic will try and find in each range item: * - If an endDate does not exist, then only check if the startDate day matches and if today's day matches. * - if time input has been enabled, then check if the seconds match. * - if an endDate does exist then just check if the startDate day and the endDate day match. * * Note: All date comparisons use local time consistently. Both sides of each comparison * originate from the same timezone context, so no UTC normalization is needed. */ const selectedRangeSelectorValue = computed(() => { return ( // eslint-disable-next-line consistent-return props?.quickSelectRanges?.find(({ value, data }) => { if (!data.endDate) { // Check if this quick select range is explicitly marked as "relative to now" if (data.isRelativeToNow) { // Retrieve previously calculated start/end dates for this relative range. // We use these if the user has already clicked on this time of quick range const newlyCalculatedQuickSelectDates = calculatedIsRelativeToNowQuickSelectRanges.value?.[value]; return newlyCalculatedQuickSelectDates ? isEqual(newlyCalculatedQuickSelectDates.startDate, internalRangeDate.startDate) && isEqual(newlyCalculatedQuickSelectDates.endDate, internalRangeDate.endDate) : // If not stored calculated dates, fall back to comparing the quick select item's value // with the internal range's stored range identifier. value === internalRangeDate.range; } // This block handles quick select ranges that do not have an endDate // AND are NOT explicitly marked as 'isRelativeToNow'. // This means the endDate will be today at the end of the day. // Uses isEqual for startDate to prevent false matches (e.g. same-day selection // accidentally matching a "This week" range). When time input is enabled, // also verify the end time is at end of day so that manual time changes // correctly fall back to "Custom range". return ( isEqual(data.startDate, internalRangeDate.startDate) && isToday(internalRangeDate.endDate) && (!props.allowTimeInput || isSameSecond(internalRangeDate.endDate, endOfDay(internalRangeDate.endDate))) && (props.granularities ? data.granularity === internalRangeDate.granularity : true) ); } if (props.allowTimeInput) { const res = isSameSecond(data.startDate, internalRangeDate.startDate) && isSameSecond(data.endDate, internalRangeDate.endDate) && (props.granularities ? data.granularity === internalRangeDate.granularity : true); return res; } if (data.endDate) { return ( isSameDay(data.startDate, internalRangeDate.startDate) && isSameDay(data.endDate, internalRangeDate.endDate) && (props.granularities ? data.granularity === internalRangeDate.granularity : true) ); } })?.value ?? RANGE_SELECTOR_CUSTOM_RANGE_KEY ); }); const formInputWrapperConditionalClasses = computed(() => ({ 'b-date-range-picker-calendar__form-input-wrapper': props.allowTimeInput, })); const quickSelectRangeDefaultItems = computed(() => [ { label: t('customRange'), value: RANGE_SELECTOR_CUSTOM_RANGE_KEY }, ...(props.quickSelectRanges ? props.quickSelectRanges : []), ]); const selectedGranularity = computed( () => props.granularities?.find(({ type }) => type === internalRangeDate.granularity) || null ); const granularityItems = computed<Array<BentoSegmentedControlItem>>( () => props.granularities?.map(({ type, disabled }) => ({ label: t(type), value: type, disabled, })) ?? [] ); const { getGranularCalendarLimits } = useGranularMinMaxDate(props); const computedMinMax = computed(() => props.granularities ? getGranularCalendarLimits(selectedGranularity.value) : { min: props.min ? startOfDay(props.min) : null, max: props.max ? endOfDay(props.max) : null } ); const { adjustDateForQuarterly, adjustDateForMonthly, adjustDateForWeekly, adjustStartDateInputForWeekly, adjustEndDateInputForWeekly, adjustStartDateInputForMonthly, adjustEndDateInputForMonthly, adjustStartDateInputForQuarterly, adjustEndDateInputForQuarterly, } = useGranularityAdjustments(props.firstDayOfWeek); const { savedRanges, saveRange, resetSavedRanges } = useGranularityMemory(); /** * Format date to a string value in current format * @param dateToFormat - Date to be formatted */ const formatDate = (dateToFormat?: Date) => { if (!dateToFormat) { return ''; } return formatDateUtil(dateToFormat); }; // Form error flags const startDateError = ref(false); const endDateError = ref(false); // Form input text used in range variant const dateTextInput = reactive({ startDate: props.dateFormData.startDate, endDate: props.dateFormData.endDate, startTime: props.dateFormData.startTime, endTime: props.dateFormData.endTime, }); /** * Sets the time of a date object from a time string. * @param dateToSetTimeIn The date object to set the time in. * @param time The time string in HH:MM:SS format. * @returns A new Date object with the time set. */ const setTimeInDateObject = (dateToSetTimeIn: Date, time: string) => { let dateWithTime = new Date(dateToSetTimeIn); const [hours, minutes, seconds] = time.split(':'); dateWithTime = setHours(dateWithTime, parseInt(hours, 10)); dateWithTime = setMinutes(dateWithTime, parseInt(minutes, 10)); dateWithTime = setSeconds(dateWithTime, parseInt(seconds, 10)); return dateWithTime; }; /** * Checks if a time string is valid and within the min/max bounds if a date is provided. * @param timeText The time string to validate. * @param date The date string to check the time against. * @returns True if the time is valid, false otherwise. */ const isValidTime = (timeText: string, date?: string) => { const isTimeAllowed = () => { if (date && isValidDate(date)) { const dateToCheckWithTime = setTimeInDateObject(parseDate(date), timeText); // Check if date is between min/max bounds, if there are any if ( (props.min && dateToCheckWithTime < setMilliseconds(props.min, 0)) || (props.max && dateToCheckWithTime > setMilliseconds(props.max, 0)) ) { return false; } } return true; }; return isValidTimeString(timeText) && isTimeAllowed(); }; /** * Sets the time in a date object if the time is valid and the variant is not 'month'. * @param date The date object to modify. * @param time The time string to set. * @returns The modified date object or the original if time is not set. */ const setTimeInDateRef = (date: Date, time: string) => { // Do not set time if month variant if (!time || !isValidTimeString(time) || props.variant === 'month') { return date; } return setTimeInDateObject(new Date(date), time); }; const onDateInput = (selectedRange: BentoDateRangePickerValue) => { dateTextInput.startTime = dateToTimeInputString( props.min && props.allowTimeInput && isSameDay(props.min, selectedRange.startDate) ? props.min : startOfDay(selectedRange.startDate) ); dateTextInput.endTime = dateToTimeInputString( props.max && props.allowTimeInput && isSameDay(props.max, selectedRange.endDate) ? props.max : endOfDay(selectedRange.endDate) ); internalRangeDate.startDate = setTimeInDateRef(selectedRange.startDate, dateTextInput.startTime); internalRangeDate.endDate = setTimeInDateRef(selectedRange.endDate, dateTextInput.endTime); // Resetting internal range date when selecting a custom range through the calendar internalRangeDate.range = null; if (props.granularities) { // Reset saved ranges on input resetSavedRanges(); } emit(DateRangePickerCalendarEvent.INPUT, { ...internalRangeDate, range: selectedRangeSelectorValue.value, }); }; /** * Checks if a date string is in current format and if the date is enabled. * @param dateText The date string to validate. * @returns True if the date is valid and enabled, false otherwise. */ const isValidDate = (dateText: string) => { if (!dateText) { return false; } // Is date selectable const isDateEnabled = (date: Date) => { // Check if date is between or equal to min/max bounds // Set the time to the start of the day to disregard time if ( (props.min && startOfDay(date) < startOfDay(props.min)) || (props.max && startOfDay(date) > startOfDay(props.max)) ) { return false; } // Check if function is set, otherwise all dates are enabled if (props.isDateDisabled) { return !props.isDateDisabled(startOfDay(date)); } return true; }; // Date matches text regex and is not disabled return isFormatValid(dateText) && isDateEnabled(parseDate(dateText)); }; /** * When mounted, set the input error states to true if the values are not valid */ onMounted(() => { // Start date validation startDateError.value = !!(props.dateFormData.startDate && !isValidDate(props.dateFormData.startDate)); // Start time validation is delegated to bento-input-time via events // End date validation endDateError.value = !!(props.dateFormData.endDate && !isValidDate(props.dateFormData.endDate)); // End time validation is delegated to bento-input-time via events }); const onStartDateSelected = (newStartDate: Date) => { dateTextInput.startDate = formatDate(newStartDate); if (!dateTextInput.startTime) { // Set the time to the beginning of the day dateTextInput.startTime = dateToTimeInputString(newStartDate); } startDateError.value = false; emit(DateRangePickerCalendarEvent.START_DATE_SELECTED, newStartDate); }; const onEndDateSelected = (newEndDate: Date) => { dateTextInput.endDate = formatDate(newEndDate); if (!dateTextInput.endTime) { // Set the time to the end of the day dateTextInput.endTime = dateToTimeInputString(endOfDay(newEndDate)); } endDateError.value = false; }; const getDate = (keyName: keyof BentoDateRangePickerValue) => { const dateProp = (props.value as BentoDateRangePickerValue)[keyName]; if (dateProp) { return dateProp; } if (props.dateFormData[keyName] && isValidDate(props.dateFormData[keyName])) { return parseDate(props.dateFormData[keyName]); } return undefined; }; const validateStartDate = debounce((startDateText: string) => { if (isValidDate(startDateText)) { let dateText = startDateText; startDateError.value = false; switch (selectedGranularity.value?.type) { case 'weekly': dateText = adjustStartDateInputForWeekly( startDateText, formatDate, computedMinMax.value.min, computedMinMax.value.max ); dateTextInput.startDate = dateText; break; case 'monthly': dateText = adjustStartDateInputForMonthly( startDateText, formatDate, computedMinMax.value.min, computedMinMax.value.max ); dateTextInput.startDate = dateText; break; case 'quarterly': dateText = adjustStartDateInputForQuarterly( startDateText, formatDate, computedMinMax.value.min, computedMinMax.value.max ); dateTextInput.startDate = dateText; break; case 'daily': default: break; } const startDate = startOfDay(parseDate(dateText)); internalRangeDate.startDate = setTimeInDateRef(startDate, dateTextInput.startTime); internalRangeDate.endDate = setTimeInDateRef( startOfDay(parseDate(dateTextInput.endDate)), dateTextInput.endTime ); if (isValidDate(dateTextInput.endDate)) { internalRangeDate.endDate = setTimeInDateRef( startOfDay(parseDate(dateTextInput.endDate)), dateTextInput.endTime ); } const endDate = getDate('endDate'); // Resetting internal range date when selecting a custom range through the calendar internalRangeDate.range = null; emit(DateRangePickerCalendarEvent.FORM_DATE, { startDate, endDate, }); emit(DateRangePickerCalendarEvent.INPUT, { ...internalRangeDate, range: selectedRangeSelectorValue.value, }); } else { // Indicate form error startDateError.value = true; // Emit Date picker calendar error emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); } }, FORM_INPUT_DEBOUNCE_TIME); const onStartDateInput = (startDateText: string) => { // Set the date as string when input through the form dateTextInput.startDate = autoFormat(startDateText); validateStartDate(dateTextInput.startDate); }; const validateEndDate = debounce((endDateText: string) => { if (isValidDate(endDateText)) { let dateText = endDateText; endDateError.value = false; switch (selectedGranularity.value?.type) { case 'weekly': dateText = adjustEndDateInputForWeekly( endDateText, formatDate, computedMinMax.value.min, computedMinMax.value.max ); dateTextInput.endDate = dateText; break; case 'monthly': dateText = adjustEndDateInputForMonthly( endDateText, formatDate, computedMinMax.value.min, computedMinMax.value.max ); dateTextInput.endDate = dateText; break; case 'quarterly': dateText = adjustEndDateInputForQuarterly( endDateText, formatDate, computedMinMax.value.min, computedMinMax.value.max ); dateTextInput.endDate = dateText; break; case 'daily': default: break; } // Format date string to Date object const endDate = startOfDay(parseDate(dateText)); internalRangeDate.endDate = setTimeInDateRef(endDate, dateTextInput.endTime); if (isValidDate(dateTextInput.startDate)) { internalRangeDate.startDate = setTimeInDateRef( startOfDay(parseDate(dateTextInput.startDate)), dateTextInput.startTime ); } const startDate = getDate('startDate'); // Resetting internal range date when selecting a custom range through the calendar internalRangeDate.range = null; emit(DateRangePickerCalendarEvent.FORM_DATE, { startDate, endDate, }); emit(DateRangePickerCalendarEvent.INPUT, { ...internalRangeDate, range: selectedRangeSelectorValue.value, }); } else { // Indicate form error endDateError.value = true; // Emit Date picker calendar error emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); } }, FORM_INPUT_DEBOUNCE_TIME); const onEndDateInput = (endDateText: string) => { // Set the date as string when input through the form dateTextInput.endDate = autoFormat(endDateText); validateEndDate(dateTextInput.endDate); }; const onDateInputKeyDown = (event: KeyboardEvent) => { onKeyDown(event); }; const onStartTimeError = () => { emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); }; const onStartTimeValid = () => { const startTimeText = dateTextInput.startTime; if (!isValidTime(startTimeText, dateTextInput.startDate)) { emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); return; } internalRangeDate.startDate = setTimeInDateRef(internalRangeDate.startDate, startTimeText); const endDate = getDate('endDate'); emit(DateRangePickerCalendarEvent.FORM_DATE, { startDate: internalRangeDate.startDate, endDate, startTime: startTimeText, endTime: dateTextInput.endTime ?? props.dateFormData.endTime, }); emit(DateRangePickerCalendarEvent.INPUT, { ...internalRangeDate, range: selectedRangeSelectorValue.value, }); }; const onEndTimeError = () => { emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); }; const onEndTimeValid = () => { const endTimeText = dateTextInput.endTime; if (!isValidTime(endTimeText, dateTextInput.endDate)) { emit(DateRangePickerCalendarEvent.ERROR, { ...dateTextInput }); return; } internalRangeDate.endDate = setTimeInDateRef(internalRangeDate.endDate, endTimeText); emit(DateRangePickerCalendarEvent.FORM_DATE, { startDate: internalRangeDate.startDate, endDate: internalRangeDate.endDate, startTime: dateTextInput.startTime ?? props.dateFormData.startTime, endTime: endTimeText, }); emit(DateRangePickerCalendarEvent.INPUT, { ...internalRangeDate, range: selectedRangeSelectorValue.value, }); }; const onStartTimeInput = (startTimeText: string) => { dateTextInput.startTime = startTimeText; }; const onEndTimeInput = (endTimeText: string) => { dateTextInput.endTime = endTimeText; }; const setInternalRange = ( startDate: Date, endDate: Date, granularity?: CalendarGranularityType, range?: string ) => { internalRangeDate.startDate = startDate; internalRangeDate.endDate = endDate; if (granularity) { internalRangeDate.granularity = granularity; } if (range) { internalRangeDate.range = range; } }; const onRangeSelectorInput = (newCustomRangeValue: string) => { const foundResult = props.quickSelectRanges?.find(({ value }) => value === newCustomRangeValue); if (foundResult) { resetSavedRanges(); const date = foundResult.data; const newEndDate = date.endDate || (date.isRelativeToNow ? new Date(Date.now()) : endOfDay(new Date(Date.now()))); // If the time difference between start and end dates is available, // calculating start date dynamically const newStartDate = date.isRelativeToNow && date.timeDifference ? new Date(newEndDate.getTime() - date.timeDifference) : date.startDate; // Set Form input Dates dateTextInput.endDate = formatDate(newEndDate); dateTextInput.startDate = formatDate(newStartDate); setInternalRange(newStartDate, newEndDate, null, newCustomRangeValue); if (props.granularities) { internalRangeDate.granularity = date.granularity || defaultGranularity.value; } // Set Form input time to quick select range time dateTextInput.startTime = dateToTimeInputString(newStartDate); dateTextInput.endTime = dateToTimeInputString(newEndDate); // Set calendar selection internalRangeDate.startDate = newStartDate; internalRangeDate.endDate = newEndDate; if (date.isRelativeToNow) { // Store this calculated start and end dates to be used in the selectedRangeSelectorValue logic calculatedIsRelativeToNowQuickSelectRanges.value = { ...calculatedIsRelativeToNowQuickSelectRanges.value, [newCustomRangeValue]: { startDate: newStartDate, endDate: newEndDate, }, }; } // Emit everytime the date range changes emit(DateRangePickerCalendarEvent.CUSTOM_RANGE, { ...foundResult.data, startDate: newStartDate, endDate: newEndDate, range: foundResult.value, }); } }; const adjustDate = { weekly: adjustDateForWeekly, monthly: adjustDateForMonthly, quarterly: adjustDateForQuarterly, }; const onGranularityInput = (value: CalendarGranularityType) => { const previousValue = internalRangeDate.granularity; internalRangeDate.granularity = value; // Save previously selected range if (previousValue) { saveRange(previousValue, structuredClone(toRaw(internalRangeDate))); } if (internalRangeDate.startDate && internalRangeDate.endDate) { // Adjust date range based on new granularity switch (value) { case 'quarterly': case 'monthly': case 'weekly': { if (savedRanges[value]) { setInternalRange(savedRanges[value].startDate, savedRanges[value].endDate, value); break; } const { startDate, endDate } = adjustDate[value]( internalRangeDate.startDate, internalRangeDate.endDate, computedMinMax.value.min, computedMinMax.value.max ); setInternalRange(startDate, endDate, value); break; } case 'daily': if (savedRanges.daily) { setInternalRange(savedRanges.daily.startDate, savedRanges.daily.endDate, value); } break; default: break; } dateTextInput.startDate = formatDate(internalRangeDate.startDate); dateTextInput.endDate = formatDate(internalRangeDate.endDate); emit(DateRangePickerCalendarEvent.INPUT, { ...internalRangeDate, range: selectedRangeSelectorValue.value, }); } }; const computedFormContainerClasses = computed(() => ({ 'b-date-range-picker-calendar__form-container--no-calendars': hasNoCalendars.value, })); </script> <script lang="ts"> /** * Calendar for range dates. Contains the logic to handle the range forms. * * @example * import DatePickerCalendarRange from './date-range-picker-calendar.vue'; * * export default { * components: { DatePickerCalendarRange }, * template: ` * <date-range-picker-calendar * :value="{startDate: new Date(), endDate: new Date() }" * :first-day-of-week="BentoDatePickerFirstDayOfWeek.MONDAY" * :is-date-disabled="(date: Date) => boolean" * :number-of-months="2" * @input="({ startDate, endDate }) => void" * /> * ` * } */ export default { name: 'date-range-picker-calendar', i18n: { messages }, }; </script> <style lang="scss" scoped src="./date-range-picker-calendar.scss" />
@@ -0,0 +1,52 @@
1
+ import { Canvas, Meta, Source } from '@storybook/blocks';
2
+ import * as InputTimeStories from './input-time.stories';
3
+
4
+ <Meta of={InputTimeStories} />
5
+
6
+ # Input Time
7
+
8
+ The input time component is designed for entering time values in `HH:MM:SS` format. It is an extension of the
9
+ `bento-input-field` component with built-in time character validation and auto-formatting logic.
10
+
11
+ <Canvas of={InputTimeStories.Default} />
12
+
13
+ ## Use Cases
14
+
15
+ Use this component if you need to collect a time of day value from a user.
16
+
17
+ ### Do not use
18
+
19
+ Avoid using this component for:
20
+
21
+ - Date inputs — use `bento-input-field` with an appropriate format or the `bento-date-picker` component.
22
+ - General text inputs — use `bento-input-field` instead.
23
+
24
+ ## Behaviour
25
+
26
+ ### Auto-formatting
27
+
28
+ As the user types, the component automatically inserts `:` separators to produce a well-formed `HH:MM:SS` string. For
29
+ example, typing `1` produces `1`, then `12` produces `12:`, then `1230` produces `12:30:`, and so on.
30
+
31
+ ### Key validation
32
+
33
+ Non-numeric characters (except `:` at valid positions and control keys such as `Backspace`, `ArrowLeft`, `ArrowRight`,
34
+ and `Tab`) are silently blocked via the `keydown` handler.
35
+
36
+ ## Modifiers
37
+
38
+ ### Error state
39
+
40
+ Use the `error-message` prop to display an inline error message and apply error styling:
41
+
42
+ ```html
43
+ <bento-input-time label="Start time" error-message="Invalid time format" />
44
+ ```
45
+
46
+ ### Disabled
47
+
48
+ Renders the time input in a disabled state.
49
+
50
+ ### Readonly
51
+
52
+ Renders the time input in a readonly state.
@@ -0,0 +1 @@
1
+ import { action } from '@storybook/addon-actions'; import { isVue2 } from 'vue-demi'; import { ref } from 'vue'; import { storybookDocsParameter } from '@/utils/ts/storybook'; import BentoInputTime from './input-time.vue'; import type { Meta, StoryObj } from '@storybook/vue'; import defaultCode from './__tests__/input-time-default-example.vue?raw'; const meta: Meta = { title: 'Input Time', component: BentoInputTime, }; const DEFAULT_PROPS = { label: 'Input time', }; export default meta; type Story = StoryObj<typeof BentoInputTime>; export const Default: Story = { args: DEFAULT_PROPS, render: (_args, { argTypes }) => ({ components: { BentoInputTime }, props: Object.keys(argTypes), setup(props) { const modelValue = ref(''); const onUpdateModelValue = (value: string) => { modelValue.value = value; action('update:model-value')(value); }; return { args: isVue2 ? props : _args, modelValue, onUpdateModelValue, onInputValid: action('input:valid'), onInputError: action('input:error'), onInputKeydown: action('keydown'), }; }, template: ` <bento-input-time v-bind="args" :model-value="modelValue" @update:model-value="onUpdateModelValue" @input:valid="onInputValid" @input:error="onInputError" @keydown="onInputKeydown" /> `, }), parameters: storybookDocsParameter(defaultCode), };
@@ -0,0 +1 @@
1
+ import type { BentoInputFieldValue } from '@/components/input-field/input-field.types'; import { type FormField } from '../../types/form-field'; export interface BentoInputTimeProps extends FormField { /** * Reduces height of the input */ condensed?: boolean; /** * Text displayed as the value of the input field */ modelValue?: BentoInputFieldValue; }
@@ -0,0 +1 @@
1
+ <template> <bento-input-field class="b-input-time" :condensed="props.condensed" :disabled="props.disabled" :error-message="computedErrorMessage" :label="props.label" :model-value="props.modelValue" :optional="props.optional" :placeholder="props.placeholder" :readonly="props.readonly" :required="props.required" :tooltip-text="props.tooltipText" v-bind="$attrs" @update:model-value="onInput" @keydown="onKeyDown" > <template v-if="hasSlot('default')" #default> <slot name="default"></slot> </template> <template #description> <slot name="description">{{ props.description ? `${props.description} ${t('timeFormatDescription')}` : t('timeFormatDescription') }}</slot> </template> </bento-input-field> </template> <script setup lang="ts"> // Components import { BentoInputField } from '@/components/input-field'; // Utils import { debounce } from '@/utils/ts/debounce'; import { useI18n } from '@/utils/ts/i18n'; import { formatTimeInputValue, isValidTimeString, validateTimeInput } from '@/utils/ts/time-input'; // Types import type { BentoInputFieldValue } from '@/components/input-field/input-field.types'; import { type BentoInputTimeProps } from './input-time.types'; // Localization import messages from './messages.json'; import { computed, ref, useSlots, watch } from 'vue'; import { useHasSlot } from '@/composables'; const VALIDATION_DEBOUNCE_TIME = 300; const slots = useSlots(); const hasSlot = useHasSlot(slots); type MessageSchema = (typeof messages)['en-US']; const { t } = useI18n<{ message: MessageSchema }>({ messages }); const props = withDefaults(defineProps<BentoInputTimeProps>(), { condensed: false, description: undefined, disabled: false, errorMessage: null, label: '', modelValue: undefined, optional: false, placeholder: '', readonly: false, required: false, tooltipText: null, }); const emit = defineEmits<{ /** * Emitted when the component's model changes */ (e: 'update:model-value', value: BentoInputFieldValue): void; /** * Emitted when a key is pressed down */ (e: 'keydown', event: KeyboardEvent): void; /** * Emitted when the time value passes validation (complete HH:MM:SS format) */ (e: 'input:valid'): void; /** * Emitted when the time value fails validation */ (e: 'input:error'): void; }>(); const internalError = ref(false); const computedErrorMessage = computed(() => { if (props.errorMessage) { return props.errorMessage; } return internalError.value ? t('invalidTimeFormat') : null; }); const validateTime = (timeText: string) => { if (!timeText) { internalError.value = false; return; } const isValid = isValidTimeString(timeText); internalError.value = !isValid; if (isValid) { emit('input:valid'); } else { emit('input:error'); } }; const validateTimeWithDebounce = debounce(validateTime, VALIDATION_DEBOUNCE_TIME); // Watch for external modelValue changes (e.g., when parent resets the value) watch( () => props.modelValue, newValue => { const timeText = String(newValue ?? ''); // Clear the error only when the new value is a complete, valid time string if (timeText.length === 8 && isValidTimeString(timeText)) { internalError.value = false; } } ); const onInput = (value: BentoInputFieldValue) => { if (props.disabled || props.readonly) { return; } const formatted = formatTimeInputValue(String(value ?? '')); emit('update:model-value', formatted); validateTimeWithDebounce(formatted); }; const onKeyDown = (event: KeyboardEvent) => { validateTimeInput(event); emit('keydown', event); }; </script> <script lang="ts"> /** * Input time component for entering time values in HH:MM:SS format. * This is an extension of the `bento-input-field` component with built-in * time formatting and validation logic. * * @usage * import { BentoInputTime } from '@adyen/bento-vue2'; * * const timeValue = ref(''); * * export default { * components: { BentoInputTime }, * template: ` * <bento-input-time * label="Start time" * :model-value="timeValue" * @update:model-value="newValue => timeValue.value = newValue" * /> * ` * } */ export default { i18n: { messages }, name: 'bento-input-time', inheritAttrs: false, model: { prop: 'modelValue' }, }; </script> <style lang="scss" scoped src="./input-time.scss" />
@@ -1 +1 @@
1
- <template> <section class="b-secondary-nav-category" :aria-label="label"> <bento-typography el="h1" class="b-secondary-nav-category__label" variant="caption" stronger>{{ label }}</bento-typography> <ul class="b-secondary-nav__list"> <li v-for="item in items" :key="item.value"> <secondary-nav-item :label="item.label" :value="item.value" /> </li> </ul> </section> </template> <script lang="ts" setup> import { BentoTypography } from '@/components/typography'; import { SecondaryNavItem } from '../secondary-nav-item'; import { type BentoSecondaryNavItemsItem } from '../../secondary-nav.types'; defineProps<{ /** * The display name of the category */ label: string; /** * A list of {@see BentoSecondaryNavItemsItem} that belongs to this category */ items: Array<BentoSecondaryNavItemsItem>; }>(); </script> <script lang="ts"> /** * This component shouldn't be used directly. It is internally used by {@see BentoSecondaryNav} */ export default {}; </script> <style lang="scss" scoped src="./secondary-nav-category.scss" />
1
+ <template> <section class="b-secondary-nav-category" :aria-label="label"> <bento-typography el="h1" class="b-secondary-nav-category__label" variant="caption" stronger>{{ label }}</bento-typography> <ul class="b-secondary-nav__list"> <li v-for="item in items" :key="item.value"> <secondary-nav-item :label="item.label" :value="item.value" :items="item.items" /> </li> </ul> </section> </template> <script lang="ts" setup> import { BentoTypography } from '@/components/typography'; import { SecondaryNavItem } from '../secondary-nav-item'; import { type BentoSecondaryNavItemsItem } from '../../secondary-nav.types'; defineProps<{ /** * The display name of the category */ label: string; /** * A list of {@see BentoSecondaryNavItemsItem} that belongs to this category */ items: Array<BentoSecondaryNavItemsItem>; }>(); </script> <script lang="ts"> /** * This component shouldn't be used directly. It is internally used by {@see BentoSecondaryNav} */ export default {}; </script> <style lang="scss" scoped src="./secondary-nav-category.scss" />
@@ -1 +1 @@
1
- <template> <button class="b-secondary-nav-item" :class="conditionalClasses" :aria-current="isItemSelected ? 'page' : undefined" @click="onClick" > <bento-typography class="b-secondary-nav-item__title" variant="body" stronger> {{ label }} </bento-typography> </button> </template> <script lang="ts" setup> import { SECONDARY_NAV_STATE } from '../../secondary-nav.keys'; import { BentoTypography } from '@/components/typography'; import { computed, inject } from 'vue'; const { activeItemValue, updateActiveItemValue } = inject(SECONDARY_NAV_STATE); const props = defineProps<{ /** * The display name of the navigation item */ label: string; /** * The value of this item. */ value: string; }>(); const isItemSelected = computed(() => activeItemValue.value === props.value); const conditionalClasses = computed(() => ({ 'b-secondary-nav-item--active': isItemSelected.value, })); const emit = defineEmits<{ /** * Event emitted when this element is clicked */ (e: 'click', clickEvent: MouseEvent): void; }>(); const onClick = (e: MouseEvent) => { emit('click', e); updateActiveItemValue(props.value); }; </script> <script lang="ts"> /** * This component shouldn't be used directly. It is internally used by {@see BentoSecondaryNav} */ export default {}; </script> <style lang="scss" scoped src="./secondary-nav-item.scss" />
1
+ <template> <div class="b-secondary-nav-item"> <div class="b-secondary-nav-item__wrapper"> <button class="b-secondary-nav-item__option" :class="conditionalClasses" :aria-current="isItemSelected ? 'page' : undefined" :aria-expanded="expandleItemIsToggleOnly ? `${isOpen}` : undefined" :aria-controls="expandleItemIsToggleOnly ? subItemsId : undefined" @click="expandleItemIsToggleOnly ? onToggleClick() : onClick($event)" > <bento-typography class="b-secondary-nav-item__title" variant="body" stronger> {{ label }} </bento-typography> </button> <bento-button v-if="hasSubItems" :aria-hidden="expandleItemIsToggleOnly" :tabindex="expandleItemIsToggleOnly ? -1 : undefined" class="b-secondary-nav-item__toggle" variant="tertiary" :aria-label="isOpen ? `Collapse ${label}` : `Expand ${label}`" :aria-expanded="!expandleItemIsToggleOnly ? `${isOpen}` : undefined" :aria-controls="!expandleItemIsToggleOnly ? subItemsId : undefined" @click.stop="onToggleClick" > <template #iconLeft> <chevron-up-icon v-if="isOpen" class="b-secondary-nav-item__icon" aria-hidden="true" /> <chevron-down-icon v-else class="b-secondary-nav-item__icon" aria-hidden="true" /> </template> </bento-button> </div> <Transition :name="CONTENT_TRANSITION_NAME"> <div v-if="hasSubItems && isOpen" :id="subItemsId" :style="subItemsMaxHeight"> <ul ref="subItemsRef" class="b-secondary-nav-item__sub-items"> <li v-for="item in items" :key="item.value"> <secondary-nav-item :label="item.label" :value="item.value" :items="item.items" is-sub-item /> </li> </ul> </div> </Transition> </div> </template> <script lang="ts" setup> import { SECONDARY_NAV_STATE } from '../../secondary-nav.keys'; import { BentoButton } from '@/components/button'; import { BentoTypography } from '@/components/typography'; import ChevronDownIcon from '@adyen/ui-assets-icons-16/vue/chevron-down'; import ChevronUpIcon from '@adyen/ui-assets-icons-16/vue/chevron-up'; import { computed, inject, ref, watch } from 'vue'; import { generateUid } from '@/core/utils/ts'; import { type BentoSecondaryNavItemsItem } from '../../secondary-nav.types'; import { observeSizeOfElement } from '@/utils/ts/resize'; import { useExpandableContentHeight } from '@/composables/use-expandable-content-height/use-expandable-content-height'; const { activeItemValue, updateActiveItemValue, allowExpandableItemNavigation } = inject(SECONDARY_NAV_STATE); const CONTENT_TRANSITION_NAME = 'b-secondary-nav-item__animation--content'; const props = defineProps<{ /** * The display name of the navigation item */ label: string; /** * The value of this item. */ value: string; /** * Nested sub-items for this item. */ items?: Array<BentoSecondaryNavItemsItem>; /** * Defines if the item is a subitem */ isSubItem?: boolean; }>(); const isOpen = ref(false); const subItemsId = generateUid('secondary-nav-sub-items'); const subItemsRef = ref<HTMLUListElement>(null); const hasSubItems = computed(() => !!props.items?.length); const isItemSelected = computed(() => activeItemValue.value === props.value); const expandleItemIsToggleOnly = computed(() => hasSubItems.value && !allowExpandableItemNavigation.value); const { updateContentHeight, contentHeight } = useExpandableContentHeight(subItemsRef, isOpen); const subItemsMaxHeight = computed(() => (contentHeight.value ? { 'max-height': contentHeight.value } : {})); const conditionalClasses = computed(() => ({ 'b-secondary-nav-item__option--active': isItemSelected.value, 'b-secondary-nav-item__option--expandable': hasSubItems.value, 'b-secondary-nav-item__option--sub-item': props.isSubItem, })); const emit = defineEmits<{ /** * Event emitted when this element is clicked */ (e: 'click', clickEvent: MouseEvent): void; }>(); const onClick = (e: MouseEvent) => { emit('click', e); updateActiveItemValue(props.value); }; const onToggleClick = () => { isOpen.value = !isOpen.value; }; const hasActiveDescendant = (items: Array<BentoSecondaryNavItemsItem>, value: string): boolean => { return items.some( item => item.value === value || (item.items ? hasActiveDescendant(item.items, value) : false) ); }; watch( () => subItemsRef.value, () => { let observer: ResizeObserver | null = null; if (!observer && subItemsRef.value) { observer = observeSizeOfElement(subItemsRef.value, () => { if (subItemsRef.value) { updateContentHeight(); } }); } } ); watch( isOpen, () => { updateContentHeight(); }, { immediate: true } ); watch( activeItemValue, value => { if (hasSubItems.value && props.items && hasActiveDescendant(props.items, value)) { isOpen.value = true; } }, { immediate: true } ); </script> <script lang="ts"> /** * This component shouldn't be used directly. It is internally used by {@see BentoSecondaryNav} */ export default {}; </script> <style lang="scss" scoped src="./secondary-nav-item.scss" />
@@ -14,7 +14,7 @@ understand and navigate through various information.
14
14
 
15
15
  Use a SecondaryNav if:
16
16
 
17
- - You need to organize information in a way that offers users structural guidance for complex pages like settings.
17
+ - You need to organize information in a way that offers users structural guidance for complex pages like settings.
18
18
 
19
19
  ## Variations
20
20
 
@@ -22,6 +22,36 @@ Use a SecondaryNav if:
22
22
 
23
23
  <Canvas of={SecondaryNavStories.WithCategories} />
24
24
 
25
+ ### With sub-items
26
+
27
+ Sub-items can be used to add an extra navigation level. Depending on the use case, items sith sub-items may link to
28
+ their own pages or just be used to organise items further.
29
+
30
+ <Canvas of={SecondaryNavStories.WithSubItems} />
31
+
32
+ #### Parent items without a page
33
+
34
+ By default (`allowExpandableItemNavigation` is `false`), clicking anywhere on a parent item expands or collapses its
35
+ sub-items. The parent item itself is not selectable and does not emit a `change` event.
36
+
37
+ #### Parent items with a page
38
+
39
+ When `allowExpandableItemNavigation` is `true`, parent items link to their own pages. Clicking the item label navigates
40
+ to that page and emits a `change` event, while the chevron button exclusively controls expanding and collapsing
41
+ sub-items.
42
+
43
+ ## Behavior
44
+
45
+ ### Desktop
46
+
47
+ - For items with sub-items, expanding/collapsing is controlled by the caret button.
48
+ - If `value` points to a sub-item, parent items auto-expand so the selected item is visible.
49
+
50
+ ### Mobile
51
+
52
+ - On mobile, `bento-secondary-nav` renders a `bento-dropdown`.
53
+ - Categories are shown as static category labels and nested items are shown with visual indentation.
54
+
25
55
  ## Accessibility
26
56
 
27
57
  ### Keyboard interaction
@@ -37,4 +67,4 @@ page. The following keys trigger the following actions:
37
67
 
38
68
  ## Resources
39
69
 
40
- - [Figma link](https://www.figma.com/design/uLabwF3243jdMDsNSP7U9I/Bento---Components?node-id=25227-18624&t=2F6m9PIJUQlVcUJj-0)
70
+ - [Figma link](https://www.figma.com/design/uLabwF3243jdMDsNSP7U9I/Bento---Components?node-id=25227-18624&t=2F6m9PIJUQlVcUJj-0)
@@ -1 +1 @@
1
- import { action } from '@storybook/addon-actions'; import { isVue2 } from 'vue-demi'; import { storybookDocsParameter } from '@/utils/ts/storybook'; import BentoSecondaryNav from './secondary-nav.vue'; import type { Meta, StoryObj } from '@storybook/vue'; import { type BentoSecondaryNavItems } from '@/components/secondary-nav/secondary-nav.types'; const meta: Meta = { title: 'Secondary Nav', component: BentoSecondaryNav, }; export default meta; type Story = StoryObj<typeof BentoSecondaryNav>; const DEFAULT_ITEMS: BentoSecondaryNavItems = [ { label: 'Option 1', value: 'option_1', }, { label: 'Option 2', value: 'option_2', }, { label: 'Option 3', value: 'option_3', }, ]; const ITEMS_WITH_CATEGORIES: BentoSecondaryNavItems = [ { label: 'Category 1', items: [ { label: 'Option 1', value: 'option_1', }, { label: 'Option 2', value: 'option_2', }, ], }, { label: 'Category 2', items: [ { label: 'Option 3', value: 'option_3', }, { label: 'Option 4', value: 'option_4', }, { label: 'Option 5', value: 'option_5', }, ], }, ]; const DEFAULT_STORY_CODE_SAMPLE = ` <template> <bento-secondary-nav :items="items" :value="activeItem" @change="onChange" /> <template> <script lang="ts" setup> const items = [ { label: 'Option 1', value: 'option_1', }, { label: 'Option 2', value: 'option_2', }, { label: 'Option 3', value: 'option_3', }, ]; const activeItem = ref(items[0].value); const onChange = (newActiveItem: string) => { activeItem.value = newActiveItem; }; </script> `; export const Default: Story = { render: (_args, { argTypes }) => ({ components: { BentoSecondaryNav, }, props: Object.keys(argTypes), template: ` <bento-secondary-nav v-bind="args" @change="clickedAction" /> `, setup(props) { const clickedAction = value => action('change')(value); return { // Values args: isVue2 ? props : _args, // Events clickedAction, }; }, }), args: { items: DEFAULT_ITEMS, value: 'option_1', }, parameters: storybookDocsParameter(DEFAULT_STORY_CODE_SAMPLE), }; const WITH_CATEGORIES_STORY_CODE_SAMPLE = ` <template> <bento-secondary-nav :items="items" :value="activeItem" @change="onChange" /> <template> <script lang="ts" setup> const items = [ { label: 'Category 1', items: [ { label: 'Option 1', value: 'option_1', }, { label: 'Option 2', value: 'option_2', }, ], }, { label: 'Category 2', items: [ { label: 'Option 3', value: 'option_3', }, { label: 'Option 4', value: 'option_4', }, { label: 'Option 5', value: 'option_5', }, ], }, ]; const activeItem = ref(items[0].value); const onChange = (newActiveItem: string) => { activeItem.value = newActiveItem; }; </script> `; export const WithCategories: Story = { render: (_args, { argTypes }) => ({ components: { BentoSecondaryNav, }, props: Object.keys(argTypes), template: ` <bento-secondary-nav v-bind="args" @change="clickedAction" /> `, setup(props) { const clickedAction = value => action('change')(value); return { // Values args: isVue2 ? props : _args, // Events clickedAction, }; }, }), args: { items: ITEMS_WITH_CATEGORIES, }, parameters: storybookDocsParameter(WITH_CATEGORIES_STORY_CODE_SAMPLE), };
1
+ import { action } from '@storybook/addon-actions'; import { isVue2 } from 'vue-demi'; import { storybookDocsParameter } from '@/utils/ts/storybook'; import BentoSecondaryNav from './secondary-nav.vue'; import WithSubItemsCodeSample from './__tests__/secondary-nav-with-sub-items.vue?raw'; import type { Meta, StoryObj } from '@storybook/vue'; import { type BentoSecondaryNavItems } from '@/components/secondary-nav/secondary-nav.types'; const meta: Meta = { title: 'Secondary Nav', component: BentoSecondaryNav, }; export default meta; type Story = StoryObj<typeof BentoSecondaryNav>; const DEFAULT_ITEMS: BentoSecondaryNavItems = [ { label: 'Option 1', value: 'option_1', }, { label: 'Option 2', value: 'option_2', }, { label: 'Option 3', value: 'option_3', }, ]; const ITEMS_WITH_CATEGORIES: BentoSecondaryNavItems = [ { label: 'Category 1', items: [ { label: 'Option 1', value: 'option_1', }, { label: 'Option 2', value: 'option_2', }, ], }, { label: 'Category 2', items: [ { label: 'Option 3', value: 'option_3', }, { label: 'Option 4', value: 'option_4', }, { label: 'Option 5', value: 'option_5', }, ], }, ]; const ITEMS_WITH_SUB_ITEMS: BentoSecondaryNavItems = ITEMS_WITH_CATEGORIES.map(category => ({ ...category, items: category.items.map(item => { if (item.value === 'option_2') { return { ...item, items: [ { label: 'Option 2.1', value: 'option_2_1', }, { label: 'Option 2.2', value: 'option_2_2', }, ], }; } if (item.value === 'option_4') { return { ...item, items: [ { label: 'Option 4.1', value: 'option_4_1', }, { label: 'Option 4.2', value: 'option_4_2', }, ], }; } return item; }), })); const DEFAULT_STORY_CODE_SAMPLE = ` <template> <bento-secondary-nav :items="items" :value="activeItem" @change="onChange" /> <template> <script lang="ts" setup> const items = [ { label: 'Option 1', value: 'option_1', }, { label: 'Option 2', value: 'option_2', }, { label: 'Option 3', value: 'option_3', }, ]; const activeItem = ref(items[0].value); const onChange = (newActiveItem: string) => { activeItem.value = newActiveItem; }; </script> `; export const Default: Story = { render: (_args, { argTypes }) => ({ components: { BentoSecondaryNav, }, props: Object.keys(argTypes), template: ` <bento-secondary-nav v-bind="args" @change="clickedAction" /> `, setup(props) { const clickedAction = value => action('change')(value); return { // Values args: isVue2 ? props : _args, // Events clickedAction, }; }, }), args: { items: DEFAULT_ITEMS, value: 'option_1', }, parameters: storybookDocsParameter(DEFAULT_STORY_CODE_SAMPLE), }; const WITH_CATEGORIES_STORY_CODE_SAMPLE = ` <template> <bento-secondary-nav :items="items" :value="activeItem" @change="onChange" /> <template> <script lang="ts" setup> const items = [ { label: 'Category 1', items: [ { label: 'Option 1', value: 'option_1', }, { label: 'Option 2', value: 'option_2', }, ], }, { label: 'Category 2', items: [ { label: 'Option 3', value: 'option_3', }, { label: 'Option 4', value: 'option_4', }, { label: 'Option 5', value: 'option_5', }, ], }, ]; const activeItem = ref(items[0].value); const onChange = (newActiveItem: string) => { activeItem.value = newActiveItem; }; </script> `; export const WithCategories: Story = { render: (_args, { argTypes }) => ({ components: { BentoSecondaryNav, }, props: Object.keys(argTypes), template: ` <bento-secondary-nav v-bind="args" @change="clickedAction" /> `, setup(props) { const clickedAction = value => action('change')(value); return { // Values args: isVue2 ? props : _args, // Events clickedAction, }; }, }), args: { items: ITEMS_WITH_CATEGORIES, }, parameters: storybookDocsParameter(WITH_CATEGORIES_STORY_CODE_SAMPLE), }; export const WithSubItems: Story = { render: (_args, { argTypes }) => ({ components: { BentoSecondaryNav, }, props: Object.keys(argTypes), template: ` <bento-secondary-nav v-bind="args" @change="clickedAction" /> `, setup(props) { const clickedAction = value => action('change')(value); return { args: isVue2 ? props : _args, clickedAction, }; }, }), args: { value: 'option_2_1', items: ITEMS_WITH_SUB_ITEMS, }, parameters: storybookDocsParameter(WithSubItemsCodeSample), };
@@ -1 +1 @@
1
- import type { Ref } from 'vue'; export interface BentoSecondaryNavState { activeItemValue: Ref<string>; updateActiveItemValue: (id: string) => void; } export interface BentoSecondaryNavItemsItem { label: string; value: string; } export interface BentoSecondaryNavItemsCategory { label: string; items: Array<BentoSecondaryNavItemsItem>; } export type BentoSecondaryNavItems = Array<BentoSecondaryNavItemsItem | BentoSecondaryNavItemsCategory>;
1
+ import type { Ref } from 'vue'; export interface BentoSecondaryNavState { activeItemValue: Ref<string>; updateActiveItemValue: (id: string) => void; allowExpandableItemNavigation: Ref<boolean>; } export interface BentoSecondaryNavItemsItem { label: string; value: string; items?: Array<BentoSecondaryNavItemsItem>; } export interface BentoSecondaryNavItemsCategory { label: string; items: Array<BentoSecondaryNavItemsItem>; } export type BentoSecondaryNavItems = Array<BentoSecondaryNavItemsItem | BentoSecondaryNavItemsCategory>;
@@ -1 +1 @@
1
- <template> <nav class="b-secondary-nav"> <bento-dropdown v-if="isMobile" :aria-label="t('mobileDropdownAriaLabel')" :items="dropdownItems" :static-categories="hasCategories" :model-value="activeItemValue" @update:model-value="updateActiveItemValue" /> <ul v-else class="b-secondary-nav__list"> <li v-for="item in items" :key="item.label"> <secondary-nav-category v-if="'items' in item" :label="item.label" :items="item.items" /> <secondary-nav-item v-else :label="item.label" :value="item.value" /> </li> </ul> </nav> </template> <script setup lang="ts"> import { computed, provide, readonly, ref, watch } from 'vue'; import { SecondaryNavItem } from './components/secondary-nav-item'; import { SecondaryNavCategory } from './components/secondary-nav-category'; import { SECONDARY_NAV_STATE } from './secondary-nav.keys'; import { type BentoSecondaryNavItems, type BentoSecondaryNavState } from './secondary-nav.types'; import { BentoDropdown } from '@/components/dropdown'; import { useBreakpoints } from '@vueuse/core'; import { useI18n } from '@/utils/ts/i18n'; import { type BentoListboxOptions } from '@/types/listbox'; import { BMediaQuerySMax } from '@adyen/bento-design-tokens/dist/js/bento/es6'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const { t } = useI18n<{ message: MessageSchema }>({ messages }); const breakpoints = useBreakpoints({ s: BMediaQuerySMax, }); const isMobile = breakpoints.smallerOrEqual('s'); const props = defineProps<{ /** * An array containing all the items on the nav. The array can have either {@see BentoSecondaryNavItemsCategory }, * which is used to group different items or {@see BentoSecondaryNavItemsItem } if you don't need to group them. */ items: BentoSecondaryNavItems; /** * The value of the selected item. * If nothing is passed, it defaults to the first item value in items. */ value?: string; }>(); const emit = defineEmits<{ /** * Emmited when the current selected item changes */ (e: 'change', category: string): void; }>(); const hasCategories = computed(() => props.items.some(item => Object.hasOwn(item, 'items'))); const dropdownItems = computed(() => props.items as BentoListboxOptions); const activeItemValueFallback = computed(() => { const firstDropdownItem = (dropdownItems.value as BentoSecondaryNavItems)[0]; if ('items' in firstDropdownItem) { return firstDropdownItem.items[0].value; } else { return firstDropdownItem.value; } }); const activeItemValue = ref(props.value ?? activeItemValueFallback.value); const updateActiveItemValue = (value: string) => { activeItemValue.value = value; emit('change', value); }; // The component's state is inject on `secondary-nav-item` to determine the // current active item and to change its value const state: BentoSecondaryNavState = { activeItemValue: readonly(activeItemValue), updateActiveItemValue, }; watch( () => props.value, value => { activeItemValue.value = value; } ); provide(SECONDARY_NAV_STATE, state); </script> <script lang="ts"> /** * Secondary Nav offers users a structured way to access information. * It also provides a clear hierarchy for users to understand and navigate through various information. * * @example * import { BentoSecondaryNav } from '@adyen/bento-vue2'; * * export default { * components: { BentoSecondaryNav }, * template: ` * <bento-secondary-nav :value="value" :items="items" /> * `, * setup() { * const value = ref(2) // Active item value * const items: BentoSecondaryNavItemInfo[] = [ * { label: 'Option 1', value: 1 }, * { label: 'Option 2', value: 2 }, * ]; * return { items, value }; * } * } */ export default { i18n: { messages }, }; </script> <style lang="scss" scoped src="./secondary-nav.scss" />
1
+ <template> <nav class="b-secondary-nav"> <bento-dropdown v-if="isMobile" :aria-label="t('mobileDropdownAriaLabel')" :items="mobileDropdownItems" :static-categories="hasCategories" :model-value="activeItemValue" :is-option-disabled="isMobileOptionDisabled" @update:model-value="updateActiveItemValue" > <template #display-value="{ label }"> <bento-typography el="span"> {{ label }} </bento-typography> </template> <template #default="{ label, data }"> <bento-typography el="span" :class="{ 'b-secondary-nav__nested-option': !!data?.level }"> {{ label }} </bento-typography> </template> </bento-dropdown> <ul v-else class="b-secondary-nav__list"> <li v-for="item in items" :key="item.label"> <secondary-nav-category v-if="'items' in item && !('value' in item)" :label="item.label" :items="item.items" /> <secondary-nav-item v-else :label="item.label" :value="item.value" :items="item.items" /> </li> </ul> </nav> </template> <script setup lang="ts"> import { computed, provide, readonly, ref, toRef, watch } from 'vue'; import { SecondaryNavItem } from './components/secondary-nav-item'; import { SecondaryNavCategory } from './components/secondary-nav-category'; import { SECONDARY_NAV_STATE } from './secondary-nav.keys'; import { type BentoSecondaryNavItems, type BentoSecondaryNavItemsCategory, type BentoSecondaryNavItemsItem, type BentoSecondaryNavState, } from './secondary-nav.types'; import { BentoDropdown } from '@/components/dropdown'; import { BentoTypography } from '@/components/typography'; import { useBreakpoints } from '@vueuse/core'; import { useI18n } from '@/utils/ts/i18n'; import { type BentoListboxOptionItem, type BentoListboxOptions } from '@/types/listbox'; import { BMediaQuerySMax } from '@adyen/bento-design-tokens/dist/js/bento/es6'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const { t } = useI18n<{ message: MessageSchema }>({ messages }); const breakpoints = useBreakpoints({ s: BMediaQuerySMax, }); const isMobile = breakpoints.smallerOrEqual('s'); const props = defineProps<{ /** * An array containing all the items on the nav. The array can have either {@see BentoSecondaryNavItemsCategory }, * which is used to group different items or {@see BentoSecondaryNavItemsItem } if you don't need to group them. */ items: BentoSecondaryNavItems; /** * The value of the selected item. * If nothing is passed, it defaults to the first item value in items. */ value?: string; /** * When true, items that contain subitems are also clickable and can be selected. * When false, those items only act as expand/collapse toggles. */ allowExpandableItemNavigation?: boolean; }>(); const emit = defineEmits<{ /** * Emmited when the current selected item changes */ (e: 'change', category: string): void; }>(); const isCategory = (item: BentoSecondaryNavItems[number]): item is BentoSecondaryNavItemsCategory => 'items' in item && !('value' in item); const mapNestedItems = (items: Array<BentoSecondaryNavItemsItem>, level = 0): BentoListboxOptions => items.flatMap((item): BentoListboxOptions => { const currentItem: BentoListboxOptionItem = { label: item.label, value: item.value, data: { level, hasSubItems: !!item.items?.length }, }; if (item.items?.length) { return [currentItem, ...mapNestedItems(item.items, level + 1)]; } return [currentItem]; }); const isMobileOptionDisabled = (option: BentoListboxOptionItem): boolean => !props.allowExpandableItemNavigation && (option.data as { hasSubItems?: boolean })?.hasSubItems; const hasCategories = computed(() => props.items.some(item => isCategory(item))); const mobileDropdownItems = computed<BentoListboxOptions>(() => { return props.items.flatMap(item => { if (isCategory(item)) { return [ { label: item.label, value: `category-${item.label}`, items: mapNestedItems(item.items), } satisfies BentoListboxOptionItem, ]; } if (item.items?.length) { return mapNestedItems([item]); } return [ { label: item.label, value: item.value, data: { level: 0 }, } satisfies BentoListboxOptionItem, ]; }); }); const flattenNestedItems = (items: Array<BentoSecondaryNavItemsItem>): Array<BentoSecondaryNavItemsItem> => items.flatMap(item => [item, ...(item.items?.length ? flattenNestedItems(item.items) : [])]); const findFirstSelectableNestedItemValue = (items: Array<BentoSecondaryNavItemsItem>): string => flattenNestedItems(items).find(item => props.allowExpandableItemNavigation || !item.items?.length)?.value ?? ''; const activeItemValueFallback = computed(() => { if (!props.items.length) { return ''; } return findFirstSelectableNestedItemValue( props.items.flatMap(item => isCategory(item) ? flattenNestedItems(item.items) : flattenNestedItems([item]) ) ); }); const activeItemValue = ref(props.value ?? activeItemValueFallback.value); const updateActiveItemValue = (value: string) => { activeItemValue.value = value; emit('change', value); }; // The component's state is inject on `secondary-nav-item` to determine the // current active item and to change its value const state: BentoSecondaryNavState = { activeItemValue: readonly(activeItemValue), updateActiveItemValue, allowExpandableItemNavigation: toRef(props, 'allowExpandableItemNavigation'), }; watch( () => props.value, value => { if (value !== undefined) { activeItemValue.value = value; } } ); provide(SECONDARY_NAV_STATE, state); </script> <script lang="ts"> /** * Secondary Nav offers users a structured way to access information. * It also provides a clear hierarchy for users to understand and navigate through various information. * * @example * import { BentoSecondaryNav } from '@adyen/bento-vue2'; * * export default { * components: { BentoSecondaryNav }, * template: ` * <bento-secondary-nav :value="value" :items="items" /> * `, * setup() { * const value = ref(2) // Active item value * const items: BentoSecondaryNavItemInfo[] = [ * { label: 'Option 1', value: 1 }, * { label: 'Option 2', value: 2 }, * ]; * return { items, value }; * } * } */ export default { i18n: { messages }, }; </script> <style lang="scss" scoped src="./secondary-nav.scss" />
@@ -49,6 +49,7 @@
49
49
  "BentoInputField",
50
50
  "BentoInputFieldPassword",
51
51
  "BentoInputFieldPhoneNumber",
52
+ "BentoInputTime",
52
53
  "BentoInspector",
53
54
  "BentoLayout",
54
55
  "BentoLayoutRow",
@@ -1 +1 @@
1
- import BentoVue from './install'; export * from './components/accordion'; export * from './components/action-bar'; export * from './components/ai-tag'; export * from './components/alert'; export * from './components/anchor-scroller'; export * from './components/avatar'; export * from './components/button'; export * from './components/dashed-underline'; export * from './components/data-grid'; export * from './components/card'; export * from './components/date-picker'; export * from './components/date-range-picker'; export * from './components/dropdown'; export * from './components/checkbox'; export * from './components/chip'; export * from './components/click-outside'; export * from './components/code-snippet'; export * from './components/country'; export * from './components/currency'; export * from './components/date'; export * from './components/divider'; export * from './components/empty-state'; export * from './components/file-uploader'; export * from './components/filter-bar'; export * from './components/form-layout'; export * from './components/header-with-views'; export * from './components/image'; export * from './components/info-icon'; export * from './components/input-field'; export * from './components/input-field-password'; export * from './components/input-field-phone-number'; export * from './components/inspector'; export * from './components/layout'; export * from './components/list'; export * from './components/loading-indicator'; export * from './components/link'; export * from './components/menu'; export * from './components/modal'; export * from './components/modal-fullscreen'; export * from './components/header'; export * from './components/pagination'; export * from './components/payment-method'; export * from './components/popover'; export * from './components/promo-banner'; export * from './components/radio-group'; export * from './components/rich-text-editor'; export * from './components/search-bar'; export * from './components/secondary-nav'; export * from './components/selection-card'; export * from './components/segmented-control'; export * from './components/sidepanel'; export * from './components/status'; export * from './components/stepper'; export * from './components/structured-list'; export * from './components/summary-grid'; export * from './components/tabs'; export * from './components/tag'; export * from './components/textarea'; export * from './components/timeline'; export * from './components/toast'; export * from './components/toggle'; export * from './components/tutorial'; export * from './components/typography'; // Export Composables export * from './composables/use-bento-base-filter'; export * from './composables/use-bento-currency'; export * from './composables/use-bento-theme'; export * from './composables/use-bento-toast-controller'; export * from './composables/use-click-outside'; export * from './composables/use-form-validate'; // Export Templates export * from './templates/dashboard-template'; export * from './templates/data-grid-template'; // Export Types export * from './types'; // Export Directives export * from './directives'; // AdlVue Plugin export default BentoVue; export { BentoTheme } from './install';
1
+ import BentoVue from './install'; export * from './components/accordion'; export * from './components/action-bar'; export * from './components/ai-tag'; export * from './components/alert'; export * from './components/anchor-scroller'; export * from './components/avatar'; export * from './components/button'; export * from './components/dashed-underline'; export * from './components/data-grid'; export * from './components/card'; export * from './components/date-picker'; export * from './components/date-range-picker'; export * from './components/dropdown'; export * from './components/checkbox'; export * from './components/chip'; export * from './components/click-outside'; export * from './components/code-snippet'; export * from './components/country'; export * from './components/currency'; export * from './components/date'; export * from './components/divider'; export * from './components/empty-state'; export * from './components/file-uploader'; export * from './components/filter-bar'; export * from './components/form-layout'; export * from './components/header-with-views'; export * from './components/image'; export * from './components/info-icon'; export * from './components/input-field'; export * from './components/input-field-password'; export * from './components/input-field-phone-number'; export * from './components/input-time'; export * from './components/inspector'; export * from './components/layout'; export * from './components/list'; export * from './components/loading-indicator'; export * from './components/link'; export * from './components/menu'; export * from './components/modal'; export * from './components/modal-fullscreen'; export * from './components/header'; export * from './components/pagination'; export * from './components/payment-method'; export * from './components/popover'; export * from './components/promo-banner'; export * from './components/radio-group'; export * from './components/rich-text-editor'; export * from './components/search-bar'; export * from './components/secondary-nav'; export * from './components/selection-card'; export * from './components/segmented-control'; export * from './components/sidepanel'; export * from './components/status'; export * from './components/stepper'; export * from './components/structured-list'; export * from './components/summary-grid'; export * from './components/tabs'; export * from './components/tag'; export * from './components/textarea'; export * from './components/timeline'; export * from './components/toast'; export * from './components/toggle'; export * from './components/tutorial'; export * from './components/typography'; // Export Composables export * from './composables/use-bento-base-filter'; export * from './composables/use-bento-currency'; export * from './composables/use-bento-theme'; export * from './composables/use-bento-toast-controller'; export * from './composables/use-click-outside'; export * from './composables/use-form-validate'; // Export Templates export * from './templates/dashboard-template'; export * from './templates/data-grid-template'; // Export Types export * from './types'; // Export Directives export * from './directives'; // AdlVue Plugin export default BentoVue; export { BentoTheme } from './install';
package/dist/main.js CHANGED
@@ -30400,7 +30400,7 @@ function addGetIcons(server) {
30400
30400
  var package_default = {
30401
30401
  name: "@adyen/bento-mcp",
30402
30402
  mcpName: "io.github.adyen/bento-mcp",
30403
- version: "0.2.1",
30403
+ version: "0.3.0",
30404
30404
  type: "module",
30405
30405
  description: "A Model Context Protocol server implementation for Bento",
30406
30406
  license: "MIT",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@adyen/bento-mcp",
3
3
  "mcpName": "io.github.adyen/bento-mcp",
4
- "version": "0.2.1",
4
+ "version": "0.3.0",
5
5
  "type": "module",
6
6
  "description": "A Model Context Protocol server implementation for Bento",
7
7
  "license": "MIT",