Files
abstract-ts-table/abstract-table.ts
CentreMetre f5833fcbf8 Fix doc typos and add Table class summary line
Correct 'A object' -> 'An object' and 'if i iss not set' -> 'if it is
not set', and add a one-line summary to the Table class JSDoc.
2026-06-15 21:15:44 +01:00

294 lines
11 KiB
TypeScript

/**
* Optional configuration for a {@link Table}.
* @template TRow The row shape; see {@link Table}.
*/
export type TableOptions<TRow extends object> = {
/**
* An object with the same properties, but all string type for the header cell values.
* Used by passing an object with the keys of {@link TRow} and the name preferred. E.g. for a user:
* ```{ id: "ID", name: "Name" }```, this makes the columns appear in the order of ID then Name.
*/
displayNamesAndOrder?: Record<keyof TRow, string>;
/**
* Used to display messages related to data output. Optional.
* Omit (undefined) to use the class provided HTMLParagraphElement.
* Pass an HTMLElement to use a custom element.
* Pass null to disable automatic message output.
*/
messageElement?: HTMLElement | null;
/** The symbol used for boolean cells that are true. Optional, default "✔". */
trueSymbol?: string;
/** The symbol used for boolean cells that are false. Optional, default "✖". */
falseSymbol?: string;
};
/**
* Abstract base class that renders typed row data as an HTML `<table>` on a web page.
* @template TRow A type that the shape of a row should be. E.g.
* ```
* type User = {
* id: number;
* name: string;
* }
* ```
* Then `User` would be passed as the generic.
*/
export abstract class Table<TRow extends object> {
table: HTMLTableElement;
tableBody: HTMLTableSectionElement;
columns: Column<TRow>[];
messageEl: HTMLElement | null;
trueSymbol: string;
falseSymbol: string;
/**
* Constructs a table based on the parameters given.
* @param exampleObject An instance of the type to be displaying. Can be an object with default/zero values for that type
* such as `0`, `""`, `false` or `true`. E.g. for a user ```{ id: 0; name: ""; }``` would be passed.
* @param options Optional configuration. See {@link TableOptions}.
*/
constructor(exampleObject: TRow, options: TableOptions<TRow> = {}) {
this.table = document.createElement("table")
this.tableBody = document.createElement("tbody");
this.columns = this.createColumnsFromType(exampleObject, options.displayNamesAndOrder);
this.setHeaderRow();
this.table.appendChild(this.tableBody);
// If null it means disabled
this.messageEl = options.messageElement === undefined ? document.createElement("p") : options.messageElement;
this.trueSymbol = options.trueSymbol ?? "✔";
this.falseSymbol = options.falseSymbol ?? "✖";
}
/**
* Creates a list of {@link Column} objects to be used in a table for the header cell values, column types, and column order (if provided, otherwise inferred).
* @param exampleObject An example object of TRow, e.g. User = { id: 0, name: "Name"}. Used to store the types for each column.
* @param displayNamesAndOrder The order and names of the columns on the table.
* e.g. User = { fullname: "Full Name", id: "User ID" }, the columns would be "Full Name" and "User ID" in that order,
* @return An array of {@link Column} type shape that holds the column info.
*/
protected createColumnsFromType(
exampleObject: TRow, // Needed for runtime, since types don't exist in JS.
displayNamesAndOrder?: Record<keyof TRow, string>
): Column<TRow>[] {
// Checks if display names and order was passed. If not, derives the keys to use for the columns from the example object.
const keys = displayNamesAndOrder
? (Object.keys(displayNamesAndOrder) as (keyof TRow)[])
: (Object.keys(exampleObject) as (keyof TRow)[]);
return keys.map(key => ({
key, // Raw property name from TRow
headerValue: displayNamesAndOrder?.[key] ?? String(key), // The value of the th html element, with a nullish coalescing operator for if it is null
type: typeof exampleObject[key], // The type of the column
}));
}
/**
* Sets the header cell values on the header row from the {@link columns} on this object.
*/
protected setHeaderRow() {
const thead = document.createElement("thead");
const headerRow = document.createElement("tr");
for (const column of this.columns) {
const th = document.createElement("th")
th.textContent = column.headerValue; // Column's header text
headerRow.appendChild(th);
}
thead.appendChild(headerRow);
this.table.appendChild(thead);
}
/**
* Returns the table element of this instance for putting onto a page.
*/
getTable(): HTMLTableElement {
return this.table;
}
/**
* Appends one more row to the current rows.
* @param data Data to append as a row.
* @remarks An HTMLElement cell value must be a unique node per row, reusing one HTMLElement instance across rows moves it to the last row only.
*/
appendRow(data: TRow): void {
const row = this.createPopulatedRow(data)
this.tableBody.appendChild(row)
}
/**
* Appends multiple rows to the table, at the end of the table.
* @param data The rows to append to the table, in shape of {@link TRow}.
* @remarks An HTMLElement cell value must be a unique node per row, reusing one HTMLElement instance across rows moves it to the last row only.
*/
appendRows(data: TRow[]): void {
if (data.length === 0) {
this.setMessage("No data to append. Empty list provided.")
return;
}
for (const element of data) {
this.appendRow(element)
}
}
/**
* Creates a row, populated with data, for displaying in the table.
* @param data The data in the shape of `TRow`, with values populated.
* @return A HTMLTableRowElement that can then be set on a table.
* @remarks If a cell value is an HTMLTableCellElement (a `<td>`/`<th>`) it is used as the cell
* directly; any other HTMLElement is appended inside a generated `<td>`. A DOM node can only
* exist in one place, so passing the same element instance to multiple rows moves it to the
* last row only. Pass a fresh element (or `el.cloneNode(true)`) per row. But keep in mind that
* el.cloneNode(true) does not keep event listeners, so if event listeners are used it is best
* to generate a fresh element for each row that needs one.
*/
protected createPopulatedRow(data: TRow): HTMLTableRowElement {
const row = document.createElement("tr");
for (const column of this.columns) {
const value = data[column.key]
if (value instanceof HTMLTableCellElement) {
row.append(value); // already a cell, use it as-is
continue;
}
const cell: HTMLTableCellElement = document.createElement("td");
// Element values are inserted as children, not stringified.
if (value instanceof HTMLElement) {
cell.appendChild(value);
row.append(cell);
continue; // skip the textContent path that would wipe it
}
let cellValue = "";
switch (column.type) {
case "boolean":
cell.classList.add("table-cell-boolean");
cellValue = value ? this.trueSymbol : this.falseSymbol;
cell.classList.add(value ? "table-cell-boolean-true" : "table-cell-boolean-false");
break;
case "number":
cellValue = String(value);
cell.classList.add("table-cell-number");
break;
case "bigint":
cellValue = String(value);
cell.classList.add("table-cell-number");
break;
default:
cellValue = value != null ? String(value) : "";
}
cell.textContent = cellValue;
row.append(cell);
}
return row;
}
/**
* Deletes all current rows.
*/
clearRows(): void {
while (this.tableBody.firstChild) {
this.tableBody.removeChild(this.tableBody.firstChild)
}
}
/**
* Deletes all current rows and sets a single row to the data in data param.
* @param data The data to set the table to. Should be of shape {@link TRow}
* @remarks An HTMLElement cell value must be a unique node per row, reusing one HTMLElement instance across rows moves it to the last row only.
*/
setSingleRow(data: TRow): void {
this.clearRows()
this.appendRow(data)
}
/**
* Deletes all current rows and sets the rows to the data in data param.
* @param data The data to set the table to. Should be of shape {@link TRow}
* @remarks An HTMLElement cell value must be a unique node per row, reusing one HTMLElement instance across rows moves it to the last row only.
*/
setRows(data: TRow[]): void {
this.clearRows()
if (data.length === 0) {
this.setMessage("No data to show. Empty list provided.")
return
}
this.appendRows(data)
}
/**
* Get the message element for the table.
* @return The HTMLElement that is used for showing messages. Or null if it is not set.
*/
getMessageElement(): HTMLElement | null {
return this.messageEl;
}
/**
* Clears the {@link messageEl}.textContent value to "". If the message element is not set it does nothing.
*/
clearMessage() {
if (!this.messageEl) return;
this.messageEl.textContent = ""
}
/**
* Appends a message to the already existing table message, if the message element is set. Works even if current message is empty ("").
* @param message The message to append to the current message.
*/
appendMessage(message: string) {
if (!this.messageEl) return;
this.messageEl.textContent = `${this.messageEl.textContent} ${message}`
}
/**
* Sets the message, overwriting what it was before. If the message element is not set, it does nothing.
* @param message The message to set the message element to.
*/
setMessage(message: string) {
if (!this.messageEl) return;
this.clearMessage();
this.appendMessage(message);
}
/**
* Change the symbols for boolean cells on the table.
* @param trueSymbol What the true symbol should be.
* @param falseSymbol What the false symbol should be.
* @param updateExisting Whether to update the existing cells. Default to true.
*/
changeBooleanSymbols(trueSymbol: string, falseSymbol: string, updateExisting: boolean = true) {
this.trueSymbol = trueSymbol;
this.falseSymbol = falseSymbol;
// Change the symbol on already rendered boolean cells.
if (updateExisting) {
this.tableBody.querySelectorAll(".table-cell-boolean-true")
.forEach(c => c.textContent = trueSymbol);
this.tableBody.querySelectorAll(".table-cell-boolean-false")
.forEach(c => c.textContent = falseSymbol);
}
}
}
export type Column<TRow> = {
key: keyof TRow; // The name of the field.
headerValue: string; // The name of the header cell
type: string; // The type of the field, can be string since typesof are returned as strings.
}