diff --git a/src/lib/bodyCells.HeaderCell.render.test.ts b/src/lib/bodyCells.HeaderCell.render.test.ts index a8e93de..52d7f77 100644 --- a/src/lib/bodyCells.HeaderCell.render.test.ts +++ b/src/lib/bodyCells.HeaderCell.render.test.ts @@ -16,6 +16,7 @@ class TestHeaderCell extends HeaderCell { id: this.id, colspan: this.colspan, label: this.label, + colstart: 1, }); } } @@ -25,6 +26,7 @@ it('renders string label', () => { id: '0', label: 'Name', colspan: 1, + colstart: 1, }); expect(actual.render()).toBe('Name'); @@ -39,6 +41,7 @@ it('renders dynamic label with state', () => { id: '0', label: ({ columns }) => `${columns.length} columns`, colspan: 1, + colstart: 1, }); actual.injectState(state); @@ -51,6 +54,7 @@ it('throws if rendering dynamically without state', () => { id: '0', label: ({ columns }) => `${columns.length} columns`, colspan: 1, + colstart: 1, }); expect(() => { diff --git a/src/lib/createViewModel.ts b/src/lib/createViewModel.ts index ee97d54..25e995e 100644 --- a/src/lib/createViewModel.ts +++ b/src/lib/createViewModel.ts @@ -8,23 +8,38 @@ import type { AnyPlugins, DeriveFlatColumnsFn, DeriveRowsFn, + DeriveFn, PluginStates, } from './types/TablePlugin'; +import { finalizeAttributes } from './utils/attributes'; import { nonUndefined } from './utils/filter'; // eslint-disable-next-line @typescript-eslint/no-unused-vars -export type TableAttributes = { +export type TableAttributes = Record< + string, + unknown +> & { role: 'table'; }; // eslint-disable-next-line @typescript-eslint/no-unused-vars -export type TableBodyAttributes = { +export type TableHeadAttributes = Record< + string, + unknown +>; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export type TableBodyAttributes = Record< + string, + unknown +> & { role: 'rowgroup'; }; export interface TableViewModel { flatColumns: FlatColumn[]; tableAttrs: Readable>; + tableHeadAttrs: Readable>; tableBodyAttrs: Readable>; visibleColumns: Readable[]>; headerRows: Readable[]>; @@ -64,18 +79,20 @@ export const createViewModel = ( const _headerRows = writable[]>(); const _rows = writable[]>([]); const _pageRows = writable[]>([]); - const tableAttrs = readable({ + const _tableAttrs = writable>({ role: 'table' as const, }); - const tableBodyAttrs = readable({ + const _tableHeadAttrs = writable>({}); + const _tableBodyAttrs = writable>({ role: 'rowgroup' as const, }); const pluginInitTableState: PluginInitTableState = { data, columns, - tableAttrs, - tableBodyAttrs, flatColumns: $flatColumns, + tableAttrs: _tableAttrs, + tableHeadAttrs: _tableHeadAttrs, + tableBodyAttrs: _tableBodyAttrs, visibleColumns: _visibleColumns, headerRows: _headerRows, originalRows, @@ -110,9 +127,10 @@ export const createViewModel = ( const tableState: TableState = { data, columns, - tableAttrs, - tableBodyAttrs, flatColumns: $flatColumns, + tableAttrs: _tableAttrs, + tableHeadAttrs: _tableHeadAttrs, + tableBodyAttrs: _tableBodyAttrs, visibleColumns: _visibleColumns, headerRows: _headerRows, originalRows, @@ -121,6 +139,53 @@ export const createViewModel = ( pluginStates, }; + const deriveTableAttrsFns: DeriveFn>[] = Object.values(pluginInstances) + .map((pluginInstance) => pluginInstance.deriveTableAttrs) + .filter(nonUndefined); + let tableAttrs = readable>({ + role: 'table', + }); + deriveTableAttrsFns.forEach((fn) => { + tableAttrs = fn(tableAttrs); + }); + const finalizedTableAttrs = derived(tableAttrs, ($tableAttrs) => { + const $finalizedAttrs = finalizeAttributes($tableAttrs) as TableAttributes; + _tableAttrs.set($finalizedAttrs); + return $finalizedAttrs; + }); + + const deriveTableHeadAttrsFns: DeriveFn>[] = Object.values( + pluginInstances + ) + .map((pluginInstance) => pluginInstance.deriveTableBodyAttrs) + .filter(nonUndefined); + let tableHeadAttrs = readable>({}); + deriveTableHeadAttrsFns.forEach((fn) => { + tableHeadAttrs = fn(tableHeadAttrs); + }); + const finalizedTableHeadAttrs = derived(tableHeadAttrs, ($tableHeadAttrs) => { + const $finalizedAttrs = finalizeAttributes($tableHeadAttrs) as TableHeadAttributes; + _tableHeadAttrs.set($finalizedAttrs); + return $finalizedAttrs; + }); + + const deriveTableBodyAttrsFns: DeriveFn>[] = Object.values( + pluginInstances + ) + .map((pluginInstance) => pluginInstance.deriveTableBodyAttrs) + .filter(nonUndefined); + let tableBodyAttrs = readable>({ + role: 'rowgroup', + }); + deriveTableBodyAttrsFns.forEach((fn) => { + tableBodyAttrs = fn(tableBodyAttrs); + }); + const finalizedTableBodyAttrs = derived(tableBodyAttrs, ($tableBodyAttrs) => { + const $finalizedAttrs = finalizeAttributes($tableBodyAttrs) as TableBodyAttributes; + _tableBodyAttrs.set($finalizedAttrs); + return $finalizedAttrs; + }); + const deriveFlatColumnsFns: DeriveFlatColumnsFn[] = Object.values(pluginInstances) .map((pluginInstance) => pluginInstance.deriveFlatColumns) .filter(nonUndefined); @@ -246,10 +311,11 @@ export const createViewModel = ( }); return { - tableAttrs, - tableBodyAttrs, - flatColumns: $flatColumns, + tableAttrs: finalizedTableAttrs, + tableHeadAttrs: finalizedTableHeadAttrs, + tableBodyAttrs: finalizedTableBodyAttrs, visibleColumns: injectedColumns, + flatColumns: $flatColumns, headerRows, originalRows, rows: injectedRows, diff --git a/src/lib/headerCells.ts b/src/lib/headerCells.ts index 01c2a7f..a2dd21a 100644 --- a/src/lib/headerCells.ts +++ b/src/lib/headerCells.ts @@ -10,6 +10,7 @@ export type HeaderCellInit = { id: string; label: HeaderLabel; colspan: number; + colstart: number; }; // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -17,16 +18,19 @@ export type HeaderCellAttributes role: 'columnheader'; colspan: number; }; + export abstract class HeaderCell< Item, Plugins extends AnyPlugins = AnyPlugins > extends TableComponent { label: HeaderLabel; colspan: number; - constructor({ id, label, colspan }: HeaderCellInit) { + colstart: number; + constructor({ id, label, colspan, colstart }: HeaderCellInit) { super({ id }); this.label = label; this.colspan = colspan; + this.colstart = colstart; } render(): RenderConfig { @@ -68,14 +72,15 @@ export class FlatHeaderCell exten Item, Plugins > { - constructor({ id, label }: FlatHeaderCellInit) { - super({ id, label, colspan: 1 }); + constructor({ id, label, colstart }: FlatHeaderCellInit) { + super({ id, label, colspan: 1, colstart }); } clone(): FlatHeaderCell { return new FlatHeaderCell({ id: this.id, label: this.label, + colstart: this.colstart, }); } } @@ -94,8 +99,8 @@ export class DataHeaderCell exten > { accessorKey?: keyof Item; accessorFn?: (item: Item) => unknown; - constructor({ id, label, accessorKey, accessorFn }: DataHeaderCellInit) { - super({ id, label }); + constructor({ id, label, accessorKey, accessorFn, colstart }: DataHeaderCellInit) { + super({ id, label, colstart }); this.accessorKey = accessorKey; this.accessorFn = accessorFn; } @@ -106,6 +111,7 @@ export class DataHeaderCell exten label: this.label, accessorFn: this.accessorFn, accessorKey: this.accessorKey, + colstart: this.colstart, }); } } @@ -121,14 +127,15 @@ export class FlatDisplayHeaderCell< Item, Plugins extends AnyPlugins = AnyPlugins > extends FlatHeaderCell { - constructor({ id, label = NBSP }: FlatDisplayHeaderCellInit) { - super({ id, label }); + constructor({ id, label = NBSP, colstart }: FlatDisplayHeaderCellInit) { + super({ id, label, colstart }); } clone(): FlatDisplayHeaderCell { return new FlatDisplayHeaderCell({ id: this.id, label: this.label, + colstart: this.colstart, }); } } @@ -148,8 +155,8 @@ export class GroupHeaderCell exte ids: string[]; allId: string; allIds: string[]; - constructor({ label, colspan, ids, allIds }: GroupHeaderCellInit) { - super({ id: `[${ids.join(',')}]`, label, colspan }); + constructor({ label, ids, allIds, colspan, colstart }: GroupHeaderCellInit) { + super({ id: `[${ids.join(',')}]`, label, colspan, colstart }); this.ids = ids; this.allId = `[${allIds.join(',')}]`; this.allIds = allIds; @@ -168,9 +175,10 @@ export class GroupHeaderCell exte clone(): GroupHeaderCell { return new GroupHeaderCell({ label: this.label, - colspan: this.colspan, ids: this.ids, allIds: this.allIds, + colspan: this.colspan, + colstart: this.colstart, }); } } @@ -189,19 +197,21 @@ export class GroupDisplayHeaderCell< > extends GroupHeaderCell { constructor({ label = NBSP, - colspan = 1, ids, allIds, + colspan = 1, + colstart, }: GroupDisplayHeaderCellInit) { - super({ label, colspan, ids, allIds }); + super({ label, ids, allIds, colspan, colstart }); } clone(): GroupDisplayHeaderCell { return new GroupDisplayHeaderCell({ label: this.label, - colspan: this.colspan, ids: this.ids, allIds: this.allIds, + colspan: this.colspan, + colstart: this.colstart, }); } } diff --git a/src/lib/headerRows.getHeaderRows.test.ts b/src/lib/headerRows.getHeaderRows.test.ts index 204549b..7807a51 100644 --- a/src/lib/headerRows.getHeaderRows.test.ts +++ b/src/lib/headerRows.getHeaderRows.test.ts @@ -42,16 +42,19 @@ it('arranges flat columns\n[][][]', () => { label: 'First Name', accessorKey: 'firstName', id: 'firstName', + colstart: 0, }), new DataHeaderCell({ label: 'Last Name', accessorKey: 'lastName', id: 'lastName', + colstart: 1, }), new DataHeaderCell({ label: 'Age', accessorKey: 'age', id: 'age', + colstart: 2, }), ], }), @@ -89,6 +92,7 @@ it('creates a group over flat columns\n[ ]\n[][][]', () => { cells: [ new GroupHeaderCell({ colspan: 3, + colstart: 0, label: 'Info', allIds: ['firstName', 'lastName', 'age'], ids: ['firstName', 'lastName', 'age'], @@ -99,16 +103,19 @@ it('creates a group over flat columns\n[ ]\n[][][]', () => { id: '1', cells: [ new DataHeaderCell({ + colstart: 0, label: 'First Name', accessorKey: 'firstName', id: 'firstName', }), new DataHeaderCell({ + colstart: 1, label: 'Last Name', accessorKey: 'lastName', id: 'lastName', }), new DataHeaderCell({ + colstart: 2, label: 'Age', accessorKey: 'age', id: 'age', @@ -162,12 +169,14 @@ it('creates two groups over different columns\n[ ][ ]\n[][][][][]', () => { cells: [ new GroupHeaderCell({ colspan: 2, + colstart: 0, label: 'Name', allIds: ['firstName', 'lastName'], ids: ['firstName', 'lastName'], }), new GroupHeaderCell({ colspan: 3, + colstart: 2, label: 'Info', allIds: ['age', 'status', 'progress'], ids: ['age', 'status', 'progress'], @@ -178,26 +187,31 @@ it('creates two groups over different columns\n[ ][ ]\n[][][][][]', () => { id: '1', cells: [ new DataHeaderCell({ + colstart: 0, label: 'First Name', accessorKey: 'firstName', id: 'firstName', }), new DataHeaderCell({ + colstart: 1, label: 'Last Name', accessorKey: 'lastName', id: 'lastName', }), new DataHeaderCell({ + colstart: 2, label: 'Age', accessorKey: 'age', id: 'age', }), new DataHeaderCell({ + colstart: 3, label: 'Status', accessorKey: 'status', id: 'status', }), new DataHeaderCell({ + colstart: 4, label: 'Profile Progress', accessorKey: 'progress', id: 'progress', @@ -246,39 +260,45 @@ it('groups a subset of columns and ungrouped columns have flat header cells on t cells: [ new GroupHeaderCell({ colspan: 2, + colstart: 0, label: 'Name', allIds: ['firstName', 'lastName'], ids: ['firstName', 'lastName'], }), - new GroupDisplayHeaderCell({ allIds: ['age'], ids: ['age'] }), - new GroupDisplayHeaderCell({ allIds: ['status'], ids: ['status'] }), - new GroupDisplayHeaderCell({ allIds: ['progress'], ids: ['progress'] }), + new GroupDisplayHeaderCell({ colstart: 2, allIds: ['age'], ids: ['age'] }), + new GroupDisplayHeaderCell({ colstart: 3, allIds: ['status'], ids: ['status'] }), + new GroupDisplayHeaderCell({ colstart: 4, allIds: ['progress'], ids: ['progress'] }), ], }), new HeaderRow({ id: '1', cells: [ new DataHeaderCell({ + colstart: 0, label: 'First Name', accessorKey: 'firstName', id: 'firstName', }), new DataHeaderCell({ + colstart: 1, label: 'Last Name', accessorKey: 'lastName', id: 'lastName', }), new DataHeaderCell({ + colstart: 2, label: 'Age', accessorKey: 'age', id: 'age', }), new DataHeaderCell({ + colstart: 3, label: 'Status', accessorKey: 'status', id: 'status', }), new DataHeaderCell({ + colstart: 4, label: 'Profile Progress', accessorKey: 'progress', id: 'progress', @@ -320,6 +340,7 @@ it('puts flat header cells on the last row if there is a gap between the group a cells: [ new GroupHeaderCell({ colspan: 2, + colstart: 0, label: 'ID', allIds: ['firstName', 'progress'], ids: ['firstName', 'progress'], @@ -331,22 +352,25 @@ it('puts flat header cells on the last row if there is a gap between the group a cells: [ new GroupHeaderCell({ colspan: 1, + colstart: 0, label: 'Name', allIds: ['firstName'], ids: ['firstName'], }), - new GroupDisplayHeaderCell({ ids: ['progress'], allIds: ['progress'] }), + new GroupDisplayHeaderCell({ colstart: 1, ids: ['progress'], allIds: ['progress'] }), ], }), new HeaderRow({ id: '2', cells: [ new DataHeaderCell({ + colstart: 0, label: 'First Name', accessorKey: 'firstName', id: 'firstName', }), new DataHeaderCell({ + colstart: 1, label: 'Profile Progress', accessorKey: 'progress', id: 'progress', @@ -393,11 +417,12 @@ it('puts group cells on the lowest row possible\n[]\n[][]\n[][]', () => { cells: [ new GroupHeaderCell({ colspan: 1, + colstart: 0, label: 'ID', allIds: ['firstName'], ids: ['firstName'], }), - new GroupDisplayHeaderCell({ allIds: ['progress'], ids: ['progress'] }), + new GroupDisplayHeaderCell({ colstart: 1, allIds: ['progress'], ids: ['progress'] }), ], }), new HeaderRow({ @@ -405,12 +430,14 @@ it('puts group cells on the lowest row possible\n[]\n[][]\n[][]', () => { cells: [ new GroupHeaderCell({ colspan: 1, + colstart: 0, label: 'Name', allIds: ['firstName'], ids: ['firstName'], }), new GroupHeaderCell({ colspan: 1, + colstart: 1, label: 'Info', allIds: ['progress'], ids: ['progress'], @@ -421,11 +448,13 @@ it('puts group cells on the lowest row possible\n[]\n[][]\n[][]', () => { id: '2', cells: [ new DataHeaderCell({ + colstart: 0, label: 'First Name', accessorKey: 'firstName', id: 'firstName', }), new DataHeaderCell({ + colstart: 1, label: 'Profile Progress', accessorKey: 'progress', id: 'progress', diff --git a/src/lib/headerRows.getMergedRow.test.ts b/src/lib/headerRows.getMergedRow.test.ts index c44b4cc..375c870 100644 --- a/src/lib/headerRows.getMergedRow.test.ts +++ b/src/lib/headerRows.getMergedRow.test.ts @@ -15,24 +15,28 @@ it('merges two sets of group cells', () => { new GroupHeaderCell({ label: 'Name', colspan: 1, + colstart: 0, allIds: ['firstName', 'lastName'], ids: ['firstName'], }), new GroupHeaderCell({ label: 'Name', colspan: 1, + colstart: 1, allIds: ['firstName', 'lastName'], ids: ['lastName'], }), new GroupHeaderCell({ label: 'Info', colspan: 1, + colstart: 2, allIds: ['age', 'status'], ids: ['age'], }), new GroupHeaderCell({ label: 'Info', colspan: 1, + colstart: 3, allIds: ['age', 'status'], ids: ['status'], }), @@ -44,12 +48,14 @@ it('merges two sets of group cells', () => { new GroupHeaderCell({ label: 'Name', colspan: 2, + colstart: 0, allIds: ['firstName', 'lastName'], ids: ['firstName', 'lastName'], }), new GroupHeaderCell({ label: 'Info', colspan: 2, + colstart: 2, allIds: ['age', 'status'], ids: ['age', 'status'], }), @@ -63,17 +69,29 @@ it('merges adjacent group cells in front', () => { new GroupHeaderCell({ label: 'Info', colspan: 1, + colstart: 0, allIds: ['age', 'status'], ids: ['age'], }), new GroupHeaderCell({ label: 'Info', colspan: 1, + colstart: 1, allIds: ['age', 'status'], ids: ['status'], }), - new DataHeaderCell({ label: 'First Name', accessorKey: 'firstName', id: 'firstName' }), - new DataHeaderCell({ label: 'Last Name', accessorKey: 'lastName', id: 'lastName' }), + new DataHeaderCell({ + colstart: 2, + label: 'First Name', + accessorKey: 'firstName', + id: 'firstName', + }), + new DataHeaderCell({ + colstart: 3, + label: 'Last Name', + accessorKey: 'lastName', + id: 'lastName', + }), ]; const actual = getMergedRow(cells); @@ -82,11 +100,22 @@ it('merges adjacent group cells in front', () => { new GroupHeaderCell({ label: 'Info', colspan: 2, + colstart: 0, allIds: ['age', 'status'], ids: ['age', 'status'], }), - new DataHeaderCell({ label: 'First Name', accessorKey: 'firstName', id: 'firstName' }), - new DataHeaderCell({ label: 'Last Name', accessorKey: 'lastName', id: 'lastName' }), + new DataHeaderCell({ + colstart: 2, + label: 'First Name', + accessorKey: 'firstName', + id: 'firstName', + }), + new DataHeaderCell({ + colstart: 3, + label: 'Last Name', + accessorKey: 'lastName', + id: 'lastName', + }), ]; expect(actual).toStrictEqual(expected); @@ -94,17 +123,29 @@ it('merges adjacent group cells in front', () => { it('merges adjacent group cells behind', () => { const cells = [ - new DataHeaderCell({ label: 'First Name', accessorKey: 'firstName', id: 'firstName' }), - new DataHeaderCell({ label: 'Last Name', accessorKey: 'lastName', id: 'lastName' }), + new DataHeaderCell({ + colstart: 0, + label: 'First Name', + accessorKey: 'firstName', + id: 'firstName', + }), + new DataHeaderCell({ + colstart: 1, + label: 'Last Name', + accessorKey: 'lastName', + id: 'lastName', + }), new GroupHeaderCell({ label: 'Info', colspan: 1, + colstart: 2, allIds: ['age', 'status'], ids: ['age'], }), new GroupHeaderCell({ label: 'Info', colspan: 1, + colstart: 3, allIds: ['age', 'status'], ids: ['status'], }), @@ -113,11 +154,22 @@ it('merges adjacent group cells behind', () => { const actual = getMergedRow(cells); const expected = [ - new DataHeaderCell({ label: 'First Name', accessorKey: 'firstName', id: 'firstName' }), - new DataHeaderCell({ label: 'Last Name', accessorKey: 'lastName', id: 'lastName' }), + new DataHeaderCell({ + colstart: 0, + label: 'First Name', + accessorKey: 'firstName', + id: 'firstName', + }), + new DataHeaderCell({ + colstart: 1, + label: 'Last Name', + accessorKey: 'lastName', + id: 'lastName', + }), new GroupHeaderCell({ label: 'Info', colspan: 2, + colstart: 2, allIds: ['age', 'status'], ids: ['age', 'status'], }), @@ -131,12 +183,24 @@ it('does not merge disjoint group cells', () => { new GroupHeaderCell({ label: 'Info', colspan: 1, + colstart: 0, allIds: ['age', 'status'], ids: ['age'], }), - new DataHeaderCell({ label: 'First Name', accessorKey: 'firstName', id: 'firstName' }), - new DataHeaderCell({ label: 'Last Name', accessorKey: 'lastName', id: 'lastName' }), + new DataHeaderCell({ + colstart: 1, + label: 'First Name', + accessorKey: 'firstName', + id: 'firstName', + }), + new DataHeaderCell({ + colstart: 2, + label: 'Last Name', + accessorKey: 'lastName', + id: 'lastName', + }), new GroupHeaderCell({ + colstart: 3, label: 'Info', colspan: 1, allIds: ['age', 'status'], @@ -150,14 +214,26 @@ it('does not merge disjoint group cells', () => { new GroupHeaderCell({ label: 'Info', colspan: 1, + colstart: 0, allIds: ['age', 'status'], ids: ['age'], }), - new DataHeaderCell({ label: 'First Name', accessorKey: 'firstName', id: 'firstName' }), - new DataHeaderCell({ label: 'Last Name', accessorKey: 'lastName', id: 'lastName' }), + new DataHeaderCell({ + colstart: 1, + label: 'First Name', + accessorKey: 'firstName', + id: 'firstName', + }), + new DataHeaderCell({ + colstart: 2, + label: 'Last Name', + accessorKey: 'lastName', + id: 'lastName', + }), new GroupHeaderCell({ label: 'Info', colspan: 1, + colstart: 3, allIds: ['age', 'status'], ids: ['status'], }), diff --git a/src/lib/headerRows.getOrderedColumnMatrix.test.ts b/src/lib/headerRows.getOrderedColumnMatrix.test.ts index 3ffb49f..62de8a7 100644 --- a/src/lib/headerRows.getOrderedColumnMatrix.test.ts +++ b/src/lib/headerRows.getOrderedColumnMatrix.test.ts @@ -17,27 +17,56 @@ it('orders the matrix columns', () => { new GroupHeaderCell({ label: 'Name', colspan: 1, + colstart: 0, allIds: ['firstName', 'lastName'], ids: [], }), - new DataHeaderCell({ label: 'First Name', accessorKey: 'firstName', id: 'firstName' }), + new DataHeaderCell({ + label: 'First Name', + colstart: 0, + accessorKey: 'firstName', + id: 'firstName', + }), ], [ new GroupHeaderCell({ label: 'Name', colspan: 1, + colstart: 1, allIds: ['firstName', 'lastName'], ids: [], }), - new DataHeaderCell({ label: 'Last Name', accessorKey: 'lastName', id: 'lastName' }), + new DataHeaderCell({ + label: 'Last Name', + colstart: 1, + accessorKey: 'lastName', + id: 'lastName', + }), ], [ - new GroupHeaderCell({ label: 'Info', colspan: 1, allIds: ['age', 'progress'], ids: [] }), - new DataHeaderCell({ label: 'Age', accessorKey: 'age', id: 'age' }), + new GroupHeaderCell({ + label: 'Info', + colspan: 1, + colstart: 2, + allIds: ['age', 'progress'], + ids: [], + }), + new DataHeaderCell({ label: 'Age', colstart: 2, accessorKey: 'age', id: 'age' }), ], [ - new GroupHeaderCell({ label: 'Info', colspan: 1, allIds: ['age', 'progress'], ids: [] }), - new DataHeaderCell({ label: 'Progress', accessorKey: 'progress', id: 'progress' }), + new GroupHeaderCell({ + label: 'Info', + colspan: 1, + colstart: 3, + allIds: ['age', 'progress'], + ids: [], + }), + new DataHeaderCell({ + label: 'Progress', + colstart: 3, + accessorKey: 'progress', + id: 'progress', + }), ], ]; @@ -48,27 +77,56 @@ it('orders the matrix columns', () => { new GroupHeaderCell({ label: 'Name', colspan: 1, + colstart: 0, allIds: ['firstName', 'lastName'], ids: [], }), - new DataHeaderCell({ label: 'First Name', accessorKey: 'firstName', id: 'firstName' }), + new DataHeaderCell({ + label: 'First Name', + colstart: 0, + accessorKey: 'firstName', + id: 'firstName', + }), ], [ - new GroupHeaderCell({ label: 'Info', colspan: 1, allIds: ['age', 'progress'], ids: [] }), - new DataHeaderCell({ label: 'Age', accessorKey: 'age', id: 'age' }), + new GroupHeaderCell({ + label: 'Info', + colspan: 1, + colstart: 1, + allIds: ['age', 'progress'], + ids: [], + }), + new DataHeaderCell({ label: 'Age', colstart: 1, accessorKey: 'age', id: 'age' }), ], [ new GroupHeaderCell({ label: 'Name', colspan: 1, + colstart: 2, allIds: ['firstName', 'lastName'], ids: [], }), - new DataHeaderCell({ label: 'Last Name', accessorKey: 'lastName', id: 'lastName' }), + new DataHeaderCell({ + label: 'Last Name', + colstart: 2, + accessorKey: 'lastName', + id: 'lastName', + }), ], [ - new GroupHeaderCell({ label: 'Info', colspan: 1, allIds: ['age', 'progress'], ids: [] }), - new DataHeaderCell({ label: 'Progress', accessorKey: 'progress', id: 'progress' }), + new GroupHeaderCell({ + label: 'Info', + colspan: 1, + colstart: 3, + allIds: ['age', 'progress'], + ids: [], + }), + new DataHeaderCell({ + label: 'Progress', + colstart: 3, + accessorKey: 'progress', + id: 'progress', + }), ], ]; @@ -81,27 +139,56 @@ it('ignores empty ordering', () => { new GroupHeaderCell({ label: 'Name', colspan: 1, + colstart: 0, allIds: ['firstName', 'lastName'], ids: [], }), - new DataHeaderCell({ label: 'First Name', accessorKey: 'firstName', id: 'firstName' }), + new DataHeaderCell({ + label: 'First Name', + colstart: 0, + accessorKey: 'firstName', + id: 'firstName', + }), ], [ new GroupHeaderCell({ label: 'Name', colspan: 1, + colstart: 1, allIds: ['firstName', 'lastName'], ids: [], }), - new DataHeaderCell({ label: 'Last Name', accessorKey: 'lastName', id: 'lastName' }), + new DataHeaderCell({ + label: 'Last Name', + colstart: 1, + accessorKey: 'lastName', + id: 'lastName', + }), ], [ - new GroupHeaderCell({ label: 'Info', colspan: 1, allIds: ['age', 'progress'], ids: [] }), - new DataHeaderCell({ label: 'Age', accessorKey: 'age', id: 'age' }), + new GroupHeaderCell({ + label: 'Info', + colspan: 1, + colstart: 2, + allIds: ['age', 'progress'], + ids: [], + }), + new DataHeaderCell({ label: 'Age', colstart: 2, accessorKey: 'age', id: 'age' }), ], [ - new GroupHeaderCell({ label: 'Info', colspan: 1, allIds: ['age', 'progress'], ids: [] }), - new DataHeaderCell({ label: 'Progress', accessorKey: 'progress', id: 'progress' }), + new GroupHeaderCell({ + label: 'Info', + colspan: 1, + colstart: 3, + allIds: ['age', 'progress'], + ids: [], + }), + new DataHeaderCell({ + label: 'Progress', + colstart: 3, + accessorKey: 'progress', + id: 'progress', + }), ], ]; @@ -112,27 +199,56 @@ it('ignores empty ordering', () => { new GroupHeaderCell({ label: 'Name', colspan: 1, + colstart: 0, allIds: ['firstName', 'lastName'], ids: [], }), - new DataHeaderCell({ label: 'First Name', accessorKey: 'firstName', id: 'firstName' }), + new DataHeaderCell({ + label: 'First Name', + colstart: 0, + accessorKey: 'firstName', + id: 'firstName', + }), ], [ new GroupHeaderCell({ label: 'Name', colspan: 1, + colstart: 1, allIds: ['firstName', 'lastName'], ids: [], }), - new DataHeaderCell({ label: 'Last Name', accessorKey: 'lastName', id: 'lastName' }), + new DataHeaderCell({ + label: 'Last Name', + colstart: 1, + accessorKey: 'lastName', + id: 'lastName', + }), ], [ - new GroupHeaderCell({ label: 'Info', colspan: 1, allIds: ['age', 'progress'], ids: [] }), - new DataHeaderCell({ label: 'Age', accessorKey: 'age', id: 'age' }), + new GroupHeaderCell({ + label: 'Info', + colspan: 1, + colstart: 2, + allIds: ['age', 'progress'], + ids: [], + }), + new DataHeaderCell({ label: 'Age', colstart: 2, accessorKey: 'age', id: 'age' }), ], [ - new GroupHeaderCell({ label: 'Info', colspan: 1, allIds: ['age', 'progress'], ids: [] }), - new DataHeaderCell({ label: 'Progress', accessorKey: 'progress', id: 'progress' }), + new GroupHeaderCell({ + label: 'Info', + colspan: 1, + colstart: 3, + allIds: ['age', 'progress'], + ids: [], + }), + new DataHeaderCell({ + label: 'Progress', + colstart: 3, + accessorKey: 'progress', + id: 'progress', + }), ], ]; diff --git a/src/lib/headerRows.ts b/src/lib/headerRows.ts index 7a11005..8348311 100644 --- a/src/lib/headerRows.ts +++ b/src/lib/headerRows.ts @@ -81,9 +81,10 @@ export const getHeaderRowMatrix = cells.map((cell, columnIdx) => { if (cell !== null) return cell; - if (rowIdx === maxHeight - 1) return new FlatDisplayHeaderCell({ id: columnIdx.toString() }); + if (rowIdx === maxHeight - 1) + return new FlatDisplayHeaderCell({ id: columnIdx.toString(), colstart: columnIdx }); const flatId = rowMatrix[maxHeight - 1][columnIdx]?.id ?? columnIdx.toString(); - return new GroupDisplayHeaderCell({ ids: [], allIds: [flatId] }); + return new GroupDisplayHeaderCell({ ids: [], allIds: [flatId], colstart: columnIdx }); }) ); }; @@ -101,6 +102,7 @@ const loadHeaderRowMatrix = ( accessorFn: column.accessorFn, accessorKey: column.accessorKey as keyof Item, id: column.id, + colstart: cellOffset, }); return; } @@ -108,6 +110,7 @@ const loadHeaderRowMatrix = ( rowMatrix[rowMatrix.length - 1][cellOffset] = new FlatDisplayHeaderCell({ id: column.id, label: column.header, + colstart: cellOffset, }); return; } @@ -119,6 +122,7 @@ const loadHeaderRowMatrix = ( colspan: 1, allIds: column.ids, ids: [], + colstart: cellOffset, }); } let childCellOffset = 0; @@ -140,7 +144,7 @@ export const getOrderedColumnMatrix = > = []; // Each row of the transposed matrix represents a column. // The `FlatHeaderCell` should be the last cell of each column. - flatColumnIds.forEach((key) => { + flatColumnIds.forEach((key, columnIdx) => { const nextColumn = columnMatrix.find((columnCells) => { const flatCell = columnCells[columnCells.length - 1]; if (!(flatCell instanceof FlatHeaderCell)) { @@ -149,7 +153,13 @@ export const getOrderedColumnMatrix = { + const clonedColumn = column.clone(); + clonedColumn.colstart = columnIdx; + return clonedColumn; + }) + ); } }); return orderedColumnMatrix; diff --git a/src/lib/plugins/addGridLayout.ts b/src/lib/plugins/addGridLayout.ts new file mode 100644 index 0000000..34e77b1 --- /dev/null +++ b/src/lib/plugins/addGridLayout.ts @@ -0,0 +1,91 @@ +import type { + TableAttributes, + TableBodyAttributes, + TableHeadAttributes, +} from '$lib/createViewModel'; +import type { DeriveFn, NewTablePropSet, TablePlugin } from '$lib/types/TablePlugin'; +import { derived } from 'svelte/store'; + +export const addGridLayout = + (): TablePlugin< + Item, + Record, + Record, + NewTablePropSet + > => + ({ tableState }) => { + const pluginState = {}; + + const deriveTableAttrs: DeriveFn> = (attrs) => { + return derived([attrs, tableState.visibleColumns], ([$attrs, $visibleColumns]) => { + return { + ...$attrs, + style: { + display: 'grid', + 'grid-template-columns': `repeat(${$visibleColumns.length}, auto)`, + }, + }; + }); + }; + + const deriveTableHeadAttrs: DeriveFn> = (attrs) => { + return derived(attrs, ($attrs) => { + return { + ...$attrs, + style: { + display: 'contents', + }, + }; + }); + }; + + const deriveTableBodyAttrs: DeriveFn> = (attrs) => { + return derived(attrs, ($attrs) => { + return { + ...$attrs, + style: { + display: 'contents', + }, + }; + }); + }; + + return { + pluginState, + deriveTableAttrs, + deriveTableHeadAttrs, + deriveTableBodyAttrs, + hooks: { + 'thead.tr': () => { + const attrs = derived([], () => { + return { + style: { + display: 'contents', + }, + }; + }); + return { attrs }; + }, + 'thead.tr.th': (cell) => { + const attrs = derived([], () => { + return { + style: { + 'grid-column': `${cell.colstart + 1} / span ${cell.colspan}`, + }, + }; + }); + return { attrs }; + }, + 'tbody.tr': () => { + const attrs = derived([], () => { + return { + style: { + display: 'contents', + }, + }; + }); + return { attrs }; + }, + }, + }; + }; diff --git a/src/lib/plugins/index.ts b/src/lib/plugins/index.ts index 60944d9..190bedd 100644 --- a/src/lib/plugins/index.ts +++ b/src/lib/plugins/index.ts @@ -8,6 +8,7 @@ export { } from './addColumnFilters'; export { addColumnOrder } from './addColumnOrder'; export { addExpandedRows } from './addExpandedRows'; +export { addGridLayout } from './addGridLayout'; export { addGroupBy } from './addGroupBy'; export { addHiddenColumns } from './addHiddenColumns'; export { addPagination } from './addPagination'; diff --git a/src/lib/tableComponent.ts b/src/lib/tableComponent.ts index eb5c650..04f4c19 100644 --- a/src/lib/tableComponent.ts +++ b/src/lib/tableComponent.ts @@ -8,7 +8,7 @@ import type { } from './types/TablePlugin'; import type { TableState } from './createViewModel'; import type { Clonable } from './utils/clone'; -import { stringifyCss } from './utils/css'; +import { finalizeAttributes, mergeAttributes } from './utils/attributes'; export interface TableComponentInit { id: string; @@ -25,21 +25,11 @@ export abstract class TableComponent>> = {}; attrs(): Readable> { return derived(Object.values(this.attrsForName), ($attrsArray) => { - const $mergedAttrs: Record = {}; - $attrsArray.forEach(({ style, ...$attrs }) => { - // Handle style object. - if (style !== undefined && typeof style === 'object') { - if ($mergedAttrs.style === undefined) { - $mergedAttrs.style = {}; - } - Object.assign($mergedAttrs.style, style); - } - Object.assign($mergedAttrs, $attrs); + let $mergedAttrs: Record = {}; + $attrsArray.forEach(($attrs) => { + $mergedAttrs = mergeAttributes($mergedAttrs, $attrs); }); - if ($mergedAttrs.style !== undefined) { - $mergedAttrs.style = stringifyCss($mergedAttrs.style as Record); - } - return $mergedAttrs; + return finalizeAttributes($mergedAttrs); }); } diff --git a/src/lib/types/TablePlugin.ts b/src/lib/types/TablePlugin.ts index d362635..b91267d 100644 --- a/src/lib/types/TablePlugin.ts +++ b/src/lib/types/TablePlugin.ts @@ -3,7 +3,12 @@ import type { BodyRow, BodyRowAttributes } from '$lib/bodyRows'; import type { DataColumn, FlatColumn } from '$lib/columns'; import type { HeaderCell, HeaderCellAttributes } from '$lib/headerCells'; import type { HeaderRow, HeaderRowAttributes } from '$lib/headerRows'; -import type { PluginInitTableState } from '$lib/createViewModel'; +import type { + PluginInitTableState, + TableAttributes, + TableBodyAttributes, + TableHeadAttributes, +} from '$lib/createViewModel'; import type { Readable } from 'svelte/store'; export type TablePlugin< @@ -35,6 +40,9 @@ export type TablePluginInstance< deriveFlatColumns?: DeriveFlatColumnsFn; deriveRows?: DeriveRowsFn; derivePageRows?: DeriveRowsFn; + deriveTableAttrs?: DeriveFn>; + deriveTableHeadAttrs?: DeriveFn>; + deriveTableBodyAttrs?: DeriveFn>; columnOptions?: ColumnOptions; hooks?: TableHooks; }; @@ -63,6 +71,8 @@ export type DeriveRowsFn = >( rows: Readable ) => Readable; +export type DeriveFn = (obj: Readable) => Readable; + export type Components = { 'thead.tr': HeaderRow; 'thead.tr.th': HeaderCell; diff --git a/src/lib/utils/attributes.finalizeAttributes.test.ts b/src/lib/utils/attributes.finalizeAttributes.test.ts new file mode 100644 index 0000000..9d8773c --- /dev/null +++ b/src/lib/utils/attributes.finalizeAttributes.test.ts @@ -0,0 +1,38 @@ +import { finalizeAttributes } from './attributes'; + +it('ignores undefined style', () => { + const actual = finalizeAttributes({ + a: 1, + b: 2, + }); + + const expected = { a: 1, b: 2 }; + + expect(actual).toStrictEqual(expected); +}); + +it('ignores string style', () => { + const actual = finalizeAttributes({ + a: 1, + b: 2, + style: 'display:flex', + }); + + const expected = { a: 1, b: 2, style: 'display:flex' }; + + expect(actual).toStrictEqual(expected); +}); + +it('stringifies the style object', () => { + const actual = finalizeAttributes({ + a: 1, + b: 2, + style: { + display: 'flex', + }, + }); + + const expected = { a: 1, b: 2, style: 'display:flex' }; + + expect(actual).toStrictEqual(expected); +}); diff --git a/src/lib/utils/attributes.mergeAttributes.test.ts b/src/lib/utils/attributes.mergeAttributes.test.ts new file mode 100644 index 0000000..251c4a0 --- /dev/null +++ b/src/lib/utils/attributes.mergeAttributes.test.ts @@ -0,0 +1,56 @@ +import { mergeAttributes } from './attributes'; + +it('merges basic attributes without styles', () => { + const actual = mergeAttributes( + { + a: 1, + b: 2, + }, + { + c: 3, + b: 4, + } + ); + + const expected = { + a: 1, + b: 4, + c: 3, + }; + + expect(actual).toStrictEqual(expected); +}); + +it('merges attributes with styles', () => { + const actual = mergeAttributes( + { + a: 1, + b: 2, + style: { + a: '1', + b: '2', + }, + }, + { + c: 3, + b: 4, + style: { + c: '3', + b: '4', + }, + } + ); + + const expected = { + a: 1, + b: 4, + c: 3, + style: { + a: '1', + b: '4', + c: '3', + }, + }; + + expect(actual).toStrictEqual(expected); +}); diff --git a/src/lib/utils/attributes.ts b/src/lib/utils/attributes.ts new file mode 100644 index 0000000..8193fb0 --- /dev/null +++ b/src/lib/utils/attributes.ts @@ -0,0 +1,33 @@ +import { stringifyCss } from './css'; + +export const mergeAttributes = < + T extends Record, + U extends Record +>( + a: T, + b: U +): T & U => { + if (a.style === undefined && b.style === undefined) { + return { ...a, ...b }; + } + return { + ...a, + ...b, + style: { + ...(typeof a.style === 'object' ? a.style : {}), + ...(typeof b.style === 'object' ? b.style : {}), + }, + }; +}; + +export const finalizeAttributes = >( + attrs: T +): Record => { + if (attrs.style === undefined || typeof attrs.style !== 'object') { + return attrs; + } + return { + ...attrs, + style: stringifyCss(attrs.style as Record), + }; +}; diff --git a/src/routes/alt-layouts.svelte b/src/routes/alt-layouts.svelte index 6ec156b..fc55c76 100644 --- a/src/routes/alt-layouts.svelte +++ b/src/routes/alt-layouts.svelte @@ -15,6 +15,8 @@ addSubRows, addGroupBy, addSelectedRows, + addGridLayout, + addResizedColumns, } from '$lib/plugins'; import { mean, sum } from '$lib/utils/math'; import { getShuffled } from './_getShuffled'; @@ -54,6 +56,8 @@ page: addPagination({ initialPageSize: 20, }), + resize: addResizedColumns(), + layout: addGridLayout(), }); const columns = table.createColumns([ @@ -67,6 +71,11 @@ isSomeSubRowsSelected, }); }, + plugins: { + resize: { + disable: true, + }, + }, }), table.display({ id: 'expanded', @@ -81,6 +90,11 @@ depth: row.depth, }); }, + plugins: { + resize: { + disable: true, + }, + }, }), table.column({ header: 'Summary', @@ -160,6 +174,9 @@ id: 'status', accessor: (item) => item.status, plugins: { + resize: { + disable: true, + }, sort: { disable: true, }, @@ -203,8 +220,15 @@ }), ]); - const { headerRows, pageRows, tableAttrs, tableBodyAttrs, visibleColumns, pluginStates } = - table.createViewModel(columns); + const { + headerRows, + pageRows, + tableAttrs, + tableHeadAttrs, + tableBodyAttrs, + visibleColumns, + pluginStates, + } = table.createViewModel(columns); const { groupByIds } = pluginStates.group; const { sortKeys } = pluginStates.sort; @@ -231,7 +255,7 @@
-
+
{#each $headerRows as headerRow (headerRow.id)}
@@ -242,6 +266,7 @@ {...attrs} on:click={props.sort.toggle} class:sorted={props.sort.order !== undefined} + use:props.resize >
@@ -263,14 +288,21 @@ {#if props.filter !== undefined} {/if} + {#if !props.resize.disabled} +
+ {/if}
{/each}
{/each} -
-
+
+
@@ -338,6 +370,21 @@ border-right: 1px solid black; } + .th { + position: relative; + } + + .th .resizer { + position: absolute; + top: 0; + bottom: 0; + right: -4px; + width: 8px; + z-index: 1; + background: lightgray; + cursor: col-resize; + } + .sorted { background: rgb(144, 191, 148); }