From 66880ce3a2a7899d4c63372713fca14971beb06f Mon Sep 17 00:00:00 2001 From: WebDevNerdStuff Date: Mon, 29 Apr 2024 10:22:13 -0700 Subject: [PATCH] feat(VDataTable): add mobile view (#19431) Co-authored-by: Chris Hudson Co-authored-by: John Leider --- .../src/locale/en/VDataTable.json | 1 - .../api-generator/src/locale/en/display.json | 3 +- .../src/components/VDataTable/VDataTable.sass | 35 ++++++- .../VDataTable/VDataTableFooter.sass | 42 ++++----- .../VDataTable/VDataTableHeaders.tsx | 92 ++++++++++++++++++- .../components/VDataTable/VDataTableRow.tsx | 55 +++++++++-- .../components/VDataTable/VDataTableRows.tsx | 5 + .../src/components/VDataTable/_variables.scss | 13 ++- .../src/components/VDataTable/types.ts | 3 + 9 files changed, 209 insertions(+), 40 deletions(-) diff --git a/packages/api-generator/src/locale/en/VDataTable.json b/packages/api-generator/src/locale/en/VDataTable.json index 22501b3e7c3..96c8ef0c5db 100644 --- a/packages/api-generator/src/locale/en/VDataTable.json +++ b/packages/api-generator/src/locale/en/VDataTable.json @@ -23,7 +23,6 @@ "itemClass": "Property on supplied `items` that contains item's row class or function that takes an item as an argument and returns the class of corresponding row.", "itemsPerPage": "Changes how many items per page should be visible. Can be used with `.sync` modifier. Setting this prop to `-1` will display all items on the page.", "locale": "Sets the locale used for sorting. This is passed into [`Intl.Collator()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Collator/Collator) in the default `customSort` function.", - "mobileBreakpoint": "Used to set when to toggle between regular table and mobile view.", "multiSort": "If `true` then one can sort on multiple properties.", "mustSort": "If `true` then one can not disable sorting, it will always switch between ascending and descending.", "page": "The current displayed page number (1-indexed).", diff --git a/packages/api-generator/src/locale/en/display.json b/packages/api-generator/src/locale/en/display.json index b60f4b1ce3f..1fea0af1766 100644 --- a/packages/api-generator/src/locale/en/display.json +++ b/packages/api-generator/src/locale/en/display.json @@ -1,5 +1,6 @@ { "props": { - "mobileBreakpoint": "Sets the designated mobile breakpoint for the component." + "mobile": "Explicitly designate as a mobile display configuration.", + "mobileBreakpoint": "Overrides the display configuration default." } } diff --git a/packages/vuetify/src/components/VDataTable/VDataTable.sass b/packages/vuetify/src/components/VDataTable/VDataTable.sass index ef19cb553e3..63025c0a5de 100644 --- a/packages/vuetify/src/components/VDataTable/VDataTable.sass +++ b/packages/vuetify/src/components/VDataTable/VDataTable.sass @@ -55,7 +55,7 @@ align-items: center > th.v-data-table__th--fixed - position: sticky + position: sticky > th.v-data-table__th--sortable:hover cursor: pointer @@ -126,3 +126,36 @@ .v-data-table-rows-loading, .v-data-table-rows-no-data text-align: center + + .v-data-table__tr--mobile + > .v-data-table__td--expanded-row + grid-template-columns: 0 + justify-content: center + + > .v-data-table__td--select-row + grid-template-columns: 0 + justify-content: end + + > td + align-items: center + column-gap: 4px + display: grid + grid-template-columns: repeat(2, 1fr) + min-height: var(--v-table-row-height) + + &:not(:last-child) + border-bottom: 0 !important + + .v-data-table__td-title + font-weight: bold + text-align: left + + .v-data-table__td-value + text-align: right + + .v-data-table__td + &-sort-icon + color: $data-table-header-mobile-chip-icon-color + + &-active + color: $data-table-header-mobile-chip-icon-color-active diff --git a/packages/vuetify/src/components/VDataTable/VDataTableFooter.sass b/packages/vuetify/src/components/VDataTable/VDataTableFooter.sass index 3a211f2785a..ca3b5cb8474 100644 --- a/packages/vuetify/src/components/VDataTable/VDataTableFooter.sass +++ b/packages/vuetify/src/components/VDataTable/VDataTableFooter.sass @@ -4,33 +4,33 @@ @include tools.layer('components') .v-data-table-footer - display: flex align-items: center + display: flex flex-wrap: wrap - padding: $data-table-footer-padding justify-content: flex-end + padding: $data-table-footer-padding - .v-data-table-footer__items-per-page - display: flex - align-items: center - justify-content: center + &__items-per-page + align-items: center + display: flex + justify-content: center - > span - padding-inline-end: $data-table-footer-items-per-page-padding + > span + padding-inline-end: $data-table-footer-items-per-page-padding - > .v-select - width: $data-table-footer-select-width + > .v-select + width: $data-table-footer-select-width - .v-data-table-footer__info - display: flex - justify-content: flex-end - min-width: $data-table-footer-info-min-width - padding: $data-table-footer-info-padding + &__info + display: flex + justify-content: flex-end + min-width: $data-table-footer-info-min-width + padding: $data-table-footer-info-padding - .v-data-table-footer__pagination - display: flex - align-items: center - margin-inline-start: $data-table-footer-pagination-margin-inline-start + &__paginationz + align-items: center + display: flex + margin-inline-start: $data-table-footer-pagination-margin-inline-start - .v-data-table-footer__page - padding: 0 8px + &__page + padding: 0 8px diff --git a/packages/vuetify/src/components/VDataTable/VDataTableHeaders.tsx b/packages/vuetify/src/components/VDataTable/VDataTableHeaders.tsx index e97bd8213c5..46dd562f844 100644 --- a/packages/vuetify/src/components/VDataTable/VDataTableHeaders.tsx +++ b/packages/vuetify/src/components/VDataTable/VDataTableHeaders.tsx @@ -1,15 +1,19 @@ // Components import { VDataTableColumn } from './VDataTableColumn' import { VCheckboxBtn } from '@/components/VCheckbox' +import { VChip } from '@/components/VChip' import { VIcon } from '@/components/VIcon' +import { VSelect } from '@/components/VSelect' // Composables import { useHeaders } from './composables/headers' import { useSelection } from './composables/select' import { useSort } from './composables/sort' import { useBackgroundColor } from '@/composables/color' +import { makeDisplayProps, useDisplay } from '@/composables/display' import { IconValue } from '@/composables/icons' import { LoaderSlot, makeLoaderProps, useLoader } from '@/composables/loader' +import { useLocale } from '@/composables/locale' // Utilities import { computed, mergeProps } from 'vue' @@ -20,6 +24,7 @@ import type { CSSProperties, PropType, UnwrapRef } from 'vue' import type { provideSelection } from './composables/select' import type { provideSort } from './composables/sort' import type { InternalDataTableHeader } from './types' +import type { ItemProps } from '@/composables/list-items' import type { LoaderSlotProps } from '@/composables/loader' export type HeadersSlotProps = { @@ -34,7 +39,7 @@ export type HeadersSlotProps = { isSorted: ReturnType['isSorted'] } -type VDataTableHeaderCellColumnSlotProps = { +export type VDataTableHeaderCellColumnSlotProps = { column: InternalDataTableHeader selectAll: ReturnType['selectAll'] isSorted: ReturnType['isSorted'] @@ -68,6 +73,7 @@ export const makeVDataTableHeadersProps = propsFactory({ type: Object as PropType>, }, + ...makeDisplayProps(), ...makeLoaderProps(), }, 'VDataTableHeaders') @@ -77,6 +83,7 @@ export const VDataTableHeaders = genericComponent()({ props: makeVDataTableHeadersProps(), setup (props, { slots }) { + const { t } = useLocale() const { toggleSort, sortBy, isSorted } = useSort() const { someSelected, allSelected, selectAll, showSelectAll } = useSelection() const { columns, headers } = useHeaders() @@ -102,6 +109,8 @@ export const VDataTableHeaders = genericComponent()({ const { backgroundColorClasses, backgroundColorStyles } = useBackgroundColor(props, 'color') + const { displayClasses, mobile } = useDisplay(props) + const slotProps = computed(() => ({ headers: headers.value, columns: columns.value, @@ -114,6 +123,15 @@ export const VDataTableHeaders = genericComponent()({ getSortIcon, } satisfies HeadersSlotProps)) + const headerCellClasses = computed(() => ([ + 'v-data-table__th', + { + 'v-data-table__th--sticky': props.sticky, + }, + displayClasses.value, + loaderClasses.value, + ])) + const VDataTableHeaderCell = ({ column, x, y }: { column: InternalDataTableHeader, x: number, y: number }) => { const noPadding = column.key === 'data-table-select' || column.key === 'data-table-expand' const headerProps = mergeProps(props.headerProps ?? {}, column.headerProps ?? {}) @@ -123,14 +141,12 @@ export const VDataTableHeaders = genericComponent()({ tag="th" align={ column.align } class={[ - 'v-data-table__th', { 'v-data-table__th--sortable': column.sortable, 'v-data-table__th--sorted': isSorted(column), 'v-data-table__th--fixed': column.fixed, - 'v-data-table__th--sticky': props.sticky, }, - loaderClasses.value, + ...headerCellClasses.value, ]} style={{ width: convertToUnit(column.width), @@ -203,8 +219,74 @@ export const VDataTableHeaders = genericComponent()({ ) } - useRender(() => { + const VDataTableMobileHeaderCell = () => { + const headerProps = mergeProps(props.headerProps ?? {} ?? {}) + + const displayItems = computed(() => { + return columns.value.filter(column => column?.sortable) + }) + + const appendIcon = computed(() => { + return allSelected.value ? '$checkboxOn' : someSelected.value ? '$checkboxIndeterminate' : '$checkboxOff' + }) + return ( + +
+ sortBy.value = [] } + appendIcon={ appendIcon.value } + onClick:append={ () => selectAll(!allSelected.value) } + > + {{ + ...slots, + chip: props => ( + toggleSort(props.item.raw) : undefined } + onMousedown={ (e: MouseEvent) => { + e.preventDefault() + e.stopPropagation() + }} + > + { props.item.title } + + + ), + }} + +
+
+ ) + } + + useRender(() => { + return mobile.value ? ( + + + + ) : ( <> { slots.headers ? slots.headers(slotProps.value) diff --git a/packages/vuetify/src/components/VDataTable/VDataTableRow.tsx b/packages/vuetify/src/components/VDataTable/VDataTableRow.tsx index 537364b0429..37a53c2d573 100644 --- a/packages/vuetify/src/components/VDataTable/VDataTableRow.tsx +++ b/packages/vuetify/src/components/VDataTable/VDataTableRow.tsx @@ -1,4 +1,5 @@ // Components +import { VDataTableColumn } from './VDataTableColumn' import { VBtn } from '@/components/VBtn' import { VCheckboxBtn } from '@/components/VCheckbox' @@ -6,7 +7,8 @@ import { VCheckboxBtn } from '@/components/VCheckbox' import { useExpanded } from './composables/expand' import { useHeaders } from './composables/headers' import { useSelection } from './composables/select' -import { VDataTableColumn } from './VDataTableColumn' +import { useSort } from './composables/sort' +import { makeDisplayProps, useDisplay } from '@/composables/display' // Utilities import { toDisplayString, withModifiers } from 'vue' @@ -15,12 +17,18 @@ import { EventProp, genericComponent, getObjectValueByPath, propsFactory, useRen // Types import type { PropType } from 'vue' import type { CellProps, DataTableItem, ItemKeySlot } from './types' +import type { VDataTableHeaderCellColumnSlotProps } from './VDataTableHeaders' import type { GenericProps } from '@/util' export type VDataTableRowSlots = { 'item.data-table-select': Omit, 'value'> 'item.data-table-expand': Omit, 'value'> -} & { [key: `item.${string}`]: ItemKeySlot } + 'header.data-table-select': VDataTableHeaderCellColumnSlotProps + 'header.data-table-expand': VDataTableHeaderCellColumnSlotProps +} & { + [key: `item.${string}`]: ItemKeySlot + [key: `header.${string}`]: VDataTableHeaderCellColumnSlotProps +} export const makeVDataTableRowProps = propsFactory({ index: Number, @@ -29,6 +37,8 @@ export const makeVDataTableRowProps = propsFactory({ onClick: EventProp<[MouseEvent]>(), onContextmenu: EventProp<[MouseEvent]>(), onDblclick: EventProp<[MouseEvent]>(), + + ...makeDisplayProps(), }, 'VDataTableRow') export const VDataTableRow = genericComponent( @@ -43,8 +53,10 @@ export const VDataTableRow = genericComponent( props: makeVDataTableRowProps(), setup (props, { slots }) { - const { isSelected, toggleSelect } = useSelection() + const { displayClasses, mobile } = useDisplay(props, 'v-data-table__tr') + const { isSelected, toggleSelect, someSelected, allSelected, selectAll } = useSelection() const { isExpanded, toggleExpand } = useExpanded() + const { toggleSort, sortBy, isSorted } = useSort() const { columns } = useHeaders() useRender(() => ( @@ -54,6 +66,7 @@ export const VDataTableRow = genericComponent( { 'v-data-table__tr--clickable': !!(props.onClick || props.onContextmenu || props.onDblclick), }, + displayClasses.value, ]} onClick={ props.onClick as any } onContextmenu={ props.onContextmenu as any } @@ -62,6 +75,7 @@ export const VDataTableRow = genericComponent( { props.item && columns.value.map((column, i) => { const item = props.item! const slotName = `item.${column.key}` as const + const headerSlotName = `header.${column.key}` as const const slotProps = { index: props.index!, item: item.raw, @@ -74,6 +88,17 @@ export const VDataTableRow = genericComponent( toggleExpand, } satisfies ItemKeySlot + const columnSlotProps: VDataTableHeaderCellColumnSlotProps = { + column, + selectAll, + isSorted, + toggleSort, + sortBy: sortBy.value, + someSelected: someSelected.value, + allSelected: allSelected.value, + getSortIcon: () => '', + } + const cellProps = typeof props.cellProps === 'function' ? props.cellProps({ index: slotProps.index, @@ -95,19 +120,23 @@ export const VDataTableRow = genericComponent( return ( {{ default: () => { - if (slots[slotName]) return slots[slotName]!(slotProps) + if (slots[slotName] && !mobile.value) return slots[slotName]?.(slotProps) if (column.key === 'data-table-select') { return slots['item.data-table-select']?.(slotProps) ?? ( @@ -130,7 +159,19 @@ export const VDataTableRow = genericComponent( ) } - return toDisplayString(slotProps.value) + const displayValue = toDisplayString(slotProps.value) + + return !mobile.value ? displayValue : ( + <> +
+ { slots[headerSlotName]?.(columnSlotProps) ?? column.title } +
+ +
+ { slots[slotName]?.(slotProps) ?? displayValue } +
+ + ) }, }}
diff --git a/packages/vuetify/src/components/VDataTable/VDataTableRows.tsx b/packages/vuetify/src/components/VDataTable/VDataTableRows.tsx index 0c2719e33c6..cd61f72b968 100644 --- a/packages/vuetify/src/components/VDataTable/VDataTableRows.tsx +++ b/packages/vuetify/src/components/VDataTable/VDataTableRows.tsx @@ -7,6 +7,7 @@ import { useExpanded } from './composables/expand' import { useGroupBy } from './composables/group' import { useHeaders } from './composables/headers' import { useSelection } from './composables/select' +import { makeDisplayProps, useDisplay } from '@/composables/display' import { useLocale } from '@/composables/locale' // Utilities @@ -46,6 +47,8 @@ export const makeVDataTableRowsProps = propsFactory({ }, rowProps: [Object, Function] as PropType>, cellProps: [Object, Function] as PropType>, + + ...makeDisplayProps(), }, 'VDataTableRows') export const VDataTableRows = genericComponent( @@ -66,6 +69,7 @@ export const VDataTableRows = genericComponent( const { isSelected, toggleSelect } = useSelection() const { toggleGroup, isGroupOpen } = useGroupBy() const { t } = useLocale() + const { mobile } = useDisplay(props) useRender(() => { if (props.loading && (!props.items.length || slots.loading)) { @@ -142,6 +146,7 @@ export const VDataTableRows = genericComponent( index, item, cellProps: props.cellProps, + mobile: mobile.value, }, getPrefixedEventHandlers(attrs, ':row', () => slotProps), typeof props.rowProps === 'function' diff --git a/packages/vuetify/src/components/VDataTable/_variables.scss b/packages/vuetify/src/components/VDataTable/_variables.scss index 9b025c1b96d..d3cda1e370c 100644 --- a/packages/vuetify/src/components/VDataTable/_variables.scss +++ b/packages/vuetify/src/components/VDataTable/_variables.scss @@ -1,12 +1,17 @@ @use '../../styles/settings'; @use '../../styles/tools'; +$data-table-header-sort-badge-size: 20px !default; +$data-table-header-sort-badge-color: rgba(var(--v-border-color), var(--v-border-opacity)) !default; + +$data-table-loading-opacity: var(--v-disabled-opacity) !default; + $data-table-footer-info-min-width: 116px !default; $data-table-footer-info-padding: 0 16px !default; -$data-table-footer-padding: 4px !default; +$data-table-footer-padding: 8px 4px !default; $data-table-footer-pagination-margin-inline-start: 16px !default; $data-table-footer-select-width: 90px !default; $data-table-footer-items-per-page-padding: 8px !default; -$data-table-header-sort-badge-size: 20px !default; -$data-table-header-sort-badge-color: rgba(var(--v-border-color), var(--v-border-opacity)) !default; -$data-table-loading-opacity: var(--v-disabled-opacity) !default; + +$data-table-header-mobile-chip-icon-color: rgba(var(--v-theme-on-surface), var(--v-disabled-opacity)) !default; +$data-table-header-mobile-chip-icon-color-active: rgba(var(--v-theme-on-surface)) !default; diff --git a/packages/vuetify/src/components/VDataTable/types.ts b/packages/vuetify/src/components/VDataTable/types.ts index 30962f52359..1a9ec44a3be 100644 --- a/packages/vuetify/src/components/VDataTable/types.ts +++ b/packages/vuetify/src/components/VDataTable/types.ts @@ -28,6 +28,8 @@ export type DataTableHeader> = { sortRaw?: DataTableCompareFunction filter?: FilterFunction + mobile?: boolean + children?: DataTableHeader[] } @@ -37,6 +39,7 @@ export type InternalDataTableHeader = Omit