diff --git a/docs/src/examples/QTable/ColumnResize.vue b/docs/src/examples/QTable/ColumnResize.vue new file mode 100644 index 00000000000..3bbe950f038 --- /dev/null +++ b/docs/src/examples/QTable/ColumnResize.vue @@ -0,0 +1,61 @@ + + + diff --git a/docs/src/pages/vue-components/table.md b/docs/src/pages/vue-components/table.md index 14092136053..35e23c63194 100644 --- a/docs/src/pages/vue-components/table.md +++ b/docs/src/pages/vue-components/table.md @@ -20,6 +20,7 @@ QTable is a component that allows you to display data in a tabular manner. It's * Column picker (through QTableColumns component described in one of the sections) * Custom top and/or bottom Table controls * Responsive design +* Resize column width manually ::: tip If you don't need pagination, sorting, filtering, and all other features of QTable, then you may want to check out [QMarkupTable](/vue-components/markup-table) component instead. @@ -165,6 +166,12 @@ For all the styling component properties, please check the API card at the top o +## Column resizing + +Enable column resizing by using the `resizable-columns` prop. Double-click on the separator to reset the column to its automatic width. + + + ## Virtual scrolling Notice that when enabling virtual scroll you will need to specify the `table-style` (with a max-height) prop. In the example below, we are also forcing QTable to display all rows at once (note the use of `pagination` and `rows-per-page-options` props). diff --git a/ui/playground/src/pages/components/data-table-column-resize.vue b/ui/playground/src/pages/components/data-table-column-resize.vue new file mode 100644 index 00000000000..685c25b6110 --- /dev/null +++ b/ui/playground/src/pages/components/data-table-column-resize.vue @@ -0,0 +1,141 @@ + + + diff --git a/ui/src/components/table/QTable.js b/ui/src/components/table/QTable.js index 54ff5a79a34..1209a628c89 100644 --- a/ui/src/components/table/QTable.js +++ b/ui/src/components/table/QTable.js @@ -22,6 +22,7 @@ import { useTablePaginationState, useTablePagination, useTablePaginationProps } import { useTableRowSelection, useTableRowSelectionProps, useTableRowSelectionEmits } from './table-row-selection.js' import { useTableRowExpand, useTableRowExpandProps, useTableRowExpandEmits } from './table-row-expand.js' import { useTableColumnSelection, useTableColumnSelectionProps } from './table-column-selection.js' +import { useTableColumnResize, useTableColumnResizeProps, useTableColumnResizeEmits } from './table-column-resize.js' import { injectProp, injectMultipleProps } from '../../utils/private.inject-obj-prop/inject-obj-prop.js' import { createComponent } from '../../utils/private.create/create.js' @@ -117,14 +118,16 @@ export default createComponent({ ...useTablePaginationProps, ...useTableRowExpandProps, ...useTableRowSelectionProps, - ...useTableSortProps + ...useTableSortProps, + ...useTableColumnResizeProps }, emits: [ 'request', 'virtualScroll', ...useFullscreenEmits, ...useTableRowExpandEmits, - ...useTableRowSelectionEmits + ...useTableRowSelectionEmits, + ...useTableColumnResizeEmits ], setup (props, { slots, emit }) { @@ -246,6 +249,8 @@ export default createComponent({ const { colList, computedCols, computedColsMap, computedColspan } = useTableColumnSelection(props, computedPagination, hasSelectionMode) + const { columnWidths, resizing, colsWithWidths, colsMapWithWidths, startResize, onDoubleClick } = useTableColumnResize(props, computedCols, emit) + const { columnToSort, computedSortMethod, sort } = useTableSort(props, computedPagination, colList, setPagination) const { @@ -411,7 +416,8 @@ export default createComponent({ const bodyCell = slots[ 'body-cell' ], - child = computedCols.value.map(col => { + cols = props.resizableColumns ? colsWithWidths.value : computedCols.value, + child = cols.map(col => { const bodyCellCol = slots[ `body-cell-${ col.name }` ], slot = bodyCellCol !== void 0 ? bodyCellCol : bodyCell @@ -660,17 +666,23 @@ export default createComponent({ ).slice() } - const child = computedCols.value.map(col => { + const cols = props.resizableColumns ? colsWithWidths.value : computedCols.value + + const child = cols.map(col => { const headerCellCol = slots[ `header-cell-${ col.name }` ], slot = headerCellCol !== void 0 ? headerCellCol : headerCell, - props = getHeaderScope({ col }) + scopeProps = getHeaderScope({ col }) return slot !== void 0 - ? slot(props) + ? slot(scopeProps) : h(QTh, { key: col.name, - props + props: scopeProps, + resizableColumns: props.resizableColumns, + resizing: resizing.value, + onStartResize: startResize, + onAutoResize: onDoubleClick }, () => col.label) }) @@ -700,6 +712,7 @@ export default createComponent({ return [ h('tr', { + key: props.resizableColumns ? JSON.stringify(columnWidths.value) : undefined, class: props.tableHeaderClass, style: props.tableHeaderStyle }, child) @@ -707,10 +720,13 @@ export default createComponent({ } function getHeaderScope (data) { + const cols = props.resizableColumns ? colsWithWidths.value : computedCols.value + const colsMap = props.resizableColumns ? colsMapWithWidths.value : computedColsMap.value + Object.assign(data, { - cols: computedCols.value, + cols, sort, - colsMap: computedColsMap.value, + colsMap, color: props.color, dark: isDark.value, dense: props.dense diff --git a/ui/src/components/table/QTable.json b/ui/src/components/table/QTable.json index 4ee04c697b8..1ab0fbbc2b7 100644 --- a/ui/src/components/table/QTable.json +++ b/ui/src/components/table/QTable.json @@ -366,6 +366,19 @@ "category": "behavior|content" }, + "resizable-columns": { + "type": "Boolean", + "desc": "Enable column resizing by dragging the column separator", + "category": "behavior" + }, + + "column-widths": { + "type": "Object", + "desc": "Object with column names as keys and their widths (in pixels) as values", + "examples": [ "{ name: 150, calories: 100 }" ], + "category": "model" + }, + "title": { "type": "String", "desc": "Table title", @@ -1942,6 +1955,30 @@ }, "events": { + "column-resize": { + "desc": "Emitted when a column is resized", + "params": { + "details": { + "type": "Object", + "desc": "Resize event details", + "definition": { + "col": { + "type": "Object", + "desc": "Column definition object" + }, + "width": { + "type": [ "Number", "String" ], + "desc": "New width of the column in pixels, or 'auto'" + }, + "widths": { + "type": "Object", + "desc": "Object with all current column widths" + } + } + } + } + }, + "row-click": { "desc": "Emitted when user clicks/taps on a row; Is not emitted when using body/row/item scoped slots", "params": { diff --git a/ui/src/components/table/QTable.sass b/ui/src/components/table/QTable.sass index 8f13f8e2533..784859b6f30 100644 --- a/ui/src/components/table/QTable.sass +++ b/ui/src/components/table/QTable.sass @@ -292,3 +292,39 @@ body.desktop .q-table > tbody > tr:not(.q-tr--no-hover):hover > td:not(.q-td--no &.q-table--vertical-separator, &.q-table--cell-separator .q-table__top border-color: $table-dark-border-color + +/* + * Column Resize + */ +.q-table + th + position: relative + + &.is-resizing + user-select: none + cursor: col-resize + + &__column-resizer + position: absolute + right: 0 + top: 0 + width: 8px + height: 100% + cursor: col-resize + user-select: none + z-index: 1 + + &::before + content: '' + position: absolute + left: 3px + top: 0 + width: 2px + height: 100% + background: transparent + transition: background 0.3s + + &:hover::before, + &.is-resizing::before + background: currentColor + opacity: 0.5 diff --git a/ui/src/components/table/QTh.js b/ui/src/components/table/QTh.js index 207cf410278..8d1e1eb1d51 100644 --- a/ui/src/components/table/QTh.js +++ b/ui/src/components/table/QTh.js @@ -10,10 +10,12 @@ export default createComponent({ props: { props: Object, - autoWidth: Boolean + autoWidth: Boolean, + resizableColumns: Boolean, + resizing: String }, - emits: [ 'click' ], + emits: [ 'click', 'startResize', 'autoResize' ], setup (props, { slots, emit }) { const vm = getCurrentInstance() @@ -59,7 +61,8 @@ export default createComponent({ const data = { class: col.__thClass - + (props.autoWidth === true ? ' q-table--col-auto-width' : ''), + + (props.autoWidth === true ? ' q-table--col-auto-width' : '') + + (props.resizing === col.name ? ' is-resizing' : ''), style: col.headerStyle, onClick: evt => { col.sortable === true && props.props.sort(col) @@ -67,6 +70,22 @@ export default createComponent({ } } + if (props.resizableColumns === true) { + const resizeHandle = h('div', { + class: 'q-table__column-resizer' + (props.resizing === col.name ? ' is-resizing' : ''), + onMousedown: evt => { + emit('startResize', evt, col) + }, + onDblclick: evt => { + evt.stopPropagation() + emit('autoResize', col) + } + }) + + const children = Array.isArray(child) ? child : [ child ] + return h('th', data, [ ...children, resizeHandle ]) + } + return h('th', data, child) } } diff --git a/ui/src/components/table/QTh.json b/ui/src/components/table/QTh.json index 40675e8a631..88f1bec4297 100644 --- a/ui/src/components/table/QTh.json +++ b/ui/src/components/table/QTh.json @@ -15,6 +15,20 @@ "type": "Boolean", "desc": "Tries to shrink header column width size; Useful for columns with a checkbox/radio/toggle", "category": "content" + }, + + "resizable-columns": { + "type": "Boolean", + "desc": "Enable column resizing (internal prop passed from QTable)", + "category": "behavior", + "internal": true + }, + + "resizing": { + "type": "String", + "desc": "Name of the column currently being resized (internal prop passed from QTable)", + "category": "state", + "internal": true } }, @@ -25,6 +39,32 @@ }, "events": { - "click": { "internal": true } + "click": { "internal": true }, + + "start-resize": { + "desc": "Emitted when user starts resizing a column", + "params": { + "evt": { + "type": "Object", + "desc": "JS mouse event object" + }, + "col": { + "type": "Object", + "desc": "Column definition object" + } + }, + "internal": true + }, + + "auto-resize": { + "desc": "Emitted when user double-clicks the resize handle to auto-size column", + "params": { + "col": { + "type": "Object", + "desc": "Column definition object" + } + }, + "internal": true + } } } diff --git a/ui/src/components/table/table-column-resize.js b/ui/src/components/table/table-column-resize.js new file mode 100644 index 00000000000..54247bc1315 --- /dev/null +++ b/ui/src/components/table/table-column-resize.js @@ -0,0 +1,143 @@ +import { ref, computed, onBeforeUnmount } from 'vue' + +export const useTableColumnResizeProps = { + resizableColumns: Boolean, + columnWidths: Object +} + +export const useTableColumnResizeEmits = [ 'columnResize' ] + +export function useTableColumnResize (props, computedCols, emit) { + const columnWidths = ref(props.columnWidths || {}) + const resizing = ref(null) + + if (props.columnWidths) { + columnWidths.value = { ...props.columnWidths } + } + + let startX = 0 + let startWidth = 0 + let currentCol = null + + function onMouseMove (evt) { + if (!resizing.value || !currentCol) return + + evt.preventDefault() + + const diff = evt.clientX - startX + const newWidth = Math.max( + currentCol.minWidth || 50, + startWidth + diff + ) + + const finalWidth = currentCol.maxWidth + ? Math.min(newWidth, currentCol.maxWidth) + : newWidth + + columnWidths.value = { + ...columnWidths.value, + [ currentCol.name ]: finalWidth + } + } + + function onMouseUp (evt) { + if (!resizing.value || !currentCol) return + + evt.preventDefault() + + emit('columnResize', { + col: currentCol, + width: columnWidths.value[ currentCol.name ], + widths: { ...columnWidths.value } + }) + + resizing.value = null + currentCol = null + document.body.style.cursor = '' + + document.removeEventListener('mousemove', onMouseMove) + document.removeEventListener('mouseup', onMouseUp) + } + + function startResize (evt, col) { + if (!props.resizableColumns) return + + evt.preventDefault() + evt.stopPropagation() + + const th = evt.target.closest('th') + if (!th) return + + startX = evt.clientX + startWidth = columnWidths.value[ col.name ] || th.offsetWidth + currentCol = col + resizing.value = col.name + + document.body.style.cursor = 'col-resize' + + document.addEventListener('mousemove', onMouseMove) + document.addEventListener('mouseup', onMouseUp) + } + + function onDoubleClick (col) { + const newWidths = { ...columnWidths.value } + delete newWidths[ col.name ] + columnWidths.value = newWidths + + emit('columnResize', { + col, + width: 'auto', + widths: { ...columnWidths.value } + }) + } + + const colsWithWidths = computed(() => { + if (!computedCols.value) return [] + if (!props.resizableColumns) return computedCols.value + + return computedCols.value.map(col => { + const width = columnWidths.value[ col.name ] + if (!width) return col + + const headerStyle = col.headerStyle + ? `${ col.headerStyle }; width: ${ width }px` + : `width: ${ width }px` + + const style = typeof col.style === 'string' + ? `${ col.style }; width: ${ width }px` + : (typeof col.style === 'function' + ? row => `${ col.style(row) }; width: ${ width }px` + : `width: ${ width }px`) + + return { + ...col, + headerStyle, + style, + __width: width + } + }) + }) + + const colsMapWithWidths = computed(() => { + const map = {} + colsWithWidths.value.forEach(col => { + map[ col.name ] = col + }) + return map + }) + + onBeforeUnmount(() => { + document.removeEventListener('mousemove', onMouseMove) + document.removeEventListener('mouseup', onMouseUp) + document.body.style.cursor = '' + }) + + return { + columnWidths, + resizing, + colsWithWidths, + colsMapWithWidths, + startResize, + onDoubleClick + } +}