// ============================================================================ // Stylescape | Table Sort Manager // ============================================================================ // Client-side column sorting for `ss-c-table`. Header cells hold a // `button.ss-c-table__sort`; the manager reorders the body rows and keeps // `aria-sort` on the active `` in sync. // Supports data-ss="table-sort" for declarative initialisation. // ============================================================================ export type TableSortDirection = "ascending" | "descending"; export type TableSortType = "text" | "number" | "auto"; /** * Configuration options for TableSortManager */ export interface TableSortOptions { /** Locale used to compare text values (defaults to the document's) */ locale?: string; /** Called after the rows have been reordered */ onSort?: (columnIndex: number, direction: TableSortDirection) => void; } const SORT_BUTTON_CLASS = "ss-c-table__sort"; /** * Sorts table rows by a column's text, number or `data-sort-value`. * * A column is sortable when its header cell contains a * `button.ss-c-table__sort`, or carries `data-ss-sortable` (the manager then * wraps its content in that button). `data-ss-sort-type="number|text"` on * the `` forces the comparison; by default numbers are detected. * * @example HTML with data-ss * ```html * * * * * * * * *
alpha1 KB
* ``` */ export class TableSortManager { private table: HTMLTableElement | null; private options: TableSortOptions; private collator: Intl.Collator; private headers: HTMLTableCellElement[] = []; constructor( selectorOrElement: string | HTMLTableElement, options: TableSortOptions = {}, ) { this.table = typeof selectorOrElement === "string" ? document.querySelector(selectorOrElement) : selectorOrElement; this.options = options; this.collator = new Intl.Collator( options.locale ?? (document.documentElement.lang || undefined), { numeric: true, sensitivity: "base" }, ); if (!this.table || !this.table.tHead) { console.warn("[Stylescape] TableSortManager table not found"); return; } this.init(); } // ======================================================================== // Public Methods // ======================================================================== /** Sort the body rows by a column and mark its header. */ public sort(columnIndex: number, direction: TableSortDirection): void { if (!this.table) return; const header = this.headers.find((th) => th.cellIndex === columnIndex); const type = (header?.dataset.ssSortType as TableSortType) ?? "auto"; Array.from(this.table.tBodies).forEach((tbody) => { const rows = Array.from(tbody.rows); const keyed = rows.map((row, position) => ({ row, position, value: this.cellValue(row.cells[columnIndex]), })); const numeric = type === "number" || (type === "auto" && keyed.every( (k) => k.value === "" || !Number.isNaN(this.toNumber(k.value)), )); const factor = direction === "ascending" ? 1 : -1; keyed.sort((a, b) => { const result = numeric ? this.compareNumbers(a.value, b.value, factor) : this.collator.compare(a.value, b.value) * factor; // Stable: equal keys keep their current order. return result || a.position - b.position; }); keyed.forEach(({ row }) => tbody.appendChild(row)); }); this.headers.forEach((th) => { if (th === header) th.setAttribute("aria-sort", direction); else th.removeAttribute("aria-sort"); }); this.options.onSort?.(columnIndex, direction); this.table.dispatchEvent( new CustomEvent("ss:table-sort", { bubbles: true, detail: { columnIndex, direction }, }), ); } public destroy(): void { this.table?.tHead?.removeEventListener("click", this.handleClick); this.table = null; this.headers = []; } // ======================================================================== // Private Methods // ======================================================================== private init(): void { const head = this.table!.tHead!; head.querySelectorAll("th").forEach((th) => { if ( !th.querySelector(`.${SORT_BUTTON_CLASS}`) && th.hasAttribute("data-ss-sortable") ) { const button = document.createElement("button"); button.type = "button"; button.className = SORT_BUTTON_CLASS; while (th.firstChild) button.appendChild(th.firstChild); th.appendChild(button); } if (th.querySelector(`.${SORT_BUTTON_CLASS}`)) { this.headers.push(th); } }); head.addEventListener("click", this.handleClick); } private cellValue(cell: HTMLTableCellElement | undefined): string { if (!cell) return ""; return (cell.dataset.sortValue ?? cell.textContent ?? "").trim(); } private toNumber(value: string): number { if (value === "") return Number.NaN; return Number(value.replace(/[\s,_]/g, "")); } /** Numbers in `factor` order; blanks and non-numbers always sort last. */ private compareNumbers(a: string, b: string, factor: number): number { const x = this.toNumber(a); const y = this.toNumber(b); const xBad = Number.isNaN(x); const yBad = Number.isNaN(y); if (xBad || yBad) return Number(xBad) - Number(yBad); return (x - y) * factor; } private handleClick = (event: MouseEvent): void => { const button = (event.target as HTMLElement).closest( `.${SORT_BUTTON_CLASS}`, ); const th = button?.closest("th") as HTMLTableCellElement | null; if (!th || !this.headers.includes(th)) return; const next: TableSortDirection = th.getAttribute("aria-sort") === "ascending" ? "descending" : "ascending"; this.sort(th.cellIndex, next); }; }