Compare commits

...

18 Commits

Author SHA1 Message Date
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
CentreMetre
95254027c4 Use a passed HTMLTableCellElement as the cell directly
When a cell value is a <td>/<th>, append it to the row as-is instead of
wrapping it in a generated <td>, which would have produced invalid nested
cells. Any other HTMLElement still gets wrapped as before.
2026-06-15 21:15:34 +01:00
CentreMetre
436217a8ff Add TypeScript build config for .d.ts declaration output
Set up tsconfig.json (declaration: true, DOM lib), package.json with
main/types/files pointing at dist, and .gitignore for node_modules/dist
so the library can be consumed with type declarations.
2026-06-15 17:30:46 +01:00
CentreMetre
8c561385b8 Add licence 2026-06-15 16:49:38 +01:00
CentreMetre
67ecf689a6 Add default css 2026-06-14 21:46:00 +01:00
CentreMetre
b0f40dea5f Reword HTMLElement cloneNode remark for clarity 2026-06-14 21:45:04 +01:00
CentreMetre
27488dd1b3 Mark internal helpers protected and document HTMLElement cell behaviour
Make createColumnsFromType, setHeaderRow and createPopulatedRow protected
to shrink the public surface, and add @remarks warning that an HTMLElement
cell value must be a unique node per row. Also fix doc typos.
2026-06-14 21:41:38 +01:00
CentreMetre
2a851983b2 Rename Column key to be clearer 2026-06-14 21:39:04 +01:00
CentreMetre
9825d25090 Add bigint handling 2026-06-14 21:38:35 +01:00
CentreMetre
4c5c444002 Add HTMLElement support 2026-06-14 21:11:09 +01:00
CentreMetre
fbaf215078 Rename Header type to Column
Rename the Header type to Column and update all references: the columns
field, the createColumnsFromType method, and the per-column loop
variables, so the naming reflects that each object describes a column.
2026-06-14 21:02:24 +01:00
CentreMetre
73f80d874b Typo fixes and minor change. 2026-06-14 20:55:04 +01:00
CentreMetre
0eb45263a4 Refactor constructor to an options object
Introduce an exported TableOptions<TRow> type and replace the trailing
positional constructor parameters (displayNamesAndOrder, messageElement,
trueSymbol, falseSymbol) with a single optional options object, so call
sites are order-independent and named.
2026-06-14 20:41:10 +01:00
CentreMetre
ccf81fa085 Add boolean symbol changing feature 2026-06-14 18:43:31 +01:00
CentreMetre
47ac4dacf8 Fix and remove unneccesary code. 2026-06-14 18:13:09 +01:00
CentreMetre
4e5428eee2 Fix and tidy TSDoc comments
Correct the constructor summary, replace stale R references with TRow,
fix {@link Header<TRow>} to {@link Header}, and fix assorted typos.
2026-06-14 18:06:44 +01:00
CentreMetre
8d8107ec49 Remove CSS handling from Table
Drop the loadCSS method and its constructor call. Styling is now the
consumer's responsibility via the emitted class names, keeping the
class self-contained and free of app-specific asset paths.
2026-06-14 18:06:44 +01:00
CentreMetre
d7c2851008 Generalise abstract Table: rename generic to TRow, add docs
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 17:28:57 +01:00
7 changed files with 308 additions and 46 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules
dist

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Martin McLaren (CentreMetre)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,4 +1,29 @@
/**
* 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 = {
@@ -8,64 +33,74 @@
* ```
* Then `User` would be passed as the generic.
*/
export abstract class Table<TRow extends Object> {
export abstract class Table<TRow extends object> {
table: HTMLTableElement;
tableBody: HTMLTableSectionElement;
headers: Header<TRow>[];
columns: Column<TRow>[];
messageEl: HTMLElement | null;
trueSymbol: string;
falseSymbol: string;
/**
* Creates a list of Header objects to be used in a table for the header cell values and column types.
* 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 displayNamesAndOrder A object with the same properties, but all string type for the header cell values.
* Used by passing an object with the keys of R 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.
* @param messageElement Used to display messaged 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.
* 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, displayNamesAndOrder?: Record<keyof TRow, string>, messageElement?: HTMLElement | null | undefined) {
constructor(exampleObject: TRow, options: TableOptions<TRow> = {}) {
this.table = document.createElement("table")
this.tableBody = document.createElement("tbody");
this.headers = this.createHeadersFromType(exampleObject, displayNamesAndOrder);
this.columns = this.createColumnsFromType(exampleObject, options.displayNamesAndOrder);
this.setHeaderRow();
this.table.appendChild(this.tableBody);
this.loadCSS()
// If null it means disabled
this.messageEl = options.messageElement === undefined ? document.createElement("p") : options.messageElement;
this.messageEl = messageElement === undefined ? document.createElement("p") : messageElement;
this.trueSymbol = options.trueSymbol ?? "✔";
this.falseSymbol = options.falseSymbol ?? "✖";
}
createHeadersFromType(
/**
* 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>
): Header<TRow>[] {
// Checks if display names and order was passed. If not, derives the keys to use for the header from the example object.
): 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
value: displayNamesAndOrder?.[key] ?? String(key), // The value of the th html element, with a nullish coalescing operator for if its null
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
}));
}
setHeaderRow() {
/**
* 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 header of this.headers) {
for (const column of this.columns) {
const th = document.createElement("th")
th.textContent = header.value;
th.textContent = column.headerValue; // Column's header text
headerRow.appendChild(th);
}
@@ -73,17 +108,6 @@ export abstract class Table<TRow extends Object> {
this.table.appendChild(thead);
}
/**
* Loads the CSS for tables into the html file for proper styling.
*/
loadCSS() {
const link = document.createElement("link");
link.rel = "stylesheet"
link.href = "../css/table.css"; // Relative path from JS folder.
link.type = 'text/css';
document.head.appendChild(link);
}
/**
* Returns the table element of this instance for putting onto a page.
*/
@@ -94,12 +118,18 @@ export abstract class Table<TRow extends Object> {
/**
* 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.")
@@ -112,22 +142,41 @@ export abstract class Table<TRow extends Object> {
/**
* Creates a row, populated with data, for displaying in the table.
* @param data The data in the shape of `R`, with values populated.
* @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.
*/
createPopulatedRow(data: TRow): HTMLTableRowElement {
protected createPopulatedRow(data: TRow): HTMLTableRowElement {
const row = document.createElement("tr");
for (const header of this.headers) {
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");
const value = data[header.key]
// 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 (header.type) {
switch (column.type) {
case "boolean":
cell.classList.add("table-cell-boolean");
cellValue = value ? "✔" : "✖";
cellValue = value ? this.trueSymbol : this.falseSymbol;
cell.classList.add(value ? "table-cell-boolean-true" : "table-cell-boolean-false");
break;
@@ -136,8 +185,11 @@ export abstract class Table<TRow extends Object> {
cell.classList.add("table-cell-number");
break;
case "bigint":
cellValue = String(value);
cell.classList.add("table-cell-number");
break;
default:
const strValue = String(value)
cellValue = value != null ? String(value) : "";
}
cell.textContent = cellValue;
@@ -159,6 +211,7 @@ export abstract class Table<TRow extends Object> {
/**
* 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()
@@ -168,38 +221,74 @@ export abstract class Table<TRow extends Object> {
/**
* 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)
}
getMessageElement() {
/**
* 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);
}
}
}
type Header<R> = {
key: keyof R; // The name of the field.
value: string; // The name of the header cell
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.
}

29
package-lock.json generated Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "abstract-ts-table",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "abstract-ts-table",
"version": "1.0.0",
"license": "MIT",
"devDependencies": {
"typescript": "^6.0.3"
}
},
"node_modules/typescript": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
}
}

22
package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "abstract-ts-table",
"version": "1.0.0",
"description": "A utility class for creating HTML tables.",
"type": "module",
"main": "dist/abstract-table.js",
"types": "dist/abstract-table.d.ts",
"files": ["dist"],
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "tsc"
},
"repository": {
"type": "git",
"url": "https://git.centremetre.com/CentreMetre/abstract-ts-table"
},
"author": "CentreMetre",
"license": "MIT",
"devDependencies": {
"typescript": "^6.0.3"
}
}

87
table.css Normal file
View File

@@ -0,0 +1,87 @@
thead {
background: hsl(0, 0%, 90%);
}
th {
border-left: 1px solid hsl(0, 0%, 16%);
border-bottom: 1px solid hsl(0, 0%, 16%);
border-right: 1px solid hsl(0, 0%, 16%);
/*padding: 5px;*/
width: 1%;
}
table {
font-family: "inter", sans-serif;
border-collapse: collapse;
}
.table-div {
/*width: 50%;*/
}
.row-id {
font-weight: bold;
border-bottom: 1px solid hsl(0, 0%, 16%);
border-right: 1px solid hsl(0, 0%, 16%);
}
tr:nth-child(even) {
background: hsl(210, 100%, 96.08%)
}
table th:first-of-type,
table td:first-of-type {
border-left: 0;
}
table th:last-of-type,
table td:last-of-type {
border-right: 0;
}
table th:first-of-type { /*add border radius to top left of table*/
border-top-left-radius: 7px;
}
table th:last-of-type { /*add border radius to top right of table*/
border-top-right-radius: 7px;
}
table td {
padding-left: 5px;
padding-right: 5px;
border-right: 1px solid #737373;
border-left: 1px solid darkgrey;
}
td input[type=checkbox] {
color: #0c2d4a;
}
.table-cell-boolean {
text-align: center;
}
.table-cell-boolean-true {
color: green;
}
.table-cell-boolean-false {
color: red;
}
.table-cell-number {
text-align: right;
}
.table-cell-datetime {
text-align: right;
/*width: 20ch;*/
/*padding-left: 2ch;*/
white-space: normal;
min-width: 0;
/*font-family: monospace;*/
max-width: 14ch
}

12
tsconfig.json Normal file
View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2020", "DOM"],
"declaration": true,
"outDir": "dist",
"strict": true
},
"include": ["abstract-table.ts"]
}