diff --git a/.changeset/tasty-plums-sparkle.md b/.changeset/tasty-plums-sparkle.md new file mode 100644 index 0000000..e2523dd --- /dev/null +++ b/.changeset/tasty-plums-sparkle.md @@ -0,0 +1,5 @@ +--- +'@careswitch/svelte-data-table': minor +--- + +Introduce `id` in column definition to allow multiple columns for the same key diff --git a/README.md b/README.md index 7c9ecf8..9779b84 100644 --- a/README.md +++ b/README.md @@ -33,9 +33,9 @@ _Requires Svelte 5 peer dependency_ { id: 2, name: 'Jane Doe', status: 'inactive' } ], columns: [ - { key: 'id', name: 'ID' }, - { key: 'name', name: 'Name' }, - { key: 'status', name: 'Status' } + { id: 'id', key: 'id', name: 'ID' }, + { id: 'name', key: 'name', name: 'Name' }, + { id: 'status', key: 'status', name: 'Status' } ] }); @@ -43,7 +43,7 @@ _Requires Svelte 5 peer dependency_ - {#each table.columns as column (column.key)} + {#each table.columns as column (column.id)} {/each} @@ -51,7 +51,7 @@ _Requires Svelte 5 peer dependency_ {#each table.rows as row (row.id)} - {#each table.columns as column (column.key)} + {#each table.columns as column (column.id)} {/each} diff --git a/src/index.test.ts b/src/index.test.ts index 41c9b8d..d2f3f8f 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -11,9 +11,16 @@ describe('DataTable', () => { ]; const columns = [ - { key: 'id', name: 'ID', sortable: true }, - { key: 'name', name: 'Name', sortable: true }, - { key: 'age', name: 'Age', sortable: true } + { id: 'id', key: 'id', name: 'ID', sortable: true }, + { id: 'name', key: 'name', name: 'Name', sortable: true }, + { id: 'age', key: 'age', name: 'Age', sortable: true }, + { + id: 'ageGroup', + key: 'age', + name: 'Age Group', + sortable: true, + getValue: (row) => (row.age < 30 ? 'Young' : 'Adult') + } ] satisfies ColumnDef<(typeof sampleData)[0]>[]; describe('Initialization', () => { @@ -60,12 +67,18 @@ describe('DataTable', () => { const incompleteData = [ { id: 1, name: 'Alice' }, { id: 2, age: 25 } - ]; + ] as any[]; const table = new DataTable({ data: incompleteData, columns }); expect(table.rows).toHaveLength(2); expect(table.rows[0].age).toBeUndefined(); expect(table.rows[1].name).toBeUndefined(); }); + + it('should handle multiple columns with the same key', () => { + const table = new DataTable({ data: sampleData, columns }); + expect(table.columns).toHaveLength(4); + expect(table.columns.filter((col) => col.key === 'age')).toHaveLength(2); + }); }); describe('Sorting', () => { @@ -101,7 +114,7 @@ describe('DataTable', () => { it('should handle sorting with custom sorter function', () => { const customColumns = columns.map((col) => - col.key === 'name' + col.id === 'name' ? { ...col, sorter: (a, b) => b.name.localeCompare(a.name) // Reverse alphabetical order @@ -136,7 +149,7 @@ describe('DataTable', () => { { id: 1, name: 'Alice', age: null }, { id: 2, name: 'Bob', age: null }, { id: 3, name: 'Charlie', age: null } - ]; + ] as any[]; const table = new DataTable({ data: nullData, columns }); table.toggleSort('age'); expect(table.rows).toHaveLength(3); @@ -144,6 +157,14 @@ describe('DataTable', () => { expect(table.rows[0].name).toBe('Alice'); expect(table.rows[2].name).toBe('Charlie'); }); + + it('should sort independently for columns with the same key', () => { + const table = new DataTable({ data: sampleData, columns }); + table.toggleSort('age'); + const ageSortState = table.getSortState('age'); + const ageGroupSortState = table.getSortState('ageGroup'); + expect(ageSortState).not.toBe(ageGroupSortState); + }); }); describe('Filtering', () => { @@ -167,6 +188,13 @@ describe('DataTable', () => { ).toBe(true); }); + it('should filter derived columns', () => { + const table = new DataTable({ data: sampleData, columns }); + table.setFilter('ageGroup', ['Young']); + expect(table.rows).toHaveLength(2); + expect(table.rows.every((row) => row.age < 30)).toBe(true); + }); + it('should clear filter', () => { const table = new DataTable({ data: sampleData, columns }); table.setFilter('age', [30]); @@ -231,6 +259,13 @@ describe('DataTable', () => { expect(table.rows).toHaveLength(2); expect(table.rows.every((row) => row.name === 'Alice' || row.name === 'Bob')).toBe(true); }); + + it('should filter independently for columns with the same key', () => { + const table = new DataTable({ data: sampleData, columns }); + table.setFilter('age', [30, 35]); + table.setFilter('ageGroup', ['Young']); + expect(table.rows).toHaveLength(0); // No rows match both filters + }); }); describe('Pagination', () => { @@ -276,19 +311,7 @@ describe('DataTable', () => { }); }); - describe('baseRows functionality', () => { - const sampleData = [ - { id: 1, name: 'Alice', age: 30 }, - { id: 2, name: 'Bob', age: 25 }, - { id: 3, name: 'Charlie', age: 35 } - ]; - - const columns = [ - { key: 'id', name: 'ID', sortable: true }, - { key: 'name', name: 'Name', sortable: true }, - { key: 'age', name: 'Age', sortable: true } - ] satisfies ColumnDef<(typeof sampleData)[0]>[]; - + describe('baseRows', () => { it('should return the original data when getting baseRows', () => { const table = new DataTable({ data: sampleData, columns }); expect(table.baseRows).toEqual(sampleData); diff --git a/src/lib/DataTable.svelte.ts b/src/lib/DataTable.svelte.ts index deb2300..841ceaa 100644 --- a/src/lib/DataTable.svelte.ts +++ b/src/lib/DataTable.svelte.ts @@ -3,6 +3,7 @@ type Sorter = (a: T, b: T) => number; type Filter = (value: V, filterValue: V, row: T) => boolean; export interface ColumnDef { + id: string; key: keyof T; name: string; sortable?: boolean; @@ -17,9 +18,9 @@ type TableConfig = { data: T[]; columns: ColumnDef[]; pageSize?: number; - initialSort?: keyof T; + initialSort?: string; initialSortDirection?: SortDirection; - initialFilters?: { [K in keyof T]?: any[] }; + initialFilters?: { [id: string]: any[] }; }; /** @@ -31,11 +32,11 @@ export class DataTable { #columns = $state[]>([]); #pageSize = $state(10); #currentPage = $state(1); - #sortState = $state<{ column: keyof T | null; direction: SortDirection }>({ - column: null, + #sortState = $state<{ columnId: string | null; direction: SortDirection }>({ + columnId: null, direction: null }); - #filterState = $state<{ [K in keyof T]: Set }>({} as any); + #filterState = $state<{ [id: string]: Set }>({}); #globalFilter = $state(''); #globalFilterRegex = $state(null); @@ -55,51 +56,54 @@ export class DataTable { this.#pageSize = config.pageSize || 10; if (config.initialSort) { this.#sortState = { - column: config.initialSort, + columnId: config.initialSort, direction: config.initialSortDirection || 'asc' }; } this.#initializeFilterState(config.initialFilters); } - #initializeFilterState(initialFilters?: { [K in keyof T]?: any[] }) { + #initializeFilterState(initialFilters?: { [id: string]: any[] }) { this.#columns.forEach((column) => { - const initialFilterValues = initialFilters?.[column.key]; + const initialFilterValues = initialFilters?.[column.id]; if (initialFilterValues) { - this.#filterState[column.key] = new Set(initialFilterValues); + this.#filterState[column.id] = new Set(initialFilterValues); } else { - this.#filterState[column.key] = new Set(); + this.#filterState[column.id] = new Set(); } }); } - #getColumnDef(key: keyof T): ColumnDef | undefined { - return this.#columns.find((col) => col.key === key); + #getColumnDef(id: string): ColumnDef | undefined { + return this.#columns.find((col) => col.id === id); } - #getValue(row: T, key: K): T[K] | any { - const colDef = this.#getColumnDef(key); - return colDef && colDef.getValue ? colDef.getValue(row) : row[key]; + #getValue(row: T, columnId: string): any { + const colDef = this.#getColumnDef(columnId); + if (!colDef) return undefined; + return colDef.getValue ? colDef.getValue(row) : row[colDef.key]; } #matchesGlobalFilter = (row: T): boolean => { if (!this.#globalFilterRegex) return true; return this.#columns.some((col) => { - const value = this.#getValue(row, col.key); + const value = this.#getValue(row, col.id); return typeof value === 'string' && this.#globalFilterRegex!.test(value); }); }; #matchesFilters = (row: T): boolean => { - return (Object.keys(this.#filterState) as Array).every((key) => { - const filterSet = this.#filterState[key]; + return Object.keys(this.#filterState).every((columnId) => { + const filterSet = this.#filterState[columnId]; if (!filterSet || filterSet.size === 0) return true; - const colDef = this.#getColumnDef(key); - const value = this.#getValue(row, key); + const colDef = this.#getColumnDef(columnId); + if (!colDef) return true; - if (colDef && colDef.filter) { + const value = this.#getValue(row, columnId); + + if (colDef.filter) { return Array.from(filterSet).some((filterValue) => colDef.filter!(value, filterValue, row)); } @@ -120,15 +124,15 @@ export class DataTable { #applySort() { if (!this.#isSortDirty) return; - const { column, direction } = this.#sortState; - if (column && direction) { - const colDef = this.#getColumnDef(column); + const { columnId, direction } = this.#sortState; + if (columnId && direction) { + const colDef = this.#getColumnDef(columnId); this.#sortedData = [...this.#filteredData].sort((a, b) => { if (colDef && colDef.sorter) { return direction === 'asc' ? colDef.sorter(a, b) : colDef.sorter(b, a); } - const aVal = this.#getValue(a, column); - const bVal = this.#getValue(b, column); + const aVal = this.#getValue(a, columnId); + const bVal = this.#getValue(b, columnId); if (aVal < bVal) return direction === 'asc' ? -1 : 1; if (aVal > bVal) return direction === 'asc' ? 1 : -1; return 0; @@ -251,17 +255,17 @@ export class DataTable { } /** - * Toggles the sort state for a given column. - * @param {keyof T} column - The key of the column to toggle sorting for. + * Toggles the sort direction for the specified column. + * @param {string} columnId - The column id to toggle sorting for. */ - toggleSort = (column: keyof T) => { - const colDef = this.#getColumnDef(column); + toggleSort = (columnId: string) => { + const colDef = this.#getColumnDef(columnId); if (!colDef || colDef.sortable === false) return; this.#isSortDirty = true; - if (this.#sortState.column === column) { + if (this.#sortState.columnId === columnId) { this.#sortState = { - column, + columnId, direction: this.#sortState.direction === 'asc' ? 'desc' @@ -270,77 +274,71 @@ export class DataTable { : 'asc' }; } else { - this.#sortState = { column, direction: 'asc' }; + this.#sortState = { columnId, direction: 'asc' }; } }; /** - * Gets the current sort state for a given column. - * @param {keyof T} column - The key of the column to get the sort state for. - * @returns {SortDirection} The current sort direction for the column. + * Gets the current sort state for the specified column. + * @param {string} columnId - The column id to get the sort state for. */ - getSortState = (column: keyof T): SortDirection => { - return this.#sortState.column === column ? this.#sortState.direction : null; + getSortState = (columnId: string): SortDirection => { + return this.#sortState.columnId === columnId ? this.#sortState.direction : null; }; /** - * Checks if a column is sortable. - * @param {keyof T} column - The key of the column to check. - * @returns {boolean} True if the column is sortable, false otherwise. + * Indicates whether the specified column is sortable. + * @param {string} columnId - The column id to check. */ - isSortable = (column: keyof T): boolean => { - const colDef = this.#getColumnDef(column); + isSortable = (columnId: string): boolean => { + const colDef = this.#getColumnDef(columnId); return colDef?.sortable !== false; }; /** - * Sets the filter for a specific column. - * @param {K} column - The key of the column to set the filter for. - * @param {any[]} values - The values to set as the filter. - * @template K + * Sets the filter values for the specified column. + * @param {string} columnId - The column id to set the filter values for. + * @param {any[]} values - The filter values to set. */ - setFilter = (column: K, values: any[]) => { + setFilter = (columnId: string, values: any[]) => { this.#isFilterDirty = true; - this.#filterState = { ...this.#filterState, [column]: new Set(values) }; + this.#filterState = { ...this.#filterState, [columnId]: new Set(values) }; this.#currentPage = 1; }; /** - * Clears the filter for a specific column. - * @param {keyof T} column - The key of the column to clear the filter for. + * Clears the filter values for the specified column. + * @param {string} columnId - The column id to clear the filter values for. */ - clearFilter = (column: keyof T) => { + clearFilter = (columnId: string) => { this.#isFilterDirty = true; - this.#filterState = { ...this.#filterState, [column]: new Set() }; + this.#filterState = { ...this.#filterState, [columnId]: new Set() }; this.#currentPage = 1; }; /** - * Toggles a filter value for a specific column. - * @param {K} column - The key of the column to toggle the filter for. - * @param {any} value - The value to toggle in the filter. - * @template K + * Toggles the filter value for the specified column. + * @param {string} columnId - The column id to toggle the filter value for. + * @param {any} value - The filter value to toggle. */ - toggleFilter = (column: K, value: any) => { + toggleFilter = (columnId: string, value: any) => { this.#isFilterDirty = true; this.#filterState = { ...this.#filterState, - [column]: this.isFilterActive(column, value) - ? new Set([...this.#filterState[column]].filter((v) => v !== value)) - : new Set([...this.#filterState[column], value]) + [columnId]: this.isFilterActive(columnId, value) + ? new Set([...this.#filterState[columnId]].filter((v) => v !== value)) + : new Set([...this.#filterState[columnId], value]) }; this.#currentPage = 1; }; /** - * Checks if a specific filter value is active for a column. - * @param {K} column - The key of the column to check the filter for. - * @param {any} value - The value to check in the filter. - * @returns {boolean} True if the filter is active for the given value, false otherwise. - * @template K + * Indicates whether the specified filter value is active for the specified column. + * @param {string} columnId - The column id to check. + * @param {any} value - The filter value to check. */ - isFilterActive = (column: K, value: any): boolean => { - return this.#filterState[column].has(value); + isFilterActive = (columnId: string, value: any): boolean => { + return this.#filterState[columnId].has(value); }; } diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 996fc83..f4cf84f 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -17,9 +17,9 @@ pageSize: 25, data: data.users, columns: [ - { key: 'id', name: 'ID' }, - { key: 'name', name: 'Name' }, - { key: 'status', name: 'Status', sortable: false } + { id: 'id', key: 'id', name: 'ID' }, + { id: 'name', key: 'name', name: 'Name' }, + { id: 'status', key: 'status', name: 'Status', sortable: false } ] }); @@ -83,19 +83,19 @@ - {#each table.columns as column (column.key)} + {#each table.columns as column (column.id)}
{column.name}
{row[column.key]}