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
*
*
*
Name
*
*
Prev
*
/
*
Next
*
*
* ```
*/
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 = [];
}
}