diff --git a/packages/varlet-cli/site/mobile/App.vue b/packages/varlet-cli/site/mobile/App.vue index 0d6c19b275c..76bc0191ef1 100644 --- a/packages/varlet-cli/site/mobile/App.vue +++ b/packages/varlet-cli/site/mobile/App.vue @@ -226,12 +226,6 @@ body { color 0.25s; } -::-webkit-scrollbar { - display: none; - width: 0; - background: transparent; -} - .app-type { width: 100%; padding: 15px 0; diff --git a/packages/varlet-ui/src/data-table/DataTable.vue b/packages/varlet-ui/src/data-table/DataTable.vue new file mode 100644 index 00000000000..3efcd0ac539 --- /dev/null +++ b/packages/varlet-ui/src/data-table/DataTable.vue @@ -0,0 +1,489 @@ + + + + + diff --git a/packages/varlet-ui/src/data-table/DataTableBodyCell.vue b/packages/varlet-ui/src/data-table/DataTableBodyCell.vue new file mode 100644 index 00000000000..6fd686697b0 --- /dev/null +++ b/packages/varlet-ui/src/data-table/DataTableBodyCell.vue @@ -0,0 +1,182 @@ + + + diff --git a/packages/varlet-ui/src/data-table/DataTableHeaderCell.vue b/packages/varlet-ui/src/data-table/DataTableHeaderCell.vue new file mode 100644 index 00000000000..7c228365656 --- /dev/null +++ b/packages/varlet-ui/src/data-table/DataTableHeaderCell.vue @@ -0,0 +1,218 @@ + + + diff --git a/packages/varlet-ui/src/data-table/__tests__/index.spec.js b/packages/varlet-ui/src/data-table/__tests__/index.spec.js new file mode 100644 index 00000000000..9f50a017ec3 --- /dev/null +++ b/packages/varlet-ui/src/data-table/__tests__/index.spec.js @@ -0,0 +1,1700 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, test } from 'vite-plus/test' +import { createApp, h, ref } from 'vue' +import DataTable from '..' +import VarDataTable from '../DataTable' +import { useExpandRow } from '../useExpandRow' +import { useSelectionColumn } from '../useSelectionColumn' +import { useTreeExpand } from '../useTreeExpand' + +test('data-table use', () => { + const app = createApp({}).use(DataTable) + expect(app.component(DataTable.name)).toBeTruthy() +}) + +const columns = [ + { key: 'name', title: 'Name' }, + { key: 'role', title: 'Role' }, +] + +const data = [ + { id: 1, name: 'Ada', role: 'Admin' }, + { id: 2, name: 'Linus', role: 'Maintainer' }, + { id: 3, name: 'Taylor', role: 'Designer' }, +] + +const treeData = [ + { + id: 1, + name: 'Frontend', + role: 'Team', + nodes: [ + { id: 11, name: 'Ada', role: 'Lead' }, + { id: 12, name: 'Taylor', role: 'Engineer' }, + ], + }, + { + id: 2, + name: 'Design', + role: 'Team', + nodes: [{ id: 21, name: 'Linus', role: 'Designer' }], + }, +] + +const isSelectionColumn = (column) => column.type === 'selection' +const isExpandColumn = (column) => column.type === 'expand' +const getTreeChildren = (row) => (Array.isArray(row.nodes) ? row.nodes : []) + +describe('test data-table component props', () => { + test('should handle selection composable without selection column', () => { + const checkedRowKeys = ref([1]) + const selection = useSelectionColumn({ + columns: () => [], + tree: () => false, + cascade: () => true, + pagedData: () => data, + allFlatRows: () => [], + treeRowMeta: () => ({ + rowByKey: new Map(), + rowByObject: new Map(), + parentKeyByChild: new Map(), + }), + checkedRowKeys, + isSelectionColumn, + getTreeChildren, + }) + + expect(selection.currentSelectableRows.value).toEqual([]) + expect(selection.allCurrentRowsSelected.value).toBe(false) + expect(selection.someCurrentRowsSelected.value).toBe(false) + + selection.toggleCurrentSelectableRows(true) + selection.toggleRowSelection({ key: 1, row: data[0], rowIndex: 0 }, true) + expect(checkedRowKeys.value).toEqual([1]) + }) + + test('should handle selection composable with non-selectable column', () => { + const checkedRowKeys = ref([]) + const selection = useSelectionColumn({ + columns: () => [{ type: 'selection', selectable: false }], + tree: () => false, + cascade: () => true, + pagedData: () => data, + allFlatRows: () => data.map((row, rowIndex) => ({ key: row.id, row, rowIndex })), + treeRowMeta: () => ({ + rowByKey: new Map(), + rowByObject: new Map(), + parentKeyByChild: new Map(), + }), + checkedRowKeys, + isSelectionColumn, + getTreeChildren, + }) + + expect(selection.currentSelectableRows.value).toEqual([]) + expect(selection.allCurrentRowsSelected.value).toBe(false) + expect(selection.someCurrentRowsSelected.value).toBe(false) + }) + + test('should ignore tree selection rows missing from meta', () => { + const selection = useSelectionColumn({ + columns: () => [{ type: 'selection' }], + tree: () => true, + cascade: () => true, + pagedData: () => [{ id: 1, nodes: [{ id: 11 }] }], + allFlatRows: () => [], + treeRowMeta: () => ({ + rowByKey: new Map(), + rowByObject: new Map(), + parentKeyByChild: new Map(), + }), + checkedRowKeys: ref([]), + isSelectionColumn, + getTreeChildren, + }) + + expect(selection.isRowKeySelected(1)).toBe(false) + expect(selection.isRowKeyIndeterminate(1)).toBe(false) + }) + + test('should handle expand composable without expand column', () => { + const expandedRowKeys = ref([1]) + const expand = useExpandRow({ + columns: () => [], + expandedRowKeys, + isExpandColumn, + }) + const bodyRow = { key: 1, row: data[0], rowIndex: 0 } + + expand.toggleRowExpanded(bodyRow) + + expect(expandedRowKeys.value).toEqual([1]) + expect(expand.renderExpandedRow(bodyRow)).toBeUndefined() + }) + + test('should handle tree expand composable when tree is disabled', () => { + const expandedTreeRowKeys = ref([1]) + const treeExpand = useTreeExpand({ + tree: () => false, + data: () => treeData, + expandedTreeRowKeys, + getRowKey: (row, rowIndex) => row.id ?? rowIndex, + getTreeChildren, + }) + + treeExpand.toggleTreeRowExpanded({ key: 1, row: treeData[0], rowIndex: 0, expandable: true }) + + expect(expandedTreeRowKeys.value).toEqual([]) + expect(treeExpand.collapsedTreeRowKeys.value.size).toBe(0) + }) + + test('should render basic table content', () => { + const wrapper = mount(VarDataTable, { + props: { + columns, + data, + pagination: false, + }, + }) + + expect(wrapper.findAll('thead th')).toHaveLength(2) + expect(wrapper.findAll('tbody tr')).toHaveLength(3) + expect(wrapper.text()).toContain('Ada') + wrapper.unmount() + }) + + test('should slice data in local pagination mode', () => { + const wrapper = mount(VarDataTable, { + props: { + columns, + data, + page: 2, + pageSize: 2, + }, + }) + + expect(wrapper.text()).not.toContain('Ada') + expect(wrapper.text()).toContain('Taylor') + wrapper.unmount() + }) + + test('should update local pagination internally when page is uncontrolled', async () => { + const wrapper = mount(VarDataTable, { + props: { + columns, + data, + pageSize: 2, + }, + }) + + wrapper.findComponent({ name: 'var-pagination' }).vm.$emit('change', 2, 2) + await wrapper.vm.$nextTick() + + expect(wrapper.text()).not.toContain('Ada') + expect(wrapper.text()).toContain('Taylor') + expect(wrapper.findComponent({ name: 'var-pagination' }).props('current')).toBe(2) + wrapper.unmount() + }) + + test('should hide page size changer by default', () => { + const wrapper = mount(VarDataTable, { + props: { + columns, + data, + }, + }) + + expect(wrapper.findComponent({ name: 'var-pagination' }).props('showSizeChanger')).toBe(false) + wrapper.unmount() + }) + + test('should not slice data in remote pagination mode', () => { + const wrapper = mount(VarDataTable, { + props: { + columns, + data: [data[2]], + page: 2, + pageSize: 2, + total: 3, + remote: true, + }, + }) + + expect(wrapper.findAll('tbody tr')).toHaveLength(1) + expect(wrapper.text()).toContain('Taylor') + wrapper.unmount() + }) + + test('should disable pagination while loading', () => { + const wrapper = mount(VarDataTable, { + props: { + columns, + data, + loading: true, + }, + }) + + expect(wrapper.findComponent({ name: 'var-pagination' }).props('disabled')).toBe(true) + wrapper.unmount() + }) + + test('should support custom pagination options', () => { + const showTotal = vi.fn((total, range) => `${range[0]}-${range[1]} / ${total}`) + const wrapper = mount(VarDataTable, { + props: { + columns, + data, + pagination: { + disabled: true, + showSizeChanger: true, + showQuickJumper: true, + maxPagerCount: 7, + sizeOption: [5, 10], + showTotal, + }, + }, + }) + + const pagination = wrapper.findComponent({ name: 'var-pagination' }) + + expect(pagination.props('disabled')).toBe(true) + expect(pagination.props('showSizeChanger')).toBe(true) + expect(pagination.props('showQuickJumper')).toBe(true) + expect(pagination.props('maxPagerCount')).toBe(7) + expect(pagination.props('sizeOption')).toEqual([5, 10]) + expect(showTotal).toHaveBeenCalledWith(3, [1, 3]) + wrapper.unmount() + }) + + test('should hide pagination when remote total is not provided', () => { + const wrapper = mount(VarDataTable, { + props: { + columns, + data, + remote: true, + }, + }) + + expect(wrapper.findComponent({ name: 'var-pagination' }).exists()).toBe(false) + expect(wrapper.findAll('tbody tr')).toHaveLength(3) + wrapper.unmount() + }) + + test('should support render function', () => { + const wrapper = mount(VarDataTable, { + props: { + columns: [ + { + key: 'name', + title: 'Name', + render: ({ row }) => h('strong', row.name), + }, + ], + data: [data[0]], + pagination: false, + }, + }) + + expect(wrapper.find('strong').text()).toBe('Ada') + wrapper.unmount() + }) + + test('should support title render function', () => { + const wrapper = mount(VarDataTable, { + props: { + columns: [ + { + key: 'name', + title: () => h('span', { class: 'custom-title' }, 'Custom Name'), + }, + ], + data: [data[0]], + pagination: false, + }, + }) + + expect(wrapper.find('thead .custom-title').text()).toBe('Custom Name') + wrapper.unmount() + }) + + test('should support sortable title render function', () => { + const wrapper = mount(VarDataTable, { + props: { + columns: [ + { + key: 'name', + title: () => h('span', { class: 'custom-sort-title' }, 'Custom Name'), + sorter: true, + }, + ], + data: [data[0]], + pagination: false, + }, + }) + + expect(wrapper.find('thead .custom-sort-title').text()).toBe('Custom Name') + wrapper.unmount() + }) + + test('should support row class', () => { + const wrapper = mount(VarDataTable, { + props: { + columns, + data, + pagination: false, + rowClass: ({ row }) => (row.id === 1 ? 'active-row' : undefined), + }, + }) + + expect(wrapper.findAll('tbody tr')[0].classes()).toContain('active-row') + expect(wrapper.findAll('tbody tr')[1].classes()).not.toContain('active-row') + + wrapper.unmount() + }) + + test('should support function row key and fallback row index key', async () => { + const onUpdateCheckedRowKeys = vi.fn() + const wrapper = mount(VarDataTable, { + props: { + columns: [{ type: 'selection' }, ...columns], + data: [ + { uid: 'ada', name: 'Ada', role: 'Admin' }, + { name: 'Linus', role: 'Maintainer' }, + ], + pagination: false, + rowKey: ({ row, rowIndex }) => row.uid ?? rowIndex, + checkedRowKeys: [], + 'onUpdate:checkedRowKeys': onUpdateCheckedRowKeys, + }, + }) + + const checkboxes = wrapper.findAllComponents({ name: 'var-checkbox' }) + + checkboxes[1].vm.$emit('update:modelValue', true) + await wrapper.vm.$nextTick() + expect(onUpdateCheckedRowKeys).toHaveBeenLastCalledWith(['ada']) + + await wrapper.setProps({ checkedRowKeys: [] }) + checkboxes[2].vm.$emit('update:modelValue', true) + await wrapper.vm.$nextTick() + expect(onUpdateCheckedRowKeys).toHaveBeenLastCalledWith([1]) + + wrapper.unmount() + }) + + test('should support summary row', () => { + const wrapper = mount(VarDataTable, { + props: { + columns: [ + { key: 'name', title: 'Name' }, + { key: 'score', title: 'Score' }, + { key: 'tasks', title: 'Tasks' }, + ], + data: [ + { id: 1, name: 'Ada', score: 92, tasks: 16 }, + { id: 2, name: 'Linus', score: 78, tasks: 24 }, + ], + pagination: false, + summary: ({ data }) => ({ + name: { + value: 'Total', + colSpan: 2, + }, + tasks: { + value: data.reduce((total, row) => total + row.tasks, 0), + }, + }), + }, + }) + + const summaryCells = wrapper.findAll('tfoot td') + + expect(summaryCells).toHaveLength(2) + expect(summaryCells[0].attributes('colspan')).toBe('2') + expect(summaryCells[0].text()).toBe('Total') + expect(summaryCells[1].text()).toBe('40') + + wrapper.unmount() + }) + + test('should support multiple summary rows with row span', () => { + const wrapper = mount(VarDataTable, { + props: { + columns: [ + { key: 'name', title: 'Name' }, + { key: 'score', title: 'Score' }, + { key: 'tasks', title: 'Tasks' }, + ], + data: [ + { id: 1, name: 'Ada', score: 92, tasks: 16 }, + { id: 2, name: 'Linus', score: 78, tasks: 24 }, + ], + pagination: false, + summary: ({ data }) => [ + { + name: { + value: 'Total', + rowSpan: 2, + }, + score: { + value: data.reduce((total, row) => total + row.score, 0), + }, + tasks: { + value: data.reduce((total, row) => total + row.tasks, 0), + }, + }, + { + score: { + value: 'Average', + }, + tasks: { + value: data.reduce((total, row) => total + row.tasks, 0) / data.length, + }, + }, + ], + }, + }) + + const summaryRows = wrapper.findAll('tfoot tr') + const firstRowCells = summaryRows[0].findAll('td') + const secondRowCells = summaryRows[1].findAll('td') + + expect(summaryRows).toHaveLength(2) + expect(firstRowCells).toHaveLength(3) + expect(firstRowCells[0].attributes('rowspan')).toBe('2') + expect(firstRowCells[0].text()).toBe('Total') + expect(firstRowCells[1].text()).toBe('170') + expect(firstRowCells[2].text()).toBe('40') + expect(secondRowCells).toHaveLength(2) + expect(secondRowCells[0].text()).toBe('Average') + expect(secondRowCells[1].text()).toBe('20') + + wrapper.unmount() + }) + + test('should support vnode summary cell value', () => { + const wrapper = mount(VarDataTable, { + props: { + columns: [ + { key: 'name', title: 'Name' }, + { key: 'score', title: 'Score' }, + ], + data, + pagination: false, + summary: () => ({ + name: { + value: h('strong', { class: 'summary-total' }, 'Total'), + }, + score: { + value: 3, + }, + }), + }, + }) + + expect(wrapper.find('tfoot .summary-total').text()).toBe('Total') + expect(wrapper.findAll('tfoot td')[1].text()).toBe('3') + + wrapper.unmount() + }) + + test('should normalize summary cell spans', () => { + const wrapper = mount(VarDataTable, { + props: { + columns: [ + { key: 'name', title: 'Name' }, + { key: 'score', title: 'Score' }, + { key: 'tasks', title: 'Tasks' }, + ], + data, + pagination: false, + summary: () => [ + { + name: { + value: 'Total', + colSpan: 2.8, + }, + tasks: { + value: '40', + rowSpan: 8, + }, + }, + { + name: { + value: 'Hidden', + colSpan: 0, + }, + score: { + value: 'Average', + }, + tasks: { + value: '20', + }, + }, + ], + }, + }) + + const summaryRows = wrapper.findAll('tfoot tr') + const firstRowCells = summaryRows[0].findAll('td') + const secondRowCells = summaryRows[1].findAll('td') + + expect(firstRowCells).toHaveLength(2) + expect(firstRowCells[0].attributes('colspan')).toBe('2') + expect(firstRowCells[0].text()).toBe('Total') + expect(firstRowCells[1].attributes('rowspan')).toBe('2') + expect(firstRowCells[1].text()).toBe('40') + expect(secondRowCells).toHaveLength(1) + expect(secondRowCells[0].text()).toBe('Average') + + wrapper.unmount() + }) + + test('should support plain table mode', () => { + const wrapper = mount(VarDataTable, { + props: { + columns, + data, + pagination: false, + plain: true, + }, + }) + + expect(wrapper.classes()).toContain('var-data-table--plain') + expect(wrapper.classes()).not.toContain('var-elevation--1') + wrapper.unmount() + }) + + test('should support grouped header columns', () => { + const wrapper = mount(VarDataTable, { + props: { + columns: [ + { + title: 'Profile', + children: [ + { key: 'name', title: 'Name' }, + { key: 'role', title: 'Role' }, + ], + }, + { + title: 'State', + children: [{ key: 'status', title: 'Status' }], + }, + ], + data, + pagination: false, + }, + }) + + expect(wrapper.findAll('thead tr')).toHaveLength(2) + expect(wrapper.findAll('thead tr')[0].findAll('th')).toHaveLength(2) + expect(wrapper.findAll('thead tr')[1].findAll('th')).toHaveLength(3) + expect(wrapper.findAll('colgroup col')).toHaveLength(3) + wrapper.unmount() + }) + + test('should support single sorter cycle', async () => { + const onUpdateSorters = vi.fn() + const wrapper = mount(VarDataTable, { + props: { + columns: [ + { key: 'name', title: 'Name', sorter: true }, + { key: 'role', title: 'Role' }, + ], + data, + pagination: false, + sorters: [], + 'onUpdate:sorters': onUpdateSorters, + }, + }) + + const sortTrigger = wrapper.find('.var-data-table__sort-trigger') + + await sortTrigger.trigger('click') + expect(onUpdateSorters).toHaveBeenLastCalledWith([{ key: 'name', order: 'asc' }]) + + await wrapper.setProps({ sorters: [{ key: 'name', order: 'asc' }] }) + await sortTrigger.trigger('click') + expect(onUpdateSorters).toHaveBeenLastCalledWith([{ key: 'name', order: 'desc' }]) + + await wrapper.setProps({ sorters: [{ key: 'name', order: 'desc' }] }) + await sortTrigger.trigger('click') + expect(onUpdateSorters).toHaveBeenLastCalledWith([]) + + wrapper.unmount() + }) + + test('should render sorter icons with explicit size', () => { + const wrapper = mount(VarDataTable, { + props: { + columns: [{ key: 'name', title: 'Name', sorter: true }], + data, + pagination: false, + }, + }) + + expect(wrapper.find('.var-data-table__sort-trigger-icon-up').attributes('style')).toContain('font-size: 18px;') + expect(wrapper.find('.var-data-table__sort-trigger-icon-down').attributes('style')).toContain('font-size: 18px;') + + wrapper.unmount() + }) + + test('should support multiple sorters', async () => { + const onUpdateSorters = vi.fn() + const wrapper = mount(VarDataTable, { + props: { + columns: [ + { key: 'name', title: 'Name', sorter: true }, + { key: 'role', title: 'Role', sorter: true }, + ], + data, + pagination: false, + sorters: [{ key: 'name', order: 'asc' }], + sortMode: 'multiple', + 'onUpdate:sorters': onUpdateSorters, + }, + }) + + const sortTriggers = wrapper.findAll('.var-data-table__sort-trigger') + + await sortTriggers[1].trigger('click') + + expect(onUpdateSorters).toHaveBeenLastCalledWith([ + { key: 'name', order: 'asc' }, + { key: 'role', order: 'asc' }, + ]) + + wrapper.unmount() + }) + + test('should cycle sorter in multiple mode', async () => { + const onUpdateSorters = vi.fn() + const wrapper = mount(VarDataTable, { + props: { + columns: [ + { key: 'name', title: 'Name', sorter: true }, + { key: 'role', title: 'Role', sorter: true }, + ], + data, + pagination: false, + sorters: [{ key: 'name', order: 'asc' }], + sortMode: 'multiple', + 'onUpdate:sorters': onUpdateSorters, + }, + }) + + const sortTrigger = wrapper.findAll('.var-data-table__sort-trigger')[0] + + await sortTrigger.trigger('click') + expect(onUpdateSorters).toHaveBeenLastCalledWith([{ key: 'name', order: 'desc' }]) + + await wrapper.setProps({ sorters: [{ key: 'name', order: 'desc' }] }) + await sortTrigger.trigger('click') + expect(onUpdateSorters).toHaveBeenLastCalledWith([]) + + wrapper.unmount() + }) + + test('should only render sorter trigger for sortable field columns', () => { + const wrapper = mount(VarDataTable, { + props: { + columns: [ + { type: 'selection' }, + { key: 'name', title: 'Name', sorter: true }, + { + type: 'expand', + renderExpand: ({ row }) => h('div', row.role), + }, + { key: 'role', title: 'Role' }, + ], + data, + pagination: false, + }, + }) + + expect(wrapper.findAll('.var-data-table__sort-trigger')).toHaveLength(1) + + wrapper.unmount() + }) + + test('should not render sorter trigger for grouped header parents', () => { + const wrapper = mount(VarDataTable, { + props: { + columns: [ + { + title: 'Profile', + children: [ + { key: 'name', title: 'Name', sorter: true }, + { key: 'role', title: 'Role', sorter: true }, + ], + }, + ], + data, + pagination: false, + }, + }) + + expect(wrapper.findAll('.var-data-table__sort-trigger')).toHaveLength(2) + expect(wrapper.findAll('thead tr')).toHaveLength(2) + + wrapper.unmount() + }) + + test('should support selection column', async () => { + const onUpdateCheckedRowKeys = vi.fn() + const wrapper = mount(VarDataTable, { + props: { + columns: [{ type: 'selection' }, ...columns], + data, + pagination: false, + checkedRowKeys: [], + 'onUpdate:checkedRowKeys': onUpdateCheckedRowKeys, + }, + }) + + const checkboxes = wrapper.findAllComponents({ name: 'var-checkbox' }) + + checkboxes[1].vm.$emit('update:modelValue', true) + await wrapper.vm.$nextTick() + + expect(onUpdateCheckedRowKeys).toHaveBeenLastCalledWith([1]) + + checkboxes[0].vm.$emit('update:modelValue', true) + await wrapper.vm.$nextTick() + + expect(onUpdateCheckedRowKeys).toHaveBeenLastCalledWith([1, 2, 3]) + wrapper.unmount() + }) + + test('should support expand column', async () => { + const wrapper = mount(VarDataTable, { + props: { + columns: [ + { + type: 'expand', + renderExpand: ({ row }) => h('div', { class: 'expanded-content' }, row.role), + }, + ...columns, + ], + data, + pagination: false, + }, + }) + + expect(wrapper.findAll('tbody tr')).toHaveLength(3) + + await wrapper.find('.var-data-table__expand-trigger').trigger('click') + + expect(wrapper.find('.expanded-content').text()).toBe('Admin') + expect(wrapper.findAll('tbody tr')).toHaveLength(4) + wrapper.unmount() + }) + + test('should support controlled expanded row keys', async () => { + const onUpdateExpandedRowKeys = vi.fn() + const wrapper = mount(VarDataTable, { + props: { + columns: [ + { + type: 'expand', + renderExpand: ({ row }) => h('div', { class: 'expanded-content' }, row.role), + }, + ...columns, + ], + data, + pagination: false, + expandedRowKeys: [1], + 'onUpdate:expandedRowKeys': onUpdateExpandedRowKeys, + }, + }) + + expect(wrapper.find('.expanded-content').text()).toBe('Admin') + + await wrapper.find('.var-data-table__expand-trigger').trigger('click') + expect(onUpdateExpandedRowKeys).toHaveBeenLastCalledWith([]) + + wrapper.unmount() + }) + + test('should support uncontrolled expanded row keys', async () => { + const wrapper = mount(VarDataTable, { + props: { + columns: [ + { + type: 'expand', + renderExpand: ({ row }) => h('div', { class: 'expanded-content' }, row.role), + }, + ...columns, + ], + data, + pagination: false, + }, + }) + + expect(wrapper.find('.expanded-content').exists()).toBe(false) + + await wrapper.find('.var-data-table__expand-trigger').trigger('click') + expect(wrapper.find('.expanded-content').text()).toBe('Admin') + + await wrapper.find('.var-data-table__expand-trigger').trigger('click') + expect(wrapper.find('.expanded-content').exists()).toBe(false) + + wrapper.unmount() + }) + + test('should support expandable in expand column', async () => { + const wrapper = mount(VarDataTable, { + props: { + columns: [ + { + type: 'expand', + expandable: ({ row }) => row.id !== 1, + renderExpand: ({ row }) => h('div', { class: 'expanded-content' }, row.role), + }, + ...columns, + ], + data, + pagination: false, + }, + }) + + const triggers = wrapper.findAll('.var-data-table__expand-trigger') + + expect(triggers[0].attributes('disabled')).toBeDefined() + expect(triggers[1].attributes('disabled')).toBeUndefined() + + await triggers[0].trigger('click') + expect(wrapper.find('.expanded-content').exists()).toBe(false) + + await triggers[1].trigger('click') + expect(wrapper.find('.expanded-content').text()).toBe('Maintainer') + wrapper.unmount() + }) + + test('should support single selection column', async () => { + const onUpdateCheckedRowKeys = vi.fn() + const wrapper = mount(VarDataTable, { + props: { + columns: [{ type: 'selection', multiple: false }, ...columns], + data, + pagination: false, + checkedRowKeys: [], + 'onUpdate:checkedRowKeys': onUpdateCheckedRowKeys, + }, + }) + + const radios = wrapper.findAllComponents({ name: 'var-radio' }) + + expect(wrapper.findAllComponents({ name: 'var-checkbox' })).toHaveLength(0) + expect(radios).toHaveLength(3) + + radios[0].vm.$emit('update:modelValue', true) + await wrapper.vm.$nextTick() + expect(onUpdateCheckedRowKeys).toHaveBeenLastCalledWith([1]) + + await wrapper.setProps({ checkedRowKeys: [1] }) + radios[1].vm.$emit('update:modelValue', true) + await wrapper.vm.$nextTick() + expect(onUpdateCheckedRowKeys).toHaveBeenLastCalledWith([2]) + + wrapper.unmount() + }) + + test('should support non-selectable selection column', async () => { + const onUpdateCheckedRowKeys = vi.fn() + const wrapper = mount(VarDataTable, { + props: { + columns: [{ type: 'selection', selectable: false }, ...columns], + data, + pagination: false, + checkedRowKeys: [], + 'onUpdate:checkedRowKeys': onUpdateCheckedRowKeys, + }, + }) + + const checkboxes = wrapper.findAllComponents({ name: 'var-checkbox' }) + + expect(checkboxes[0].vm.disabled).toBe(true) + expect(checkboxes[1].vm.disabled).toBe(true) + + checkboxes[1].vm.$emit('update:modelValue', true) + checkboxes[0].vm.$emit('update:modelValue', true) + await wrapper.vm.$nextTick() + + expect(onUpdateCheckedRowKeys).not.toHaveBeenCalled() + wrapper.unmount() + }) + + test('should support row non-selectable selection column', async () => { + const onUpdateCheckedRowKeys = vi.fn() + const wrapper = mount(VarDataTable, { + props: { + columns: [ + { + type: 'selection', + selectable: ({ row }) => row.id !== 2, + }, + ...columns, + ], + data, + pagination: false, + checkedRowKeys: [], + 'onUpdate:checkedRowKeys': onUpdateCheckedRowKeys, + }, + }) + + const checkboxes = wrapper.findAllComponents({ name: 'var-checkbox' }) + + expect(checkboxes[0].vm.disabled).toBe(false) + expect(checkboxes[1].vm.disabled).toBe(false) + expect(checkboxes[2].vm.disabled).toBe(true) + expect(checkboxes[3].vm.disabled).toBe(false) + + checkboxes[2].vm.$emit('update:modelValue', true) + await wrapper.vm.$nextTick() + expect(onUpdateCheckedRowKeys).not.toHaveBeenCalled() + + checkboxes[0].vm.$emit('update:modelValue', true) + await wrapper.vm.$nextTick() + expect(onUpdateCheckedRowKeys).toHaveBeenLastCalledWith([1, 3]) + + wrapper.unmount() + }) + + test('should support tree data with custom children key', async () => { + const wrapper = mount(VarDataTable, { + props: { + columns, + data: treeData, + pagination: false, + tree: true, + childrenKey: 'nodes', + }, + }) + + expect(wrapper.text()).toContain('Frontend') + expect(wrapper.text()).toContain('Design') + expect(wrapper.text()).not.toContain('Ada') + expect(wrapper.text()).not.toContain('Taylor') + expect(wrapper.text()).not.toContain('Linus') + + const triggers = wrapper.findAll('.var-data-table__tree-trigger') + expect(triggers).toHaveLength(2) + + await triggers[0].trigger('click') + + expect(wrapper.text()).toContain('Ada') + expect(wrapper.text()).toContain('Taylor') + expect(wrapper.text()).not.toContain('Linus') + + wrapper.unmount() + }) + + test('should support controlled expanded tree row keys', async () => { + const onUpdateExpandedTreeRowKeys = vi.fn() + const wrapper = mount(VarDataTable, { + props: { + columns, + data: treeData, + pagination: false, + tree: true, + childrenKey: 'nodes', + expandedTreeRowKeys: [1], + 'onUpdate:expandedTreeRowKeys': onUpdateExpandedTreeRowKeys, + }, + }) + + expect(wrapper.text()).toContain('Ada') + expect(wrapper.text()).toContain('Taylor') + expect(wrapper.text()).not.toContain('Linus') + + await wrapper.find('.var-data-table__tree-trigger').trigger('click') + expect(onUpdateExpandedTreeRowKeys).toHaveBeenLastCalledWith([]) + + wrapper.unmount() + }) + + test('should sync expanded tree row keys when tree is disabled or data changes', async () => { + const onUpdateExpandedTreeRowKeys = vi.fn() + const wrapper = mount(VarDataTable, { + props: { + columns, + data: treeData, + pagination: false, + tree: true, + childrenKey: 'nodes', + expandedTreeRowKeys: [1, 999], + 'onUpdate:expandedTreeRowKeys': onUpdateExpandedTreeRowKeys, + }, + }) + + expect(onUpdateExpandedTreeRowKeys).toHaveBeenLastCalledWith([1]) + + await wrapper.setProps({ tree: false }) + expect(onUpdateExpandedTreeRowKeys).toHaveBeenLastCalledWith([]) + + wrapper.unmount() + }) + + test('should cascade tree selection in multiple mode by default', async () => { + const onUpdateCheckedRowKeys = vi.fn() + const wrapper = mount(VarDataTable, { + props: { + columns: [{ type: 'selection' }, ...columns], + data: treeData, + pagination: false, + tree: true, + childrenKey: 'nodes', + expandedTreeRowKeys: [1, 2], + checkedRowKeys: [], + 'onUpdate:checkedRowKeys': onUpdateCheckedRowKeys, + }, + }) + + const checkboxes = wrapper.findAllComponents({ name: 'var-checkbox' }) + + checkboxes[1].vm.$emit('update:modelValue', true) + await wrapper.vm.$nextTick() + expect(onUpdateCheckedRowKeys).toHaveBeenLastCalledWith([1, 11, 12]) + + wrapper.unmount() + }) + + test('should sync ancestor selection when toggling tree children', async () => { + const onUpdateCheckedRowKeys = vi.fn() + const wrapper = mount(VarDataTable, { + props: { + columns: [{ type: 'selection' }, ...columns], + data: treeData, + pagination: false, + tree: true, + childrenKey: 'nodes', + expandedTreeRowKeys: [1, 2], + checkedRowKeys: [11], + 'onUpdate:checkedRowKeys': onUpdateCheckedRowKeys, + }, + }) + + const checkboxes = wrapper.findAllComponents({ name: 'var-checkbox' }) + + checkboxes[3].vm.$emit('update:modelValue', true) + await wrapper.vm.$nextTick() + expect(onUpdateCheckedRowKeys).toHaveBeenLastCalledWith([11, 12, 1]) + + await wrapper.setProps({ checkedRowKeys: [11, 12, 1] }) + checkboxes[1].vm.$emit('update:modelValue', false) + await wrapper.vm.$nextTick() + expect(onUpdateCheckedRowKeys).toHaveBeenLastCalledWith([]) + + wrapper.unmount() + }) + + test('should support non-cascading tree selection when cascade is false', async () => { + const onUpdateCheckedRowKeys = vi.fn() + const wrapper = mount(VarDataTable, { + props: { + columns: [{ type: 'selection' }, ...columns], + data: treeData, + pagination: false, + tree: true, + cascade: false, + childrenKey: 'nodes', + expandedTreeRowKeys: [1, 2], + checkedRowKeys: [], + 'onUpdate:checkedRowKeys': onUpdateCheckedRowKeys, + }, + }) + + const checkboxes = wrapper.findAllComponents({ name: 'var-checkbox' }) + + checkboxes[1].vm.$emit('update:modelValue', true) + await wrapper.vm.$nextTick() + expect(onUpdateCheckedRowKeys).toHaveBeenLastCalledWith([1]) + + wrapper.unmount() + }) + + test('should not cascade tree selection in single selection mode', () => { + const wrapper = mount(VarDataTable, { + props: { + columns: [{ type: 'selection', multiple: false }, ...columns], + data: treeData, + pagination: false, + tree: true, + childrenKey: 'nodes', + expandedTreeRowKeys: [1, 2], + checkedRowKeys: [11], + }, + }) + + const radios = wrapper.findAllComponents({ name: 'var-radio' }) + + expect(radios[0].vm.modelValue).toBe(false) + expect(radios[1].vm.modelValue).toBe(true) + expect(radios[2].vm.modelValue).toBe(false) + expect(radios[3].vm.modelValue).toBe(false) + expect(radios[4].vm.modelValue).toBe(false) + + wrapper.unmount() + }) + + test('should replace checked key instead of cascading in single tree selection mode', async () => { + const onUpdateCheckedRowKeys = vi.fn() + const wrapper = mount(VarDataTable, { + props: { + columns: [{ type: 'selection', multiple: false }, ...columns], + data: treeData, + pagination: false, + tree: true, + childrenKey: 'nodes', + expandedTreeRowKeys: [1, 2], + checkedRowKeys: [11], + 'onUpdate:checkedRowKeys': onUpdateCheckedRowKeys, + }, + }) + + const radios = wrapper.findAllComponents({ name: 'var-radio' }) + + radios[3].vm.$emit('update:modelValue', true) + await wrapper.vm.$nextTick() + + expect(onUpdateCheckedRowKeys).toHaveBeenLastCalledWith([2]) + + wrapper.unmount() + }) + + test('should count collapsed selected tree rows in header checkbox state', () => { + const wrapper = mount(VarDataTable, { + props: { + columns: [{ type: 'selection' }, ...columns], + data: treeData, + pagination: false, + tree: true, + childrenKey: 'nodes', + checkedRowKeys: [11], + }, + }) + + const checkboxes = wrapper.findAllComponents({ name: 'var-checkbox' }) + expect(checkboxes[0].vm.modelValue).toBe(false) + expect(checkboxes[0].vm.indeterminate).toBe(true) + + wrapper.unmount() + }) + + test('should ignore non-selectable rows when toggling all current rows', async () => { + const onUpdateCheckedRowKeys = vi.fn() + const wrapper = mount(VarDataTable, { + props: { + columns: [ + { + type: 'selection', + selectable: ({ row }) => row.id !== 2, + }, + ...columns, + ], + data, + pagination: false, + checkedRowKeys: [], + 'onUpdate:checkedRowKeys': onUpdateCheckedRowKeys, + }, + }) + + const checkboxes = wrapper.findAllComponents({ name: 'var-checkbox' }) + + checkboxes[0].vm.$emit('update:modelValue', true) + await wrapper.vm.$nextTick() + + expect(onUpdateCheckedRowKeys).toHaveBeenLastCalledWith([1, 3]) + + wrapper.unmount() + }) + + test('should keep selectable tree parent checked when all children are non-selectable', async () => { + const onUpdateCheckedRowKeys = vi.fn() + const wrapper = mount(VarDataTable, { + props: { + columns: [ + { + type: 'selection', + selectable: ({ row }) => row.id === 1, + }, + ...columns, + ], + data: treeData, + pagination: false, + tree: true, + childrenKey: 'nodes', + expandedTreeRowKeys: [1], + checkedRowKeys: [], + 'onUpdate:checkedRowKeys': onUpdateCheckedRowKeys, + }, + }) + + const checkboxes = wrapper.findAllComponents({ name: 'var-checkbox' }) + + expect(checkboxes[1].vm.disabled).toBe(false) + expect(checkboxes[2].vm.disabled).toBe(true) + expect(checkboxes[3].vm.disabled).toBe(true) + + checkboxes[1].vm.$emit('update:modelValue', true) + await wrapper.vm.$nextTick() + expect(onUpdateCheckedRowKeys).toHaveBeenLastCalledWith([1]) + + await wrapper.setProps({ checkedRowKeys: [1] }) + expect(checkboxes[1].vm.modelValue).toBe(true) + + wrapper.unmount() + }) + + test('should support maxHeight with sticky header', () => { + const wrapper = mount(VarDataTable, { + props: { + columns, + data, + pagination: false, + maxHeight: 240, + }, + }) + + expect(wrapper.find('.var-data-table__container').attributes('style')).toContain('max-height: 240px;') + expect(wrapper.find('.var-data-table__container').classes()).toContain('var--scrollbar') + expect(wrapper.find('.var-data-table__table').exists()).toBe(true) + expect(wrapper.find('.var-data-table__body-scroller').exists()).toBe(false) + wrapper.unmount() + }) + + test('should support scrollX with horizontal scrolling', () => { + const wrapper = mount(VarDataTable, { + props: { + columns, + data, + pagination: false, + scrollX: 640, + }, + }) + + expect(wrapper.find('.var-data-table__container').attributes('style')).toContain('overflow-x: auto;') + expect(wrapper.find('.var-data-table__table').attributes('style')).toContain('min-width: 100%;') + expect(wrapper.find('.var-data-table__table').attributes('style')).toContain('width: 640px;') + wrapper.unmount() + }) + + test('should support scrollX together with maxHeight', () => { + const wrapper = mount(VarDataTable, { + props: { + columns, + data, + pagination: false, + maxHeight: 240, + scrollX: 640, + }, + }) + + const mainStyle = wrapper.find('.var-data-table__container').attributes('style') + + expect(mainStyle).toContain('max-height: 240px;') + expect(mainStyle).toContain('overflow: auto;') + expect(mainStyle).toContain('overflow-x: auto;') + wrapper.unmount() + }) + + test('should keep sticky header above fixed body cells', () => { + const wrapper = mount(VarDataTable, { + props: { + columns: [ + { key: 'name', title: 'Name', fixed: 'left', width: 120 }, + { key: 'role', title: 'Role', width: 120 }, + { key: 'status', title: 'Status', fixed: 'right', width: 120 }, + ], + data, + pagination: false, + maxHeight: 240, + scrollX: 640, + }, + }) + + const headerCells = wrapper.findAll('thead th') + const bodyCells = wrapper.findAll('tbody td') + + expect(headerCells[0].attributes('style')).toContain('z-index: 3;') + expect(headerCells[1].attributes('style')).toContain('z-index: 2;') + expect(bodyCells[0].attributes('style')).toContain('z-index: 1;') + expect(bodyCells[2].attributes('style')).toContain('z-index: 1;') + + wrapper.unmount() + }) + + test('should support loading description slot', () => { + const wrapper = mount(VarDataTable, { + props: { + columns, + data, + loading: true, + }, + slots: { + 'loading-description': () => h('span', { class: 'loading-text' }, 'Loading rows'), + }, + }) + + expect(wrapper.find('.loading-text').text()).toBe('Loading rows') + + wrapper.unmount() + }) + + test('should support column maxWidth', () => { + const wrapper = mount(VarDataTable, { + props: { + columns: [ + { key: 'name', title: 'Name', maxWidth: 120 }, + { key: 'role', title: 'Role' }, + ], + data, + pagination: false, + }, + }) + + expect(wrapper.find('col').attributes('style')).toContain('max-width: 120px;') + wrapper.unmount() + }) + + test('should resolve control column width and column width limits', () => { + const wrapper = mount(VarDataTable, { + props: { + columns: [ + { type: 'selection' }, + { + type: 'expand', + renderExpand: ({ row }) => h('div', row.role), + }, + { key: 'name', title: 'Name', width: 200, minWidth: 120, maxWidth: 160 }, + { key: 'role', title: 'Role', minWidth: 180, maxWidth: 120 }, + ], + data, + pagination: false, + }, + }) + + const cols = wrapper.findAll('col') + + expect(cols[0].attributes('style')).toContain('width: 52px;') + expect(cols[1].attributes('style')).toContain('width: 52px;') + expect(cols[2].attributes('style')).toContain('width: 160px;') + expect(cols[2].attributes('style')).toContain('min-width: 120px;') + expect(cols[2].attributes('style')).toContain('max-width: 160px;') + expect(cols[3].attributes('style')).toContain('min-width: 120px;') + expect(cols[3].attributes('style')).toContain('max-width: 120px;') + + wrapper.unmount() + }) + + test('should support resizable columns and respect maxWidth', async () => { + const wrapper = mount(VarDataTable, { + props: { + columns: [ + { key: 'name', title: 'Name', width: 120, maxWidth: 160, resizable: true }, + { key: 'role', title: 'Role' }, + ], + data, + pagination: false, + }, + }) + + const resizeTrigger = wrapper.find('.var-data-table__resize-trigger') + const firstHeaderCell = wrapper.find('thead th').element + + firstHeaderCell.getBoundingClientRect = () => ({ + width: 120, + height: 46, + top: 0, + right: 120, + bottom: 46, + left: 0, + x: 0, + y: 0, + toJSON: () => ({}), + }) + + await resizeTrigger.trigger('mousedown', { clientX: 100 }) + document.dispatchEvent(new MouseEvent('mousemove', { clientX: 130 })) + await wrapper.vm.$nextTick() + + expect(wrapper.find('col').attributes('style')).toContain('width: 150px;') + + document.dispatchEvent(new MouseEvent('mousemove', { clientX: 200 })) + await wrapper.vm.$nextTick() + + expect(wrapper.find('col').attributes('style')).toContain('width: 160px;') + + document.dispatchEvent(new MouseEvent('mouseup')) + wrapper.unmount() + }) + + test('should not render resize trigger for the last leaf column', () => { + const wrapper = mount(VarDataTable, { + props: { + columns: [ + { key: 'name', title: 'Name', resizable: true }, + { key: 'role', title: 'Role', resizable: true }, + ], + data, + pagination: false, + }, + }) + + expect(wrapper.findAll('.var-data-table__resize-trigger')).toHaveLength(1) + + wrapper.unmount() + }) + + test('should resolve grouped header fixed side from child columns', () => { + const wrapper = mount(VarDataTable, { + props: { + columns: [ + { + title: 'Left Group', + children: [ + { key: 'name', title: 'Name', fixed: 'left' }, + { key: 'role', title: 'Role', fixed: 'left' }, + ], + }, + { key: 'status', title: 'Status' }, + ], + data, + pagination: false, + }, + }) + + expect(wrapper.findAll('thead tr')[0].find('th').classes()).toContain('var-data-table__fixed-cell') + + wrapper.unmount() + }) + + test('should not resolve grouped header fixed side when child columns differ', () => { + const wrapper = mount(VarDataTable, { + props: { + columns: [ + { + title: 'Mixed Group', + children: [ + { key: 'name', title: 'Name', fixed: 'left' }, + { key: 'role', title: 'Role' }, + ], + }, + { key: 'status', title: 'Status' }, + ], + data, + pagination: false, + }, + }) + + expect(wrapper.findAll('thead tr')[0].find('th').classes()).not.toContain('var-data-table__fixed-cell') + + wrapper.unmount() + }) + + test('should support tableLayout prop', async () => { + const wrapper = mount(VarDataTable, { + props: { + columns, + data, + pagination: false, + }, + }) + + expect(wrapper.find('.var-data-table__table').attributes('style')).toContain('table-layout: auto;') + + await wrapper.setProps({ tableLayout: 'fixed' }) + + expect(wrapper.find('.var-data-table__table').attributes('style')).toContain('table-layout: fixed;') + wrapper.unmount() + }) + + test('should support rowProps and cellProps', () => { + const wrapper = mount(VarDataTable, { + props: { + columns: [ + { + key: 'name', + title: 'Name', + cellProps: ({ row, rowIndex }) => ({ + class: 'custom-cell', + 'data-name': row.name, + 'data-row-index': rowIndex, + }), + }, + ], + data: [data[0]], + pagination: false, + rowProps: ({ row, rowIndex }) => ({ + class: 'custom-row', + 'data-id': row.id, + 'data-row-index': rowIndex, + }), + }, + }) + + expect(wrapper.find('tbody tr').classes()).toContain('custom-row') + expect(wrapper.find('tbody tr').attributes('data-id')).toBe('1') + expect(wrapper.find('tbody td').classes()).toContain('custom-cell') + expect(wrapper.find('tbody td').attributes('data-name')).toBe('Ada') + wrapper.unmount() + }) + + test('should support titleColSpan colSpan and rowSpan', () => { + const wrapper = mount(VarDataTable, { + props: { + columns: [ + { + key: 'name', + title: 'Identity', + titleColSpan: 2, + rowSpan: ({ rowIndex }) => (rowIndex === 0 ? 2 : 1), + }, + { + key: 'role', + title: 'Role', + colSpan: ({ rowIndex }) => (rowIndex === 0 ? 2 : 1), + }, + { + key: 'status', + title: 'Status', + }, + ], + data: [ + { id: 1, name: 'Ada', role: 'Admin', status: 'Online' }, + { id: 2, name: 'Linus', role: 'Maintainer', status: 'Offline' }, + ], + pagination: false, + }, + }) + + const headerCells = wrapper.findAll('thead th') + const firstRowCells = wrapper.findAll('tbody tr')[0].findAll('td') + const secondRowCells = wrapper.findAll('tbody tr')[1].findAll('td') + + expect(headerCells).toHaveLength(2) + expect(headerCells[0].attributes('colspan')).toBe('2') + expect(headerCells[0].text()).toContain('Identity') + expect(headerCells[1].text()).toContain('Status') + + expect(firstRowCells).toHaveLength(2) + expect(firstRowCells[0].attributes('rowspan')).toBe('2') + expect(firstRowCells[1].attributes('colspan')).toBe('2') + expect(firstRowCells[1].text()).toContain('Admin') + + expect(secondRowCells).toHaveLength(2) + expect(secondRowCells[0].text()).toContain('Maintainer') + expect(secondRowCells[1].text()).toContain('Offline') + wrapper.unmount() + }) + + test('should render sortable title col span without absolute trigger style', () => { + const wrapper = mount(VarDataTable, { + props: { + columns: [ + { key: 'name', title: 'Name', titleColSpan: 2, sorter: true }, + { key: 'role', title: 'Role' }, + ], + data, + pagination: false, + }, + }) + + expect(wrapper.find('thead th').attributes('colspan')).toBe('2') + expect(wrapper.find('.var-data-table__sort-trigger').attributes('style')).toBeUndefined() + + wrapper.unmount() + }) + + test('should render default empty text', () => { + const wrapper = mount(VarDataTable, { + props: { + columns, + data: [], + pagination: false, + }, + }) + + expect(wrapper.find('.var-data-table__empty').text()).toBeTruthy() + wrapper.unmount() + }) + + test('should render empty slot', () => { + const wrapper = mount(VarDataTable, { + props: { + columns, + data: [], + pagination: false, + }, + slots: { + empty: () => h('span', 'Nothing here'), + }, + }) + + expect(wrapper.find('.var-data-table__empty').text()).toBe('Nothing here') + wrapper.unmount() + }) + + test('should render loading state', () => { + const wrapper = mount(VarDataTable, { + props: { + columns, + data: [], + loading: true, + }, + }) + + expect(wrapper.find('.var-loading__body').exists()).toBe(true) + wrapper.unmount() + }) + + test('should not render table when no normalized columns exist', () => { + const wrapper = mount(VarDataTable, { + props: { + columns: [], + data, + pagination: false, + }, + }) + + expect(wrapper.find('.var-data-table__table').exists()).toBe(false) + expect(wrapper.find('.var-data-table__empty').exists()).toBe(false) + + wrapper.unmount() + }) + + test('should keep footer inside loading content', () => { + const wrapper = mount(VarDataTable, { + props: { + columns, + data, + loading: true, + }, + }) + + expect(wrapper.find('.var-loading__content .var-data-table__footer').exists()).toBe(true) + wrapper.unmount() + }) + + test('should emit pagination updates', async () => { + const onUpdatePage = vi.fn() + const onUpdatePageSize = vi.fn() + + const wrapper = mount(VarDataTable, { + props: { + columns, + data, + 'onUpdate:page': onUpdatePage, + 'onUpdate:pageSize': onUpdatePageSize, + }, + }) + + wrapper.findComponent({ name: 'var-pagination' }).vm.$emit('change', 2, 20) + await wrapper.vm.$nextTick() + + expect(onUpdatePage).toHaveBeenCalledWith(2) + expect(onUpdatePageSize).toHaveBeenCalledWith(20) + wrapper.unmount() + }) +}) diff --git a/packages/varlet-ui/src/data-table/dataTable.less b/packages/varlet-ui/src/data-table/dataTable.less new file mode 100644 index 00000000000..3a20adcfe3a --- /dev/null +++ b/packages/varlet-ui/src/data-table/dataTable.less @@ -0,0 +1,376 @@ +:root { + --data-table-background: #fff; + --data-table-surface-low-background: var(--color-surface-container-highest); + --data-table-header-cell-background: #fff; + --data-table-header-cell-text-color: rgba(0, 0, 0, 0.6); + --data-table-body-cell-text-color: #555; + --data-table-border-color: var(--color-outline); + --data-table-row-hover-background: #f5f5f5; + --data-table-surface-low-row-hover-background: var(--color-surface-container-highest); + --data-table-plain-row-hover-background: hsla(var(--hsl-on-surface), 0.04); + --data-table-sort-trigger-color: hsla(var(--hsl-on-surface), 0.42); + --data-table-sort-trigger-active-color: var(--color-primary); + --data-table-sort-trigger-hover-background: hsla(var(--hsl-primary), 0.08); + --data-table-empty-text-color: var(--color-text-disabled); + --data-table-resize-trigger-color: hsla(var(--hsl-on-surface-variant), 0.36); + --data-table-fixed-shadow-color: rgba(0, 0, 0, 0.04); + --data-table-border-radius: 2px; + --data-table-cell-padding: 0 16px; + --data-table-selection-cell-padding: 0 8px; + --data-table-expand-cell-padding: 0 8px; + --data-table-cell-font-size: 16px; + --data-table-header-font-size: 14px; + --data-table-row-height: 46px; + --data-table-row-small-height: 40px; + --data-table-row-large-height: 52px; + --data-table-footer-padding: 12px 16px; + --data-table-empty-padding: 48px 16px; +} + +.var-data-table { + --scrollbar-track-background: var(--data-table-background); + width: 100%; + border-radius: var(--data-table-border-radius); + background: var(--data-table-background); + + * { + box-sizing: border-box; + } + + &__container { + position: relative; + width: 100%; + max-width: 100%; + } + + &__table { + min-width: 100%; + border-spacing: 0; + border-collapse: collapse; + line-height: normal; + table-layout: fixed; + } + + &__header-row { + background: var(--data-table-header-cell-background); + } + + &__row { + background: var(--data-table-background); + border-bottom: 1px solid var(--data-table-border-color); + transition: background-color 0.25s; + + &:hover { + background: var(--data-table-row-hover-background); + } + } + + &__row:last-child { + border-bottom: 0; + } + + &--surface-low { + --data-table-background: var(--data-table-surface-low-background); + --data-table-header-cell-background: var(--data-table-surface-low-background); + --data-table-row-hover-background: var(--data-table-surface-low-row-hover-background); + } + + &--plain { + --data-table-background: transparent; + --data-table-header-cell-background: transparent; + --data-table-row-hover-background: var(--data-table-plain-row-hover-background); + border-radius: 0; + } + + &__cell { + height: var(--data-table-row-height); + padding: var(--data-table-cell-padding); + font-size: var(--data-table-cell-font-size); + vertical-align: middle; + + .var-data-table--cell-bordered & { + &:not(:last-child) { + border-right: 1px solid var(--data-table-border-color); + } + } + } + + &__header-cell { + color: var(--data-table-header-cell-text-color); + font-size: var(--data-table-header-font-size); + font-weight: 500; + background: var(--data-table-header-cell-background); + box-shadow: inset 0 -1px 0 var(--data-table-border-color); + position: sticky; + top: 0; + } + + &__sort-trigger { + display: flex; + align-items: center; + width: auto; + height: auto; + padding: var(--data-table-cell-padding); + border: 0; + border-radius: 0; + background: transparent; + color: var(--data-table-sort-trigger-color); + cursor: pointer; + text-align: inherit; + transition: + color 0.2s ease, + background-color 0.2s ease; + + &:hover, + &:focus-visible { + background: var(--data-table-sort-trigger-hover-background); + } + + &:focus-visible { + outline: none; + } + } + + &__sort-trigger--align-left { + justify-content: flex-start; + } + + &__sort-trigger--align-center { + justify-content: center; + } + + &__sort-trigger--align-right { + justify-content: flex-end; + } + + &__sort-trigger--active { + color: var(--data-table-sort-trigger-active-color); + } + + &__sort-trigger-text { + display: inline-flex; + align-items: center; + min-width: 0; + } + + &__sort-trigger-icon { + display: inline-flex; + flex-direction: column; + justify-content: center; + gap: 0; + margin-inline-start: 8px; + vertical-align: middle; + color: inherit; + opacity: 0.72; + } + + &__sort-trigger-icon-up[var-data-table-cover], + &__sort-trigger-icon-down[var-data-table-cover] { + display: block; + line-height: 1; + opacity: 0.45; + transition: opacity 0.2s ease; + } + + &__sort-trigger-icon-up[var-data-table-cover] { + margin-bottom: -5px; + } + + &__sort-trigger-icon-down[var-data-table-cover] { + margin-top: -5px; + } + + &__sort-trigger-icon--active[var-data-table-cover] { + opacity: 1; + } + + &__resize-trigger { + position: absolute; + top: 0; + right: -5px; + z-index: 1; + width: 10px; + height: 100%; + padding: 0; + border: 0; + background: transparent; + cursor: col-resize; + touch-action: none; + + &::before { + content: ''; + position: absolute; + top: 10px; + left: 50%; + bottom: 10px; + width: 2px; + transform: translateX(-50%); + background: var(--data-table-resize-trigger-color); + } + } + + &__body-cell { + color: var(--data-table-body-cell-text-color); + background: inherit; + background-clip: padding-box; + } + + &__summary-row { + background: var(--data-table-background); + border-top: 1px solid var(--data-table-border-color); + } + + &__summary-cell { + color: var(--data-table-body-cell-text-color); + background: inherit; + background-clip: padding-box; + font-weight: 500; + } + + &__fixed-cell { + background: inherit; + background-clip: padding-box; + } + + &__fixed-cell--shadow-right { + &::after { + content: ''; + position: absolute; + top: 0; + right: -14px; + bottom: 0; + width: 14px; + pointer-events: none; + background: linear-gradient(to right, var(--data-table-fixed-shadow-color), transparent); + } + } + + &__fixed-cell--shadow-left { + &::before { + content: ''; + position: absolute; + top: 0; + left: -14px; + bottom: 0; + width: 14px; + pointer-events: none; + background: linear-gradient(to left, var(--data-table-fixed-shadow-color), transparent); + } + } + + &__selection-cell, + &__expand-cell { + text-align: center; + min-width: 52px; + } + + &__selection-cell { + padding: var(--data-table-selection-cell-padding); + } + + &__expand-cell { + padding: var(--data-table-expand-cell-padding); + } + + &__selection-cell { + .var-checkbox[var-data-table-cover], + .var-radio[var-data-table-cover] { + transform: none; + } + + .var-checkbox[var-data-table-cover] .var-checkbox__wrap, + .var-radio[var-data-table-cover] .var-radio__wrap { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + } + } + + &__expand-trigger { + display: inline-flex; + align-items: center; + justify-content: center; + width: 100%; + padding: 0; + border: 0; + background: transparent; + color: inherit; + cursor: pointer; + + &:disabled { + opacity: 0.4; + cursor: not-allowed; + } + } + + &__tree-cell { + display: flex; + align-items: center; + min-width: 0; + } + + &__tree-trigger, + &__tree-indent { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + flex: none; + margin-right: 4px; + } + + &__tree-trigger { + padding: 0; + border: 0; + background: transparent; + color: inherit; + cursor: pointer; + } + + &__expanded-row { + background: var(--data-table-background); + border-bottom: 1px solid var(--data-table-border-color); + } + + &__expanded-cell { + padding-top: 12px; + padding-bottom: 12px; + } + + &__empty { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + color: var(--data-table-empty-text-color); + padding: var(--data-table-empty-padding); + } + + &__footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--data-table-footer-padding); + border-top: 1px solid var(--data-table-border-color); + background: var(--data-table-background); + overflow: auto; + + .var-pagination[var-data-table-cover] { + margin-left: auto; + } + } + + &--small { + .var-data-table__cell { + height: var(--data-table-row-small-height); + } + } + + &--large { + .var-data-table__cell { + height: var(--data-table-row-large-height); + } + } +} diff --git a/packages/varlet-ui/src/data-table/docs/en-US.md b/packages/varlet-ui/src/data-table/docs/en-US.md new file mode 100644 index 00000000000..3ab77a7dd3d --- /dev/null +++ b/packages/varlet-ui/src/data-table/docs/en-US.md @@ -0,0 +1,1008 @@ +# DataTable + +### Intro + +Data-driven table. + +### Basic Usage + +```html + + + +``` + + +### Custom Column Render + +```html + + + +``` + +### Custom Header + +```html + + + +``` + + +### Frontend Pagination + +```html + + + +``` + +### Remote Pagination + +```html + + + +``` + +### Single Column Sorting + +```html + + + +``` + +### Multiple Column Sorting + +```html + + + +``` + +### Column Options + +```html + + + +``` + +### Grouped Header + +```html + + + +``` + +### Custom Row Props + +```html + + + +``` + +### Row Class + +```html + + + + + +``` + +### Summary + +```html + + + +``` + +### Cell Spans + +```html + + + +``` + +### Selection + +```html + + + +``` + +### Single Selection + +```html + + + +``` + +### Tree Data + +```html + + + +``` + +### Tree Non-Cascade + +```html + + + +``` + +### Tree Single Selection + +```html + + + +``` + +### Expand + +```html + + + +``` + +### Fixed Header / Columns + +```html + + + +``` + +### Resizable Columns + +```html + + + +``` + +### Custom Empty Content + +```html + + + + + +``` + +### Loading + +```html + + + +``` + +### Sizes + +```html + + + +``` + +### Subtle Background + +```html + + + +``` + +### Plain Table + +```html + + + +``` + +## API + +### Props + +| Prop | Description | Type | Default | +| --- | --- | --- | --- | +| `data` | Data source. Full data in local mode, current page data in remote mode | _any[]_ | `[]` | +| `columns` | Column definitions | _DataTableColumn[]_ | `[]` | +| `row-key` | Row key field or getter | _string \| number \| ((context: { row, rowIndex }) => string \| number)_ | `'id'` | +| `row-props` | Custom row props, supports object or function | _object \| (context) => object_ | `-` | +| `row-class` | Custom row class, supports string, array, object, or function | _string \| array \| object \| (context) => string \| array \| object_ | `-` | +| `summary` | Summary row render function. Return an array to render multiple summary rows | _(context) => Record \| Array>_ | `-` | +| `loading` | Whether to show loading overlay | _boolean_ | `false` | +| `pagination` | Built-in pagination config | _boolean \| DataTablePagination_ | `true` | +| `remote` | Whether to enable remote pagination mode | _boolean_ | `false` | +| `v-model:page` | Current page | _number_ | `1` | +| `v-model:page-size` | Current page size | _number_ | `10` | +| `v-model:checked-row-keys` | Selected row keys | _Array_ | `[]` | +| `v-model:expanded-row-keys` | Expanded detail row keys for `type: 'expand'` columns | _Array_ | `[]` | +| `v-model:expanded-tree-row-keys` | Expanded tree row keys for `tree` mode | _Array_ | `[]` | +| `total` | Total item count in remote mode | _number_ | `-` | +| `max-height` | Max height of the table body. When set, the header stays fixed and the body scrolls internally | _number \| string_ | `-` | +| `scroll-x` | Table width used to enable horizontal scrolling. Usually paired with fixed columns | _number \| string_ | `-` | +| `v-model:sorters` | Controlled sorter states | _DataTableSorter[]_ | `[]` | +| `sort-mode` | Sorter mode | _'single' \| 'multiple'_ | `'single'` | +| `plain` | Whether to render as a plain table without card shadow, background, or radius | _boolean_ | `false` | +| `table-layout` | Native `table-layout` value | _'auto' \| 'fixed'_ | `'auto'` | +| `tree` | Whether to explicitly enable tree data mode | _boolean_ | `false` | +| `cascade` | Whether tree selection should cascade | _boolean_ | `true` | +| `children-key` | Child node field name for tree rows | _string_ | `'children'` | +| `elevation` | Elevation level | _boolean \| number \| string_ | `true` | +| `surface` | Subtle background style | _'low'_ | `-` | +| `cell-bordered` | Whether to show cell dividers | _boolean_ | `false` | +| `size` | Table size | _'small' \| 'normal' \| 'large'_ | `'normal'` | + +#### DataTableColumn + +| Prop | Description | Type | Default | +| --- | --- | --- | --- | +| `type` | Column type. Supports `selection` and `expand` | _'selection' \| 'expand'_ | `-` | +| `key` | Unique column key | _string_ | `-` | +| `title` | Column title. Supports render function | _VNodeChild \| () => VNodeChild_ | `-` | +| `children` | Child columns used to render a grouped header | _DataTableColumn[]_ | `-` | +| `sorter` | Whether the field column shows sorter interaction | _boolean_ | `false` | +| `multiple` | Whether the selection column allows multiple rows | _boolean_ | `true` | +| `selectable` | Whether selection is enabled. Supports `boolean` or `(context) => boolean` | _boolean \| (context) => boolean_ | `true` | +| `expandable` | Whether the row can be expanded. Only works on expand columns | _(context) => boolean_ | `-` | +| `renderExpand` | Custom expanded content. Only works on expand columns | _(context) => VNodeChild_ | `-` | +| `resizable` | Whether the column width can be resized by dragging | _boolean_ | `false` | +| `width` | Column width | _number \| string_ | `-` | +| `minWidth` | Column min width | _number \| string_ | `-` | +| `maxWidth` | Column max width. Also used as the upper resize limit when `resizable` is enabled | _number \| string_ | `-` | +| `align` | Body cell align | _'left' \| 'center' \| 'right'_ | `'left'` | +| `titleAlign` | Header title align | _'left' \| 'center' \| 'right'_ | `align` | +| `titleColSpan` | Header col span. Set to `0` to hide the current header cell | _number_ | `1` | +| `colSpan` | Body cell col span. Supports a number or function. Return `0` to hide the current cell | _number \| (context) => number_ | `1` | +| `rowSpan` | Body cell row span. Supports a number or function. Return `0` to hide the current cell | _number \| (context) => number_ | `1` | +| `cellProps` | Custom cell props, supports object or function | _object \| (context) => object_ | `-` | +| `render` | Custom cell render | _(context) => VNodeChild_ | `-` | + +#### DataTablePagination + +| Prop | Description | Type | Default | +| --- | --- | --- | --- | +| `simple` | Whether to use simple pagination | _boolean_ | `false` | +| `disabled` | Whether to disable pagination | _boolean_ | `false` | +| `showSizeChanger` | Whether to show page size changer | _boolean_ | `false` | +| `showQuickJumper` | Whether to show quick jumper | _boolean_ | `false` | +| `maxPagerCount` | Max pager count | _number_ | `5` | +| `sizeOption` | Page size options | _number[]_ | `[10, 20, 50, 100]` | +| `showTotal` | Total text renderer | _(total: number, range: [number, number]) => string_ | `-` | + +#### DataTableSummaryCell + +| Prop | Description | Type | Default | +| --- | --- | --- | --- | +| `value` | Cell content | _VNodeChild_ | `-` | +| `colSpan` | Cell col span | _number_ | `1` | +| `rowSpan` | Cell row span | _number_ | `1` | + +#### DataTableSorter + +| Prop | Description | Type | Default | +| --- | --- | --- | --- | +| `key` | Target column key | _string_ | `-` | +| `order` | Sort order | _'asc' \| 'desc'_ | `-` | + +### Slots + +| Name | Description | Parameters | +| --- | --- | --- | +| `empty` | Custom empty content | `-` | +| `loading-description` | Custom loading description | `-` | +| `footer-prefix` | Content before pagination | `-` | + +### Style Variables + +| Variable | Default | +| --- | --- | +| `--data-table-background` | `#fff` | +| `--data-table-header-cell-background` | `#fff` | +| `--data-table-surface-low-background` | `var(--color-surface-container-highest)` | +| `--data-table-header-cell-text-color` | `rgba(0, 0, 0, 0.6)` | +| `--data-table-body-cell-text-color` | `#555` | +| `--data-table-border-color` | `var(--color-outline)` | +| `--data-table-row-hover-background` | `#f5f5f5` | +| `--data-table-surface-low-row-hover-background` | `var(--color-surface-container-highest)` | +| `--data-table-plain-row-hover-background` | `hsla(var(--hsl-on-surface), 0.04)` | +| `--data-table-sort-trigger-color` | `hsla(var(--hsl-on-surface), 0.42)` | +| `--data-table-sort-trigger-active-color` | `var(--color-primary)` | +| `--data-table-sort-trigger-hover-background` | `hsla(var(--hsl-primary), 0.08)` | +| `--data-table-empty-text-color` | `var(--color-text-disabled)` | +| `--data-table-resize-trigger-color` | `hsla(var(--hsl-on-surface-variant), 0.36)` | +| `--data-table-fixed-shadow-color` | `rgba(0, 0, 0, 0.04)` | +| `--data-table-border-radius` | `2px` | +| `--data-table-cell-padding` | `0 16px` | +| `--data-table-selection-cell-padding` | `0 8px` | +| `--data-table-expand-cell-padding` | `0 8px` | +| `--data-table-cell-font-size` | `16px` | +| `--data-table-header-font-size` | `14px` | +| `--data-table-row-height` | `46px` | +| `--data-table-row-small-height` | `40px` | +| `--data-table-row-large-height` | `52px` | +| `--data-table-footer-padding` | `12px 16px` | +| `--data-table-empty-padding` | `48px 16px` | diff --git a/packages/varlet-ui/src/data-table/docs/zh-CN.md b/packages/varlet-ui/src/data-table/docs/zh-CN.md new file mode 100644 index 00000000000..895bc1f23cd --- /dev/null +++ b/packages/varlet-ui/src/data-table/docs/zh-CN.md @@ -0,0 +1,1008 @@ +# DataTable + +### 介绍 + +数据驱动的表格。 + +### 基本使用 + +```html + + + +``` + + +### 自定义列渲染 + +```html + + + +``` + +### 自定义表头 + +```html + + + +``` + + +### 前端分页 + +```html + + + +``` + +### 远程分页 + +```html + + + +``` + +### 单列排序 + +```html + + + +``` + +### 多列排序 + +```html + + + +``` + +### 列配置 + +```html + + + +``` + +### 分组表头 + +```html + + + +``` + +### 自定义行属性 + +```html + + + +``` + +### 行类名 + +```html + + + + + +``` + +### 总结栏 + +```html + + + +``` + +### 单元格合并 + +```html + + + +``` + +### 选择列 + +```html + + + +``` + +### 单选 + +```html + + + +``` + +### 树形数据 + +```html + + + +``` + +### 树形非级联 + +```html + + + +``` + +### 树形单选 + +```html + + + +``` + +### 展开列 + +```html + + + +``` + +### 固定表头/列 + +```html + + + +``` + +### 可调整列宽 + +```html + + + +``` + +### 自定义空内容 + +```html + + + + + +``` + +### 加载状态 + +```html + + + +``` + +### 尺寸 + +```html + + + +``` + +### 弱背景色 + +```html + + + +``` + +### 纯表格 + +```html + + + +``` + +## API + +### Props + +| 参数 | 说明 | 类型 | 默认值 | +| --- | --- | --- | --- | +| `data` | 数据源。前端分页下传全量数据,远程分页下传当前页数据 | _any[]_ | `[]` | +| `columns` | 列配置 | _DataTableColumn[]_ | `[]` | +| `row-key` | 行 key 字段或获取函数 | _string \| number \| ((context: { row, rowIndex }) => string \| number)_ | `'id'` | +| `row-props` | 自定义行属性,支持对象或函数 | _object \| (context) => object_ | `-` | +| `row-class` | 自定义行类名,支持字符串、数组、对象或函数 | _string \| array \| object \| (context) => string \| array \| object_ | `-` | +| `summary` | 总结栏渲染函数。返回数组时渲染多行总结栏 | _(context) => Record \| Array>_ | `-` | +| `loading` | 是否显示加载遮罩 | _boolean_ | `false` | +| `pagination` | 内置分页配置 | _boolean \| DataTablePagination_ | `true` | +| `remote` | 是否启用远程分页模式 | _boolean_ | `false` | +| `v-model:page` | 当前页码 | _number_ | `1` | +| `v-model:page-size` | 当前每页条数 | _number_ | `10` | +| `v-model:checked-row-keys` | 选中行的 key 集合 | _Array_ | `[]` | +| `v-model:expanded-row-keys` | `type: 'expand'` 展开详情行的 key 集合 | _Array_ | `[]` | +| `v-model:expanded-tree-row-keys` | `tree` 模式下展开树节点的 key 集合 | _Array_ | `[]` | +| `total` | 远程分页总条数 | _number_ | `-` | +| `max-height` | 表格主体最大高度。设置后表头固定,内容区域内部滚动 | _number \| string_ | `-` | +| `scroll-x` | 用于开启横向滚动的表格宽度,通常和固定列一起使用 | _number \| string_ | `-` | +| `v-model:sorters` | 受控排序状态集合 | _DataTableSorter[]_ | `[]` | +| `sort-mode` | 排序器模式 | _'single' \| 'multiple'_ | `'single'` | +| `plain` | 是否以纯表格形态渲染,不带卡片阴影、背景色和圆角 | _boolean_ | `false` | +| `table-layout` | 原生 `table-layout` 布局方式 | _'auto' \| 'fixed'_ | `'auto'` | +| `tree` | 是否显式开启树形数据 | _boolean_ | `false` | +| `cascade` | 树形选择是否开启级联 | _boolean_ | `true` | +| `children-key` | 树形子节点字段名 | _string_ | `'children'` | +| `elevation` | 海拔层级 | _boolean \| number \| string_ | `true` | +| `surface` | 弱背景色风格 | _'low'_ | `-` | +| `cell-bordered` | 是否显示单元格分割线 | _boolean_ | `false` | +| `size` | 表格尺寸 | _'small' \| 'normal' \| 'large'_ | `'normal'` | + +#### DataTableColumn + +| 参数 | 说明 | 类型 | 默认值 | +| --- | --- | --- | --- | +| `type` | 列类型。支持 `selection` 和 `expand` | _'selection' \| 'expand'_ | `-` | +| `key` | 列唯一 key | _string_ | `-` | +| `title` | 列标题,支持渲染函数 | _VNodeChild \| () => VNodeChild_ | `-` | +| `children` | 子列配置,用于渲染分组表头 | _DataTableColumn[]_ | `-` | +| `sorter` | 字段列是否显示排序交互 | _boolean_ | `false` | +| `multiple` | 选择列是否允许多选,仅对选择列生效 | _boolean_ | `true` | +| `selectable` | 是否允许选择。支持 `boolean` 或 `(context) => boolean`,仅对选择列生效 | _boolean \| (context) => boolean_ | `true` | +| `expandable` | 是否允许展开该行,仅对展开列生效 | _(context) => boolean_ | `-` | +| `renderExpand` | 自定义展开内容,仅对展开列生效 | _(context) => VNodeChild_ | `-` | +| `resizable` | 是否允许通过拖拽调整列宽 | _boolean_ | `false` | +| `width` | 列宽 | _number \| string_ | `-` | +| `minWidth` | 列最小宽度 | _number \| string_ | `-` | +| `maxWidth` | 列最大宽度。开启 `resizable` 时也会作为拖拽的上限 | _number \| string_ | `-` | +| `align` | 内容对齐方式 | _'left' \| 'center' \| 'right'_ | `'left'` | +| `titleAlign` | 表头标题对齐方式 | _'left' \| 'center' \| 'right'_ | `align` | +| `titleColSpan` | 表头列合并数量,设为 `0` 时当前表头不渲染 | _number_ | `1` | +| `colSpan` | 表体单元格列合并数量,支持数字或函数,返回 `0` 时当前单元格不渲染 | _number \| (context) => number_ | `1` | +| `rowSpan` | 表体单元格行合并数量,支持数字或函数,返回 `0` 时当前单元格不渲染 | _number \| (context) => number_ | `1` | +| `cellProps` | 自定义单元格属性,支持对象或函数 | _object \| (context) => object_ | `-` | +| `render` | 自定义单元格渲染 | _(context) => VNodeChild_ | `-` | + +#### DataTablePagination + +| 参数 | 说明 | 类型 | 默认值 | +| --- | --- | --- | --- | +| `simple` | 是否使用简洁分页 | _boolean_ | `false` | +| `disabled` | 是否禁用分页 | _boolean_ | `false` | +| `showSizeChanger` | 是否显示每页条数切换器 | _boolean_ | `false` | +| `showQuickJumper` | 是否显示快速跳转 | _boolean_ | `false` | +| `maxPagerCount` | 最多显示的页码数量 | _number_ | `5` | +| `sizeOption` | 每页条数选项 | _number[]_ | `[10, 20, 50, 100]` | +| `showTotal` | 总数文案渲染函数 | _(total: number, range: [number, number]) => string_ | `-` | + +#### DataTableSummaryCell + +| 参数 | 说明 | 类型 | 默认值 | +| --- | --- | --- | --- | +| `value` | 单元格内容 | _VNodeChild_ | `-` | +| `colSpan` | 单元格列合并数量 | _number_ | `1` | +| `rowSpan` | 单元格行合并数量 | _number_ | `1` | + +#### DataTableSorter + +| 参数 | 说明 | 类型 | 默认值 | +| --- | --- | --- | --- | +| `key` | 对应列 key | _string_ | `-` | +| `order` | 排序方向 | _'asc' \| 'desc'_ | `-` | + +### Slots + +| 名称 | 说明 | 参数 | +| --- | --- | --- | +| `empty` | 自定义空态内容 | `-` | +| `loading-description` | 自定义加载描述 | `-` | +| `footer-prefix` | 分页前置内容 | `-` | + +### 样式变量 + +| 变量名 | 默认值 | +| --- | --- | +| `--data-table-background` | `#fff` | +| `--data-table-header-cell-background` | `#fff` | +| `--data-table-surface-low-background` | `var(--color-surface-container-highest)` | +| `--data-table-header-cell-text-color` | `rgba(0, 0, 0, 0.6)` | +| `--data-table-body-cell-text-color` | `#555` | +| `--data-table-border-color` | `var(--color-outline)` | +| `--data-table-row-hover-background` | `#f5f5f5` | +| `--data-table-surface-low-row-hover-background` | `var(--color-surface-container-highest)` | +| `--data-table-plain-row-hover-background` | `hsla(var(--hsl-on-surface), 0.04)` | +| `--data-table-sort-trigger-color` | `hsla(var(--hsl-on-surface), 0.42)` | +| `--data-table-sort-trigger-active-color` | `var(--color-primary)` | +| `--data-table-sort-trigger-hover-background` | `hsla(var(--hsl-primary), 0.08)` | +| `--data-table-empty-text-color` | `var(--color-text-disabled)` | +| `--data-table-resize-trigger-color` | `hsla(var(--hsl-on-surface-variant), 0.36)` | +| `--data-table-fixed-shadow-color` | `rgba(0, 0, 0, 0.04)` | +| `--data-table-border-radius` | `2px` | +| `--data-table-cell-padding` | `0 16px` | +| `--data-table-selection-cell-padding` | `0 8px` | +| `--data-table-expand-cell-padding` | `0 8px` | +| `--data-table-cell-font-size` | `16px` | +| `--data-table-header-font-size` | `14px` | +| `--data-table-row-height` | `46px` | +| `--data-table-row-small-height` | `40px` | +| `--data-table-row-large-height` | `52px` | +| `--data-table-footer-padding` | `12px 16px` | +| `--data-table-empty-padding` | `48px 16px` | diff --git a/packages/varlet-ui/src/data-table/example/index.vue b/packages/varlet-ui/src/data-table/example/index.vue new file mode 100644 index 00000000000..b2fa88c14e6 --- /dev/null +++ b/packages/varlet-ui/src/data-table/example/index.vue @@ -0,0 +1,437 @@ + + + + + + + diff --git a/packages/varlet-ui/src/data-table/example/locale/en-US.ts b/packages/varlet-ui/src/data-table/example/locale/en-US.ts new file mode 100644 index 00000000000..4fe4fe91308 --- /dev/null +++ b/packages/varlet-ui/src/data-table/example/locale/en-US.ts @@ -0,0 +1,63 @@ +export default { + basicUsage: 'Basic Usage', + sizes: 'Sizes', + columnOptions: 'Column Options', + groupedHeader: 'Grouped Header', + customProps: 'Custom Row Props', + rowClass: 'Row Class', + summary: 'Summary', + customRender: 'Custom Column Render', + customHeader: 'Custom Header', + surfaceLow: 'Subtle Background', + plainTable: 'Plain Table', + sorter: 'Single Column Sorting', + multipleSorter: 'Multiple Column Sorting', + spans: 'Cell Spans', + selection: 'Selection', + singleSelection: 'Single Selection', + tree: 'Tree Data', + treeNonCascade: 'Tree Non-Cascade', + treeSingleSelection: 'Tree Single Selection', + expand: 'Expand', + selectedRows: 'Selected Rows', + localPagination: 'Frontend Pagination', + remotePagination: 'Remote Pagination', + fixedHeaderAndColumns: 'Fixed Header / Columns', + resizableColumns: 'Resizable Columns', + emptyText: 'Custom Empty Content', + emptyTip: 'No matching data', + loading: 'Loading', + name: 'Name', + role: 'Role', + status: 'Status', + score: 'Score', + tasks: 'Tasks', + priority: 'Priority', + total: 'Total', + identity: 'Identity', + profile: 'Profile', + state: 'State', + department: 'Dept', + city: 'City', + admin: 'Admin', + maintainer: 'Maintainer', + designer: 'Designer', + reviewer: 'Reviewer', + engineer: 'Engineer', + operator: 'Operator', + lead: 'Lead', + platform: 'Platform', + team: 'Team', + frontend: 'Frontend', + design: 'Design', + experience: 'Exp', + infrastructure: 'Infra', + online: 'Online', + offline: 'Offline', + busy: 'Busy', + user: 'User', + details: 'Details', + currentRole: 'Current Role', + currentStatus: 'Current Status', + lastUpdated: 'Last Updated', +} diff --git a/packages/varlet-ui/src/data-table/example/locale/index.ts b/packages/varlet-ui/src/data-table/example/locale/index.ts new file mode 100644 index 00000000000..89fca66084c --- /dev/null +++ b/packages/varlet-ui/src/data-table/example/locale/index.ts @@ -0,0 +1,11 @@ +import { useLocale } from '../../../locale' +import enUS from './en-US' +import zhCN from './zh-CN' + +const { add, use, t } = useLocale() + +add('zh-CN', zhCN) +add('en-US', enUS) +use('zh-CN') + +export { use, t } diff --git a/packages/varlet-ui/src/data-table/example/locale/zh-CN.ts b/packages/varlet-ui/src/data-table/example/locale/zh-CN.ts new file mode 100644 index 00000000000..32da0da203a --- /dev/null +++ b/packages/varlet-ui/src/data-table/example/locale/zh-CN.ts @@ -0,0 +1,63 @@ +export default { + basicUsage: '基本使用', + sizes: '尺寸', + columnOptions: '列配置', + groupedHeader: '分组表头', + customProps: '自定义行属性', + rowClass: '行类名', + summary: '总结栏', + customRender: '自定义列渲染', + customHeader: '自定义表头', + surfaceLow: '弱背景色', + plainTable: '纯表格', + sorter: '单列排序', + multipleSorter: '多列排序', + spans: '单元格合并', + selection: '选择列', + singleSelection: '单选', + tree: '树形数据', + treeNonCascade: '树形非级联', + treeSingleSelection: '树形单选', + expand: '展开列', + selectedRows: '已选行', + localPagination: '前端分页', + remotePagination: '远程分页', + fixedHeaderAndColumns: '固定表头/列', + resizableColumns: '可调整列宽', + emptyText: '自定义空内容', + emptyTip: '暂无匹配数据', + loading: '加载状态', + name: '姓名', + role: '角色', + status: '状态', + score: '评分', + tasks: '任务数', + priority: '优先级', + total: '合计', + identity: '身份', + profile: '资料', + state: '状态分组', + department: '部门', + city: '城市', + admin: '管理员', + maintainer: '维护者', + designer: '设计师', + reviewer: '评审', + engineer: '工程师', + operator: '操作员', + lead: '负责人', + platform: '平台', + team: '团队', + frontend: '前端', + design: '设计', + experience: '体验', + infrastructure: '基础设施', + online: '在线', + offline: '离线', + busy: '忙碌', + user: '用户', + details: '详情', + currentRole: '当前角色', + currentStatus: '当前状态', + lastUpdated: '最近更新时间', +} diff --git a/packages/varlet-ui/src/data-table/index.ts b/packages/varlet-ui/src/data-table/index.ts new file mode 100644 index 00000000000..d507fa2edf3 --- /dev/null +++ b/packages/varlet-ui/src/data-table/index.ts @@ -0,0 +1,12 @@ +import { withInstall, withPropsDefaultsSetter } from '../utils/components' +import DataTable from './DataTable.vue' +import { props as dataTableProps } from './props' + +withInstall(DataTable) +withPropsDefaultsSetter(DataTable, dataTableProps) + +export { dataTableProps } + +export const _DataTableComponent = DataTable + +export default DataTable diff --git a/packages/varlet-ui/src/data-table/props.ts b/packages/varlet-ui/src/data-table/props.ts new file mode 100644 index 00000000000..855cf1b043c --- /dev/null +++ b/packages/varlet-ui/src/data-table/props.ts @@ -0,0 +1,213 @@ +import { type HTMLAttributes, type PropType, type VNodeChild } from 'vue' +import { defineListenerProp } from '../utils/components' + +export type DataTableSurface = 'low' + +export type DataTableTableLayout = 'auto' | 'fixed' + +export type DataTableSortMode = 'single' | 'multiple' + +export type DataTableSorterOrder = 'asc' | 'desc' + +export type DataTableColumnAlign = 'left' | 'center' | 'right' + +export type DataTableColumnFixed = 'left' | 'right' + +export type DataTableRowData = Record + +export type DataTableRowKey = string | number | ((context: DataTableRowBaseContext) => string | number) + +export interface DataTableRowBaseContext { + row: Row + rowIndex: number +} + +export interface DataTableColumnRenderContext extends DataTableRowBaseContext {} + +export interface DataTableRowPropsContext extends DataTableRowBaseContext {} + +export interface DataTableColumnCellPropsContext extends DataTableRowBaseContext {} + +export interface DataTableSummaryContext { + data: Row[] +} + +export type DataTableColumnCellSpan = number | ((context: DataTableRowBaseContext) => number) + +export type DataTableColumnSelectable = boolean | ((context: DataTableRowBaseContext) => boolean) + +export type DataTableColumnTitle = VNodeChild | (() => VNodeChild) + +export type DataTableRowProps = + | HTMLAttributes + | ((context: DataTableRowPropsContext) => HTMLAttributes | undefined) + +export type DataTableRowClass = + | HTMLAttributes['class'] + | ((context: DataTableRowPropsContext) => HTMLAttributes['class']) + +export type DataTableColumnCellProps = + | HTMLAttributes + | ((context: DataTableColumnCellPropsContext) => HTMLAttributes | undefined) + +export interface DataTableSummaryCell { + value?: VNodeChild + colSpan?: number + rowSpan?: number +} + +export type DataTableSummary = ( + context: DataTableSummaryContext, +) => Record | Array> + +export interface DataTableBaseColumn { + fixed?: DataTableColumnFixed + resizable?: boolean + width?: string | number + minWidth?: string | number + maxWidth?: string | number + align?: DataTableColumnAlign + titleAlign?: DataTableColumnAlign + titleColSpan?: number + colSpan?: DataTableColumnCellSpan + rowSpan?: DataTableColumnCellSpan + cellProps?: DataTableColumnCellProps +} + +export interface DataTableSorter { + key: string + order: DataTableSorterOrder +} + +export interface DataTableFieldColumn extends DataTableBaseColumn { + type?: undefined + key: string + title: DataTableColumnTitle + children?: DataTableColumn[] + sorter?: boolean + render?: (context: DataTableColumnRenderContext) => VNodeChild +} + +export interface DataTableSelectionColumn extends DataTableBaseColumn { + type: 'selection' + key?: string + title?: DataTableColumnTitle + multiple?: boolean + selectable?: DataTableColumnSelectable + render?: never +} + +export interface DataTableExpandColumn extends DataTableBaseColumn { + type: 'expand' + key?: string + title?: DataTableColumnTitle + render?: never + expandable?: (context: DataTableRowPropsContext) => boolean + renderExpand: (context: DataTableRowBaseContext) => VNodeChild +} + +export type DataTableColumn = + | DataTableFieldColumn + | DataTableSelectionColumn + | DataTableExpandColumn + +export interface DataTablePagination { + simple?: boolean + disabled?: boolean + showSizeChanger?: boolean + showQuickJumper?: boolean + maxPagerCount?: number + sizeOption?: number[] + showTotal?: (total: number, range: [number, number]) => string +} + +export const props = { + data: { + type: Array as PropType, + default: () => [], + }, + columns: { + type: Array as PropType, + default: () => [], + }, + rowKey: { + type: [String, Number, Function] as PropType, + default: 'id', + }, + rowProps: { + type: [Object, Function] as PropType, + }, + rowClass: { + type: [String, Array, Object, Function] as PropType, + }, + summary: { + type: Function as PropType, + }, + loading: Boolean, + pagination: { + type: [Boolean, Object] as PropType, + default: true, + }, + remote: Boolean, + page: { + type: Number, + default: 1, + }, + pageSize: { + type: Number, + default: 10, + }, + total: Number, + maxHeight: [Number, String], + scrollX: [Number, String], + sorters: { + type: Array as PropType, + default: () => [], + }, + sortMode: { + type: String as PropType, + default: 'single', + }, + tree: Boolean, + surface: String as PropType, + cascade: { + type: Boolean, + default: true, + }, + childrenKey: { + type: String, + default: 'children', + }, + plain: Boolean, + checkedRowKeys: { + type: Array as PropType>, + default: () => [], + }, + expandedRowKeys: { + type: Array as PropType>, + default: () => [], + }, + expandedTreeRowKeys: { + type: Array as PropType>, + default: () => [], + }, + elevation: { + type: [Boolean, Number, String], + default: true, + }, + cellBordered: Boolean, + tableLayout: { + type: String as PropType, + default: 'auto', + }, + size: { + type: String as PropType<'small' | 'normal' | 'large'>, + default: 'normal', + }, + 'onUpdate:checkedRowKeys': defineListenerProp<(checkedRowKeys: Array) => void>(), + 'onUpdate:expandedRowKeys': defineListenerProp<(expandedRowKeys: Array) => void>(), + 'onUpdate:expandedTreeRowKeys': defineListenerProp<(expandedTreeRowKeys: Array) => void>(), + 'onUpdate:page': defineListenerProp<(page: number) => void>(), + 'onUpdate:pageSize': defineListenerProp<(pageSize: number) => void>(), + 'onUpdate:sorters': defineListenerProp<(sorters: DataTableSorter[]) => void>(), +} diff --git a/packages/varlet-ui/src/data-table/span.ts b/packages/varlet-ui/src/data-table/span.ts new file mode 100644 index 00000000000..7af0fdc3e84 --- /dev/null +++ b/packages/varlet-ui/src/data-table/span.ts @@ -0,0 +1,41 @@ +import { callOrReturn, clamp, floor, times } from '@varlet/shared' + +export type CellSpan = number | ((context: Context) => number) | undefined + +export type CellSpanMatrix = boolean[][] + +export interface CellSpanMatrixContext { + rawMatrix: CellSpanMatrix + isCovered: (rowIndex: number, columnIndex: number) => boolean + cover: (rowIndex: number, columnIndex: number, rowSpan: number, colSpan: number) => void +} + +export function createCellSpanMatrix(rowCount: number, columnCount: number): CellSpanMatrixContext { + const rawMatrix = times(rowCount, () => Array(columnCount).fill(false)) + + return { + rawMatrix, + isCovered: (rowIndex, columnIndex) => rawMatrix[rowIndex][columnIndex], + cover: (rowIndex, columnIndex, rowSpan, colSpan) => { + times(rowSpan, (rowOffset) => { + times(colSpan, (columnOffset) => { + if (rowOffset === 0 && columnOffset === 0) { + return + } + + rawMatrix[rowIndex + rowOffset][columnIndex + columnOffset] = true + }) + }) + }, + } +} + +export function resolveSpan(span: CellSpan, maxSpan: number, context?: Context) { + const resolvedSpan = span == null ? 1 : floor(callOrReturn(span, context as Context)) + + if (resolvedSpan <= 0) { + return 0 + } + + return clamp(resolvedSpan, 1, maxSpan) +} diff --git a/packages/varlet-ui/src/data-table/useBodyRows.ts b/packages/varlet-ui/src/data-table/useBodyRows.ts new file mode 100644 index 00000000000..b506b6b3dc7 --- /dev/null +++ b/packages/varlet-ui/src/data-table/useBodyRows.ts @@ -0,0 +1,191 @@ +import { computed, type ComputedRef } from 'vue' +import type { DataTableColumn } from './props' +import { createCellSpanMatrix, resolveSpan } from './span' + +export interface DataTableBodyCell { + key: string + columnIndex: number + column: DataTableColumn + treeLevel?: number + treeExpandable?: boolean + treeExpanded?: boolean + colSpan?: number + rowSpan?: number +} + +export interface DataTableFlatRow { + key: string | number + row: Record + rowIndex: number + level: number + parentKey?: string | number + expandable: boolean + treeExpanded: boolean +} + +export interface DataTableBodyRow extends DataTableFlatRow { + expanded: boolean + cells: DataTableBodyCell[] +} + +export interface DataTableTreeRowMeta { + rowByKey: Map + rowByObject: Map, DataTableFlatRow> + parentKeyByChild: Map +} + +interface UseBodyRowsOptions { + columns: () => DataTableColumn[] + sourceRows: () => Record[] + tree: () => boolean + collapsedTreeRowKeys: ComputedRef> + expandedRowKeySet: ComputedRef> + firstTreeColumnIndex: ComputedRef + getRowKey: (row: Record, rowIndex: number) => string | number + getTreeChildren: (row: Record) => Record[] +} + +export function useBodyRows({ + columns, + sourceRows, + tree, + collapsedTreeRowKeys, + expandedRowKeySet, + firstTreeColumnIndex, + getRowKey, + getTreeChildren, +}: UseBodyRowsOptions) { + const allFlatRows = computed(() => { + return tree() ? buildTreeFlatRows(sourceRows(), true) : buildRows(sourceRows()) + }) + + const visibleFlatRows = computed(() => { + return tree() ? buildTreeFlatRows(sourceRows(), false) : allFlatRows.value + }) + + const treeRowMeta = computed(() => { + const rowByKey = new Map() + const rowByObject = new Map, DataTableFlatRow>() + const parentKeyByChild = new Map() + + for (const flatRow of allFlatRows.value) { + rowByKey.set(flatRow.key, flatRow) + rowByObject.set(flatRow.row, flatRow) + + if (flatRow.parentKey != null) { + parentKeyByChild.set(flatRow.key, flatRow.parentKey) + } + } + + return { + rowByKey, + rowByObject, + parentKeyByChild, + } + }) + + const bodyRows = computed(() => { + const resolvedColumns = columns() + const rowCount = visibleFlatRows.value.length + const columnCount = resolvedColumns.length + const matrix = createCellSpanMatrix(rowCount, columnCount) + + return visibleFlatRows.value.map((flatRow, visibleRowIndex) => { + const cells: DataTableBodyCell[] = [] + + resolvedColumns.forEach((column, columnIndex) => { + if (matrix.isCovered(visibleRowIndex, columnIndex)) { + return + } + + const context = { row: flatRow.row, rowIndex: flatRow.rowIndex, column } + const maxColSpan = columnCount - columnIndex + const maxRowSpan = rowCount - visibleRowIndex + const colSpan = resolveSpan(column.colSpan, maxColSpan, context) + const rowSpan = resolveSpan(column.rowSpan, maxRowSpan, context) + + if (colSpan === 0 || rowSpan === 0) { + return + } + + matrix.cover(visibleRowIndex, columnIndex, rowSpan, colSpan) + + const isTreeColumn = columnIndex === firstTreeColumnIndex.value + cells.push({ + key: `${column.key ?? column.type ?? columnIndex}-${visibleRowIndex}-${columnIndex}`, + columnIndex, + column, + treeLevel: isTreeColumn ? flatRow.level : undefined, + treeExpandable: isTreeColumn ? flatRow.expandable : undefined, + treeExpanded: isTreeColumn ? flatRow.treeExpanded : undefined, + colSpan: colSpan > 1 ? colSpan : undefined, + rowSpan: rowSpan > 1 ? rowSpan : undefined, + }) + }) + + return { + ...flatRow, + expanded: expandedRowKeySet.value.has(flatRow.key), + cells, + } + }) + }) + + function buildRows(sourceRows: Record[]): DataTableFlatRow[] { + return sourceRows.map((row, rowIndex) => ({ + key: getRowKey(row, rowIndex), + row, + rowIndex, + level: 0, + expandable: false, + treeExpanded: true, + })) + } + + function buildTreeFlatRows(sourceRows: Record[], includeCollapsedChildren: boolean): DataTableFlatRow[] { + const rows: DataTableFlatRow[] = [] + let rowIndex = 0 + + function visit( + source: Record[], + level: number, + parentKey: string | number | undefined, + visible: boolean, + ) { + for (const row of source) { + const currentRowIndex = rowIndex + rowIndex += 1 + + const key = getRowKey(row, currentRowIndex) + const children = getTreeChildren(row) + const expandable = tree() && children.length > 0 + const treeExpanded = !expandable || !collapsedTreeRowKeys.value.has(key) + + if (includeCollapsedChildren || visible) { + rows.push({ + key, + row, + rowIndex: currentRowIndex, + level, + parentKey, + expandable, + treeExpanded, + }) + } + + visit(children, level + 1, key, visible && treeExpanded) + } + } + + visit(sourceRows, 0, undefined, true) + + return rows + } + + return { + allFlatRows, + visibleFlatRows, + treeRowMeta, + bodyRows, + } +} diff --git a/packages/varlet-ui/src/data-table/useColumnSizes.ts b/packages/varlet-ui/src/data-table/useColumnSizes.ts new file mode 100644 index 00000000000..4f85d714003 --- /dev/null +++ b/packages/varlet-ui/src/data-table/useColumnSizes.ts @@ -0,0 +1,205 @@ +import { computed, onBeforeUnmount, ref, watch, type CSSProperties } from 'vue' +import { toPxNum, toSizeUnit } from '../utils/elements' +import type { DataTableColumn, DataTableExpandColumn, DataTableSelectionColumn } from './props' + +const defaultDataTableControlColumnWidth = 52 + +interface UseColumnSizesOptions { + columns: () => DataTableColumn[] + isSelectionColumn: (column: DataTableColumn) => column is DataTableSelectionColumn + isExpandColumn: (column: DataTableColumn) => column is DataTableExpandColumn +} + +export interface DataTableResizableHeaderCell { + column: DataTableColumn + columnIndex: number +} + +export function useColumnSizes({ columns, isSelectionColumn, isExpandColumn }: UseColumnSizesOptions) { + const resizedColumnWidths = ref>({}) + let stopActiveResize: (() => void) | undefined + + const columnWidths = computed(() => { + return columns().map((column, columnIndex) => { + return getResolvedColumnWidth(column, columnIndex) ?? 0 + }) + }) + + watch( + columns, + () => { + const activeColumnIds = new Set(columns().map((column, columnIndex) => getColumnId(column, columnIndex))) + const nextWidths: Record = {} + + Object.entries(resizedColumnWidths.value).forEach(([columnId, width]) => { + if (activeColumnIds.has(columnId)) { + nextWidths[columnId] = width + } + }) + + resizedColumnWidths.value = nextWidths + }, + { immediate: true }, + ) + + onBeforeUnmount(() => { + stopActiveResize?.() + }) + + function isColumnResizable(column: DataTableColumn) { + return column.resizable === true + } + + function getColStyle(column: DataTableColumn, columnIndex: number) { + const style: CSSProperties = {} + const resizedWidth = getResizedColumnWidth(column, columnIndex) + + if (resizedWidth != null) { + style.width = toSizeUnit(resizedWidth) + style.minWidth = toSizeUnit(resizedWidth) + style.maxWidth = toSizeUnit(resizedWidth) + return style + } + + const defaultWidth = getColumnDefaultWidth(column) + + if (defaultWidth != null) { + style.width = toSizeUnit(getLimitedColumnWidth(column, toPxNum(defaultWidth))) + } + + const minWidth = getColumnMinWidth(column) + + if (minWidth != null) { + style.minWidth = toSizeUnit(minWidth) + } else if (defaultWidth != null) { + style.minWidth = toSizeUnit(getLimitedColumnWidth(column, toPxNum(defaultWidth))) + } + + const maxWidth = getColumnMaxWidth(column) + + if (maxWidth != null) { + style.maxWidth = toSizeUnit(maxWidth) + } + + return style + } + + function startColumnResize(event: MouseEvent, headerCell: DataTableResizableHeaderCell) { + if (!isColumnResizable(headerCell.column)) { + return + } + + event.preventDefault() + event.stopPropagation() + + const headerCellElement = (event.currentTarget as HTMLElement | null)?.closest('th') + + if (!headerCellElement) { + return + } + + stopActiveResize?.() + + const startX = event.clientX + const startWidth = headerCellElement.getBoundingClientRect().width + const columnId = getColumnId(headerCell.column, headerCell.columnIndex) + + const handlePointerMove = (moveEvent: MouseEvent) => { + const nextWidth = getLimitedColumnWidth(headerCell.column, startWidth + moveEvent.clientX - startX) + + resizedColumnWidths.value = { + ...resizedColumnWidths.value, + [columnId]: nextWidth, + } + } + + const handlePointerUp = () => { + detach() + } + + const detach = () => { + document.removeEventListener('mousemove', handlePointerMove) + document.removeEventListener('mouseup', handlePointerUp) + stopActiveResize = undefined + } + + document.addEventListener('mousemove', handlePointerMove) + document.addEventListener('mouseup', handlePointerUp) + stopActiveResize = detach + } + + function getResizedColumnWidth(column: DataTableColumn, columnIndex: number) { + return resizedColumnWidths.value[getColumnId(column, columnIndex)] + } + + function getResolvedColumnWidth(column: DataTableColumn, columnIndex: number) { + const resizedWidth = getResizedColumnWidth(column, columnIndex) + + if (resizedWidth != null) { + return resizedWidth + } + + const defaultWidth = getColumnDefaultWidth(column) + + if (defaultWidth != null) { + return getLimitedColumnWidth(column, toPxNum(defaultWidth)) + } + + const minWidth = getColumnMinWidth(column) + + if (minWidth != null) { + return minWidth + } + } + + function getColumnDefaultWidth(column: DataTableColumn) { + if (column.width != null) { + return column.width + } + + if (isSelectionColumn(column) || isExpandColumn(column)) { + return defaultDataTableControlColumnWidth + } + } + + function getColumnId(column: DataTableColumn, columnIndex: number) { + return `${column.key ?? column.type ?? 'column'}-${columnIndex}` + } + + function getColumnMinWidth(column: DataTableColumn) { + if (column.minWidth == null) { + return + } + + const minWidth = toPxNum(column.minWidth) + const maxWidth = getColumnMaxWidth(column) + + if (maxWidth == null) { + return minWidth + } + + return Math.min(minWidth, maxWidth) + } + + function getColumnMaxWidth(column: DataTableColumn) { + if (column.maxWidth == null) { + return + } + + return toPxNum(column.maxWidth) + } + + function getLimitedColumnWidth(column: DataTableColumn, width: number) { + const minWidth = getColumnMinWidth(column) ?? 0 + const maxWidth = getColumnMaxWidth(column) ?? Number.POSITIVE_INFINITY + + return Math.min(Math.max(width, minWidth), maxWidth) + } + + return { + columnWidths, + getColStyle, + isColumnResizable, + startColumnResize, + } +} diff --git a/packages/varlet-ui/src/data-table/useColumnsFixedOffsets.ts b/packages/varlet-ui/src/data-table/useColumnsFixedOffsets.ts new file mode 100644 index 00000000000..5ea83681a8d --- /dev/null +++ b/packages/varlet-ui/src/data-table/useColumnsFixedOffsets.ts @@ -0,0 +1,114 @@ +import { computed, type CSSProperties } from 'vue' +import type { DataTableColumn, DataTableColumnFixed } from './props' + +export interface UseColumnsFixedOffsetsOptions { + columns: () => DataTableColumn[] + columnWidths: () => number[] +} + +export function useColumnsFixedOffsets({ columns, columnWidths }: UseColumnsFixedOffsetsOptions) { + const lastLeftFixedColumnIndex = computed(() => { + return findEdgeFixedColumnIndex('left') + }) + + const firstRightFixedColumnIndex = computed(() => { + return findEdgeFixedColumnIndex('right') + }) + + const leftFixedOffsets = computed(() => { + return buildFixedOffsets('left') + }) + + const rightFixedOffsets = computed(() => { + return buildFixedOffsets('right') + }) + + function getFixedStyle(fixed: DataTableColumnFixed | undefined, columnIndex: number): CSSProperties { + if (fixed === 'left') { + return { + left: `${leftFixedOffsets.value[columnIndex] ?? 0}px`, + position: 'sticky', + zIndex: 2, + } + } + + if (fixed === 'right') { + return { + right: `${rightFixedOffsets.value[columnIndex] ?? 0}px`, + position: 'sticky', + zIndex: 2, + } + } + + return {} + } + + function buildFixedOffsets(direction: DataTableColumnFixed) { + const resolvedColumns = columns() + const resolvedColumnWidths = columnWidths() + const offsets = Array(resolvedColumns.length).fill(undefined) + let offset = 0 + + if (direction === 'left') { + for (let index = 0; index < resolvedColumns.length; index += 1) { + if (resolvedColumns[index].fixed !== 'left') { + continue + } + + offsets[index] = offset + offset += resolvedColumnWidths[index] + } + + return offsets + } + + for (let index = resolvedColumns.length - 1; index >= 0; index -= 1) { + if (resolvedColumns[index].fixed !== 'right') { + continue + } + + offsets[index] = offset + offset += resolvedColumnWidths[index] + } + + return offsets + } + + function isLastLeftFixedColumn(columnIndex: number) { + return lastLeftFixedColumnIndex.value === columnIndex + } + + function isFirstRightFixedColumn(columnIndex: number) { + return firstRightFixedColumnIndex.value === columnIndex + } + + function findEdgeFixedColumnIndex(direction: DataTableColumnFixed) { + const resolvedColumns = columns() + + if (direction === 'left') { + for (let index = resolvedColumns.length - 1; index >= 0; index -= 1) { + if (resolvedColumns[index].fixed === 'left') { + return index + } + } + + return -1 + } + + for (let index = 0; index < resolvedColumns.length; index += 1) { + if (resolvedColumns[index].fixed === 'right') { + return index + } + } + + return -1 + } + + return { + getFixedStyle, + isFirstRightFixedColumn, + isLastLeftFixedColumn, + leftFixedOffsets, + rightFixedOffsets, + } +} diff --git a/packages/varlet-ui/src/data-table/useExpandRow.ts b/packages/varlet-ui/src/data-table/useExpandRow.ts new file mode 100644 index 00000000000..de6cb791c77 --- /dev/null +++ b/packages/varlet-ui/src/data-table/useExpandRow.ts @@ -0,0 +1,64 @@ +import { computed, type Ref } from 'vue' +import type { DataTableColumn, DataTableExpandColumn } from './props' +import type { DataTableBodyRow } from './useBodyRows' + +interface UseExpandRowOptions { + columns: () => DataTableColumn[] + expandedRowKeys: Ref> + isExpandColumn: (column: DataTableColumn) => column is DataTableExpandColumn +} + +export function useExpandRow({ columns, expandedRowKeys, isExpandColumn }: UseExpandRowOptions) { + const expandedRowKeySet = computed(() => new Set(expandedRowKeys.value)) + + const expandColumn = computed(() => columns().find(isExpandColumn)) + + function isRowExpandable(bodyRow: DataTableBodyRow, column?: DataTableExpandColumn) { + if (!column?.expandable) { + return true + } + + return column.expandable({ + row: bodyRow.row, + rowIndex: bodyRow.rowIndex, + }) + } + + function toggleRowExpanded(bodyRow: DataTableBodyRow) { + const column = expandColumn.value + + if (!column || !isRowExpandable(bodyRow, column)) { + return + } + + const target = new Set(expandedRowKeys.value) + + if (target.has(bodyRow.key)) { + target.delete(bodyRow.key) + } else { + target.add(bodyRow.key) + } + + expandedRowKeys.value = [...target] + } + + function renderExpandedRow(bodyRow: DataTableBodyRow) { + const column = expandColumn.value + + if (!column) { + return + } + + return column.renderExpand({ + row: bodyRow.row, + rowIndex: bodyRow.rowIndex, + }) + } + + return { + expandedRowKeySet, + isRowExpandable, + toggleRowExpanded, + renderExpandedRow, + } +} diff --git a/packages/varlet-ui/src/data-table/useFootRows.ts b/packages/varlet-ui/src/data-table/useFootRows.ts new file mode 100644 index 00000000000..4a025cf9443 --- /dev/null +++ b/packages/varlet-ui/src/data-table/useFootRows.ts @@ -0,0 +1,76 @@ +import { isArray } from '@varlet/shared' +import { computed, type VNodeChild } from 'vue' +import type { DataTableColumn, DataTableSummary, DataTableSummaryCell } from './props' +import { createCellSpanMatrix, resolveSpan } from './span' +import type { DataTableBodyCell } from './useBodyRows' + +type DataTableSummaryRecord = Record + +export interface DataTableFootCell extends DataTableBodyCell { + value?: VNodeChild +} + +interface UseFootRowsOptions { + columns: () => DataTableColumn[] + sourceRows: () => Record[] + summary: () => DataTableSummary | undefined +} + +export function useFootRows({ columns, sourceRows, summary }: UseFootRowsOptions) { + const footRows = computed(() => { + const summaryGetter = summary() + + if (!summaryGetter) { + return [] + } + + const summaryResult = summaryGetter({ + data: sourceRows(), + }) + const summaryRecords: DataTableSummaryRecord[] = isArray(summaryResult) ? summaryResult : [summaryResult] + const resolvedColumns = columns() + const rowCount = summaryRecords.length + const columnCount = resolvedColumns.length + const matrix = createCellSpanMatrix(rowCount, columnCount) + + return summaryRecords.map((summaryRecord, rowIndex) => + resolvedColumns.flatMap((column, columnIndex) => { + if (matrix.isCovered(rowIndex, columnIndex)) { + return [] + } + + const key = getColumnSummaryKey(column, columnIndex) + const summaryCell = summaryRecord[key] + const maxColSpan = columnCount - columnIndex + const maxRowSpan = rowCount - rowIndex + const colSpan = resolveSpan(summaryCell?.colSpan, maxColSpan) + const rowSpan = resolveSpan(summaryCell?.rowSpan, maxRowSpan) + + if (colSpan === 0 || rowSpan === 0) { + return [] + } + + matrix.cover(rowIndex, columnIndex, rowSpan, colSpan) + + return [ + { + key: `${rowIndex}-${key}`, + columnIndex, + column, + value: summaryCell?.value, + colSpan: colSpan > 1 ? colSpan : undefined, + rowSpan: rowSpan > 1 ? rowSpan : undefined, + }, + ] + }), + ) + }) + + function getColumnSummaryKey(column: DataTableColumn, columnIndex: number) { + return column.key ?? column.type ?? String(columnIndex) + } + + return { + footRows, + } +} diff --git a/packages/varlet-ui/src/data-table/useHeaderRows.ts b/packages/varlet-ui/src/data-table/useHeaderRows.ts new file mode 100644 index 00000000000..7065549a07a --- /dev/null +++ b/packages/varlet-ui/src/data-table/useHeaderRows.ts @@ -0,0 +1,146 @@ +import { isArray } from '@varlet/shared' +import { computed } from 'vue' +import type { DataTableColumn, DataTableColumnFixed, DataTableFieldColumn } from './props' +import { createCellSpanMatrix, resolveSpan } from './span' + +export interface DataTableHeaderCell { + key: string + column: DataTableColumn + columnIndex: number + startLeafColumnIndex: number + endLeafColumnIndex: number + colSpan?: number + rowSpan?: number + fixed?: DataTableColumnFixed +} + +interface UseHeaderRowsOptions { + columns: () => DataTableColumn[] +} + +export function useHeaderRows({ columns }: UseHeaderRowsOptions) { + const normalizedColumns = computed(() => flattenLeafColumns(columns())) + + const headerRows = computed(() => { + const resolvedColumns = columns() + + if (!resolvedColumns.some(isGroupColumn)) { + const cells: DataTableHeaderCell[] = [] + const matrix = createCellSpanMatrix(1, resolvedColumns.length) + + resolvedColumns.forEach((column, columnIndex) => { + if (matrix.isCovered(0, columnIndex)) { + return + } + + const maxColSpan = resolvedColumns.length - columnIndex + const colSpan = resolveSpan(column.titleColSpan, maxColSpan) + + if (colSpan === 0) { + return + } + + matrix.cover(0, columnIndex, 1, colSpan) + cells.push({ + key: `${column.key ?? column.type ?? columnIndex}-header-${columnIndex}`, + column, + columnIndex, + startLeafColumnIndex: columnIndex, + endLeafColumnIndex: columnIndex + colSpan - 1, + colSpan: colSpan > 1 ? colSpan : undefined, + fixed: resolveHeaderCellFixed(normalizedColumns.value.slice(columnIndex, columnIndex + colSpan)), + }) + }) + + return [cells] + } + + const rows: DataTableHeaderCell[][] = [] + const maxDepth = getColumnDepth(resolvedColumns) + let leafColumnIndex = 0 + + function visit(currentColumns: DataTableColumn[], depth: number) { + currentColumns.forEach((column, columnIndex) => { + rows[depth] ??= [] + + if (isGroupColumn(column)) { + const childColumns = column.children + const startLeafColumnIndex = leafColumnIndex + const leafCount = countLeafColumns(childColumns) + leafColumnIndex += leafCount + const endLeafColumnIndex = leafColumnIndex - 1 + + rows[depth].push({ + key: `${column.key ?? 'group'}-header-${depth}-${columnIndex}`, + column, + columnIndex: startLeafColumnIndex, + startLeafColumnIndex, + endLeafColumnIndex, + colSpan: leafCount, + fixed: resolveHeaderCellFixed(normalizedColumns.value.slice(startLeafColumnIndex, endLeafColumnIndex + 1)), + }) + + visit(childColumns, depth + 1) + return + } + + const startLeafColumnIndex = leafColumnIndex + leafColumnIndex += 1 + + rows[depth].push({ + key: `${column.key ?? column.type ?? columnIndex}-header-${depth}-${startLeafColumnIndex}`, + column, + columnIndex: startLeafColumnIndex, + startLeafColumnIndex, + endLeafColumnIndex: startLeafColumnIndex, + rowSpan: maxDepth - depth > 1 ? maxDepth - depth : undefined, + fixed: column.fixed, + }) + }) + } + + visit(resolvedColumns, 0) + + return rows + }) + + function isGroupColumn(column: DataTableColumn): column is DataTableFieldColumn & { children: DataTableColumn[] } { + return 'children' in column && isArray(column.children) && column.children.length > 0 + } + + function flattenLeafColumns(columns: DataTableColumn[]): DataTableColumn[] { + return columns.flatMap((column) => (isGroupColumn(column) ? flattenLeafColumns(column.children) : [column])) + } + + function countLeafColumns(columns: DataTableColumn[]) { + return flattenLeafColumns(columns).length + } + + function getColumnDepth(columns: DataTableColumn[]): number { + if (!columns.length) { + return 0 + } + + return Math.max(...columns.map((column) => (isGroupColumn(column) ? 1 + getColumnDepth(column.children) : 1))) + } + + function resolveHeaderCellFixed(columns: DataTableColumn[]): DataTableColumnFixed | undefined { + if (!columns.length) { + return + } + + if (columns.every((column) => column.fixed === 'left')) { + return 'left' + } + + if (columns.every((column) => column.fixed === 'right')) { + return 'right' + } + } + + return { + normalizedColumns, + headerRows, + isGroupColumn, + } +} diff --git a/packages/varlet-ui/src/data-table/usePagination.ts b/packages/varlet-ui/src/data-table/usePagination.ts new file mode 100644 index 00000000000..95a44b4c8dc --- /dev/null +++ b/packages/varlet-ui/src/data-table/usePagination.ts @@ -0,0 +1,116 @@ +import { call, clamp, isBoolean } from '@varlet/shared' +import { computed, watch } from 'vue' +import type { ListenerProp } from '../utils/components' +import type { DataTablePagination } from './props' + +const defaultPaginationOptions = { + simple: false, + disabled: false, + showSizeChanger: false, + showQuickJumper: false, + maxPagerCount: 5, + sizeOption: [10, 20, 50, 100], + showTotal: undefined, +} satisfies Required< + Pick< + DataTablePagination, + 'simple' | 'disabled' | 'showSizeChanger' | 'showQuickJumper' | 'maxPagerCount' | 'sizeOption' + > +> & + Pick + +interface UsePaginationOptions> { + pagination: () => boolean | DataTablePagination + remote: () => boolean + loading: () => boolean + page: () => number + pageSize: () => number + total: () => number | undefined + data: () => Row[] + onUpdatePage?: () => ListenerProp<(page: number) => void> | undefined +} + +export function usePagination>({ + pagination, + remote, + loading, + page, + pageSize, + total, + data, + onUpdatePage, +}: UsePaginationOptions) { + const paginationProps = computed(() => { + const resolvedPagination = pagination() + + if (isBoolean(resolvedPagination)) { + return { + ...defaultPaginationOptions, + disabled: loading(), + } + } + + return { + ...defaultPaginationOptions, + ...resolvedPagination, + disabled: loading() || resolvedPagination.disabled === true, + } + }) + + const paginationTotal = computed(() => { + if (pagination() === false) { + return data().length + } + + return remote() ? (total() ?? 0) : data().length + }) + + const showPagination = computed(() => { + return pagination() !== false && paginationTotal.value > 0 + }) + + const pageCount = computed(() => { + if (!showPagination.value) { + return 1 + } + + return clamp(Math.ceil(paginationTotal.value / pageSize()), 1, Number.MAX_SAFE_INTEGER) + }) + + const normalizedPage = computed(() => { + if (!showPagination.value) { + return 1 + } + + return clamp(page(), 1, pageCount.value) + }) + + const pagedData = computed(() => { + if (!showPagination.value || remote()) { + return data() + } + + const start = (normalizedPage.value - 1) * pageSize() + return data().slice(start, start + pageSize()) + }) + + watch( + [pagination, remote, page, normalizedPage], + ([currentPagination, currentRemote, currentPage, nextPage]) => { + if (currentPagination === false || currentRemote || currentPage === nextPage) { + return + } + + call(onUpdatePage?.(), nextPage) + }, + { immediate: true }, + ) + + return { + paginationProps, + paginationTotal, + showPagination, + normalizedPage, + pagedData, + } +} diff --git a/packages/varlet-ui/src/data-table/useSelectionColumn.ts b/packages/varlet-ui/src/data-table/useSelectionColumn.ts new file mode 100644 index 00000000000..20b6ffdd1a2 --- /dev/null +++ b/packages/varlet-ui/src/data-table/useSelectionColumn.ts @@ -0,0 +1,314 @@ +import { computed, type Ref } from 'vue' +import type { DataTableColumn, DataTableSelectionColumn } from './props' +import type { DataTableBodyRow, DataTableFlatRow, DataTableTreeRowMeta } from './useBodyRows' + +interface DataTableTreeSelectionState { + checked: boolean + indeterminate: boolean + selectable: boolean +} + +interface UseColumnSelectionOptions { + columns: () => DataTableColumn[] + tree: () => boolean + cascade: () => boolean + pagedData: () => Record[] + allFlatRows: () => DataTableFlatRow[] + treeRowMeta: () => DataTableTreeRowMeta + checkedRowKeys: Ref> + isSelectionColumn: (column: DataTableColumn) => column is DataTableSelectionColumn + getTreeChildren: (row: Record) => Record[] +} + +export function useSelectionColumn({ + columns, + tree, + cascade, + pagedData, + allFlatRows, + treeRowMeta, + checkedRowKeys, + isSelectionColumn, + getTreeChildren, +}: UseColumnSelectionOptions) { + const selectionColumn = computed(() => columns().find(isSelectionColumn)) + + const cascadeSelectionEnabled = computed(() => { + return tree() && cascade() && !!selectionColumn.value && isMultipleSelectionColumn(selectionColumn.value) + }) + + const checkedRowKeySet = computed(() => new Set(checkedRowKeys.value)) + + const treeSelectionStates = computed(() => { + const states = new Map() + const column = selectionColumn.value + + if (!column) { + return states + } + + for (const row of pagedData()) { + visit(row) + } + + function visit(row: Record): DataTableTreeSelectionState { + const flatRow = treeRowMeta().rowByObject.get(row) + + if (!flatRow) { + return { + checked: false, + indeterminate: false, + selectable: false, + } + } + + const children = getTreeChildren(row) + const childStates = children.map(visit) + const selectable = isRowSelectable(row, flatRow.rowIndex, column) + const selfChecked = checkedRowKeySet.value.has(flatRow.key) + + if (!cascadeSelectionEnabled.value || childStates.length === 0) { + const state = { + checked: selfChecked, + indeterminate: false, + } + + states.set(flatRow.key, state) + + return { + ...state, + selectable, + } + } + + const selectableChildren = childStates.filter((childState) => childState.selectable) + + const allChildrenChecked = + selectableChildren.length > 0 ? selectableChildren.every((childState) => childState.checked) : selfChecked + + const someChildrenSelected = selectableChildren.some( + (childState) => childState.checked || childState.indeterminate, + ) + + const state = { + checked: selectable ? allChildrenChecked : false, + indeterminate: selectableChildren.length > 0 && !allChildrenChecked && someChildrenSelected, + } + + states.set(flatRow.key, state) + + return { + ...state, + selectable: selectable || selectableChildren.length > 0, + } + } + + return states + }) + + const currentSelectableRows = computed(() => { + const column = selectionColumn.value + + if (!column) { + return [] + } + + return allFlatRows().filter((flatRow) => isRowSelectable(flatRow.row, flatRow.rowIndex, column)) + }) + + const allCurrentRowsSelected = computed(() => { + return ( + currentSelectableRows.value.length > 0 && + currentSelectableRows.value.every((flatRow) => { + return isRowKeySelected(flatRow.key) + }) + ) + }) + + const someCurrentRowsSelected = computed(() => { + return ( + currentSelectableRows.value.some( + (flatRow) => isRowKeySelected(flatRow.key) || isRowKeyIndeterminate(flatRow.key), + ) && !allCurrentRowsSelected.value + ) + }) + + function isMultipleSelectionColumn(column: DataTableSelectionColumn) { + return column.multiple !== false + } + + function isSelectionColumnSelectable(column: DataTableSelectionColumn) { + return column.selectable !== false + } + + function isRowSelectable(row: Record, rowIndex: number, column?: DataTableSelectionColumn) { + if (!column || column.selectable == null || column.selectable === true) { + return true + } + + if (column.selectable === false) { + return false + } + + return column.selectable({ + row, + rowIndex, + }) + } + + function updateCheckedRowKeys(value: Array) { + checkedRowKeys.value = value + } + + function isRowKeySelected(key: string | number) { + return treeSelectionStates.value.get(key)?.checked ?? checkedRowKeySet.value.has(key) + } + + function isRowKeyIndeterminate(key: string | number) { + return treeSelectionStates.value.get(key)?.indeterminate ?? false + } + + function toggleRowSelection(bodyRow: DataTableBodyRow, selected: boolean) { + const column = selectionColumn.value + + if (!column || !isSelectionColumnSelectable(column) || !isRowSelectable(bodyRow.row, bodyRow.rowIndex, column)) { + return + } + + if (!isMultipleSelectionColumn(column)) { + updateCheckedRowKeys( + selected + ? [bodyRow.key] + : checkedRowKeys.value.filter((key) => { + return key !== bodyRow.key + }), + ) + return + } + + const nextKeys = new Set(checkedRowKeys.value) + + if (cascadeSelectionEnabled.value) { + for (const key of collectSelectableBranchKeys(bodyRow.row)) { + if (selected) { + nextKeys.add(key) + } else { + nextKeys.delete(key) + } + } + + syncAncestorSelection(nextKeys, bodyRow.key) + } else if (selected) { + nextKeys.add(bodyRow.key) + } else { + nextKeys.delete(bodyRow.key) + } + + updateCheckedRowKeys([...nextKeys]) + } + + function toggleCurrentSelectableRows(selected: boolean) { + const column = selectionColumn.value + + if (!column || !isSelectionColumnSelectable(column) || !isMultipleSelectionColumn(column)) { + return + } + + const nextKeys = new Set(checkedRowKeys.value) + + for (const flatRow of currentSelectableRows.value) { + if (selected) { + nextKeys.add(flatRow.key) + } else { + nextKeys.delete(flatRow.key) + } + } + + updateCheckedRowKeys([...nextKeys]) + } + + function collectSelectableBranchKeys(row: Record) { + const column = selectionColumn.value + const keys: Array = [] + + if (!column) { + return keys + } + + function visit(currentRow: Record) { + const flatRow = treeRowMeta().rowByObject.get(currentRow) + + if (!flatRow) { + return + } + + if (isRowSelectable(currentRow, flatRow.rowIndex, column)) { + keys.push(flatRow.key) + } + + for (const child of getTreeChildren(currentRow)) { + visit(child) + } + } + + visit(row) + + return keys + } + + function shouldAncestorBeChecked(row: Record, nextKeys: Set): boolean { + const column = selectionColumn.value + const flatRow = treeRowMeta().rowByObject.get(row) + + if (!column || !flatRow) { + return false + } + + const selectable = isRowSelectable(row, flatRow.rowIndex, column) + const children = getTreeChildren(row) + + if (!children.length) { + return selectable ? nextKeys.has(flatRow.key) : true + } + + return ( + selectable && + children.every((child) => { + return shouldAncestorBeChecked(child, nextKeys) + }) + ) + } + + function syncAncestorSelection(nextKeys: Set, key: string | number) { + let parentKey = treeRowMeta().parentKeyByChild.get(key) + + while (parentKey != null) { + const parentRow = treeRowMeta().rowByKey.get(parentKey) + + if (!parentRow) { + break + } + + if (shouldAncestorBeChecked(parentRow.row, nextKeys)) { + nextKeys.add(parentKey) + } else { + nextKeys.delete(parentKey) + } + + parentKey = treeRowMeta().parentKeyByChild.get(parentKey) + } + } + + return { + currentSelectableRows, + allCurrentRowsSelected, + someCurrentRowsSelected, + isMultipleSelectionColumn, + isSelectionColumnSelectable, + isRowSelectable, + isRowKeySelected, + isRowKeyIndeterminate, + toggleRowSelection, + toggleCurrentSelectableRows, + } +} diff --git a/packages/varlet-ui/src/data-table/useSorter.ts b/packages/varlet-ui/src/data-table/useSorter.ts new file mode 100644 index 00000000000..5ac8c69e697 --- /dev/null +++ b/packages/varlet-ui/src/data-table/useSorter.ts @@ -0,0 +1,75 @@ +import { call } from '@varlet/shared' +import { computed } from 'vue' +import type { + DataTableColumn, + DataTableFieldColumn, + DataTableSortMode, + DataTableSorter, + DataTableSorterOrder, +} from './props' + +interface UseSorterOptions { + sorters: () => DataTableSorter[] + sortMode: () => DataTableSortMode + onUpdateSorters: () => ((sorters: DataTableSorter[]) => void) | ((sorters: DataTableSorter[]) => void)[] | undefined +} + +export function useSorter({ sorters, sortMode, onUpdateSorters }: UseSorterOptions) { + const activeSorters = computed(() => sorters()) + + function isColumnSortable(column: DataTableColumn): column is DataTableFieldColumn { + return column.type == null && !column.children?.length && column.sorter === true + } + + function getColumnSorterOrder(columnKey: string): DataTableSorterOrder | undefined { + return activeSorters.value.find((sorter) => sorter.key === columnKey)?.order + } + + function toggleColumnSorter(columnKey: string) { + const currentOrder = getColumnSorterOrder(columnKey) + + if (sortMode() === 'single') { + call( + onUpdateSorters(), + currentOrder == null + ? [{ key: columnKey, order: 'asc' as const }] + : currentOrder === 'asc' + ? [{ key: columnKey, order: 'desc' as const }] + : [], + ) + return + } + + if (currentOrder == null) { + call(onUpdateSorters(), [...activeSorters.value, { key: columnKey, order: 'asc' as const }]) + return + } + + if (currentOrder === 'asc') { + call( + onUpdateSorters(), + activeSorters.value.map((sorter) => + sorter.key !== columnKey + ? sorter + : { + ...sorter, + order: 'desc' as const, + }, + ), + ) + return + } + + call( + onUpdateSorters(), + activeSorters.value.filter((sorter) => sorter.key !== columnKey), + ) + } + + return { + activeSorters, + isColumnSortable, + getColumnSorterOrder, + toggleColumnSorter, + } +} diff --git a/packages/varlet-ui/src/data-table/useTreeExpand.ts b/packages/varlet-ui/src/data-table/useTreeExpand.ts new file mode 100644 index 00000000000..27daa9c434b --- /dev/null +++ b/packages/varlet-ui/src/data-table/useTreeExpand.ts @@ -0,0 +1,94 @@ +import { computed, watch, type Ref } from 'vue' +import type { DataTableBodyRow } from './useBodyRows' + +interface UseTreeExpandOptions { + tree: () => boolean + data: () => Record[] + expandedTreeRowKeys: Ref> + getRowKey: (row: Record, rowIndex: number) => string | number + getTreeChildren: (row: Record) => Record[] +} + +export function useTreeExpand({ tree, data, expandedTreeRowKeys, getRowKey, getTreeChildren }: UseTreeExpandOptions) { + const expandedTreeRowKeySet = computed(() => new Set(expandedTreeRowKeys.value)) + const collapsedTreeRowKeys = computed(() => { + if (!tree()) { + return new Set() + } + + const validKeys = collectExpandableRowKeys(data()) + const collapsedKeys = new Set() + + for (const key of validKeys) { + if (!expandedTreeRowKeySet.value.has(key)) { + collapsedKeys.add(key) + } + } + + return collapsedKeys + }) + + watch( + [data, tree], + () => { + syncExpandedTreeRowKeys() + }, + { immediate: true }, + ) + + function toggleTreeRowExpanded(bodyRow: DataTableBodyRow) { + if (!tree() || !bodyRow.expandable) { + return + } + + const target = new Set(expandedTreeRowKeys.value) + + if (target.has(bodyRow.key)) { + target.delete(bodyRow.key) + } else { + target.add(bodyRow.key) + } + + expandedTreeRowKeys.value = [...target] + } + + function syncExpandedTreeRowKeys() { + if (!tree()) { + expandedTreeRowKeys.value = [] + return + } + + const validKeys = collectExpandableRowKeys(data()) + expandedTreeRowKeys.value = expandedTreeRowKeys.value.filter((key) => validKeys.has(key)) + } + + function collectExpandableRowKeys(rows: Record[]) { + const keys = new Set() + let rowIndex = 0 + + function visit(source: Record[]) { + for (const row of source) { + const currentRowIndex = rowIndex + rowIndex += 1 + + const children = getTreeChildren(row) + + if (children.length > 0) { + keys.add(getRowKey(row, currentRowIndex)) + } + + visit(children) + } + } + + visit(rows) + + return keys + } + + return { + collapsedTreeRowKeys, + expandedTreeRowKeySet, + toggleTreeRowExpanded, + } +} diff --git a/packages/varlet-ui/src/locale/en-US.ts b/packages/varlet-ui/src/locale/en-US.ts index dafbd5de026..c71f2244606 100644 --- a/packages/varlet-ui/src/locale/en-US.ts +++ b/packages/varlet-ui/src/locale/en-US.ts @@ -106,4 +106,6 @@ export default { timePickerHint: 'SELECT TIME', // select selectEmptyText: 'No Data', + // data-table + dataTableEmptyText: 'No Data', } satisfies Message diff --git a/packages/varlet-ui/src/locale/fa-IR.ts b/packages/varlet-ui/src/locale/fa-IR.ts index 12921a1f473..315b0fb7e8d 100644 --- a/packages/varlet-ui/src/locale/fa-IR.ts +++ b/packages/varlet-ui/src/locale/fa-IR.ts @@ -106,4 +106,6 @@ export default { timePickerHint: 'انتخاب زمان', // select selectEmptyText: 'داده‌ای وجود ندارد', + // data-table + dataTableEmptyText: 'داده‌ای وجود ندارد', } satisfies Message diff --git a/packages/varlet-ui/src/locale/index.ts b/packages/varlet-ui/src/locale/index.ts index 7580f2a6ef2..68d695a8f55 100644 --- a/packages/varlet-ui/src/locale/index.ts +++ b/packages/varlet-ui/src/locale/index.ts @@ -36,6 +36,8 @@ export type Message = { timePickerHint: string // select selectEmptyText: string + // data-table + dataTableEmptyText: string // internal lang?: string [key: PropertyKey]: any diff --git a/packages/varlet-ui/src/locale/ja-JP.ts b/packages/varlet-ui/src/locale/ja-JP.ts index 49f71a22fd4..a7b3813ad1b 100644 --- a/packages/varlet-ui/src/locale/ja-JP.ts +++ b/packages/varlet-ui/src/locale/ja-JP.ts @@ -106,4 +106,6 @@ export default { timePickerHint: '時間を選択', // select selectEmptyText: 'データがありません', + // data-table + dataTableEmptyText: 'データがありません', } satisfies Message diff --git a/packages/varlet-ui/src/locale/zh-CN.ts b/packages/varlet-ui/src/locale/zh-CN.ts index 373304d443b..6dc9777f778 100644 --- a/packages/varlet-ui/src/locale/zh-CN.ts +++ b/packages/varlet-ui/src/locale/zh-CN.ts @@ -106,4 +106,6 @@ export default { timePickerHint: '选择时间', // select selectEmptyText: '暂无数据', + // data-table + dataTableEmptyText: '暂无数据', } satisfies Message diff --git a/packages/varlet-ui/src/locale/zh-TW.ts b/packages/varlet-ui/src/locale/zh-TW.ts index 5c3dbe8ea92..cb3bbd40095 100644 --- a/packages/varlet-ui/src/locale/zh-TW.ts +++ b/packages/varlet-ui/src/locale/zh-TW.ts @@ -106,4 +106,6 @@ export default { timePickerHint: '選擇時間', // select selectEmptyText: '暫無數據', + // data-table + dataTableEmptyText: '暫無數據', } satisfies Message diff --git a/packages/varlet-ui/src/menu-select/MenuSelect.vue b/packages/varlet-ui/src/menu-select/MenuSelect.vue index ad7d3aececc..f986a443458 100644 --- a/packages/varlet-ui/src/menu-select/MenuSelect.vue +++ b/packages/varlet-ui/src/menu-select/MenuSelect.vue @@ -29,7 +29,7 @@