import { DATA_VIEW_CHANGE_EVENT, DATA_VIEW_ROW_ACTION_EVENT, type DataViewChangeDetail, type DataViewOptions, type DataViewRowActionDetail, type DataViewState, type SortOrder, } from './data-view.types'; const SELECTORS = { search: '[data-c42-dataview-search]', sortField: '[data-c42-dataview-sort-field]', rows: '[data-c42-dataview-rows]', row: '[data-c42-dataview-row]', prev: '[data-c42-dataview-prev]', next: '[data-c42-dataview-next]', page: '[data-c42-dataview-page]', pages: '[data-c42-dataview-pages]', info: '[data-c42-dataview-info]', columnToggle: '[data-c42-dataview-column]', loading: '[data-c42-dataview-loading]', rowAction: '[data-c42-dataview-action]', } as const; function parseSort(value: string | undefined): { field: string | null; order: SortOrder } { if (!value) { return { field: null, order: 'asc' }; } const [field, order] = value.split(':'); return { field: field || null, order: order === 'desc' ? 'desc' : 'asc' }; } /** Compare two raw cell values, numeric when both parse as finite numbers. */ function compareValues(a: string, b: string): number { const na = Number(a); const nb = Number(b); if (a !== '' && b !== '' && !Number.isNaN(na) && !Number.isNaN(nb)) { return na - nb; } return a.localeCompare(b); } /** * Headless data view: a sort / search / paginate state machine ported from the * maildrill `useDataView` hook. Wires search input, sortable headers and * pagination controls, emits `dataview:change` with the full state, and — when * `[data-c42-dataview-row]` elements are present — also filters, reorders and * paginates them directly in the DOM. Server-driven consumers omit the rows and * react to the change event instead. * * Markup: * ```html *
* * *
*
*
* * / * * *
* ``` */ export class DataView { private readonly root: HTMLElement; private readonly searchInput: HTMLInputElement | null; private readonly rowsContainer: HTMLElement | null; private readonly prevBtn: HTMLElement | null; private readonly nextBtn: HTMLElement | null; private readonly pageEl: HTMLElement | null; private readonly pagesEl: HTMLElement | null; private readonly infoEl: HTMLElement | null; private readonly sortButtons: HTMLElement[]; private readonly allRows: HTMLElement[]; private readonly manualTotal: number; private pageSize: number; private query: string; private sortField: string | null; private sortOrder: SortOrder; private page = 1; private total = 0; private loading: boolean; private visibleColumns: string[]; private cleanups: Array<() => void> = []; constructor(root: HTMLElement, options: DataViewOptions = {}) { this.root = root; this.searchInput = root.querySelector(SELECTORS.search); this.rowsContainer = root.querySelector(SELECTORS.rows); this.prevBtn = root.querySelector(SELECTORS.prev); this.nextBtn = root.querySelector(SELECTORS.next); this.pageEl = root.querySelector(SELECTORS.page); this.pagesEl = root.querySelector(SELECTORS.pages); this.infoEl = root.querySelector(SELECTORS.info); this.sortButtons = Array.from(root.querySelectorAll(SELECTORS.sortField)); this.allRows = this.rowsContainer ? Array.from(this.rowsContainer.querySelectorAll(SELECTORS.row)) : []; this.pageSize = Math.max(1, options.pageSize ?? 10); this.manualTotal = options.total ?? 0; this.query = options.defaultQuery ?? ''; const sort = parseSort(options.defaultSort); this.sortField = sort.field; this.sortOrder = sort.order; this.loading = options.loading ?? false; this.visibleColumns = options.visibleColumns ?? []; this.init(); } private init(): void { if (this.searchInput && this.query) { this.searchInput.value = this.query; } this.root.dataset.state = this.loading ? 'loading' : 'ready'; const onSearch = (): void => this.setQuery(this.searchInput?.value ?? ''); const onPrev = (): void => this.setPage(this.page - 1); const onNext = (): void => this.setPage(this.page + 1); this.searchInput?.addEventListener('input', onSearch); this.prevBtn?.addEventListener('click', onPrev); this.nextBtn?.addEventListener('click', onNext); this.cleanups.push( () => this.searchInput?.removeEventListener('input', onSearch), () => this.prevBtn?.removeEventListener('click', onPrev), () => this.nextBtn?.removeEventListener('click', onNext), ); this.sortButtons.forEach((button) => { const field = button.dataset.c42DataviewSortField ?? ''; const onClick = (): void => this.toggleSort(field); button.addEventListener('click', onClick); this.cleanups.push(() => button.removeEventListener('click', onClick)); }); // Column toggle checkboxes const columnToggles = Array.from( this.root.querySelectorAll(SELECTORS.columnToggle), ); columnToggles.forEach((toggle) => { const col = toggle.dataset.c42DataviewColumn ?? ''; if (this.visibleColumns.length === 0 || this.visibleColumns.includes(col)) { toggle.checked = true; } const onChange = (): void => { if (toggle.checked) { this.showColumn(col); } else { this.hideColumn(col); } }; toggle.addEventListener('change', onChange); this.cleanups.push(() => toggle.removeEventListener('change', onChange)); }); // Initialize visible columns from toggles if not set via options if (this.visibleColumns.length === 0) { this.visibleColumns = columnToggles.map((t) => t.dataset.c42DataviewColumn ?? ''); } // Row actions (delegated) if (this.rowsContainer) { const onAction = (event: Event): void => this.onRowAction(event); this.rowsContainer.addEventListener('click', onAction); this.cleanups.push(() => this.rowsContainer?.removeEventListener('click', onAction)); } this.applyColumnVisibility(); this.render(false); } /* ---------- internal computation ---------- */ private matchesQuery(row: HTMLElement): boolean { if (this.query === '') { return true; } const haystack = (row.dataset.search ?? row.textContent ?? '').toLowerCase(); return haystack.includes(this.query.toLowerCase()); } private fieldValue(row: HTMLElement, field: string): string { return row.getAttribute(`data-field-${field}`) ?? ''; } /** Recompute the visible set, optionally emitting a change event. */ private render(emit = true): void { if (this.rowsContainer && this.allRows.length > 0) { this.renderRows(); } else { this.total = this.manualTotal; this.page = Math.min(this.page, Math.max(1, this.totalPages)); } this.renderControls(); if (emit) { this.emitChange(); } } private renderRows(): void { const filtered = this.allRows.filter((row) => this.matchesQuery(row)); if (this.sortField) { const field = this.sortField; const dir = this.sortOrder === 'asc' ? 1 : -1; filtered.sort((a, b) => dir * compareValues(this.fieldValue(a, field), this.fieldValue(b, field))); } this.total = filtered.length; this.page = Math.min(this.page, Math.max(1, this.totalPages)); // Reorder the DOM to match the sorted+filtered order, then paginate. const start = (this.page - 1) * this.pageSize; const end = start + this.pageSize; filtered.forEach((row, index) => { this.rowsContainer!.appendChild(row); row.toggleAttribute('hidden', index < start || index >= end); }); // Rows filtered out entirely are always hidden. this.allRows .filter((row) => !filtered.includes(row)) .forEach((row) => row.setAttribute('hidden', '')); } private renderControls(): void { if (this.pageEl) { this.pageEl.textContent = String(this.page); } if (this.pagesEl) { this.pagesEl.textContent = String(this.totalPages); } if (this.infoEl) { const start = this.total === 0 ? 0 : (this.page - 1) * this.pageSize + 1; const end = Math.min(this.page * this.pageSize, this.total); this.infoEl.textContent = `${start}-${end} of ${this.total}`; } if (this.prevBtn) { (this.prevBtn as HTMLButtonElement).disabled = this.page <= 1; } if (this.nextBtn) { (this.nextBtn as HTMLButtonElement).disabled = this.page >= this.totalPages; } this.sortButtons.forEach((button) => { const field = button.dataset.c42DataviewSortField; if (field === this.sortField) { button.setAttribute('aria-sort', this.sortOrder === 'asc' ? 'ascending' : 'descending'); button.dataset.sort = this.sortOrder; } else { button.removeAttribute('aria-sort'); delete button.dataset.sort; } }); } private emitChange(): void { const detail: DataViewChangeDetail = this.getState(); this.root.dispatchEvent(new CustomEvent(DATA_VIEW_CHANGE_EVENT, { detail, bubbles: true })); } /* ---------- public API ---------- */ setQuery(query: string): void { if (query === this.query) { return; } this.query = query; this.page = 1; this.render(); } setSort(field: string | null, order: SortOrder = 'asc'): void { this.sortField = field; this.sortOrder = order; this.page = 1; this.render(); } /** Toggle sort on a field: asc → desc → same field stays, first click asc. */ toggleSort(field: string): void { if (this.sortField === field) { this.sortOrder = this.sortOrder === 'asc' ? 'desc' : 'asc'; } else { this.sortField = field; this.sortOrder = 'asc'; } this.page = 1; this.render(); } setPage(page: number): void { const clamped = Math.max(1, Math.min(page, this.totalPages)); if (clamped === this.page) { return; } this.page = clamped; this.render(); } setPageSize(size: number): void { this.pageSize = Math.max(1, size); this.page = 1; this.render(); } /** Set loading state. When set to false, re-scans rows from the DOM. */ setLoading(loading: boolean): void { this.loading = loading; this.root.dataset.state = loading ? 'loading' : 'ready'; const loadingEl = this.root.querySelector(SELECTORS.loading); if (loadingEl) { loadingEl.toggleAttribute('hidden', !loading); } if (!loading) { this.rescanRows(); } } /** Re-scan rows from the DOM and re-render (call after async data insert). */ rescanRows(): void { if (this.rowsContainer) { this.allRows.length = 0; this.allRows.push( ...Array.from(this.rowsContainer.querySelectorAll(SELECTORS.row)), ); } this.page = 1; this.render(); } /** Show a column by key. */ showColumn(column: string): void { if (!this.visibleColumns.includes(column)) { this.visibleColumns.push(column); } this.applyColumnVisibility(); this.emitChange(); } /** Hide a column by key. */ hideColumn(column: string): void { this.visibleColumns = this.visibleColumns.filter((c) => c !== column); this.applyColumnVisibility(); this.emitChange(); } /** Set which columns are visible. */ setColumns(columns: string[]): void { this.visibleColumns = [...columns]; this.applyColumnVisibility(); this.emitChange(); } private applyColumnVisibility(): void { if (this.visibleColumns.length === 0) return; // Toggle [data-col="x"] elements this.root.querySelectorAll('[data-col]').forEach((el) => { const col = el.dataset.col ?? ''; el.toggleAttribute('hidden', !this.visibleColumns.includes(col)); }); } private onRowAction(event: Event): void { const btn = (event.target as HTMLElement).closest(SELECTORS.rowAction); if (!btn) return; const row = btn.closest(SELECTORS.row); if (!row) return; const action = btn.dataset.c42DataviewAction ?? ''; const visibleRows = this.allRows.filter((r) => !r.hasAttribute('hidden')); const rowIndex = visibleRows.indexOf(row); const detail: DataViewRowActionDetail = { action, row, rowIndex }; this.root.dispatchEvent( new CustomEvent(DATA_VIEW_ROW_ACTION_EVENT, { detail, bubbles: true }), ); } get totalPages(): number { return Math.max(1, Math.ceil(this.total / this.pageSize)); } getState(): DataViewState { return { query: this.query, sortField: this.sortField, sortOrder: this.sortOrder, page: this.page, pageSize: this.pageSize, total: this.total, totalPages: this.totalPages, loading: this.loading, visibleColumns: [...this.visibleColumns], }; } on(event: string, handler: (event: E) => void): () => void { const listener = handler as EventListener; this.root.addEventListener(event, listener); const off = (): void => this.root.removeEventListener(event, listener); this.cleanups.push(off); return off; } destroy(): void { this.cleanups.forEach((fn) => fn()); this.cleanups = []; } }