@adyen/bento-mcp 0.5.1 → 0.5.2
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 +5 -0
- package/dist/assets/components/accordion/components/accordion-item.vue +1 -1
- package/dist/assets/components/card/card.vue +1 -1
- package/dist/assets/components/data-grid/components/data-grid-row/data-grid-row.vue +1 -1
- package/dist/assets/components/draggable/draggable.docs.mdx +76 -135
- package/dist/assets/components/draggable/draggable.stories.ts +1 -1
- package/dist/assets/components/draggable/draggable.types.ts +1 -1
- package/dist/assets/components/draggable/draggable.vue +1 -1
- package/dist/assets/components/filter-bar/components/base-filter/base-filter.vue +1 -1
- package/dist/assets/components/internal/calendar/calendar.types.ts +1 -1
- package/dist/assets/components/internal/calendar/calendar.vue +1 -1
- package/dist/assets/components/internal/calendar/components/calendar-month/calendar-month.stories.ts +1 -1
- package/dist/assets/components/internal/calendar/components/calendar-month/calendar-month.types.ts +1 -1
- package/dist/assets/components/internal/calendar/components/calendar-month/calendar-month.vue +1 -1
- package/dist/assets/components/internal/calendar/components/calendar-month/composables/granular-min-max-date.types.ts +1 -1
- package/dist/assets/components/internal/calendar/components/calendar-year/calendar-year.types.ts +1 -1
- package/dist/assets/components/internal/calendar/components/calendar-year/calendar-year.vue +1 -1
- package/dist/assets/components/internal/teleport/teleport.vue +1 -1
- package/dist/assets/components/navigation-menu/components/navigation-menu-group/navigation-menu-group.vue +1 -1
- package/dist/assets/components/secondary-nav/components/secondary-nav-category/secondary-nav-category.vue +1 -1
- package/dist/assets/components/secondary-nav/components/secondary-nav-item/secondary-nav-item.vue +1 -1
- package/dist/assets/components/secondary-nav/secondary-nav.stories.ts +1 -1
- package/dist/assets/components/secondary-nav/secondary-nav.types.ts +1 -1
- package/dist/assets/components/secondary-nav/secondary-nav.vue +1 -1
- package/dist/assets/components/tabs/components/tab.types.ts +1 -1
- package/dist/assets/components/tabs/components/tab.vue +1 -1
- package/dist/assets/components/tabs/tabs.stories.ts +1 -1
- package/dist/assets/components/tabs/tabs.types.ts +1 -1
- package/dist/assets/components/tabs/tabs.vue +1 -1
- package/dist/assets/components.json +1 -0
- package/dist/assets/index.ts +1 -1
- package/dist/assets/usage.json +5 -4
- package/dist/main.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
<template> <div class="b-accordion-item"> <bento-typography el="h3" :variant="titleSize === 'small' ? 'body' : 'title'" :strongest="titleSize === 'small'" > <button :id="buttonId" :aria-controls="contentId" :aria-expanded="`${isOpen}`" type="button" class="b-accordion-item__button" @click="isOpen = !isOpen" > <chevron-up-icon v-if="isOpen" class="b-accordion-item__icon" :svg-title="t('collapse')" aria-hidden="true" /> <chevron-down-icon v-else class="b-accordion-item__icon" :svg-title="t('expand')" aria-hidden="true" /> {{ title }} </button> </bento-typography> <Transition :name="CONTENT_TRANSITION_NAME"> <div v-if="isOpen" :id="contentId" :aria-labelledby="buttonId" role="region" :style="contentMaxHeight"> <div ref="contentDiv" class="b-accordion-item__content"> <slot /> </div> </div> </Transition> </div> </template> <script setup lang="ts"> import { computed, inject, ref, watch } from 'vue'; 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 { generateUid } from '@/core/utils/ts'; import {
|
|
1
|
+
<template> <div class="b-accordion-item"> <bento-typography el="h3" :variant="titleSize === 'small' ? 'body' : 'title'" :strongest="titleSize === 'small'" > <button :id="buttonId" :aria-controls="contentId" :aria-expanded="`${isOpen}`" type="button" class="b-accordion-item__button" @click="isOpen = !isOpen" > <chevron-up-icon v-if="isOpen" class="b-accordion-item__icon" :svg-title="t('collapse')" aria-hidden="true" /> <chevron-down-icon v-else class="b-accordion-item__icon" :svg-title="t('expand')" aria-hidden="true" /> {{ title }} </button> </bento-typography> <Transition :name="CONTENT_TRANSITION_NAME"> <div v-if="isOpen" :id="contentId" :aria-labelledby="buttonId" role="region" :style="contentMaxHeight"> <div ref="contentDiv" class="b-accordion-item__content"> <slot /> </div> </div> </Transition> </div> </template> <script setup lang="ts"> import { computed, inject, ref, watch } from 'vue'; 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 { generateUid } from '@/core/utils/ts'; import { useI18n } from '@/utils/ts/i18n'; import { ACCORDION_TITLE_SIZE_INJECTION_KEY } from '../accordion.keys'; import type { BentoAccordionTitleSize } from '../accordion.types'; import { useExpandableContentHeight } from '@/composables/use-expandable-content-height/use-expandable-content-height'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const CONTENT_TRANSITION_NAME = 'b-accordion-item__animation--content'; const props = defineProps({ /** * Sets the title of the accordion item. */ title: { type: String, required: true, }, /** * Controls if the content is visible or hidden. */ isExpanded: { type: Boolean, default: false, }, }); const emit = defineEmits<{ /** * Emits update event when content is toggled */ (e: 'update:is-expanded', value: boolean): void; }>(); const { t } = useI18n<{ message: MessageSchema }>({ messages }); // Refs const contentDiv = ref<HTMLDivElement>(null); const isOpen = ref(props.isExpanded); // Generating IDs const buttonId = generateUid('accordionItemButton'); const contentId = generateUid('accordionItemContent'); // Injecting prop from parent const titleSize = inject<`${BentoAccordionTitleSize}`>(ACCORDION_TITLE_SIZE_INJECTION_KEY, 'default'); // Getting and setting the item height to be able to handle height animation const { contentHeight } = useExpandableContentHeight(contentDiv, isOpen); const contentMaxHeight = computed(() => (contentHeight.value ? { 'max-height': contentHeight.value } : {})); watch( () => props.isExpanded, value => { isOpen.value = value; } ); watch( isOpen, value => { // Enable two-way data binding for `isExpanded` emit('update:is-expanded', value); }, { immediate: true } ); </script> <script lang="ts"> /** * An accordion item is an interactive header used to reveal or hide a * section of content associated with this header. * * This component must always be wrapped by a `BentoAccordion` component. * * @example * import { BentoAccordion, BentoAccordionItem } from '@adyen/bento-vue2'; * * export default { * components: { BentoAccordion, BentoAccordionItem }, * template: ` * <bento-accordion> * <bento-accordion-item title="Item 1"> * Content for item 1 * </bento-accordion-item> * <bento-accordion-item title="Item 2"> * Content for item 2 * </bento-accordion-item> * </bento-accordion> * ` * } */ export default { i18n: { messages }, }; </script> <style lang="scss" scoped src="./accordion-item.scss" />
|
|
@@ -1 +1 @@
|
|
|
1
|
-
<template> <div class="b-card" :class="cardConditionalClasses" :tabindex="tabIndex" data-testid="card" @click="onClick" @keydown.enter="onKeyDown" @keydown.space="onKeyDown" > <div v-if="hasSlot('image')" class="b-card__image"> <slot name="image"></slot> </div> <div v-if="showHeader" class="b-card__header" :class="cardHeaderConditionalClasses" :tabindex="headerTabIndex" :aria-controls="expandable && showCardBody ? cardId : null" :aria-expanded="expandable ? `${showContent}` : null" :role="expandable ? 'button' : null" @click="toggleExpansion" @keypress.enter="toggleExpansion" @keypress.space="toggleExpansion" > <div class="b-card__header-wrapper"> <div v-if="showTitleWrapper" class="b-card__header-slot-wrapper"> <div class="b-card__title-wrapper" :class="cardTitleWrapperConditionalClasses"> <div v-if="expandable" class="b-card__toggle" :class="cardToggleConditionalClasses"> <chevron-up-icon v-if="showContent" :svg-title="t('hideContent')" aria-hidden="true" /> <chevron-down-icon v-else :svg-title="t('showContent')" aria-hidden="true" /> </div> <bento-typography v-if="hasSlot('default')" class="b-card__title" v-bind="typography"> <slot /> </bento-typography> </div> <template v-if="hasSlot('header')"> <slot name="header"></slot> </template> </div> <bento-typography v-if="hasSlot('description')" class="b-card__description" el="div" variant="body"> <slot name="description" /> </bento-typography> </div> </div> <Transition name="b-card__animation"> <div v-if="showCardBody" class="b-card__wrapper" :style="contentMaxHeight"> <div :id="expandable ? cardId : null" ref="contentDiv" class="b-card__body" :class="cardBodyConditionalClasses" > <div v-if="hasSlot('content')" class="b-card__content" :class="cardContentConditionalClasses"> <slot name="content" /> </div> <div v-if="actions" class="b-card__actions" :class="cardActionsConditionalClasses"> <bento-button-actions :actions="actions" :layout="actionsLayout"></bento-button-actions> </div> </div> </div> </Transition> </div> </template> <script setup lang="ts"> import { computed, ref, useSlots, watch } from 'vue'; // Components import { BentoTypography } from '@/components/typography'; import { BentoButtonActions, BentoButtonActionsLayout, type BentoButtonActionsList } from '@/components/button'; import { BentoCardBackground, BentoCardTitleSize } from '@/components/card/card.types'; import ChevronDownIcon from '@adyen/ui-assets-icons-16/vue/chevron-down'; import ChevronUpIcon from '@adyen/ui-assets-icons-16/vue/chevron-up'; // Composables import { useExpandableContentHeight } from '@/composables/use-expandable-content-height/use-expandable-content-height'; // Utils import { useHasSlot } from '@/composables'; import { generateUid } from '@/core/utils/ts'; import { useI18n } from '@/utils/ts/i18n'; import
|
|
1
|
+
<template> <div class="b-card" :class="cardConditionalClasses" :tabindex="tabIndex" data-testid="card" @click="onClick" @keydown.enter="onKeyDown" @keydown.space="onKeyDown" > <div v-if="hasSlot('image')" class="b-card__image"> <slot name="image"></slot> </div> <div v-if="showHeader" class="b-card__header" :class="cardHeaderConditionalClasses" :tabindex="headerTabIndex" :aria-controls="expandable && showCardBody ? cardId : null" :aria-expanded="expandable ? `${showContent}` : null" :role="expandable ? 'button' : null" @click="toggleExpansion" @keypress.enter="toggleExpansion" @keypress.space="toggleExpansion" > <div class="b-card__header-wrapper"> <div v-if="showTitleWrapper" class="b-card__header-slot-wrapper"> <div class="b-card__title-wrapper" :class="cardTitleWrapperConditionalClasses"> <div v-if="expandable" class="b-card__toggle" :class="cardToggleConditionalClasses"> <chevron-up-icon v-if="showContent" :svg-title="t('hideContent')" aria-hidden="true" /> <chevron-down-icon v-else :svg-title="t('showContent')" aria-hidden="true" /> </div> <bento-typography v-if="hasSlot('default')" class="b-card__title" v-bind="typography"> <slot /> </bento-typography> </div> <template v-if="hasSlot('header')"> <slot name="header"></slot> </template> </div> <bento-typography v-if="hasSlot('description')" class="b-card__description" el="div" variant="body"> <slot name="description" /> </bento-typography> </div> </div> <Transition name="b-card__animation"> <div v-if="showCardBody" class="b-card__wrapper" :style="contentMaxHeight"> <div :id="expandable ? cardId : null" ref="contentDiv" class="b-card__body" :class="cardBodyConditionalClasses" > <div v-if="hasSlot('content')" class="b-card__content" :class="cardContentConditionalClasses"> <slot name="content" /> </div> <div v-if="actions" class="b-card__actions" :class="cardActionsConditionalClasses"> <bento-button-actions :actions="actions" :layout="actionsLayout"></bento-button-actions> </div> </div> </div> </Transition> </div> </template> <script setup lang="ts"> import { computed, ref, useSlots, watch } from 'vue'; // Components import { BentoTypography } from '@/components/typography'; import { BentoButtonActions, BentoButtonActionsLayout, type BentoButtonActionsList } from '@/components/button'; import { BentoCardBackground, BentoCardTitleSize } from '@/components/card/card.types'; import ChevronDownIcon from '@adyen/ui-assets-icons-16/vue/chevron-down'; import ChevronUpIcon from '@adyen/ui-assets-icons-16/vue/chevron-up'; // Composables import { useExpandableContentHeight } from '@/composables/use-expandable-content-height/use-expandable-content-height'; // Utils import { useHasSlot } from '@/composables'; import { generateUid } from '@/core/utils/ts'; import { useI18n } from '@/utils/ts/i18n'; import messages from './messages.json'; import { deprecate } from '@/utils/ts/deprecate'; type MessageSchema = (typeof messages)['en-US']; const { t } = useI18n<{ message: MessageSchema }>({ messages }); const slots = useSlots(); const hasSlot = useHasSlot(slots); const props = withDefaults( defineProps<{ /** * List of actions that will be used to render the buttons. * First element in the list will be rendered as "primary". * All elements after the first will be rendered as "secondary". * * Each object in the list should have a `title`, an `event` and * (optional) a Vue `icon` from the library `@adyen/ui-assets-icons-16`. * * `title`: Button's text. * * `event`: Event triggered when clicking the Button. * * `icon`: Vue Icon from the library `@adyen/ui-assets-icons-16`. * * ``` * [{ * title: 'Primary', * event: () => { ... }, * icon: FourPeopleIcon * }, { * title: 'Secondary', * event: () => { ... } * }] * ``` */ actions?: BentoButtonActionsList; /** * Defines the layout in which the button actions is presented. * It can be one of: * * @values BUTTONS_START * @values BUTTONS_END * @values FILL_CONTAINER * @values SPACE_BETWEEN * @values VERTICAL_STACK */ actionsLayout?: BentoButtonActionsLayout | `${BentoButtonActionsLayout}`; /** * Defines the color of card background */ background?: BentoCardBackground | `${BentoCardBackground}`; /** * Makes the whole card clickable */ clickable?: boolean; /** * Sets the expandable card in a closed state */ closed?: boolean; /** * Disables the expandable card */ disabled?: boolean; /** * The whole content of the card will be expandable in accordion manner */ expandable?: boolean; /** * Defines the title size of the card header */ titleSize?: BentoCardTitleSize | `${BentoCardTitleSize}`; }>(), { actions: null, actionsLayout: BentoButtonActionsLayout.BUTTONS_END, background: BentoCardBackground.PRIMARY, clickable: false, closed: false, disabled: false, expandable: false, titleSize: BentoCardTitleSize.DEFAULT, } ); const emit = defineEmits<{ (e: 'click'); (e: 'expanded', isExpanded: boolean); }>(); const contentDiv = ref<HTMLDivElement>(null); const cardId = generateUid('card'); const showContent = ref(!props.closed); watch( () => props.closed, value => { showContent.value = !value; } ); const showHeader = computed(() => hasSlot('default') || hasSlot('description') || hasSlot('header')); const showTitleWrapper = computed(() => hasSlot('default') || hasSlot('header')); const showCardBody = computed( () => (hasSlot('content') || props.actions?.length) && (!props.expandable || (props.expandable && showContent.value)) ); const tabIndex = computed(() => (props.clickable && !props.disabled ? 0 : -1)); const headerTabIndex = computed(() => (props.expandable && !props.disabled ? 0 : -1)); const cardConditionalClasses = computed(() => ({ [`b-card--${props.background}`]: props.background, 'b-card--clickable': props.clickable, 'b-card--expandable': props.expandable, 'b-card--disabled': (props.clickable || props.expandable) && props.disabled, })); const cardHeaderConditionalClasses = computed(() => ({ 'b-card__header--expandable': props.expandable, 'b-card__header--without-body': !props.expandable && !showCardBody.value, })); const cardToggleConditionalClasses = computed(() => ({ 'b-card__toggle--small-text': props.titleSize === BentoCardTitleSize.SMALL, })); const cardBodyConditionalClasses = computed(() => ({ 'b-card__body--expandable': props.expandable && hasSlot('content'), 'b-card__body--without-header': !props.expandable && !showHeader.value, })); const cardContentConditionalClasses = computed(() => ({ 'b-card__content--without-header': !props.expandable && !showHeader.value, 'b-card__content--expandable': props.expandable, })); const cardActionsConditionalClasses = computed(() => { const isNotExpandableWithoutContent = !props.expandable && props.actions && !hasSlot('content'); return { 'b-card__actions--without-content': isNotExpandableWithoutContent, 'b-card__actions--without-content-and-with-header': isNotExpandableWithoutContent && showHeader.value, }; }); const cardTitleWrapperConditionalClasses = computed(() => ({ 'b-card__title-wrapper--truncated': hasSlot('header'), })); const typography = computed( () => ({ el: 'div', variant: props.titleSize === BentoCardTitleSize.DEFAULT ? 'title' : 'body', strongest: props.titleSize === BentoCardTitleSize.SMALL, wide: props.titleSize === BentoCardTitleSize.SMALL, }) as InstanceType<typeof BentoTypography>['$props'] ); // Toggle open and close logic and animation const { contentHeight } = useExpandableContentHeight(contentDiv, showContent); const contentMaxHeight = computed(() => props.expandable && contentHeight.value ? { 'max-height': contentHeight.value } : {} ); const toggleExpansion = () => { if (!props.disabled && props.expandable) { showContent.value = !showContent.value; emit('expanded', showContent.value); } }; const onClick = () => { if (!props.disabled) { emit('click'); if (!props.clickable) { deprecate( 'BentoCard "@click" property', `Use the BentoCard "@click" with the "clickable" property to enable accessible styles.`, '2.0.0' ); } } }; const onKeyDown = () => { if (props.clickable && !props.disabled) { emit('click'); } }; </script> <script lang="ts"> /** * A card is a flexible and composable container. * Use cards to represent a single object or to visually group certain content, like legal information within company settings. * * @example * import { BentoCard } from '@adyen/bento-vue2'; * * export default { * components: { BentoCard }, * template: ` * <bento-card> * Title of the Card * </bento-card> * ` * }; */ export default { i18n: { messages }, name: 'bento-card', }; </script> <style lang="scss" scoped src="./card.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> </template> <div v-if="rowActions" ref="rowActionsRef" :aria-colindex="sumColumnAmountsInColumnSets().toString()" class="b-data-grid-row-container__row-actions-container" :class="conditionalRowActionsContainerClasses" :style="computedRowActionsStyles" :data-cellindex="getRowActionsCellIndex()" tabindex="-1" role="gridcell" @focus="onRowActionsCellFocus" @focusOut="onRowActionsCellFocusOut" @keydown="onRowActionsCellOnKeyDown" > <data-grid-cell-actions class="b-data-grid-row-container__row-actions" disable-arrow-down-opening-menu :cell-actions="rowActions" :has-right-overflow="hasRightOverflow" :row-data-item="rowData" :has-zebra-background="isRowEven" :is-highlighted="isHighlightedRow(rowData)" :is-selected="isSelectedRow(rowData)" teleport-menu @menu-open="onRowActionMenuOpen" @menu-close="onRowActionMenuClose" /> </div> </div> <Transition v-if=" (hasNestedContent && hasSlot('nested-slot') && shouldShowNestedContent(rowData)) || (hasNestedRows && shouldShowNestedRowsBooleanResult) " :key="`${rowIndex}-nest-content`" name="b-data-grid-row-container__animation--nested-content" > <div v-if="isNestedContentShown && shouldShowRenderNestedRows" :class="conditionalNestedRowClasses" data-testid="nested-content-container" :style="nestedContentStyles" role="presentation" > <div ref="nestedContentRef"> <div v-if="isLoadingNestedRows" class="b-data-grid-row-container__row-nested-rows-loading-container" > <bento-loading-indicator /> </div> <data-grid-row v-for="(childRowData, childRowIndex) in rowData.children" v-bind="props" :id="getRowId(childRowIndex)" :key="`child-${childRowIndex}`" :ref="el => registerNestedRowRef(el, childRowIndex)" :row-data="childRowData" :row-index="childRowIndex" :row-set-size="rowData.children.length" is-nested-row @cell-hovered="emit('cell-hovered', $event)" @cell-focus="emit('cell-focus', $event)" @row-click="emitRowClick" @on-frozen-columns-key-press="emit('on-frozen-columns-key-press', $event)" @select-row="onNestedRowSelect(childRowData, $event)" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </data-grid-row> </div> </div> <div v-else-if="isNestedContentShown" v-bind="nestedContentContainerDivAriaAttributes" :class="conditionalNestedRowClasses" data-testid="nested-content-container" :style="nestedContentStyles" role="row" tabindex="-1" > <div ref="nestedContentRef" role="gridcell"> <div v-if="isLoadingNestedRows" class="b-data-grid-row-container__row-nested-rows-loading-container" > <bento-loading-indicator /> </div> <div v-else-if="hasSlot('nested-slot') && shouldShowNestedContent(rowData)"> <slot :item="rowData" name="nested-slot" /> </div> </div> </div> </Transition> </div> </template> <script setup lang="ts"> import { type ComponentPublicInstance, computed, type ComputedRef, inject, onMounted, type PropType, ref, useSlots, watch, } from 'vue'; import { BentoLoadingIndicator } from '@/components/loading-indicator'; import { focusOnInteractiveChild, makeAllInteractiveChildrenFocusable, makeAllInteractiveChildrenUnFocusable, } from '@/components/data-grid/utils'; import DataGridRow from '@/components/data-grid/components/data-grid-row/data-grid-row.vue'; import DataGridCell from '@/components/data-grid/components/data-grid-cell/data-grid-cell.vue'; import DataGridCellActions from '@/components/data-grid/components/data-grid-cell-actions/data-grid-cell-actions.vue'; // Types and enums import { type BentoColumn, type BentoDatagridDataItem, type BentoDatagridDataItemId, type BentoDatagridGetDataItemIdFn, type BentoDatagridIsDataItemDisabledFn, type BentoDatagridShouldShowNestedContentFn, type BentoDatagridShouldShowNestedRowsFn, BentoDatagridStyleState, } from '@/components/data-grid/data-grid.types'; import type { DataGridCellColumnSet, DataGridCellDragEventsForColumn, } from '../data-grid-cell/data-grid-cell.types'; import type { BentoDatagridDraggingColumnState } from '../data-grid-columns/data-grid-columns.types'; import type { DatagridColumnHoveringState } from '@/components/data-grid/composables/use-column-hovering'; // Translations import { type Booleanish } from '@/types/prop-types'; import { useHasSlot } from '@/composables/use-has-slot/use-has-slot'; import { useExpandableContentHeight } from '@/composables/use-expandable-content-height/use-expandable-content-height'; import { observeSizeOfElement } from '@/utils/ts/resize'; import { DATAGRID_ACTIONS_COLUMN_FIELD_NAME, DATAGRID_CELL_OFFSET_PADDING, } from '@/components/data-grid/composables/use-calculate-column-width/use-calculate-column-width'; import { generateUid } from '@/core/utils/ts'; import { type BentoDataGridRowActionsProp } from '@/components/data-grid/components/data-grid-cell-actions/data-grid-cell-actions.types'; import type { HTMLAttributes } from 'vue/types/jsx'; import { EventKey } from '@/types/utils-events'; import { DATA_GRID_SMALL_CONTAINER } from '@/components/data-grid/data-grid.keys'; const slots = useSlots(); const hasSlot = useHasSlot(slots); const props = defineProps({ /** * Row actions property that generates which button to show in row actions area */ rowActions: { type: [Function, Array] as PropType<BentoDataGridRowActionsProp>, default: undefined, }, /** * When true, will emit a 'rowClick' event when a row is clicked. Will add cursor: pointer to rows. */ allowRowClicks: { type: Boolean, default: false }, /** * The columns sets including */ columnSets: { type: Array as PropType<Array<DataGridCellColumnSet>>, required: true, }, /** * Sets the row cells to the condensed padding state. */ condensed: { type: Boolean, default: false, }, /** * Columns being dragged, contains dragging column and a target column */ draggingColumns: { type: Object as PropType<BentoDatagridDraggingColumnState>, default: undefined, }, /** * Dragging event handlers for each row cell */ draggingEventsForRowCell: { type: Function as PropType<DataGridCellDragEventsForColumn>, required: true, }, /** * A customisable method to retrieve a unique identifier for a data item in the array of data supplied. * This is used selecting and performance reasons and must exist in each data item. */ getDataItemId: { type: Function as PropType<BentoDatagridGetDataItemIdFn>, default: (item: BentoDatagridDataItem) => item.id, }, /** * Set to true if the datagrid has been scrolled and the left side has overflowing space. */ hasLeftOverflow: { type: Boolean, default: false, }, /** * Enables the nested content slot functionality. */ hasNestedContent: { type: Boolean, default: false, }, /** * Enables the nested content slot functionality. */ hasNestedRows: { type: Boolean, default: false, }, /** * Set to true if the datagrid has been scrolled and the right side has overflowing space. */ hasRightOverflow: { type: Boolean, default: false, }, /** * Highlighted row IDs. */ highlightedRows: { type: Array as PropType<Array<BentoDatagridDataItemId>>, default: () => [], }, /** * The row index of a currently hovered row. */ hoveredRowIndex: { type: Number, default: -1, }, /** * The column hovered over state and whether the hovered over column should show a border right or left. */ hoveredOverColumnState: { type: Object as PropType<DatagridColumnHoveringState>, required: true, }, /** * A customisable method to determine if a row is not selectable. * The default is that every row is selectable. */ isDataItemDisabled: { type: Function as PropType<BentoDatagridIsDataItemDisabledFn>, default: () => false, }, /** * If the row is of an even number. * Rows are 0th based index e.g. index 0 is an odd-numbered row. */ isRowEven: { type: Boolean, default: false, }, /** * If the row is the last child in it's parent container. */ isRowLastChild: { type: Boolean, default: false, }, /** * If columns are being measured for the autoWidth functionality. */ isMeasuringColumnWidths: { type: Boolean, default: false, }, /** * Determines if this row is nested inside another row. */ isNestedRow: { type: Boolean, default: false, }, /** * The maximum width of the row actions in all rows */ rowActionsMaximumWidth: { type: Number, default: 0, }, /** * The row data object */ rowData: { type: Object as PropType<BentoDatagridDataItem>, required: true, }, /** * The row index. */ rowIndex: { type: Number, required: true, }, /** * The row ID to be using for the ID attribute. */ rowId: { type: Number, default: undefined, }, /** * The number of rows that this row is within. */ rowSetSize: { type: Number, default: 0, }, /** * Determines whether to render frozen seperator on the row. */ showFrozenColumnsSeparator: { type: Boolean, default: false, }, /** * Determines if the row selection is enabled. */ selectable: { type: Boolean, default: false, }, /** * The array of IDs for the selected rows. */ selectedItems: { type: Array as PropType<Array<BentoDatagridDataItemId>>, required: true, }, /** * Enables / Disables the nested-content slot */ shouldShowNestedContent: { type: Function as PropType<BentoDatagridShouldShowNestedContentFn>, default: (_item: BentoDatagridDataItem) => true, }, /** * Returns the nested rows for a given BentoDatagridDataItem. */ shouldShowNestedRows: { type: Function as PropType<BentoDatagridShouldShowNestedRowsFn>, default: (_item: BentoDatagridDataItem) => true, }, }); const emit = defineEmits<{ /** * Emitted when a cell has been hovered over. This is used to keep the row in a hovered state when hovering over a menu. */ (e: 'cell-hovered', value: number); /** * Emitted when an a frozen column cell fires a key up or down event. */ (e: 'on-frozen-columns-key-press', event: Event); /** * Emitted when a row has been selected. */ (e: 'select-row', value: Array<BentoDatagridDataItemId>, rowData: BentoDatagridDataItem); /** * Emitted when a nested row has been selected. */ ( e: 'select-nested-row', value: Array<BentoDatagridDataItemId>, parentRowData: BentoDatagridDataItem, rowData: BentoDatagridDataItem ); /** * Emitted when a row has been clicked. */ (e: 'row-click', rowData: BentoDatagridDataItem, event: MouseEvent | KeyboardEvent); /** * Emitted when a row with nested content is toggled */ (e: 'nested-content-toggled', rowData: BentoDatagridDataItem); /** * Emitted when a row cell is focused. */ ( e: 'cell-focus', value: { rowData: BentoDatagridDataItem; column: BentoColumn; rowIndex: number; colIndex: number } ); }>(); /** * Checks if the relatedTarget element appears after the reference element in DOM order. * Used to detect backward navigation (Shift+Tab). */ const isRelatedTargetAfterInDOM = (relatedTarget: HTMLElement | null, referenceEl: HTMLElement): boolean => { if (!relatedTarget || !referenceEl) { return false; } return !!(referenceEl.compareDocumentPosition(relatedTarget) & Node.DOCUMENT_POSITION_FOLLOWING); }; // Refs const gridcellRefs = ref<{ [key: string]: HTMLDivElement }>({}); const isNestedContentShown = ref(false); const nestedContentRef = ref(); const rowActionsRef = ref(); const nestedRowRefs = ref<Array<InstanceType<typeof DataGridRow>>>([]); const nestedContentSlotId = generateUid('nested-content-slot'); const isRowActionMenuOpen = ref(false); const isExitingRowActionInteraction = ref(false); const focusableItems = ref<Array<{ node: HTMLElement }>>(); /** * Returns the row-action container element, handling the case where Vue 2 * may wrap refs inside a v-for as an array. */ const getRowActionsElement = (): HTMLElement | undefined => { const val = rowActionsRef.value; return Array.isArray(val) ? val[0] : val; }; onMounted(() => { const container = getRowActionsElement(); if (container) { focusableItems.value = makeAllInteractiveChildrenUnFocusable(container); } }); const shouldShowNestedRowsBooleanResult = computed(() => props.shouldShowNestedRows(props.rowData)); const shouldShowRenderNestedRows = computed( () => !props.isNestedRow && props.hasNestedRows && shouldShowNestedRowsBooleanResult.value && props.rowData?.children?.length > 0 ); const isLoadingNestedRows = computed( () => !props.isNestedRow && props.hasNestedRows && shouldShowNestedRowsBooleanResult.value && (props.rowData?.children === undefined || props?.rowData?.children.length === 0) ); const shouldShowNestedContentToggle = computed( () => (props.hasNestedContent && props.shouldShowNestedContent(props.rowData)) || (props.hasNestedRows && shouldShowNestedRowsBooleanResult.value && !props.isNestedRow) ); const ariaRowAttributes = computed<HTMLAttributes>(() => { if (props.hasNestedContent || props.hasNestedRows) { const isNestedContentShownBooleanishVal = isNestedContentShown.value.toString() as Booleanish; return { 'aria-expanded': shouldShowNestedContentToggle.value ? isNestedContentShownBooleanishVal : undefined, 'aria-level': props.isNestedRow ? '2' : '1', 'aria-posinset': props.rowIndex + 1, 'aria-setsize': props.rowSetSize, }; } return { 'aria-rowindex': props.rowIndex + 1 }; }); const nestedContentContainerDivAriaAttributes = computed<HTMLAttributes>(() => { if (isLoadingNestedRows.value) { return { 'aria-busy': 'true', 'aria-live': 'polite', }; } if ( shouldShowRenderNestedRows.value || !props.hasNestedContent || !props.shouldShowNestedContent(props.rowData) ) { return undefined; } return { 'aria-level': props.isNestedRow ? '3' : '2', 'aria-posinset': '1', 'aria-setsize': '1', id: nestedContentSlotId, role: 'row', tabindex: '-1', }; }); const nestedRowIds = computed(() => { if (shouldShowRenderNestedRows.value) { return props.rowData?.children?.reduce((acc, _val, index) => { acc[index] = generateUid('nested-row'); return acc; }, {}); } return []; }); const allNestedRowIds = computed(() => { if (!isNestedContentShown.value) { return undefined; } if (props.hasNestedContent && props.shouldShowNestedContent(props.rowData)) { return [nestedContentSlotId]; } if (shouldShowRenderNestedRows.value && Object.keys(nestedRowIds.value)?.length > 0) { const generatedRowIds = Object.values(nestedRowIds.value); if (generatedRowIds?.length > 0) { return generatedRowIds; } } return []; }); const hasUnSelectedNestedRows = computed(() => { const childrendDataItemIds = props.rowData?.children?.map(nestedItem => props.getDataItemId(nestedItem)); if (childrendDataItemIds?.length > 0) { const everyChildIsSelected = childrendDataItemIds.every(childId => props?.selectedItems?.includes(childId)); const someItemsAreSelected = childrendDataItemIds.some(childId => props?.selectedItems?.includes(childId)); return !everyChildIsSelected && someItemsAreSelected; } return false; }); // Methods const registerRowCellRef = (el: Element | ComponentPublicInstance, field: string) => { // We always know this will be used on a DataGridCell component so we can cast it const gridCellComponent = el as InstanceType<typeof DataGridCell>; if (el !== null && gridCellComponent?.gridcellRef) { gridcellRefs.value[field] = gridCellComponent.gridcellRef as HTMLDivElement; } }; const registerNestedRowRef = (el: Element | ComponentPublicInstance, childRowIndex: number) => { if (el !== null) { nestedRowRefs.value[childRowIndex] = el as InstanceType<typeof DataGridRow>; } }; const getRowId = (childRowIndex: number) => { return nestedRowIds.value[childRowIndex]; }; const getRowAriaSelectedValue = (row: BentoDatagridDataItem): Booleanish => { if (!props.selectable) { return null; } const id = props.getDataItemId(row); return props.selectedItems.includes(id).toString() as Booleanish; }; const emitRowClick = (rowData: BentoDatagridDataItem, event: MouseEvent | KeyboardEvent) => { emit('row-click', rowData, event); }; /** * Handles row clicks when the `allowRowClicks` prop is true. * * This method also prevents the `row-click` event from firing if the click * originated from an interactive element (like a button, input, or link) * within the row. This is to avoid unintended behavior when a user * interacts with controls inside a row. * * @param event The mouse or keyboard event from the click or key press. */ const onRowClick = (event: MouseEvent | KeyboardEvent) => { // Only proceed if allowRowClicks is true if (!props.allowRowClicks) { return; } let isEventTargetInteractiveElement = false; const dataGridRowInteractiveTypes = ['button', 'input', 'a']; for (let i = 0; i < dataGridRowInteractiveTypes.length; i++) { if ((event?.target as HTMLElement)?.closest(dataGridRowInteractiveTypes[i])) { isEventTargetInteractiveElement = true; break; } } // Only trigger if element is NOT interactive if (!isEventTargetInteractiveElement) { emitRowClick(props.rowData, event); } }; const onRowSelect = (value: Array<BentoDatagridDataItemId>) => { emit('select-row', value, props.rowData); }; const onNestedRowSelect = (rowData: BentoDatagridDataItem, value: Array<BentoDatagridDataItemId>) => { emit('select-nested-row', value, props.rowData, rowData); }; /** * Sets the state to indicate that a row action menu is open. This is used to apply a higher * z-index to the row's action container, ensuring the menu displays correctly above * subsequent rows. */ const onRowActionMenuOpen = () => { isRowActionMenuOpen.value = true; }; /** * Resets the state when a row action menu is closed, removing the elevated z-index from the * row's action container. */ const onRowActionMenuClose = () => { isRowActionMenuOpen.value = false; }; /** * When the row actions cell is focused, determine navigation direction and focus * the appropriate interactive child. Backward navigation (Shift+Tab) focuses the * last child; forward navigation focuses the first. */ const onRowActionsCellFocus = (e: FocusEvent) => { if (isExitingRowActionInteraction.value) { isExitingRowActionInteraction.value = false; return; } const container = getRowActionsElement(); if (!container || !focusableItems.value?.length) { return; } const isBackwardNavigation = isRelatedTargetAfterInDOM(e.relatedTarget as HTMLElement, container); const lastIndex = focusableItems.value.length - 1; const targetItem = isBackwardNavigation ? focusableItems.value[lastIndex] : focusableItems.value[0]; makeAllInteractiveChildrenFocusable([targetItem]); focusOnInteractiveChild(container, { last: isBackwardNavigation }); }; /** * When focus leaves the row actions cell, make all interactive children unfocusable. * This ensures that tabbing through the grid skips over the individual actions within the cell. */ const onRowActionsCellFocusOut = () => { const container = getRowActionsElement(); if (container) { makeAllInteractiveChildrenUnFocusable(container); } }; /** * Handles keyboard navigation within the row actions cell. * Allows using left and right arrow keys to move between interactive elements (e.g., buttons) in the cell. * @param e The keyboard event. */ const onRowActionsCellOnKeyDown = (e: KeyboardEvent) => { const container = getRowActionsElement(); // Escape returns focus to the gridcell container if (e.key === EventKey.ESCAPE) { if (container) { makeAllInteractiveChildrenUnFocusable(container); } isExitingRowActionInteraction.value = true; container?.focus(); e.stopPropagation(); e.preventDefault(); return; } if (e.key !== EventKey.TAB && container) { makeAllInteractiveChildrenUnFocusable(container); } if ( e.key === EventKey.ARROW_RIGHT && focusableItems.value?.length > 1 && focusableItems.value[0].node === document.activeElement ) { if (container) { makeAllInteractiveChildrenUnFocusable(container); } focusableItems.value[1].node.setAttribute('tabindex', '0'); focusableItems.value[1].node.focus(); e.stopPropagation(); e.preventDefault(); } else if ( e.key === EventKey.ARROW_LEFT && focusableItems.value?.length > 1 && focusableItems.value[1].node === document.activeElement ) { if (container) { makeAllInteractiveChildrenUnFocusable(container); } focusableItems.value[0].node.setAttribute('tabindex', '0'); focusableItems.value[0].node.focus(); e.stopPropagation(); e.preventDefault(); } }; // Cell and Columns index calculation const sumColumnAmountsInColumnSets = () => props.columnSets.reduce((total, { columnSet }) => total + columnSet.length, 0); const getRowActionsCellIndex = () => sumColumnAmountsInColumnSets() * props.rowIndex + sumColumnAmountsInColumnSets(); // Nested content expanding logic const { updateContentHeight, contentHeight } = useExpandableContentHeight(nestedContentRef, isNestedContentShown); const nestedContentStyles = computed(() => { if (props.hasNestedContent || props.hasNestedRows) { // calculate the padding by getting the action column width and the first column left padding: const paddingLeft = shouldShowRenderNestedRows.value || isLoadingNestedRows.value ? 0 : gridcellRefs.value[DATAGRID_ACTIONS_COLUMN_FIELD_NAME].offsetWidth + DATAGRID_CELL_OFFSET_PADDING; return { paddingLeft: `${paddingLeft}px`, 'max-height': (props.hasNestedContent || props.hasNestedRows) && contentHeight.value && !contentHeight?.value?.includes('null') ? contentHeight.value : 0, }; } return undefined; }); watch( () => nestedContentRef.value, () => { let observer: ResizeObserver | null = null; if (!observer && nestedContentRef.value) { observer = observeSizeOfElement(nestedContentRef.value, () => { if (nestedContentRef.value) { updateContentHeight(); } }); } } ); const nestedContentToggled = () => { isNestedContentShown.value = !isNestedContentShown.value; updateContentHeight(); emit('nested-content-toggled', props.rowData); }; watch( () => props.rowData?.children, (_newChildren, oldChildren) => { if (props?.rowData?.children?.length > 0 && oldChildren === undefined && !isNestedContentShown.value) { updateContentHeight(); } } ); const isSmallContainer: ComputedRef<boolean> = inject( DATA_GRID_SMALL_CONTAINER, computed(() => false) ); const isHighlightedRow = row => props.highlightedRows.includes(props.getDataItemId(row)); const isSelectedRow = row => props.selectedItems.includes(props.getDataItemId(row)); // Styling const conditionalRowClasses = (row: BentoDatagridDataItem, rowIndex: number) => ({ 'b-data-grid-row-container__row--disabled': props.isDataItemDisabled(row), 'b-data-grid-row-container__row--row-clicks-enabled': props.allowRowClicks, 'b-data-grid-row-container__row--highlighted': isHighlightedRow(row), 'b-data-grid-row-container__row--selected': isSelectedRow(row), 'b-data-grid-row-container__row--hovered': props.hoveredRowIndex === rowIndex, 'b-data-grid-row-container__row--is-last-child': props.isRowLastChild && !isNestedContentShown.value, 'b-data-grid-row-container__row--nested-row': props.isNestedRow, 'b-data-grid-row-container__row--zebra': props.isRowEven, }); const conditionalRowSetClasses = (isFrozenRowSet: boolean, isSmallContainer: boolean) => ({ 'b-data-grid-row-container__row-cell-set--frozen': isFrozenRowSet && !isSmallContainer, [`b-data-grid-row-container__row-cell-set--${BentoDatagridStyleState.OVERFLOW_SHADOW_LEFT}`]: !isSmallContainer && isFrozenRowSet && props.hasLeftOverflow, 'b-data-grid-row-container__row-cell-set--frozen-separator': !isSmallContainer && isFrozenRowSet && props.showFrozenColumnsSeparator, 'b-data-grid-row-container__row-cell-set--unfrozen': !isFrozenRowSet || isSmallContainer, 'b-data-grid-row-container__row-cell-set--is-measuring': props.isMeasuringColumnWidths, }); const conditionalNestedRowClasses = computed(() => ({ 'b-data-grid-row-container__row-nested-slot--is-last-child': props.isRowLastChild, 'b-data-grid-row-container__row-nested-slot': !shouldShowRenderNestedRows.value && props.shouldShowNestedContent(props.rowData), })); const conditionalRowActionsContainerClasses = computed(() => ({ 'b-data-grid-row-container__row-actions-container--row-actions-menu-open': isRowActionMenuOpen.value, })); const computedRowActionsStyles = computed(() => ({ minWidth: `${props.rowActionsMaximumWidth}px`, })); // Exposed refs defineExpose({ gridcellRefs, isNestedContentShown, nestedRowRefs, rowActionsRef, }); </script> <script lang="ts"> /** * Internal component to display the BentoDataGrid data rows containig the data row cells. * * @example * * export default { * components: { DataGridRow }, * template: ` * <data-grid-row * :ref="el => registerRowCellRefs(el, rowIndex)" * :style="gridColumnWidthStyle" * :allow-row-clicks="allowRowClicks" * :column-sets="columnSets" * :condensed="condensed" * :dragging-columns="draggingColumns" * :dragging-events-for-row-cell="draggingEventsForRowCell" * :get-data-item-id="getDataItemId" * :has-left-overflow="hasLeftOverflow" * :highlighted-rows="highlightedRows" * :hovered-row-index="hoveredRowIndex" * :hovered-over-column-state="hoveredOverColumnState" * :is-data-item-disabled="isDataItemDisabled" * :is-measuring-column-widths="isMeasuringColumnWidths" * :row-data="rowData" * :row-index="rowIndex" * :show-frozen-columns-separator="showFrozenColumnsSeparator" * :selectable="selectable" * :selected-items="selectedItems" * @cell-hovered="onCellHover" * @row-click="onRowClick" * @on-frozen-columns-key-press="updateScrollPositionOnFrozenColumnsNavigation" * @select-row="onRowSelect" * > * <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> * <slot :name="innerSlot" v-bind="scope" /> * </template> * </data-grid-row> * ` */ export default { name: 'data-grid-row', }; </script> <style lang="scss" scoped src="./data-grid-row.scss" />
|
|
1
|
+
<template> <div class="b-data-grid-row-container" role="presentation"> <div v-bind="ariaRowAttributes" :id="rowId?.toString()" :key="rowIndex" class="b-data-grid-row-container__row" :class="conditionalRowClasses(rowData, rowIndex)" role="row" :aria-selected="getRowAriaSelectedValue(rowData)" @click="onRowClick" @keyup.enter="onRowClick" @keyup.space="onRowClick" > <template v-for="({ columnSet, id, isFrozenColumns }, columnSetIndex) of columnSets"> <div v-if="columnSet?.length" :key="`data-grid-rows-${id}`" class="b-data-grid-row-container__cells" :class="conditionalRowSetClasses(isFrozenColumns, isSmallContainer)" :data-testid="`data-grid-${id}-rows`" > <data-grid-cell v-for="(column, colIndex) in columnSet" :key="`cell-${column.field}`" :ref="el => registerRowCellRef(el, column.field)" class="b-data-grid-row-container__cell" :column="column" :column-set-index="columnSetIndex" :col-index="colIndex" :column-sets="columnSets" :condensed="condensed" :dragging-columns="draggingColumns" :dragging-events-for-row-cell="draggingEventsForRowCell" :get-data-item-id="getDataItemId" :has-zebra-background="isRowEven" :is-highlighted="isHighlightedRow(rowData)" :is-selected="isSelectedRow(rowData)" :hovered-over-column-state="hoveredOverColumnState" :is-data-item-disabled="isDataItemDisabled" :is-measuring-column-widths="isMeasuringColumnWidths" :is-nested-content-expanded="isNestedContentShown" :nested-content="shouldShowNestedContentToggle" :nested-rows-element-ids="allNestedRowIds" :row-data="rowData" :row-index="rowIndex" :selection-is-indeterminate="hasUnSelectedNestedRows" :selectable="selectable" :selected-items="selectedItems" @cell-hovered="emit('cell-hovered', $event)" @cell-focus="emit('cell-focus', $event)" @on-frozen-columns-key-press="emit('on-frozen-columns-key-press', $event)" @select-row="onRowSelect" @nested-content-toggled="nestedContentToggled" > <template v-for="(_, innerSlot) of slots" #[innerSlot]="scope"> <slot :name="innerSlot" v-bind="scope" /> </template> </data-grid-cell> </div> </template> <div v-if="rowActions" ref="rowActionsRef" :aria-colindex="sumColumnAmountsInColumnSets().toString()" class="b-data-grid-row-container__row-actions-container" :class="conditionalRowActionsContainerClasses" :style="computedRowActionsStyles" :data-cellindex="getRowActionsCellIndex()" tabindex="-1" role="gridcell" @focus="onRowActionsCellFocus" @focusOut="onRowActionsCellFocusOut" @keydown="onRowActionsCellOnKeyDown" > <data-grid-cell-actions class="b-data-grid-row-container__row-actions" disable-arrow-down-opening-menu :cell-actions="rowActions" :has-right-overflow="hasRightOverflow" :row-data-item="rowData" :has-zebra-background="isRowEven" :is-highlighted="isHighlightedRow(rowData)" :is-selected="isSelectedRow(rowData)" teleport-menu @menu-open="onRowActionMenuOpen" @menu-close="onRowActionMenuClose" /> </div> </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, } 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 { 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 { 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; }); const nestedContentToggled = () => { isNestedContentShown.value = !isNestedContentShown.value; emit('nested-content-toggled', props.rowData); }; 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" />
|
|
@@ -6,33 +6,23 @@ import * as DraggableStories from './draggable.stories';
|
|
|
6
6
|
# Draggable
|
|
7
7
|
|
|
8
8
|
The `bento-draggable` component allows you to create lists of items that can be reordered by dragging and dropping. It
|
|
9
|
-
is a wrapper around
|
|
9
|
+
is a wrapper around [SortableJS](https://github.com/SortableJS/Sortable).
|
|
10
10
|
|
|
11
11
|
## Usage
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
For accessibility and to provide a handle for dragging, each draggable item should include a `bento-draggable-handle`
|
|
17
|
-
component. This handle provides a grab icon and a menu with actions for reordering items using a keyboard.
|
|
13
|
+
Provide a list of `items` (or use `v-model`) and render each item via the `default` slot. Each item should include a
|
|
14
|
+
`bento-draggable-handle` for both drag-and-drop and keyboard reordering.
|
|
18
15
|
|
|
19
16
|
<Canvas withSource="closed">
|
|
20
17
|
<Story of={DraggableStories.Default} />
|
|
21
18
|
</Canvas>
|
|
22
19
|
|
|
23
|
-
## Customization
|
|
24
|
-
|
|
25
|
-
You can render any custom component or HTML structure within the default slot. The slot provides the `item` and its
|
|
26
|
-
`index` from the `items` array, allowing you to build complex draggable elements.
|
|
27
|
-
|
|
28
20
|
## Nested Draggable Lists
|
|
29
21
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
`group` name in their `options` prop.
|
|
22
|
+
Recursively render `bento-draggable` components for tree-like structures. All instances must share the same `group` name
|
|
23
|
+
in their `options` prop to allow dragging between levels.
|
|
33
24
|
|
|
34
|
-
The component
|
|
35
|
-
items within the default slot, as shown in this example.
|
|
25
|
+
> The component does not handle `children` directly — render nested items yourself within the default slot.
|
|
36
26
|
|
|
37
27
|
<Canvas withSource="closed">
|
|
38
28
|
<Story of={DraggableStories.Nested} />
|
|
@@ -40,137 +30,88 @@ items within the default slot, as shown in this example.
|
|
|
40
30
|
|
|
41
31
|
## Grid Layout
|
|
42
32
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
For the `bento-draggable-handle` to display all movement options (including left and right), set its `variant` prop to
|
|
47
|
-
`all-directions`.
|
|
33
|
+
Style the draggable container with CSS Flexbox or Grid to create draggable grids. Set the handle's `variant` to
|
|
34
|
+
`all-directions` to display left/right movement options.
|
|
48
35
|
|
|
49
36
|
<Canvas withSource="closed">
|
|
50
37
|
<Story of={DraggableStories.Grid} />
|
|
51
38
|
</Canvas>
|
|
52
39
|
|
|
53
|
-
##
|
|
54
|
-
|
|
55
|
-
You can get a `ref` to the Draggable component to access the underlying SortableJS instance and other properties.
|
|
56
|
-
|
|
57
|
-
<Source
|
|
58
|
-
dark
|
|
59
|
-
language="html"
|
|
60
|
-
code={`
|
|
61
|
-
<template>
|
|
62
|
-
<bento-draggable ref="draggableRef" :items="items" />
|
|
63
|
-
</template>
|
|
64
|
-
|
|
65
|
-
<script setup lang="ts">
|
|
66
|
-
import { ref, onMounted } from 'vue';
|
|
40
|
+
## Multi-Draggable (Board)
|
|
67
41
|
|
|
68
|
-
|
|
42
|
+
Multiple `bento-draggable` instances can share a SortableJS `group` to allow items to be dragged between lists (e.g. a
|
|
43
|
+
Kanban board). This requires additional setup because `bento-draggable` only manages its own list — cross-list transfers
|
|
44
|
+
must be handled manually.
|
|
69
45
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
// Example: get the order of items
|
|
75
|
-
const order = sortableInstance.toArray();
|
|
76
|
-
console.log(order);
|
|
77
|
-
});
|
|
78
|
-
</script>
|
|
79
|
-
|
|
80
|
-
`} />
|
|
81
|
-
|
|
82
|
-
The following methods and properties are available on the component's `ref`:
|
|
46
|
+
<Canvas withSource="closed">
|
|
47
|
+
<Story of={DraggableStories.Board} />
|
|
48
|
+
</Canvas>
|
|
83
49
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
50
|
+
When building a multi-draggable layout, keep in mind:
|
|
51
|
+
|
|
52
|
+
- **Provide a custom `onSort`:** When a custom `onSort` is passed via `options`, the component's built-in auto-sort is
|
|
53
|
+
bypassed. You are responsible for updating the reactive list yourself to prevent double DOM reorders (SortableJS
|
|
54
|
+
mutates the DOM, then Vue would re-render from the model).
|
|
55
|
+
- **Handle `onAdd` / `onRemove` yourself:** SortableJS fires `onAdd` on the receiving list and `onRemove` on the source
|
|
56
|
+
list, but neither event carries the item data. Track the dragged item manually via `onStart` and splice it into the
|
|
57
|
+
target list in `onAdd`.
|
|
58
|
+
- **`moveLeft` / `moveRight` require custom implementation:** The built-in `moveLeft` and `moveRight` slot helpers only
|
|
59
|
+
move items within the same list (they are aliases of `moveUp` / `moveDown`). For cross-list keyboard movement,
|
|
60
|
+
override `disabledMenus` to enable the direction, then bind the handle's `@move-right` or `@move-left` to your own
|
|
61
|
+
transfer function.
|
|
62
|
+
- **Route announcements to the receiving list's ref:** Each `bento-draggable` instance has its own `announce` method
|
|
63
|
+
exposed via `ref`. When moving an item to another list, call `announce` on the **target** instance so the announcement
|
|
64
|
+
appears in the correct live region.
|
|
65
|
+
|
|
66
|
+
## Properties
|
|
67
|
+
|
|
68
|
+
| Property | Type | Default | Description |
|
|
69
|
+
| ----------------- | --------------------------- | ------- | -------------------------------------------------------------------------------------------------------- |
|
|
70
|
+
| `items` | `Array<BentoDraggableItem>` | `[]` | List of items to render. Supports `v-model`. |
|
|
71
|
+
| `el` | `string` | `div` | HTML tag for the container element. |
|
|
72
|
+
| `options` | `BentoDraggableOptions` | `{}` | Options passed to SortableJS. [See SortableJS docs](https://github.com/SortableJS/Sortable#readme). |
|
|
73
|
+
| `announcement` | `string \| 'disabled'` | `''` | Custom announcement text override. Set to `'disabled'` to suppress announcements. |
|
|
74
|
+
| `disableAutoSort` | `boolean` | `false` | Prevents automatic list reordering on sort. Use when providing a custom `onSort` callback via `options`. |
|
|
75
|
+
|
|
76
|
+
## Slots
|
|
77
|
+
|
|
78
|
+
The `default` slot provides the following properties:
|
|
79
|
+
|
|
80
|
+
| Name | Type | Description |
|
|
81
|
+
| --------------- | ------------------------- | ------------------------------------------------------------------------------ |
|
|
82
|
+
| `item` | `BentoDraggableItem` | The current item from the `items` array. |
|
|
83
|
+
| `index` | `number` | The index of the current item. |
|
|
84
|
+
| `moveUp` | `() => void` | Move the item one position up. Includes announcement. |
|
|
85
|
+
| `moveDown` | `() => void` | Move the item one position down. Includes announcement. |
|
|
86
|
+
| `moveLeft` | `() => void` | Alias for `moveUp`. Override for cross-list movement. |
|
|
87
|
+
| `moveRight` | `() => void` | Alias for `moveDown`. Override for cross-list movement. |
|
|
88
|
+
| `moveTop` | `() => void` | Move the item to the first position. Includes announcement. |
|
|
89
|
+
| `moveBottom` | `() => void` | Move the item to the last position. Includes announcement. |
|
|
90
|
+
| `disabledMenus` | `Record<string, boolean>` | Pre-calculated disabled state for each direction based on the item's position. |
|
|
91
|
+
|
|
92
|
+
## Exposed
|
|
93
|
+
|
|
94
|
+
Access the following via a template `ref` on the component:
|
|
95
|
+
|
|
96
|
+
| Name | Type | Description |
|
|
97
|
+
| ------------------ | ------------------------------------ | ---------------------------------------------------------------- |
|
|
98
|
+
| `isDragging` | `Ref<boolean>` | `true` while an item is being dragged. |
|
|
99
|
+
| `containerRef` | `Ref<HTMLElement>` | The container DOM element. |
|
|
100
|
+
| `draggableElement` | `Sortable` | The SortableJS instance (`toArray()`, `sort()`, `destroy()`, …). |
|
|
101
|
+
| `announce` | `(message: string) => void` | Trigger a screen reader announcement on this instance. |
|
|
102
|
+
| `moveItem` | `(from: number, to: number) => void` | Move an item between indices and emit the updated list. |
|
|
103
|
+
| `focus` | `(index: number) => void` | Focus the draggable handle at the given index. |
|
|
90
104
|
|
|
91
|
-
|
|
105
|
+
## Accessibility
|
|
92
106
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
| `option(name, value)` | Get or set a SortableJS option. |
|
|
96
|
-
| `destroy()` | Removes the sortable functionality completely. |
|
|
97
|
-
| `toArray()` | Returns an array of the `data-id` attributes of the items in their current order. |
|
|
98
|
-
| `sort(order)` | Sorts the items according to a specified array of `data-id`s. |
|
|
107
|
+
The `bento-draggable-handle` provides keyboard-accessible controls ("Move up", "Move down", "Move to top", "Move to
|
|
108
|
+
bottom") and an optional `all-directions` variant that adds "Move left" and "Move right".
|
|
99
109
|
|
|
100
|
-
|
|
110
|
+
`bento-draggable` **automatically** announces reordering actions to screen reader users via a built-in live region
|
|
111
|
+
(`role="status"`). Announcements fire both after drag-and-drop and after keyboard-initiated moves.
|
|
101
112
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
"Move down", "Move to top", and "Move to bottom" actions.
|
|
105
|
-
|
|
106
|
-
### Screen Reader Announcements
|
|
107
|
-
|
|
108
|
-
To provide a fully accessible experience, it is **strongly recommended** to announce reordering or movement actions to
|
|
109
|
-
screen reader users. The `bento-draggable` component exposes an `announce` function that can be used to provide these
|
|
110
|
-
updates.
|
|
111
|
-
|
|
112
|
-
**Note:** The component does not automatically announce movements. It is your responsibility to call this function
|
|
113
|
-
whenever the list order changes or an item is moved:
|
|
114
|
-
|
|
115
|
-
- **After dragging with the mouse:** When an item is dropped and the list is reordered within the same list, or
|
|
116
|
-
moved/cloned to another list.
|
|
117
|
-
- **After using keyboard controls:** When a user selects a reordering action from the `bento-draggable-handle` menu.
|
|
118
|
-
|
|
119
|
-
These announcements help users who rely on assistive technologies to understand the result of their actions and the
|
|
120
|
-
current state of the list.
|
|
121
|
-
|
|
122
|
-
<Source
|
|
123
|
-
dark
|
|
124
|
-
language="html"
|
|
125
|
-
code={`
|
|
126
|
-
<template>
|
|
127
|
-
<bento-draggable
|
|
128
|
-
ref="draggableRef"
|
|
129
|
-
:items="items"
|
|
130
|
-
:options="{ onSort }"
|
|
131
|
-
>
|
|
132
|
-
<template #default="{ item, index }">
|
|
133
|
-
<bento-draggable-handle
|
|
134
|
-
:label="item.label"
|
|
135
|
-
@move-up="moveUp(index)"
|
|
136
|
-
@move-down="moveDown(index)"
|
|
137
|
-
/>
|
|
138
|
-
<span>{{ item.label }}</span>
|
|
139
|
-
</template>
|
|
140
|
-
</bento-draggable>
|
|
141
|
-
</template>
|
|
142
|
-
|
|
143
|
-
<script setup lang="ts">
|
|
144
|
-
import { ref } from 'vue';
|
|
145
|
-
|
|
146
|
-
const draggableRef = ref(null);
|
|
147
|
-
const items = ref([
|
|
148
|
-
{ id: '1', label: 'Item 1' },
|
|
149
|
-
{ id: '2', label: 'Item 2' },
|
|
150
|
-
{ id: '3', label: 'Item 3' },
|
|
151
|
-
]);
|
|
152
|
-
|
|
153
|
-
const announceMove = (itemLabel: string, fromIndex: number, toIndex: number) => {
|
|
154
|
-
draggableRef.value?.announce(
|
|
155
|
-
\`Moved \${itemLabel} from position \${fromIndex + 1} to position \${toIndex + 1} of \${items.value.length}.\`
|
|
156
|
-
);
|
|
157
|
-
};
|
|
158
|
-
|
|
159
|
-
const onSort = (event) => {
|
|
160
|
-
const { oldIndex, newIndex } = event;
|
|
161
|
-
const item = items.value[oldIndex];
|
|
162
|
-
// ... update the items list ...
|
|
163
|
-
announceMove(item.label, oldIndex, newIndex);
|
|
164
|
-
};
|
|
165
|
-
|
|
166
|
-
const moveUp = (index) => {
|
|
167
|
-
const item = items.value[index];
|
|
168
|
-
// ... update the items list ...
|
|
169
|
-
announceMove(item.label, index, index - 1);
|
|
170
|
-
};
|
|
171
|
-
</script>
|
|
172
|
-
|
|
173
|
-
`} />
|
|
113
|
+
Use the `announcement` prop to override the default message, or set it to `'disabled'` to suppress announcements when
|
|
114
|
+
you handle them externally (e.g. via the exposed `announce` method in a multi-draggable setup).
|
|
174
115
|
|
|
175
116
|
## Resources
|
|
176
117
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import type { Meta, StoryObj } from '@storybook/vue'; import { type BentoDraggableItems, type BentoDraggableOptions, type BentoDraggableProps, type BentoDraggableSortableEvent, } from './draggable.types'; import { action } from '@storybook/addon-actions'; import { isVue2 } from 'vue-demi'; import { ref } from 'vue'; import { storybookDocsParameter } from '@/utils/ts/storybook'; import { BentoDraggableHandle } from './components/draggable-handle'; import { BentoTypography } from '../typography'; import { BentoButton } from '@/components/button'; import BinIcon from '@adyen/ui-assets-icons-16/vue/bin'; import BentoDraggable from './draggable.vue'; import BentoDraggableDefaultExample from './__tests__/bento-draggable-default-example.vue?raw'; import BentoDraggableNestedExample from './__tests__/bento-draggable-nested-example.vue?raw'; import BentoDraggableGridExample from './__tests__/bento-draggable-grid-example.vue?raw'; import './draggable.stories.scss?module'; const meta: Meta = { title: 'Draggable', component: BentoDraggable, }; export default meta; type Story = StoryObj<typeof BentoDraggable>; const ITEMS: BentoDraggableItems = [ { id: '1', label: 'Item 1' }, { id: '2', label: 'Item 2' }, { id: '3', label: 'Item 3' }, { id: '4', label: 'Item 4' }, { id: '5', label: 'Item 5' }, { id: '6', label: 'Item 6' }, ]; export const Default: Story = { render: (_args, { argTypes }) => ({ components: { BentoDraggable, BentoDraggableHandle, BentoTypography }, props: Object.keys(argTypes), template: ` <bento-draggable v-bind="args" ref="draggableElementRef" :items="itemsList" class="b-draggable-default__container" :options="options" > <template #default="{item, index}"> <div class="b-draggable-default__item" :key="item.id"> <div :style="{ display: 'flex', alignItems: 'center'}"> <bento-draggable-handle :label="item.label" condensed :disabled="item.label === 'Item 2'" :disabled-menus="{ up: index === 0, top: index === 0, down: index === itemsList.length - 1, bottom: index === itemsList.length - 1, }" variant="up-and-down" @move-up="() => moveUp(index)" @move-down="() => moveDown(index)" @move-top="() => moveTop(index)" @move-bottom="() => moveBottom(index)" /> <bento-typography>{{ item.label }}</bento-typography> </div> </div> </template> </bento-draggable> `, setup(props) { const args = isVue2 ? props : _args; const itemsList = ref(args.items); const draggableElementRef = ref<typeof BentoDraggable>(null); const shiftList = (indexOrigin: number, indexTarget: number) => { if (indexOrigin === -1 || indexTarget === -1) { return Array.from(itemsList.value); } const newList = Array.from(itemsList.value as BentoDraggableItems); const [removed] = newList.splice(indexOrigin, 1); newList.splice(indexTarget, 0, removed); const itemLabel = removed.label; draggableElementRef.value.announce( `You've moved ${itemLabel} from position ${indexOrigin + 1} to position ${indexTarget + 1} of ${itemsList.value.length}.` ); return newList; }; const moveUp = (index: number) => { itemsList.value = shiftList(index, index - 1); action('move-up')(itemsList.value); }; const moveDown = (index: number) => { itemsList.value = shiftList(index, index + 1); action('move-down')(itemsList.value); }; const moveTop = (index: number) => { itemsList.value = shiftList(index, 0); action('move-top')(itemsList.value); }; const moveBottom = (index: number) => { itemsList.value = shiftList(index, itemsList.value.length - 1); action('move-bottom')(itemsList.value); }; const onSort = (event: BentoDraggableSortableEvent) => { itemsList.value = shiftList(event.oldIndex, event.newIndex); action('sort')(event, itemsList.value); }; const options: BentoDraggableOptions = { onAdd: action('add'), onChange: () => action('change'), onChoose: action('choose'), onClone: action('clone'), onEnd: action('end'), onFilter: action('filter'), onMove: action('move'), onRemove: action('remove'), onStart: action('start'), onSort, onUnchoose: action('unchoose'), onUpdate: action('update'), }; return { // Values args: isVue2 ? props : _args, itemsList, draggableElementRef, // Events options, moveUp, moveDown, moveTop, moveBottom, }; }, }), args: { items: ITEMS, options: { group: 'nested', animation: 150, multiDrag: true, } as BentoDraggableProps, }, parameters: storybookDocsParameter(BentoDraggableDefaultExample), }; const NESTED_ITEMS: BentoDraggableItems = [ { id: '1.1', label: 'Item 1.1', children: [ { id: '2.1', label: 'Item 2.1' }, { id: '2.2', label: 'Item 2.2', children: [ { id: '3.1', label: 'Item 3.1' }, { id: '3.2', label: 'Item 3.2' }, ], }, { id: '2.3', label: 'Item 2.3' }, ], }, { id: '1.2', label: 'Item 1.2' }, { id: '1.3', label: 'Item 1.3' }, ]; export const Nested: Story = { render: (_args, { argTypes }) => ({ components: { BentoDraggable, BentoDraggableHandle, BentoTypography }, props: Object.keys(argTypes), template: ` <bento-draggable v-bind="args" class="b-draggable-default__container" v-on="listeners" > <template #default="{item, index}"> <div class="b-draggable-default__item" :key="item.id"> <div :style="{ display: 'flex', alignItems: 'center'}"> <bento-draggable-handle :label="item.label" condensed variant="up-and-down" /> <bento-typography>{{ item.label }}</bento-typography> </div> <!-- Children --> <template v-if="item.children"> <bento-draggable class="b-draggable-default__container" :options="args.options" :items="item.children" v-on="listeners" > <template v-slot:default="{item: childItem, index: childIndex}"> <div class="b-draggable-default__item" :key="childItem.id"> <div :style="{ display: 'flex', alignItems: 'center'}"> <bento-draggable-handle :label="childItem.label" condensed variant="up-and-down" /> <bento-typography>{{ childItem.label }}</bento-typography> </div> </div> </template> </bento-draggable> </template> </div> </template> </bento-draggable> `, setup(props) { const clickedAction = (eventName: string) => (event: BentoDraggableSortableEvent) => action(eventName)(event); const listeners = { add: clickedAction('add'), change: clickedAction('change'), choose: clickedAction('choose'), clone: clickedAction('clone'), end: clickedAction('end'), filter: clickedAction('filter'), move: clickedAction('move'), remove: clickedAction('remove'), start: clickedAction('start'), sort: clickedAction('sort'), unchoose: clickedAction('unchoose'), update: clickedAction('update'), }; return { // Values args: isVue2 ? props : _args, // Events listeners, }; }, }), args: { items: NESTED_ITEMS, options: { group: 'nested', animation: 150, }, } as BentoDraggableProps, parameters: storybookDocsParameter(BentoDraggableNestedExample), }; export const Grid: Story = { render: (_args, { argTypes }) => ({ components: { BentoButton, BentoDraggable, BentoDraggableHandle, BentoTypography, BinIcon }, props: Object.keys(argTypes), template: ` <bento-draggable v-bind="args" :style="{ padding: 'var(--b-spacer-050)', width: '670px', display: 'flex', flexDirection: 'row', flexWrap: 'wrap', gap: 'var(--b-spacer-050)', }" v-on="listeners" > <template #default="{item, index}"> <div :key="item.id" :style="{ width: '160px' }"> <div :style="{ display: 'flex', background: 'var(--b-color-background-tertiary)', borderTopLeftRadius: 'var(--b-border-radius-m)', borderTopRightRadius: 'var(--b-border-radius-m)', justifyContent: 'space-between', padding: 'var(--b-spacer-010) var(--b-spacer-020)', }" > <bento-draggable-handle :label="item.label" :disabled-menus="{ up: index <= 3, top: index <= 3, down: index > 3, bottom: index > 3, left: index === 0 || index === 4, right: index === 3 || index === 5, }" condensed variant="all-directions" /> <bento-button condensed variant="secondary"> <template #iconLeft> <bin-icon svg-title="remove" /> </template> </bento-button> </div> <div :style="{ backgroundColor: 'var(--b-color-background-secondary)', display: 'flex', flexDirection: 'column', borderBottomLeftRadius: 'var(--b-border-radius-m)', borderBottomRightRadius: 'var(--b-border-radius-m)', gap: 'var(--b-spacer-060)', padding: 'var(--b-spacer-040)', }" > <bento-typography>Card {{ index + 1 }}</bento-typography> </div> </div> </template> </bento-draggable> `, setup(props) { const clickedAction = (eventName: string) => (event: BentoDraggableSortableEvent) => action(eventName)(event); const listeners = { add: clickedAction('add'), change: clickedAction('change'), choose: clickedAction('choose'), clone: clickedAction('clone'), end: clickedAction('end'), filter: clickedAction('filter'), move: clickedAction('move'), remove: clickedAction('remove'), start: clickedAction('start'), sort: clickedAction('sort'), unchoose: clickedAction('unchoose'), update: clickedAction('update'), }; return { // Values args: isVue2 ? props : _args, // Events listeners, }; }, }), args: { items: ITEMS, options: { group: 'nested', animation: 150, } as BentoDraggableProps, }, parameters: storybookDocsParameter(BentoDraggableGridExample), };
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/vue'; import { type BentoDraggableItems, type BentoDraggableOptions, type BentoDraggableProps } from './draggable.types'; import { action } from '@storybook/addon-actions'; import { isVue2 } from 'vue-demi'; import { ref } from 'vue'; import { storybookDocsParameter } from '@/utils/ts/storybook'; import { BentoDraggableHandle } from './components/draggable-handle'; import { BentoTypography } from '../typography'; import { BentoButton } from '@/components/button'; import BinIcon from '@adyen/ui-assets-icons-16/vue/bin'; import BentoDraggable from './draggable.vue'; import BentoDraggableDefaultExample from './__tests__/bento-draggable-default-example.vue?raw'; import BentoDraggableNestedExample from './__tests__/bento-draggable-nested-example.vue?raw'; import BentoDraggableGridExample from './__tests__/bento-draggable-grid-example.vue?raw'; import BentoDraggableBoardExample from './__tests__/bento-draggable-board-example.vue?raw'; import './draggable.stories.scss?module'; const meta: Meta = { title: 'Draggable', component: BentoDraggable, }; export default meta; type Story = StoryObj<typeof BentoDraggable>; const ITEMS: BentoDraggableItems = [ { id: '1', label: 'Item 1' }, { id: '2', label: 'Item 2' }, { id: '3', label: 'Item 3' }, { id: '4', label: 'Item 4' }, { id: '5', label: 'Item 5' }, { id: '6', label: 'Item 6' }, ]; export const Default: Story = { render: (_args, { argTypes }) => ({ components: { BentoDraggable, BentoDraggableHandle, BentoTypography }, props: Object.keys(argTypes), template: ` <bento-draggable v-bind="args" :items="itemsList" class="b-draggable-default__container" @update:items="onUpdateItems" > <template #default="{item, moveUp, moveDown, moveTop, moveBottom, disabledMenus}"> <div class="b-draggable-default__item" :key="item.id"> <div :style="{ display: 'flex', alignItems: 'center'}"> <bento-draggable-handle :label="item.label" condensed :disabled="item.label === 'Item 2'" :disabled-menus="disabledMenus" variant="up-and-down" @move-up="moveUp" @move-down="moveDown" @move-top="moveTop" @move-bottom="moveBottom" /> <bento-typography>{{ item.label }}</bento-typography> </div> </div> </template> </bento-draggable> `, setup(props) { const args = isVue2 ? props : _args; const itemsList = ref(args.items); const onUpdateItems = (items: BentoDraggableItems) => { itemsList.value = [...items]; action('update:items')(items); }; return { args: isVue2 ? props : _args, itemsList, onUpdateItems, }; }, }), args: { items: ITEMS, options: { group: 'default', }, }, parameters: storybookDocsParameter(BentoDraggableDefaultExample), }; const NESTED_ITEMS: BentoDraggableItems = [ { id: '1.1', label: 'Item 1.1', children: [ { id: '2.1', label: 'Item 2.1' }, { id: '2.2', label: 'Item 2.2', children: [ { id: '3.1', label: 'Item 3.1' }, { id: '3.2', label: 'Item 3.2' }, ], }, { id: '2.3', label: 'Item 2.3' }, ], }, { id: '1.2', label: 'Item 1.2' }, { id: '1.3', label: 'Item 1.3' }, ]; export const Nested: Story = { render: (_args, { argTypes }) => ({ components: { BentoDraggable, BentoDraggableHandle, BentoTypography }, props: Object.keys(argTypes), template: ` <bento-draggable v-bind="args" :items="itemsList" class="b-draggable-nested__container" @update:items="itemsList = $event" > <template #default="{item, moveUp, moveDown, moveTop, moveBottom, disabledMenus}"> <div class="b-draggable-nested__item" :key="item.id"> <div :style="{ display: 'flex', alignItems: 'center'}"> <bento-draggable-handle :label="item.label" condensed variant="up-and-down" :disabled-menus="disabledMenus" @move-up="moveUp" @move-down="moveDown" @move-top="moveTop" @move-bottom="moveBottom" /> <bento-typography>{{ item.label }}</bento-typography> </div> <!-- Children --> <template v-if="item.children"> <bento-draggable class="b-draggable-nested__container" :options="args.options" :items="item.children" @update:items="item.children = $event" > <template v-slot:default="{item: childItem, moveUp: moveUpChild, moveDown: moveDownChild, moveTop: moveTopChild, moveBottom: moveBottomChild, disabledMenus: disabledMenusChild}"> <div class="b-draggable-nested__item" :key="childItem.id"> <div :style="{ display: 'flex', alignItems: 'center'}"> <bento-draggable-handle :label="childItem.label" condensed variant="up-and-down" :disabled-menus="disabledMenusChild" @move-up="moveUpChild" @move-down="moveDownChild" @move-top="moveTopChild" @move-bottom="moveBottomChild" /> <bento-typography>{{ childItem.label }}</bento-typography> </div> </div> </template> </bento-draggable> </template> </div> </template> </bento-draggable> `, setup(props) { const args = isVue2 ? props : _args; const itemsList = ref(args.items); return { args, itemsList, }; }, }), args: { items: NESTED_ITEMS, options: {}, } as BentoDraggableProps, parameters: storybookDocsParameter(BentoDraggableNestedExample), }; export const Grid: Story = { render: (_args, { argTypes }) => ({ components: { BentoButton, BentoDraggable, BentoDraggableHandle, BentoTypography, BinIcon }, props: Object.keys(argTypes), template: ` <bento-draggable ref="draggableRef" v-bind="args" :items="itemsList" @update:items="itemsList = $event" :style="{ padding: 'var(--b-spacer-050)', width: '670px', display: 'flex', flexDirection: 'row', flexWrap: 'wrap', gap: 'var(--b-spacer-050)', }" > <template #default="{item, index, moveTop, moveBottom, moveLeft, moveRight, disabledMenus}"> <div :key="item.id" :style="{ width: '160px' }"> <div :style="{ display: 'flex', background: 'var(--b-color-background-tertiary)', borderTopLeftRadius: 'var(--b-border-radius-m)', borderTopRightRadius: 'var(--b-border-radius-m)', justifyContent: 'space-between', padding: 'var(--b-spacer-010) var(--b-spacer-020)', }" > <bento-draggable-handle :label="item.label" :disabled-menus="{ ...disabledMenus, up: index < COLUMNS, down: index + COLUMNS >= itemsList.length, left: index % COLUMNS === 0, right: index % COLUMNS === COLUMNS - 1 || index === itemsList.length - 1, }" condensed variant="all-directions" @move-up="gridMoveUp(index)" @move-down="gridMoveDown(index)" @move-top="moveTop" @move-bottom="moveBottom" @move-left="moveLeft" @move-right="moveRight" /> <bento-button condensed variant="secondary"> <template #iconLeft> <bin-icon svg-title="remove" /> </template> </bento-button> </div> <div :style="{ backgroundColor: 'var(--b-color-background-secondary)', display: 'flex', flexDirection: 'column', borderBottomLeftRadius: 'var(--b-border-radius-m)', borderBottomRightRadius: 'var(--b-border-radius-m)', gap: 'var(--b-spacer-060)', padding: 'var(--b-spacer-040)', }" > <bento-typography>Card {{ item.id }}</bento-typography> </div> </div> </template> </bento-draggable> `, setup(props) { const args = isVue2 ? props : _args; const itemsList = ref(args.items); const draggableRef = ref(null); const COLUMNS = 4; const gridMoveUp = (index: number) => { draggableRef.value?.moveItem(index, index - COLUMNS); }; const gridMoveDown = (index: number) => { draggableRef.value?.moveItem(index, index + COLUMNS); }; return { args, itemsList, draggableRef, COLUMNS, gridMoveUp, gridMoveDown, }; }, }), args: { items: ITEMS, options: {}, }, parameters: storybookDocsParameter(BentoDraggableGridExample), }; export const Board: Story = { render: (_args, { argTypes }) => ({ components: { BentoDraggable, BentoDraggableHandle, BentoTypography }, props: Object.keys(argTypes), template: ` <div :style="{ display: 'flex', gap: 'var(--b-spacer-100)', alignItems: 'flex-start', padding: 'var(--b-spacer-080)', backgroundColor: 'var(--b-color-background-tertiary)', minHeight: '400px' }"> <!-- TODO Column --> <div :style="{ flex: 1, background: 'var(--b-color-background-secondary)', padding: 'var(--b-spacer-040)', borderRadius: 'var(--b-border-radius-m)', boxShadow: 'var(--b-shadow-small)' }"> <bento-typography variant="title" :style="{ marginBottom: 'var(--b-spacer-040)', padding: 'var(--b-spacer-020) var(--b-spacer-040)', display: 'block' }"> TODO </bento-typography> <bento-draggable ref="todoDraggableRef" :items="todoItems" :options="toDoOptions" @update:items="todoItems = $event" class="b-draggable-board__container" style="min-height: 50px;" > <template #default="{ item, index, moveUp, moveDown, moveTop, moveBottom, disabledMenus }"> <div class="b-draggable-board__item" :key="item.id"> <div :style="{ display: 'flex', alignItems: 'center' }"> <bento-draggable-handle :label="item.label" condensed variant="all-directions" :disabled-menus="{ ...disabledMenus, right: false, left: true, }" @move-up="moveUp" @move-down="moveDown" @move-top="moveTop" @move-bottom="moveBottom" @move-right="moveToDone(index)" /> <bento-typography>{{ item.label }}</bento-typography> </div> </div> </template> </bento-draggable> </div> <!-- DONE Column --> <div :style="{ flex: 1, background: 'var(--b-color-background-secondary)', padding: 'var(--b-spacer-040)', borderRadius: 'var(--b-border-radius-m)', boxShadow: 'var(--b-shadow-small)' }"> <bento-typography variant="title" :style="{ marginBottom: 'var(--b-spacer-040)', padding: 'var(--b-spacer-020) var(--b-spacer-040)', display: 'block' }"> DONE </bento-typography> <bento-draggable ref="doneDraggableRef" :items="doneItems" :options="doneOptions" @update:items="doneItems = $event" class="b-draggable-board__container" style="min-height: 50px;" > <template #default="{ item, index, moveUp, moveDown, moveTop, moveBottom, moveLeft, disabledMenus }"> <div class="b-draggable-board__item" :key="item.id"> <div :style="{ display: 'flex', alignItems: 'center' }"> <bento-draggable-handle :label="item.label" condensed variant="all-directions" :disabled-menus="{ ...disabledMenus, left: false, }" @move-up="moveUp" @move-down="moveDown" @move-top="moveTop" @move-bottom="moveBottom" @move-left="moveToTodo(index)" /> <bento-typography>{{ item.label }}</bento-typography> </div> </div> </template> </bento-draggable> </div> </div> `, setup() { const todoItems = ref<BentoDraggableItems>([ { id: '1', label: 'Improve documentation' }, { id: '2', label: 'Fix navigation bug' }, { id: '3', label: 'Add unit tests' }, ]); const doneItems = ref<BentoDraggableItems>([{ id: '4', label: 'Initial release' }]); const todoDraggableRef = ref<InstanceType<typeof BentoDraggable> | null>(null); const doneDraggableRef = ref<InstanceType<typeof BentoDraggable> | null>(null); // Since we're moving items between completely separate refs, we track the item being dragged // manually to facilitate transfers between the todoItems and doneItems lists. let draggedItem: BentoDraggableItems[number] | null = null; const moveToDone = (index: number) => { const [item] = todoItems.value.splice(index, 1); doneItems.value.push(item); doneDraggableRef.value?.announce( `${item.label} moved to Done, position ${doneItems.value.length} of ${doneItems.value.length}` ); action('move-to-done')(item, { todo: todoItems.value, done: doneItems.value }); }; const moveToTodo = (index: number) => { const [item] = doneItems.value.splice(index, 1); todoItems.value.push(item); todoDraggableRef.value?.announce( `${item.label} moved to Todo, position ${todoItems.value.length} of ${todoItems.value.length}` ); action('move-to-todo')(item, { todo: todoItems.value, done: doneItems.value }); }; const toDoOptions: BentoDraggableOptions = { group: 'board', animation: 150, onStart: event => { draggedItem = todoItems.value[event.oldIndex!]; }, onAdd: event => { todoItems.value.splice(event.newIndex!, 0, draggedItem!); todoDraggableRef.value?.announce( `${draggedItem!.label} added to Todo, position ${event.newIndex! + 1} of ${todoItems.value.length}` ); }, onRemove: event => { todoItems.value.splice(event.oldIndex!, 1); }, onSort: event => { const { oldIndex, newIndex, from, to } = event; if (from === to && oldIndex !== undefined && newIndex !== undefined) { const [item] = todoItems.value.splice(oldIndex, 1); todoItems.value.splice(newIndex, 0, item); todoDraggableRef.value?.announce( `${item.label} moved to position ${newIndex + 1} of ${todoItems.value.length} in Todo` ); } }, }; const doneOptions: BentoDraggableOptions = { group: 'board', animation: 150, onStart: event => { draggedItem = doneItems.value[event.oldIndex!]; }, onAdd: event => { doneItems.value.splice(event.newIndex!, 0, draggedItem!); doneDraggableRef.value?.announce( `${draggedItem!.label} added to Done, position ${event.newIndex! + 1} of ${doneItems.value.length}` ); }, onRemove: event => { doneItems.value.splice(event.oldIndex!, 1); }, onSort: event => { const { oldIndex, newIndex, from, to } = event; if (from === to && oldIndex !== undefined && newIndex !== undefined) { const [item] = doneItems.value.splice(oldIndex, 1); doneItems.value.splice(newIndex, 0, item); doneDraggableRef.value?.announce( `${item.label} moved to position ${newIndex + 1} of ${doneItems.value.length} in Done` ); action('sort-done')(event, doneItems.value); } }, }; return { todoItems, doneItems, toDoOptions, doneOptions, moveToDone, moveToTodo, todoDraggableRef, doneDraggableRef, }; }, }), parameters: storybookDocsParameter(BentoDraggableBoardExample), };
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import { type SortableOptions } from 'sortablejs'; import { type MoveEvent, type SortableEvent } from 'sortablejs'; /** * Event instances generated by each of the events * emitted by the draggable component. */ export type BentoDraggableSortableEvent = SortableEvent; /** * Event emitted by the draggable move event */ export type BentoDraggableMoveEvent = MoveEvent; /** * Options to be passed to SortableJS. * @see {@link https://github.com/SortableJS/Sortable#readme | SortableJS documentation} for all the details of the options available */ export type BentoDraggableOptions = Omit<SortableOptions, 'handle' | 'filter'>; /** * Events to hook on to the draggable component through the options */ export type BentoDraggableEvents = Pick< BentoDraggableOptions, | 'onUnchoose' | 'onChoose' | 'onStart' | 'onEnd' | 'onAdd' | 'onUpdate' | 'onSort' | 'onRemove' | 'onFilter' | 'onMove' | 'onClone' | 'onChange' >; /** * Item data of the items to be rendered as draggable elements */ export interface BentoDraggableItem { label: string; [x: string]: any; children?: Array<BentoDraggableItem>; } /** * List of items to be rendered as draggable elements */ export type BentoDraggableItems = Array<BentoDraggableItem>; export interface BentoDraggableProps { /** * Element that will contain the draggable elements. * @default 'div' */ el?: keyof HTMLElementTagNameMap; /** * List of items to be rendered as draggable elements * @default [] */ items?: Array<BentoDraggableItem>; /** * Options to be passed to SortableJS. * @see {@link https://github.com/SortableJS/Sortable#readme | SortableJS documentation} for all the details of the options available * @default {} */ options?: BentoDraggableOptions; }
|
|
1
|
+
import { type SortableOptions } from 'sortablejs'; import { type MoveEvent, type SortableEvent } from 'sortablejs'; /** * Event instances generated by each of the events * emitted by the draggable component. */ export type BentoDraggableSortableEvent = SortableEvent; /** * Event emitted by the draggable move event */ export type BentoDraggableMoveEvent = MoveEvent; /** * Options to be passed to SortableJS. * @see {@link https://github.com/SortableJS/Sortable#readme | SortableJS documentation} for all the details of the options available */ export type BentoDraggableOptions = Omit<SortableOptions, 'handle' | 'filter'>; /** * Events to hook on to the draggable component through the options */ export type BentoDraggableEvents = Pick< BentoDraggableOptions, | 'onUnchoose' | 'onChoose' | 'onStart' | 'onEnd' | 'onAdd' | 'onUpdate' | 'onSort' | 'onRemove' | 'onFilter' | 'onMove' | 'onClone' | 'onChange' >; /** * Item data of the items to be rendered as draggable elements */ export interface BentoDraggableItem { label: string; [x: string]: any; children?: Array<BentoDraggableItem>; } /** * List of items to be rendered as draggable elements */ export type BentoDraggableItems = Array<BentoDraggableItem>; /** * Events emitted by the draggable component */ export interface BentoDraggableEmits { /** * Emitted when the items list is updated */ (e: 'update:items', items: BentoDraggableItems): void; } export interface BentoDraggableProps { /** * Custom announcement message override. When set, this value is used instead of the * default reorder announcement. Set to `'disabled'` to suppress announcements entirely. */ announcement?: string | 'disabled'; /** * When enabled, the component will not automatically reorder items on sort. * Use this when providing a custom `onSort` callback via options. * @default false */ disableAutoSort?: boolean; /** * Element that will contain the draggable elements. * @default 'div' */ el?: keyof HTMLElementTagNameMap; /** * List of items to be rendered as draggable elements * @default [] */ items?: Array<BentoDraggableItem>; /** * Options to be passed to SortableJS. * @see {@link https://github.com/SortableJS/Sortable#readme | SortableJS documentation} for all the details of the options available * @default {} */ options?: BentoDraggableOptions; }
|
|
@@ -1 +1 @@
|
|
|
1
|
-
<template> <component :is="el" ref="containerRef"> <slot v-for="(item, index) of items" :item="item" :index="index" name="default"></slot> </component> </template> <script setup lang="ts"> import { computed,
|
|
1
|
+
<template> <component :is="el" ref="containerRef"> <slot v-for="(item, index) of items" :item="item" :index="index" :move-up="() => moveItemUp(index)" :move-right="() => moveItemRight(index)" :move-down="() => moveItemDown(index)" :move-left="() => moveItemLeft(index)" :move-top="() => moveItemTop(index)" :move-bottom="() => moveItemBottom(index)" :disabled-menus="disabledMenuItems(index)" name="default" ></slot> <div class="b-draggable__announcement" role="status" aria-live="polite"> {{ announcementMessage }} </div> </component> </template> <script setup lang="ts"> import { computed, nextTick, ref, watch } from 'vue'; import type { BentoDraggableEmits, BentoDraggableItems, BentoDraggableOptions, BentoDraggableProps, BentoDraggableSortableEvent, } from './draggable.types'; import { useBentoDraggable } from './composables/use-bento-draggable/use-bento-draggable'; import { useDraggableAnnouncement } from './composables/use-draggable-announcement'; import { useTrackKeyboardUser } from './composables/use-track-keyboard-user'; import { useI18n } from '@/utils/ts/i18n'; import messages from './messages.json'; type MessageSchema = (typeof messages)['en-US']; const props = withDefaults(defineProps<BentoDraggableProps>(), { el: 'div', items: () => [], options: () => ({}), announcement: '', disableAutoSort: false, }); const emit = defineEmits<BentoDraggableEmits>(); /** * Reference to the container of the draggable elements. */ const containerRef = ref<HTMLElement | null>(null); /** * Indicates if there is an element being dragged inside the container. */ const isDragging = ref<boolean>(false); /** * Internal copy of items used to track position changes for focus restoration. */ const internalItems = ref<BentoDraggableItems>([]); /** * Tracks the item being moved via keyboard so focus can be restored to the correct element. */ const lastMovedItem = ref<BentoDraggableItems[number] | null>(null); const computedOptions = computed<BentoDraggableOptions>(() => { if (!containerRef.value) { return {}; } return { ...props.options, onStart: (event: BentoDraggableSortableEvent) => { isDragging.value = true; if (props.options?.onStart) { props.options?.onStart(event); } }, onEnd: (event: BentoDraggableSortableEvent) => { // This is a hack to move the event to the end of the event queue. // cf this issue: https://github.com/SortableJS/Sortable/issues/1184 setTimeout(() => { isDragging.value = false; if (props.options?.onEnd) { props.options?.onEnd(event); } }); }, onSort: (event: BentoDraggableSortableEvent) => { // Always execute the user's custom onSort callback if provided if (props.options?.onSort) { props.options.onSort(event); return; } // Automatically sync the v-model unless the user explicitly disabled it if (!props.disableAutoSort) { const { oldIndex, newIndex, from, to } = event; if ( from === to && from === containerRef.value && oldIndex !== undefined && newIndex !== undefined ) { moveItem(oldIndex, newIndex); } } }, }; }); /** * Handle reordering announcements for screen readers. */ const { announcementMessage, announce } = useDraggableAnnouncement(); const { t } = useI18n<{ message: MessageSchema }>({ messages }); const { draggableElement } = useBentoDraggable(containerRef, computedOptions); // When moving items between sibling `bento-draggable`, the item may be removed, causing the DOM to be destroyed and temporarily lose focus // This is an alternative to query element `:focus-within` as the `:focus-within` will be gone until the other `bento-draggable` adds it into the list const { isKeyboardUser } = useTrackKeyboardUser(); /** * Focuses the draggable handle button at the given index. * Waits for the next tick to ensure the DOM has been updated before focusing. * * @param index - The index of the item whose handle should receive focus. */ const focus = async (index: number) => { await nextTick(); if (index !== -1 && containerRef.value) { const itemElement = containerRef.value.children[index] as HTMLElement; const button = itemElement?.querySelector<HTMLButtonElement>('.b-draggable-handle button'); await nextTick(); button?.focus(); } }; /** * Moves an item from one position to another within the list, * emits the updated list, and announces the change for screen readers. * * @param indexOrigin - The current index of the item to move. * @param indexTarget - The desired index to move the item to. */ const moveItem = async (indexOrigin: number, indexTarget: number) => { if ( indexOrigin < 0 || indexTarget < 0 || indexOrigin >= props.items.length || indexTarget >= props.items.length || indexOrigin === indexTarget ) { return; } const newList = Array.from(props.items as BentoDraggableItems); const [removed] = newList.splice(indexOrigin, 1); newList.splice(indexTarget, 0, removed); lastMovedItem.value = removed; // Occasionally, there may be race conditions where the index is out-of-date due to the fast drag-and-drop or // other async operations like onEnd > setTimeout. // Filtering out null/undefined values ensures the list remains valid. emit('update:items', newList.filter(Boolean)); const itemLabel = removed.label; const message = props?.announcement || t('youHaveMovedFromPositionToPosition', { itemLabel, indexOrigin: indexOrigin + 1, indexTarget: indexTarget + 1, total: props.items.length, }); if (props.announcement !== 'disabled') { announce(message); } }; const moveItemUp = (index: number) => moveItem(index, index - 1); const moveItemDown = (index: number) => moveItem(index, index + 1); const moveItemLeft = (index: number) => moveItem(index, index - 1); const moveItemRight = (index: number) => moveItem(index, index + 1); const moveItemTop = (index: number) => moveItem(index, 0); const moveItemBottom = (index: number) => moveItem(index, props.items.length - 1); /** * Returns a pre-calculated disabled menus object for the current item. */ const disabledMenuItems = (index: number) => { return { up: index === 0, top: index === 0, down: index === props.items.length - 1, bottom: index === props.items.length - 1, left: true, right: true, }; }; /** * Watch for items changes and restore focus automatically */ watch( () => props.items, async newItems => { if (!newItems) { return; } if (isKeyboardUser.value && newItems.length > 0 && internalItems.value.length > 0) { if (lastMovedItem.value) { // Within-list reorder: focus the moved item at its new position const movedItemIndex = newItems.indexOf(lastMovedItem.value); if (movedItemIndex !== -1) { await focus(movedItemIndex); } lastMovedItem.value = null; } else if (newItems.length > internalItems.value.length) { // Cross-list addition: focus the newly added item const newItemIndex = newItems.findIndex(item => !internalItems.value.includes(item)); if (newItemIndex !== -1) { await focus(newItemIndex); } } } internalItems.value = [...newItems]; }, { deep: true, immediate: true } ); defineExpose({ /** * A ref to the container DOM element. */ containerRef, /** * The underlying SortableJS instance, which provides methods like `toArray()`, `sort()`, and `destroy()`. */ draggableElement, /** * A boolean ref that is `true` when an item is being dragged. */ isDragging, /** * Announces a message to screen reader users via the live region. */ announce, /** * Moves an item from one index to another and emits the updated list. */ moveItem, /** * Programmatically focuses the draggable handle button at the given index. */ focus, }); </script> <script lang="ts"> /** * Drag and drop container that transforms a list of items into draggable elements. * The library is based on SortableJS. * @see {@link https://github.com/SortableJS/Sortable#readme | SortableJS documentation} * * @example * import { BentoDraggable, BentoDraggableHandle, type BentoDraggableEvents } from '@adyen/bento-vue2'; * * export default { * components: { BentoDraggable, BentoDraggableHandle }, * template: ` * <bento-draggable * v-model="items" * condensed * :options="{ * group: 'nested', * animation: 150 * }" * > * <template #default="{ item, index, moveUp, moveDown }"> * <div :key="item.id"> * <bento-draggable-handle * :label="item.label" * @move-up="moveUp" * @move-down="moveDown" * /> * </div> * </template> * </bento-draggable> * `, * setup () { * const items = ref([ * { id: '1', label: 'Item 1' }, * { id: '2', label: 'Item 2' } * ]); * return { * items, * } * } * } */ export default { name: 'bento-draggable', model: { prop: 'items', event: 'update:items' }, i18n: { messages }, }; </script> <style lang="scss" scoped src="./draggable.scss" />
|