drab
Version:
Interactivity for You
169 lines (144 loc) • 4.51 kB
text/typescript
import {
Announce,
Content,
type ContentAttributes,
Lifecycle,
Trigger,
type TriggerAttributes,
} from "../base/index.js";
export interface TableSortAttributes
extends TriggerAttributes,
ContentAttributes {}
export interface TableSortTriggerAttributes {
"data-type": "string" | "boolean" | "number";
"data-value": string;
}
/**
* Wrap a `HTMLTableElement` in the `TableSort` element to have sortable column
* headers. Set each `th` that you want to sort to the `trigger`. Set the `tbody`
* element to the `content`.
*
* The values of each cell default to the cell's `textContent`. If you would like to
* provide an alternate value than what appears in the cell to sort by instead,
* you can set a different value using the `data-value` attribute on the cell.
*
* The cells will be sorted as `string` by default. If you want to provide a different
* datatype `number` or `boolean`, set `data-type="number"` on the corresponding
* `th`/`trigger` element. The data will be converted to the specified type before sorting.
*/
export class TableSort extends Lifecycle(Trigger(Content(Announce()))) {
constructor() {
super();
}
get #th() {
return this.triggers(HTMLTableCellElement);
}
/**
* Removes `data-asc` or `data-desc` from other triggers then sets the correct attribute on the selected trigger.
*
* @param trigger
* @returns `true` if ascending, `false` if descending
*/
#setAttributes(trigger: HTMLElement) {
const asc = "data-asc";
const desc = "data-desc";
for (const t of this.triggers(HTMLTableCellElement)) {
if (t !== trigger) {
t.removeAttribute(asc);
t.removeAttribute(desc);
}
}
if (trigger.hasAttribute(asc)) {
trigger.removeAttribute(asc);
trigger.setAttribute(desc, "");
return false;
}
trigger.removeAttribute(desc);
trigger.setAttribute(asc, "");
return true;
}
override mount() {
const tbody = this.content(HTMLTableSectionElement);
for (const trigger of this.#th) {
trigger.tabIndex = 0;
trigger.role = "button";
const listener = () => {
const asc = this.#setAttributes(trigger);
Array.from(tbody.querySelectorAll("tr"))
.sort(comparer(trigger, asc))
.forEach((tr) => tbody.appendChild(tr));
this.announce(
`sorted table by ${trigger.textContent} in ${asc ? "ascending" : "descending"} order`,
);
};
trigger.addEventListener(this.event, listener);
if (this.event === "click") {
trigger.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
listener();
}
});
}
}
}
}
// adapted from: https://stackoverflow.com/questions/14267781/sorting-html-table-with-javascript/49041392#49041392
const comparer = (th: HTMLElement, ascending: boolean) => {
// this function is returned and used by `sort`
const sorter = (a: HTMLTableRowElement, b: HTMLTableRowElement) => {
// find the column to sort by using the index of the `th`
const columnIndex = Array.from(th.parentNode?.children ?? []).indexOf(th);
const compare = (aVal: string, bVal: string) => {
// default to `string` sorting
const dataType = (th.dataset.type ?? "string") as
| "string"
| "boolean"
| "number";
if (dataType === "string") {
const collator = new Intl.Collator();
return collator.compare(aVal, bVal);
} else if (dataType === "boolean") {
return falsyBoolean(aVal) === falsyBoolean(bVal)
? 0
: falsyBoolean(aVal)
? -1
: 1;
} else {
// "number"
return Number(aVal) - Number(bVal);
}
};
return compare(
getValue(ascending ? a : b, columnIndex),
getValue(ascending ? b : a, columnIndex),
);
};
return sorter;
};
/**
* @param tr the row
* @param i index of the `td` to find
* @returns a string, the `data-value` attribute, or the `textContent`
*/
const getValue = (tr: HTMLTableRowElement, i: number) => {
const cell = tr.children[i];
if (cell instanceof HTMLElement) {
// first look for `data-value` attribute, then use `textContent`
return cell.dataset.value ?? cell.textContent ?? "";
}
return "";
};
/**
* if value is one of these and type is boolean
* it should be considered falsy
* since actually `Boolean("false") === true`
* @param val string pulled from the textContent or attr
* @returns a boolean of the provided string
*/
const falsyBoolean = (val: string) => {
if (["0", "false", "null", "undefined"].includes(val)) {
return false;
}
return Boolean(val);
};