92 lines
4.7 KiB
Plaintext
92 lines
4.7 KiB
Plaintext
<%#
|
|
Data table: sortable headers, row-select, typed cells, badges, kebab row actions.
|
|
Zero-JS (sort = links, select highlight = CSS).
|
|
Config:
|
|
caption?, selectable?, actions? sr-only caption; toggle the check / kebab columns
|
|
actionsId? id stem for the row-action menus (default `row-actions`);
|
|
name it when two tables share a page
|
|
columns: { label, sortable?, sort?: "asc"|"desc", href?, className? }[]
|
|
rows: { name?, cells: Cell[], actions?: Action[] }[]
|
|
Cell ∈ string | { text, className? } | { user:{name,initials} } | { rowHeader:{text,href?} } | { badge:{tone,label} } | { html, className? }
|
|
user + rowHeader cells render as <th scope="row"> — they identify the row (the row header).
|
|
Action = { label, icon?, href?, danger?, separatorBefore? }
|
|
Every href (sort headers, row-header links, row actions) is run through localeHref, so a sorted or
|
|
paged list stays in the visitor's language without the caller wiring it.
|
|
%><%
|
|
const caption = locals.caption;
|
|
const selectable = !!locals.selectable;
|
|
const withActions = !!locals.actions;
|
|
const columns = locals.columns || [];
|
|
const rows = locals.rows || [];
|
|
const emptyText = locals.emptyText || t("table.empty"); // shown when a table that has columns has no rows
|
|
const actionsId = locals.actionsId || "row-actions";
|
|
-%>
|
|
<div class="table-wrap">
|
|
<table class="table">
|
|
<% if (caption) { -%>
|
|
<caption class="sr-only"><%= caption %></caption>
|
|
<% } -%>
|
|
<thead>
|
|
<tr>
|
|
<% if (selectable) { -%>
|
|
<th class="col-check" scope="col"><input type="checkbox" aria-label="<%= t("table.selectAll") %>"></th>
|
|
<% } -%>
|
|
<% columns.forEach((col) => { -%>
|
|
<% if (col.sortable) { -%>
|
|
<th scope="col"<% if (col.sort === "asc") { %> aria-sort="ascending"<% } else if (col.sort === "desc") { %> aria-sort="descending"<% } %><% if (col.className) { %> class="<%= col.className %>"<% } %>><a class="th-sort" href="<%= localeHref(col.href) %>"><%= col.label %> <svg class="ico ico-sm sort-ico"><use href="#<%= col.sort ? "i-up" : "i-sort" %>"/></svg></a></th>
|
|
<% } else { -%>
|
|
<th scope="col"<% if (col.className) { %> class="<%= col.className %>"<% } %>><%= col.label %></th>
|
|
<% } -%>
|
|
<% }) -%>
|
|
<% if (withActions) { -%>
|
|
<th class="col-actions" scope="col"><span class="sr-only"><%= t("table.actions") %></span></th>
|
|
<% } -%>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<% if (rows.length === 0 && columns.length) { -%>
|
|
<tr><td class="table-empty" colspan="<%= columns.length + (selectable ? 1 : 0) + (withActions ? 1 : 0) %>"><%= emptyText %></td></tr>
|
|
<% } -%>
|
|
<% rows.forEach((row, i) => { -%>
|
|
<tr>
|
|
<% if (selectable) { -%>
|
|
<td class="col-check"><input type="checkbox" class="row-select" aria-label="<%= t("table.select", { name: row.name || t("table.row") }) %>"></td>
|
|
<% } -%>
|
|
<% (row.cells || []).forEach((cell) => { -%>
|
|
<% if (typeof cell === "string") { -%>
|
|
<td><%= cell %></td>
|
|
<% } else if (cell.user) { -%>
|
|
<th scope="row"><span class="cell-user"><span class="avatar" aria-hidden="true"><%= cell.user.initials %></span><span class="cell-strong"><%= cell.user.name %></span></span></th>
|
|
<% } else if (cell.rowHeader) { -%>
|
|
<th scope="row"><% if (cell.rowHeader.href) { %><a class="cell-strong" href="<%= localeHref(cell.rowHeader.href) %>"><%= cell.rowHeader.text %></a><% } else { %><span class="cell-strong"><%= cell.rowHeader.text %></span><% } %></th>
|
|
<% } else if (cell.badge) { -%>
|
|
<td><span class="badge <%= cell.badge.tone %>"><span class="dot"></span><%= cell.badge.label %></span></td>
|
|
<% } else if (cell.html != null) { -%>
|
|
<td<% if (cell.className) { %> class="<%= cell.className %>"<% } %>><%- cell.html %></td>
|
|
<% } else { -%>
|
|
<td<% if (cell.className) { %> class="<%= cell.className %>"<% } %>><%= cell.text %></td>
|
|
<% } -%>
|
|
<% }) -%>
|
|
<% if (withActions) { -%>
|
|
<% if ((row.actions || []).length) { -%>
|
|
<td class="col-actions"><%- include("menu", {
|
|
id: `${actionsId}-${i + 1}`,
|
|
align: null, up: false, width: null, // explicit: EJS would otherwise inherit the page's own
|
|
kebab: true,
|
|
trigger: { class: "", icon: "i-kebab", label: t("table.rowActions", { name: row.name || t("table.row") }) },
|
|
// Mapped field by field, not spread: an Action and a menu Item are separate shapes.
|
|
items: row.actions.flatMap((a) => {
|
|
const item = { danger: a.danger, href: a.href, icon: a.icon, label: a.label };
|
|
return a.separatorBefore ? [{ sep: true }, item] : [item];
|
|
}),
|
|
}) %></td>
|
|
<% } else { -%>
|
|
<td class="col-actions"></td>
|
|
<% } -%>
|
|
<% } -%>
|
|
</tr>
|
|
<% }) -%>
|
|
</tbody>
|
|
</table>
|
|
</div>
|