diff --git a/.changeset/rare-beans-reflect.md b/.changeset/rare-beans-reflect.md new file mode 100644 index 0000000..e06111e --- /dev/null +++ b/.changeset/rare-beans-reflect.md @@ -0,0 +1,5 @@ +--- +'@careswitch/svelte-data-table': patch +--- + +perf: filter matching, sort handling null/undefined, non-capturing group for global filter regex diff --git a/src/index.test.ts b/src/index.test.ts index 2ef8ce6..f1ad906 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -150,6 +150,45 @@ describe('DataTable', () => { const ageGroupSortState = table.getSortState('ageGroup'); expect(ageSortState).not.toBe(ageGroupSortState); }); + + it('should handle sorting with custom getValue returning undefined', () => { + const customColumns: ColumnDef[] = [ + { + id: 'customSort', + key: 'value', + name: 'Custom Sort', + sortable: true, + getValue: (row) => (row.value === 3 ? undefined : row.value) + } + ]; + const customData = [ + { id: 1, value: 3 }, + { id: 2, value: 1 }, + { id: 3, value: 2 } + ]; + const table = new DataTable({ data: customData, columns: customColumns }); + table.toggleSort('customSort'); + expect(table.rows[0].value).toBe(1); + expect(table.rows[1].value).toBe(2); + expect(table.rows[2].value).toBe(3); + }); + + it('should maintain sort stability for equal elements', () => { + const data = [ + { id: 1, value: 'A', order: 1 }, + { id: 2, value: 'B', order: 2 }, + { id: 3, value: 'A', order: 3 }, + { id: 4, value: 'C', order: 4 }, + { id: 5, value: 'B', order: 5 } + ]; + const columns: ColumnDef<(typeof data)[0]>[] = [ + { id: 'value', key: 'value', name: 'Value', sortable: true }, + { id: 'order', key: 'order', name: 'Order', sortable: true } + ]; + const table = new DataTable({ data, columns }); + table.toggleSort('value'); + expect(table.rows.map((r) => r.id)).toEqual([1, 3, 2, 5, 4]); + }); }); describe('Enhanced Sorting', () => { @@ -332,6 +371,48 @@ describe('DataTable', () => { table.setFilter('ageGroup', ['Young']); expect(table.rows).toHaveLength(0); // No rows match both filters }); + + it('should handle filtering with complex custom filter function', () => { + const customColumns: ColumnDef[] = [ + { + id: 'complexFilter', + key: 'value', + name: 'Complex Filter', + filter: (value, filterValue, row) => { + return value > filterValue && row.id % 2 === 0; + } + } + ]; + const customData = [ + { id: 1, value: 10 }, + { id: 2, value: 20 }, + { id: 3, value: 30 }, + { id: 4, value: 40 } + ]; + const table = new DataTable({ data: customData, columns: customColumns }); + table.setFilter('complexFilter', [15]); + expect(table.rows).toHaveLength(2); + expect(table.rows[0].id).toBe(2); + expect(table.rows[1].id).toBe(4); + }); + + it('should handle filtering with extremely long filter lists', () => { + const longFilterList = Array.from({ length: 10000 }, (_, i) => i); + const table = new DataTable({ data: sampleData, columns }); + table.setFilter('age', longFilterList); + expect(table.rows).toHaveLength(5); // All rows should match + }); + + it('should handle global filter with special regex characters', () => { + const data = [ + { id: 1, name: 'Alice (Manager)' }, + { id: 2, name: 'Bob [Developer]' } + ] as any; + const table = new DataTable({ data, columns }); + table.globalFilter = '(Manager)'; + expect(table.rows).toHaveLength(1); + expect(table.rows[0].name).toBe('Alice (Manager)'); + }); }); describe('Pagination', () => { @@ -375,6 +456,21 @@ describe('DataTable', () => { expect(table.rows).toHaveLength(5); expect(table.totalPages).toBe(1); }); + + it('should handle setting page size to 0', () => { + const table = new DataTable({ data: sampleData, columns, pageSize: 0 }); + expect(table.rows).toHaveLength(5); // Should default to showing all rows + }); + + it('should handle navigation near total page count', () => { + const table = new DataTable({ data: sampleData, columns, pageSize: 2 }); + table.currentPage = 3; + expect(table.canGoForward).toBe(false); + expect(table.canGoBack).toBe(true); + table.currentPage = 2; + expect(table.canGoForward).toBe(true); + expect(table.canGoBack).toBe(true); + }); }); describe('baseRows', () => { diff --git a/src/lib/DataTable.svelte.ts b/src/lib/DataTable.svelte.ts index 0f35223..e1e3c6e 100644 --- a/src/lib/DataTable.svelte.ts +++ b/src/lib/DataTable.svelte.ts @@ -28,9 +28,10 @@ type TableConfig = { * @template T The type of data items in the table. */ export class DataTable { + #columns: ColumnDef[]; + #pageSize: number; + #originalData = $state([]); - #columns = $state[]>([]); - #pageSize = $state(10); #currentPage = $state(1); #sortState = $state<{ columnId: string | null; direction: SortDirection }>({ columnId: null, @@ -38,8 +39,8 @@ export class DataTable { }); #filterState = $state<{ [id: string]: Set }>({}); #globalFilter = $state(''); - #globalFilterRegex = $state(null); + #globalFilterRegex: RegExp | null = null; #isFilterDirty = true; #isSortDirty = true; #filteredData: T[] = []; @@ -93,8 +94,7 @@ export class DataTable { }; #matchesFilters = (row: T): boolean => { - return Object.keys(this.#filterState).every((columnId) => { - const filterSet = this.#filterState[columnId]; + return Object.entries(this.#filterState).every(([columnId, filterSet]) => { if (!filterSet || filterSet.size === 0) return true; const colDef = this.#getColumnDef(columnId); @@ -103,7 +103,12 @@ export class DataTable { const value = this.#getValue(row, columnId); if (colDef.filter) { - return Array.from(filterSet).some((filterValue) => colDef.filter!(value, filterValue, row)); + for (const filterValue of filterSet) { + if (colDef.filter(value, filterValue, row)) { + return true; + } + } + return false; } return filterSet.has(value); @@ -130,12 +135,19 @@ export class DataTable { const aVal = this.#getValue(a, columnId); const bVal = this.#getValue(b, columnId); + if (aVal === undefined || aVal === null) return direction === 'asc' ? 1 : -1; + if (bVal === undefined || bVal === null) return direction === 'asc' ? -1 : 1; + if (colDef && colDef.sorter) { return direction === 'asc' ? colDef.sorter(aVal, bVal, a, b) : colDef.sorter(bVal, aVal, b, a); } + if (typeof aVal === 'string' && typeof bVal === 'string') { + return direction === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal); + } + if (aVal < bVal) return direction === 'asc' ? -1 : 1; if (aVal > bVal) return direction === 'asc' ? 1 : -1; return 0; @@ -170,13 +182,14 @@ export class DataTable { get rows() { // React to changes in original data, filter state, and sort state this.#originalData; - this.#filterState; this.#sortState; - this.#globalFilterRegex; + this.#filterState; + this.#globalFilter; this.#applyFilters(); this.#applySort(); - const startIndex = (this.currentPage - 1) * this.#pageSize; + + const startIndex = (this.#currentPage - 1) * this.#pageSize; const endIndex = startIndex + this.#pageSize; return this.#sortedData.slice(startIndex, endIndex); } @@ -212,9 +225,10 @@ export class DataTable { get totalPages() { // React to changes in filter state this.#filterState; - this.#globalFilterRegex; + this.#globalFilter; this.#applyFilters(); + return Math.max(1, Math.ceil(this.#filteredData.length / this.#pageSize)); } @@ -270,7 +284,14 @@ export class DataTable { */ set globalFilter(value: string) { this.#globalFilter = value; - this.#globalFilterRegex = value.trim() !== '' ? new RegExp(value, 'i') : null; + + try { + this.#globalFilterRegex = value.trim() !== '' ? new RegExp(`(?:${value})`, 'i') : null; + } catch (error) { + console.error('Invalid regex pattern:', error); + this.#globalFilterRegex = null; + } + this.#currentPage = 1; this.#isFilterDirty = true; }