Files
WRNexusJS/packages/ui/components/DataTable.wrn
Clintchiz 2c960fc1dc
Quality / quality (ubuntu-latest) (push) Failing after 9m49s
Quality / quality (windows-latest) (push) Canceled after 0s
refactor: migrate legacy wire namespace to wrn
2026-08-12 18:51:15 +05:30

1714 lines
53 KiB
Plaintext

//
// DataTable -- columns, sorting, filtering, pagination and selection over an
// array of records.
//
// <DataTable
// columns='[{"key":"name","label":"Name","sortable":true},
// {"key":"plan","label":"Plan"},
// {"key":"seats","label":"Seats","align":"end","sortable":true}]'
// rows='[{"id":1,"name":"Acme","plan":"Scale","seats":42}]'
// pageSize={10}
// selectable={true}
// />
//
// Columns are plain objects so they survive being passed as an attribute:
// key which field of the row to show (required)
// label header text; defaults to the key
// align start | center | end
// width any CSS width for the column
// sortable show a sort control on the header
//
// Rows are matched by `rowKey` (default "id"). Sorting, filtering and paging
// are all derived through shared functions, so the first paint is server
// rendered and the same code keeps it live in the browser.
//
// Turn pagination off with paginated={false} to render every row.
//
// NOTE: the style block uses /* */ comments only -- // is not a CSS comment
// and silently swallows the rule that follows it.
import Dropdown from "./Dropdown.wrn"
component DataTable {
outputs {
sort(payload: { key: string; direction: string })
search(payload: { query: string })
pageChange(payload: { page: number; pageSize: number })
select(payload: { selected: Array<string | number>; all: boolean })
change(payload: { page: number; pageSize: number; total: number; query: string; sortKey: string; sortDirection: string })
rowClick(payload: { row: object; sourceEvent: Event })
action(payload: { id: string; selected: Array<string | number>; rows: object[]; sourceEvent: Event })
request(payload: { instanceId: number; page: number; pageSize: number; sortKey: string; sortDirection: string; query: string })
}
props {
color: string = "primary"
size: string = "default"
columns: unknown[] = []
rows: unknown[] = []
rowKey: string = "id"
// Async source.
//
// Set remote={true} and the table stops deriving anything locally: it
// emits a `request` output carrying { instanceId, page, pageSize,
// sortKey, sortDirection, query } whenever the view changes, and waits to
// be handed rows back. Sorting, filtering and paging all become the
// server's job, because the table only ever sees the page it was given.
//
// Answer it by dispatching the result back with the SAME instanceId:
//
// function loadRows(payload) {
// fetch("/api/rows?page=" + payload.page)
// .then(function (r) { return r.json() })
// .then(function (data) {
// window.dispatchEvent(new CustomEvent("wrnexus:datatable:rows", {
// detail: { instanceId: payload.instanceId,
// rows: data.rows, total: data.total }
// }))
// })
// }
//
// It is an event rather than a `load` function prop because props travel
// as HTML attributes: a function passed that way arrives as its own source
// text, not something callable.
remote: boolean = false
loadingLabel: string = "Loading"
errorLabel: string = "Could not load this data"
retryLabel: string = "Try again"
caption: string = ""
description: string = ""
// Filtering
searchable: boolean = true
searchPlaceholder: string = "Search"
// Pagination. paginated={false} renders every row and hides the footer.
paginated: boolean = true
pageSize: number = 10
// compact = previous/next arrows only. numbered = clickable page numbers.
paginationStyle: string = "compact"
// Typed concretely, not unknown[]: the showcase generator fills an
// unknown[] prop with rich demo objects, and every option in the picker
// then rendered as [object Object].
pageSizes: number[] = [10, 25, 50]
// Selection
selectable: boolean = false
// Toolbar actions. Each entry is an object:
// id returned with the action output so a host can tell them apart
// label button text
// icon optional icon class
// tone "danger" styles it destructively
// when "always" (default) or "selection" -- selection-only actions
// stay hidden until at least one row is ticked
// onClick optional callback, receives the selected rows
actions: unknown[] = []
// Presentation
striped: boolean = true
bordered: boolean = true
// rows = horizontal rules only. grid = full row and column gridlines.
gridlines: string = "rows"
density: string = "default"
emptyLabel: string = "No records to show"
// Shown when a filter matches nothing, as opposed to there being no data
// at all -- the two need different wording and different remedies.
noResultsLabel: string = "No records match your search"
clearSearchLabel: string = "Clear search"
// Keeps the first column in view while the rest scrolls sideways.
stickyFirstColumn: boolean = false
// rows = one record per row. comparison = transposed, fields down the
// left and one column per record.
layout: string = "rows"
class: string = ""
}
state query = ""
state sortKey = ""
state sortDirection = "asc"
state paginationPage = 1
state perPage = pageSize
state selectedKeys = []
state remoteRows = []
state remoteTotal = 0
state loading = false
state loadError = ""
// Distinguishes this instance from any other table on the page, since the
// async result comes back through a window event.
state instanceId = 0
state lastAnchor = ""
functions {
// --- derivation -------------------------------------------------------
// Shared so the server renders the first page and the browser keeps it in
// step from the same source. Each step is a pure function of state, which
// is what lets the view call them straight from data-for.
shared function columnList() {
return Array.isArray(columns) ? columns : []
}
shared function isRemote() {
return !!remote
}
shared function allRows() {
if (isRemote()) {
return Array.isArray(remoteRows) ? remoteRows : []
}
return Array.isArray(rows) ? rows : []
}
// Falls back to the row's position when it carries no key. Returning a
// constant made every keyless row share one identity, so ticking one
// ticked them all -- a silent, and very confusing, data bug.
shared function keyOf(row, index) {
if (row && row[rowKey] !== undefined && row[rowKey] !== null) {
return row[rowKey]
}
return "__row:" + (index === undefined ? allRows().indexOf(row) : index)
}
shared function cellText(row, column) {
var value = row ? row[column.key] : ""
return value === undefined || value === null ? "" : String(value)
}
// A column may render markup instead of text, either by naming a field
// that already holds markup (html: "statusBadge") or by supplying a
// render(row) function. Returns "" when the column is plain text, which
// is what keeps the escaping default in place for everything else.
shared function cellMarkup(row, column) {
if (!column || !row) {
return ""
}
if (typeof column.render === "function") {
try {
var rendered = column.render(row)
return rendered === undefined || rendered === null ? "" : String(rendered)
} catch (error) {
console.error("[wrnexus] data table column render failed", error)
return ""
}
}
if (column.html) {
var field = column.html === true ? column.key : column.html
var value = row[field]
return value === undefined || value === null ? "" : String(value)
}
return ""
}
shared function matchesQuery(row) {
if (!query) {
return true
}
var needle = String(query).toLowerCase()
return columnList().some(function (column) {
return cellText(row, column).toLowerCase().indexOf(needle) !== -1
})
}
shared function filteredRows() {
// The server did the filtering; re-filtering here would hide rows it
// deliberately returned.
if (isRemote()) {
return allRows()
}
return allRows().filter(function (row) { return matchesQuery(row) })
}
shared function sortedRows() {
var list = filteredRows().slice()
if (isRemote() || !sortKey) {
return list
}
var factor = sortDirection === "desc" ? -1 : 1
var kind = sortColumnType()
return list.sort(function (left, right) {
var a = left ? left[sortKey] : ""
var b = right ? right[sortKey] : ""
// Dates compared as text put "9 Feb" after "10 Jan"; parse them.
if (kind === "date") {
var at = Date.parse(a)
var bt = Date.parse(b)
if (!isNaN(at) && !isNaN(bt)) {
return (at - bt) * factor
}
}
if (kind === "number" || (typeof a === "number" && typeof b === "number")) {
return ((Number(a) || 0) - (Number(b) || 0)) * factor
}
var as = a === undefined || a === null ? "" : String(a)
var bs = b === undefined || b === null ? "" : String(b)
return as.localeCompare(bs) * factor
})
}
// A column may declare type: "date" | "number" | "text".
shared function sortColumnType() {
var found = ""
columnList().forEach(function (column) {
if (column.key === sortKey && column.type) {
found = column.type
}
})
return found
}
shared function totalCount() {
// Only the source knows how many records exist beyond this page.
if (isRemote()) {
return Number(remoteTotal) || 0
}
return sortedRows().length
}
shared function pageCount() {
if (!paginated) {
return 1
}
var rowsPerPage = Number(perPage) > 0 ? Number(perPage) : 10
return Math.max(1, Math.ceil(totalCount() / rowsPerPage))
}
// Clamped rather than stored: deleting or filtering rows can strand the
// page number past the end, and a table showing nothing with rows
// available is worse than one that quietly lands on the last page.
shared function currentPage() {
return Math.min(Math.max(1, Number(paginationPage) || 1), pageCount())
}
shared function visibleRows() {
var list = sortedRows()
if (isRemote()) {
return list
}
if (!paginated) {
return list
}
var rowsPerPage = Number(perPage) > 0 ? Number(perPage) : 10
var start = (currentPage() - 1) * rowsPerPage
return list.slice(start, start + rowsPerPage)
}
// Cells are built per row so the view can nest a loop inside its rows.
shared function cellsFor(row) {
return columnList().map(function (column) {
return {
key: column.key,
label: columnLabel(column),
value: cellText(row, column),
html: cellMarkup(row, column),
align: columnAlign(column),
width: column.width || ""
}
})
}
/*
* One view object per visible row, carrying everything the row renders.
*
* Same rule as headerColumns: a loop only re-runs when something its LIST
* expression reads changes. The row loop read visibleRows(), which knows
* nothing about selection, so ticking a checkbox left every row exactly as
* it was built. Reading selectedKeys here ties the two together, and the
* cells come along so the nested loop has nothing left to compute.
*/
shared function rowViews() {
var keys = selectedKeys
return visibleRows().map(function (row, index) {
var identity = keyOf(row, index)
return {
key: identity,
raw: row,
selected: keys.indexOf(identity) !== -1 ? "true" : "false",
cells: cellsFor(row)
}
})
}
// Comparison layout: the table is transposed, so a rendered row is a
// FIELD and each of its cells is one record. Built here rather than in the
// view because a loop can only iterate one list.
shared function comparisonRows() {
var records = visibleRows()
return columnList().map(function (column, columnIndex) {
return {
key: column.key || String(columnIndex),
label: columnLabel(column),
align: columnAlign(column),
cells: records.map(function (record, recordIndex) {
return {
key: keyOf(record, recordIndex),
value: cellText(record, column),
html: cellMarkup(record, column),
align: columnAlign(column)
}
})
}
})
}
// Column headings for the transposed table: the first record field makes
// a readable caption for each record column.
shared function comparisonHeadings() {
var first = columnList()[0]
return visibleRows().map(function (record, index) {
return {
key: keyOf(record, index),
label: first ? cellText(record, first) : String(index + 1)
}
})
}
shared function rangeStart() {
return totalCount() === 0 ? 0 : (currentPage() - 1) * (Number(perPage) || 10) + 1
}
shared function rangeEnd() {
if (!paginated) {
return totalCount()
}
return Math.min(currentPage() * (Number(perPage) || 10), totalCount())
}
// No data at all, versus a filter that happens to match nothing.
shared function isFiltered() {
return !!query
}
shared function emptyMessage() {
return isFiltered() ? noResultsLabel : emptyLabel
}
shared function rangeLabel() {
if (totalCount() === 0) {
return emptyLabel
}
return "Showing " + rangeStart() + " to " + rangeEnd() + " of " + totalCount()
}
shared function columnAlign(column) {
return column && column.align ? column.align : "start"
}
shared function columnLabel(column) {
return column && column.label ? column.label : (column ? column.key : "")
}
/*
* Headers carry their own sort state rather than each cell asking for it.
* Attributes inside a data-for row are resolved when the row is built, and
* this loop only depends on the column list -- so changing the sort left
* aria-sort and the active highlight frozen at whatever they were on first
* render. Reading sortKey/sortDirection here makes the header loop itself
* depend on them, so the row is rebuilt with fresh values.
*/
shared function headerColumns() {
var activeKey = sortKey
var direction = sortDirection
return columnList().map(function (column) {
var isActive = activeKey === column.key
return {
key: column.key,
label: columnLabel(column),
align: columnAlign(column),
width: column.width || "",
sortable: column.sortable ? true : false,
active: isActive ? "true" : "false",
ariaSort: isActive ? (direction === "desc" ? "descending" : "ascending") : "none"
}
})
}
// Windowed around the current page: a thousand-page table should not try
// to render a thousand buttons.
shared function actionList() {
return Array.isArray(actions) ? actions : []
}
// The rows behind the current selection, in the order they appear in the
// data, so a handler receives records rather than bare keys.
shared function selectedRows() {
var keys = selectedKeys
return allRows().filter(function (row, index) {
return keys.indexOf(keyOf(row, index)) !== -1
})
}
shared function selectedCount() {
return selectedRows().length
}
// Reads selectedKeys so the toolbar re-renders as the selection changes;
// a loop only re-runs when its list expression depends on what changed.
shared function visibleActions() {
var count = selectedKeys.length
return actionList()
.filter(function (action) {
return action.when === "selection" ? count > 0 : true
})
.map(function (action) {
return {
id: action.id || action.label,
label: action.label || "",
icon: action.icon || "",
tone: action.tone || "",
raw: action
}
})
}
shared function pageSizeItems() {
var current = Number(perPage) || 10
return (Array.isArray(pageSizes) ? pageSizes : [10, 25, 50]).map(function (option) {
return {
label: String(option),
value: Number(option),
selected: Number(option) === current
}
})
}
shared function pageNumbers() {
var total = pageCount()
var active = currentPage()
var first = Math.max(1, active - 2)
var last = Math.min(total, active + 2)
var list = []
var index = first
while (index <= last) {
list.push({ number: index, active: index === active ? "true" : "false" })
index = index + 1
}
return list
}
shared function sortState(key) {
if (sortKey !== key) {
return "none"
}
return sortDirection === "desc" ? "descending" : "ascending"
}
shared function isSelectedKey(identity) {
return selectedKeys.indexOf(identity) !== -1
}
shared function isSelected(row, index) {
return isSelectedKey(keyOf(row, index))
}
shared function allVisibleSelected() {
var visible = visibleRows()
if (!visible.length) {
return false
}
return visible.every(function (row, index) { return isSelected(row, index) })
}
// A snapshot of the view, sent with `change` after anything that alters
// what the table is showing, so a host can mirror the state (deep links,
// a server query) without wiring up five separate outputs.
shared function viewState() {
return {
page: currentPage(),
pageSize: Number(perPage) || 10,
total: totalCount(),
query: query,
sortKey: sortKey,
sortDirection: sortDirection
}
}
// --- async source -----------------------------------------------------
//
// The result of load() cannot be written to state from its own .then():
// a client function copies state in at entry and flushes it back when the
// body returns, so anything a promise callback assigns lands in a dead
// local. The callback therefore only DISPATCHES, carrying the instance id
// so two tables on one page cannot answer each other, and the declarative
// @window handlers below apply the result with live state.
// Begins a load: flags the pending state and asks for rows. The answer
// arrives later on a window event, because a promise callback cannot write
// state -- see applyRows.
client function reload() {
if (!remote) {
return
}
loading = true
loadError = ""
output.request({
instanceId: instanceId,
page: currentPage(),
pageSize: Number(perPage) || 10,
sortKey: sortKey,
sortDirection: sortDirection,
query: query
})
}
client function applyRows(sourceEvent) {
var detail = sourceEvent.detail || {}
if (detail.instanceId !== instanceId) {
return
}
remoteRows = detail.rows
remoteTotal = detail.total
loading = false
loadError = ""
}
client function applyError(sourceEvent) {
var detail = sourceEvent.detail || {}
if (detail.instanceId !== instanceId) {
return
}
loading = false
loadError = detail.message || errorLabel
}
// --- interaction ------------------------------------------------------
// Every write below is synchronous: a client function only flushes its
// state when its body returns, so anything deferred would be lost.
client function sortBy(key) {
if (sortKey === key) {
sortDirection = sortDirection === "asc" ? "desc" : "asc"
} else {
sortKey = key
sortDirection = "asc"
}
paginationPage = 1
output.sort({ key: sortKey, direction: sortDirection })
reload()
output.change(viewState())
}
client function updateQuery(sourceEvent) {
query = sourceEvent.target.value
paginationPage = 1
output.search({ query: query })
reload()
output.change(viewState())
}
client function goToPage(next) {
var target = Math.min(Math.max(1, next), pageCount())
paginationPage = target
output.pageChange({ page: target, pageSize: Number(perPage) || 10 })
reload()
output.change(viewState())
}
// Component outputs deliver a payload rather than a DOM event, so this
// takes the Dropdown select detail directly.
client function pickPageSize(detail) {
perPage = Number(detail && detail.value) || 10
paginationPage = 1
output.pageChange({ page: 1, pageSize: perPage })
reload()
output.change(viewState())
}
// Shift-click extends from the previous click, the way every file list and
// mail client behaves; without it selecting twenty rows means twenty
// clicks.
client function clearSearch() {
query = ""
paginationPage = 1
output.search({ query: "" })
reload()
}
client function toggleRowAt(identity, sourceEvent) {
if (sourceEvent && sourceEvent.shiftKey && lastAnchor) {
var order = rowViews().map(function (view) { return view.key })
var from = order.indexOf(lastAnchor)
var to = order.indexOf(identity)
if (from !== -1 && to !== -1) {
var start = Math.min(from, to)
var end = Math.max(from, to)
var next = selectedKeys.slice()
var step = start
while (step <= end) {
if (next.indexOf(order[step]) === -1) {
next.push(order[step])
}
step = step + 1
}
selectedKeys = next
output.select({ selected: next, all: allVisibleSelected() })
return
}
}
lastAnchor = identity
toggleKey(identity)
}
client function toggleKey(key) {
var next = []
var found = false
selectedKeys.forEach(function (existing) {
if (existing === key) {
found = true
} else {
next.push(existing)
}
})
if (!found) {
next.push(key)
}
selectedKeys = next
output.select({ selected: next, all: allVisibleSelected() })
}
client function toggleAll() {
var visible = visibleRows()
var everySelected = visible.every(function (row, index) { return isSelected(row, index) })
var next = []
if (!everySelected) {
selectedKeys.forEach(function (existing) { next.push(existing) })
visible.forEach(function (row, index) {
var key = keyOf(row, index)
if (next.indexOf(key) === -1) {
next.push(key)
}
})
} else {
selectedKeys.forEach(function (existing) {
var stillVisible = visible.some(function (row, index) { return keyOf(row, index) === existing })
if (!stillVisible) {
next.push(existing)
}
})
}
selectedKeys = next
output.select({ selected: next, all: !everySelected })
}
// Runs a toolbar action with the selected records.
//
// NOTHING may call a peer function after the callback: it is application
// code and may change state (clearing the selection, adding a row), and a
// peer call would flush this function's pre-callback snapshot back over
// whatever it did. This function assigns no state, so it flushes nothing.
client function runAction(entry, sourceEvent) {
var rows = selectedRows()
output.action({
id: entry.id,
selected: selectedKeys,
rows: rows,
sourceEvent: sourceEvent
})
var handler = entry.raw && entry.raw.onClick
if (typeof handler === "function") {
try {
handler(rows, selectedKeys)
} catch (error) {
console.error("[wrnexus] data table action handler failed", error)
}
}
}
client function emitRowClick(row, sourceEvent) {
output.rowClick({ row: row, sourceEvent: sourceEvent })
}
}
lifecycle {
mount {
// A per-instance id so the async result, which travels on a window
// event, is only picked up by the table that asked for it.
instanceId = Math.floor(Math.random() * 1000000) + 1
reload()
}
}
view {
<div
{...attrs}
data-ui-component="DataTable"
data-color='{color}'
data-size='{size}'
data-density='{density}'
data-striped='{striped ? "true" : "false"}'
data-bordered='{bordered ? "true" : "false"}'
data-gridlines='{gridlines}'
data-layout='{layout}'
data-loading='{loading ? "true" : "false"}'
data-sticky-first='{stickyFirstColumn ? "true" : "false"}'
class='wrn-table {class}'
@window:wrnexus:datatable:rows='applyRows(event)'
@window:wrnexus:datatable:error='applyError(event)'
>
{#if caption || description || searchable}
<div class="wrn-table__toolbar">
<div class="wrn-table__heading">
{#if caption}
<h3 class="wrn-table__caption">{caption}</h3>
{/if}
{#if description}
<p class="wrn-table__description">{description}</p>
{/if}
</div>
<div class="wrn-table__actions" data-show="visibleActions().length">
<span class="wrn-table__selected-count" data-show="selectedCount()">
{selectedCount()} selected
</span>
<button
type="button"
class="wrn-table__action"
data-for="entry in visibleActions()"
data-key="entry.id"
data-tone='{entry.tone}'
@click='runAction(entry, event)'
>
<span class='{entry.icon}' data-show="entry.icon" aria-hidden="true"></span>
<span>{entry.label}</span>
</button>
</div>
{#if searchable}
<div class="wrn-table__search">
<svg
viewBox="0 0 24 24"
width="15"
height="15"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
aria-hidden="true"
>
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.2-3.2" />
</svg>
<input
type="search"
value='{query}'
placeholder='{searchPlaceholder}'
aria-label='{searchPlaceholder}'
@input='updateQuery(event)'
/>
</div>
{/if}
</div>
{/if}
<div class="wrn-table__scroll">
<table class="wrn-table__table" data-show="layout !== 'comparison'">
<thead>
<tr>
{#if selectable}
<th class="wrn-table__select-cell" scope="col">
<button
type="button"
class="wrn-table__check"
role="checkbox"
aria-label="Select all rows on this page"
aria-checked='{allVisibleSelected() ? "true" : "false"}'
@click='toggleAll()'
>
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m5 12 5 5 9-9" />
</svg>
</button>
</th>
{/if}
<th
data-for="column in headerColumns()"
data-key="column.key"
scope="col"
data-align='{column.align}'
style='width: {column.width}'
aria-sort='{column.ariaSort}'
>
<button
type="button"
class="wrn-table__sort"
data-show="column.sortable"
data-active='{column.active}'
@click='sortBy(column.key)'
>
<span>{column.label}</span>
<svg
class="wrn-table__sort-icon"
viewBox="0 0 24 24"
width="12"
height="12"
fill="none"
stroke="currentColor"
stroke-width="2.4"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="m7 15 5 5 5-5" />
<path d="m7 9 5-5 5 5" />
</svg>
</button>
<span data-show="!column.sortable">{column.label}</span>
</th>
</tr>
</thead>
<tbody>
<tr
data-for="row in rowViews()"
data-key="row.key"
data-selected='{row.selected}'
@click='emitRowClick(row.raw, event)'
>
{#if selectable}
<td class="wrn-table__select-cell">
<button
type="button"
class="wrn-table__check"
role="checkbox"
aria-label="Select row"
aria-checked='{row.selected}'
@click='toggleRowAt(row.key, event)'
>
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m5 12 5 5 9-9" />
</svg>
</button>
</td>
{/if}
<td
data-for="cell in row.cells"
data-key="cell.key"
data-align='{cell.align}'
data-label='{cell.label}'
>
<span data-show="cell.html" data-html="cell.html"></span>
<span data-show="!cell.html">{cell.value}</span>
</td>
</tr>
</tbody>
</table>
<!-- Comparison layout: fields down the left, one column per record. -->
<table class="wrn-table__table wrn-table__table--comparison" data-show="layout === 'comparison'">
<thead>
<tr>
<th scope="col">{caption}</th>
<th
data-for="heading in comparisonHeadings()"
data-key="heading.key"
scope="col"
>
{heading.label}
</th>
</tr>
</thead>
<tbody>
<tr data-for="field in comparisonRows()" data-key="field.key">
<th scope="row" class="wrn-table__field-label">{field.label}</th>
<td
data-for="cell in field.cells"
data-key="cell.key"
data-align='{cell.align}'
>
<span data-show="cell.html" data-html="cell.html"></span>
<span data-show="!cell.html">{cell.value}</span>
</td>
</tr>
</tbody>
</table>
<div class="wrn-table__status" data-show="loading">
<span class="wrn-table__spinner" aria-hidden="true"></span>
<span>{loadingLabel}</span>
</div>
<div class="wrn-table__status wrn-table__status--error" data-show="loadError">
<span>{loadError}</span>
<button type="button" class="wrn-table__action" @click='reload()'>
{retryLabel}
</button>
</div>
<div
class="wrn-table__status"
data-show="!loading && !loadError && totalCount() === 0"
>
<span>{emptyMessage()}</span>
<button
type="button"
class="wrn-table__action"
data-show="isFiltered()"
@click='clearSearch()'
>
{clearSearchLabel}
</button>
</div>
</div>
<div class="wrn-table__footer" data-show="paginated && totalCount() > 0">
<p class="wrn-table__range">{rangeLabel()}</p>
<div class="wrn-table__pager">
<span class="wrn-table__page-size">
<span>Rows</span>
<!-- The shared Dropdown rather than a native select: a select
renders its list with the operating system styling, which
ignores the theme entirely. The trigger label lives in the
slot so it stays reactive -- a prop would be fixed at the
value the dropdown was first rendered with. -->
<Dropdown
class="wrn-table__page-dropdown"
width="sm"
size="sm"
placement="top-end"
label=""
items='{pageSizeItems()}'
@select='pickPageSize(payload)'
>
<span data-slot="trigger">{perPage}</span>
</Dropdown>
</span>
<button
type="button"
class="wrn-table__page-button"
aria-label="Previous page"
disabled='{currentPage() <= 1}'
@click='goToPage(currentPage() - 1)'
>
<svg
viewBox="0 0 24 24"
width="14"
height="14"
fill="none"
stroke="currentColor"
stroke-width="2.4"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="m15 18-6-6 6-6" />
</svg>
</button>
<span
class="wrn-table__page-indicator"
data-show="paginationStyle !== 'numbered'"
>
{currentPage()} / {pageCount()}
</span>
<span
class="wrn-table__page-numbers"
data-show="paginationStyle === 'numbered'"
>
<button
type="button"
class="wrn-table__page-number"
data-for="entry in pageNumbers()"
data-key="entry.number"
data-active='{entry.active}'
aria-label='{"Page " + entry.number}'
@click='goToPage(entry.number)'
>
{entry.number}
</button>
</span>
<button
type="button"
class="wrn-table__page-button"
aria-label="Next page"
disabled='{currentPage() >= pageCount()}'
@click='goToPage(currentPage() + 1)'
>
<svg
viewBox="0 0 24 24"
width="14"
height="14"
fill="none"
stroke="currentColor"
stroke-width="2.4"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="m9 18 6-6-6-6" />
</svg>
</button>
</div>
</div>
<slot></slot>
</div>
}
style {
.wrn-next--data-map {
display: grid;
gap: 0.75rem;
padding: 1rem;
border: 1px solid var(--wrn-color-border);
border-radius: var(--wrn-radius-md);
background: var(--wrn-color-surface);
box-shadow: var(--wrn-shadow-1);
}
.wrn-table {
--table-accent: var(--wrn-color-primary);
display: flex;
flex-direction: column;
gap: 0.85rem;
width: 100%;
min-width: 0;
max-width: 100%;
color: var(--wrn-color-text);
}
.wrn-table[data-color="secondary"] {
--table-accent: var(--wrn-color-secondary);
}
.wrn-table[data-color="success"] {
--table-accent: var(--wrn-color-success);
}
.wrn-table[data-color="danger"] {
--table-accent: var(--wrn-color-danger);
}
.wrn-table[data-color="info"] {
--table-accent: var(--wrn-color-info);
}
.wrn-table__toolbar {
display: flex;
align-items: flex-end;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.75rem;
}
.wrn-table__heading {
display: grid;
gap: 0.2rem;
min-width: 0;
}
.wrn-table__caption {
margin: 0;
color: var(--wrn-color-text);
font-size: 0.98rem;
font-weight: 650;
}
.wrn-table__description {
margin: 0;
color: var(--wrn-color-text-muted);
font-size: 0.82rem;
}
.wrn-table__actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.4rem;
}
.wrn-table__selected-count {
color: var(--wrn-color-text-muted);
font-size: 0.78rem;
font-variant-numeric: tabular-nums;
}
.wrn-table__action {
appearance: none;
display: inline-flex;
align-items: center;
gap: 0.35rem;
min-height: 2.1rem;
padding: 0 0.7rem;
color: var(--wrn-color-text);
background: var(--wrn-color-surface-soft);
border: 1px solid var(--wrn-color-border);
border-radius: var(--wrn-radius-sm);
font: inherit;
font-size: 0.79rem;
cursor: pointer;
transition: border-color 140ms ease, color 140ms ease, background 140ms ease;
}
.wrn-table__action:hover {
color: var(--table-accent);
border-color: color-mix(in srgb, var(--table-accent) 45%, var(--wrn-color-border));
}
.wrn-table__action[data-tone="danger"] {
color: var(--wrn-color-danger);
border-color: color-mix(in srgb, var(--wrn-color-danger) 35%, var(--wrn-color-border));
}
.wrn-table__action[data-tone="danger"]:hover {
background: color-mix(in srgb, var(--wrn-color-danger) 12%, transparent);
}
/* Full gridlines: vertical rules between columns as well as rows. */
.wrn-table[data-gridlines="grid"] .wrn-table__table th,
.wrn-table[data-gridlines="grid"] .wrn-table__table td {
border-right: 1px solid color-mix(in srgb, var(--wrn-color-border) 60%, transparent);
}
.wrn-table[data-gridlines="grid"] .wrn-table__table th:last-child,
.wrn-table[data-gridlines="grid"] .wrn-table__table td:last-child {
border-right: 0;
}
.wrn-table__search {
display: inline-flex;
align-items: center;
gap: 0.45rem;
min-height: 2.35rem;
padding: 0 0.7rem;
color: var(--wrn-color-text-muted);
background: var(--wrn-color-surface-soft);
border: 1px solid var(--wrn-color-border);
border-radius: 0.7rem;
}
.wrn-table__search input {
width: 12rem;
max-width: 100%;
padding: 0;
color: var(--wrn-color-text);
background: transparent;
border: 0;
font: inherit;
font-size: 0.83rem;
outline: none;
}
.wrn-table__search:focus-within {
border-color: color-mix(in srgb, var(--table-accent) 45%, var(--wrn-color-border));
}
/*
* The table scrolls sideways rather than squashing its columns.
*
* max-width matters as much as width here: a wide table -- a comparison
* layout turns every record into a column -- makes the flex item grow to
* fit its content unless it is told not to, and the whole table then
* spilled out over the page beside it instead of scrolling inside its own
* box. min-width: 0 alone is not enough once the parent is a grid.
*/
.wrn-table__scroll {
position: relative;
width: 100%;
min-width: 0;
max-width: 100%;
overflow-x: auto;
border: 1px solid var(--wrn-color-border);
border-radius: 0.9rem;
}
.wrn-table[data-bordered="false"] .wrn-table__scroll {
border: 0;
border-radius: 0;
}
.wrn-table__table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.wrn-table__table th,
.wrn-table__table td {
padding: 0.7rem 0.85rem;
text-align: left;
vertical-align: middle;
white-space: nowrap;
}
.wrn-table[data-density="compact"] .wrn-table__table th,
.wrn-table[data-density="compact"] .wrn-table__table td {
padding: 0.42rem 0.6rem;
}
.wrn-table[data-density="comfortable"] .wrn-table__table th,
.wrn-table[data-density="comfortable"] .wrn-table__table td {
padding: 0.95rem 1rem;
}
.wrn-table__table th[data-align="center"],
.wrn-table__table td[data-align="center"] {
text-align: center;
}
.wrn-table__table th[data-align="end"],
.wrn-table__table td[data-align="end"] {
text-align: right;
}
.wrn-table__table thead th {
position: sticky;
top: 0;
z-index: 1;
color: var(--wrn-color-text-muted);
background: var(--wrn-color-surface-soft);
border-bottom: 1px solid var(--wrn-color-border);
font-size: 0.76rem;
font-weight: 650;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.wrn-table__table tbody tr {
border-bottom: 1px solid color-mix(in srgb, var(--wrn-color-border) 60%, transparent);
}
.wrn-table__table tbody tr:last-child {
border-bottom: 0;
}
/*
* Row background: stripe, then hover, then selection, each beating the one
* before it.
*
* These were written at whatever specificity fell out naturally, and the
* stripe selector was the strongest -- so a selected row on an even line
* kept its stripe and looked different from a selected row on an odd line,
* and hovering flipped rows between three shades. Selection is a state
* about the row, not decoration, so it wins outright; every rule below
* sits at the same specificity and is ordered deliberately.
*/
.wrn-table[data-striped="true"] .wrn-table__table tbody tr:nth-child(even) {
background: color-mix(in srgb, var(--wrn-color-surface-soft) 55%, transparent);
}
.wrn-table[data-striped] .wrn-table__table tbody tr:hover:not([data-selected="true"]) {
background: color-mix(in srgb, var(--table-accent) 7%, transparent);
}
/* Every selected row identical, striped or not. */
.wrn-table[data-striped] .wrn-table__table tbody tr[data-selected="true"] {
background: color-mix(in srgb, var(--table-accent) 15%, transparent);
}
.wrn-table[data-striped] .wrn-table__table tbody tr[data-selected="true"]:hover {
background: color-mix(in srgb, var(--table-accent) 22%, transparent);
}
.wrn-table__sort {
appearance: none;
display: inline-flex;
align-items: center;
gap: 0.32rem;
padding: 0;
color: inherit;
background: transparent;
border: 0;
font: inherit;
font-size: inherit;
font-weight: inherit;
letter-spacing: inherit;
text-transform: inherit;
cursor: pointer;
}
.wrn-table__sort:hover {
color: var(--table-accent);
}
.wrn-table__sort-icon {
flex: 0 0 auto;
opacity: 0.45;
}
.wrn-table__sort[data-active="true"] {
color: var(--table-accent);
}
.wrn-table__sort[data-active="true"] .wrn-table__sort-icon {
opacity: 1;
}
.wrn-table__select-cell {
width: 2.6rem;
text-align: center;
}
.wrn-table__check {
appearance: none;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
width: 1.05rem;
height: 1.05rem;
color: transparent;
background: var(--wrn-color-surface-raised);
border: 1px solid var(--wrn-color-border);
border-radius: 0.32rem;
cursor: pointer;
transition: background 140ms ease, border-color 140ms ease, color 140ms ease;
}
.wrn-table__check[aria-checked="true"] {
color: var(--wrn-color-primary-contrast);
background: var(--table-accent);
border-color: var(--table-accent);
}
.wrn-table__check:focus-visible {
outline: none;
box-shadow: 0 0 0 3px color-mix(in srgb, var(--table-accent) 35%, transparent);
}
.wrn-table__table--comparison {
width: auto;
min-width: 100%;
}
.wrn-table__table--comparison th,
.wrn-table__table--comparison td {
min-width: 7rem;
}
.wrn-table__table--comparison th[scope="row"] {
color: var(--wrn-color-text-muted);
background: var(--wrn-color-surface-soft);
font-size: 0.76rem;
font-weight: 650;
text-transform: uppercase;
white-space: nowrap;
}
.wrn-table[data-layout="comparison"] .wrn-table__table th:first-child,
.wrn-table[data-layout="comparison"] .wrn-table__table td:first-child {
position: sticky;
left: 0;
z-index: 2;
}
.wrn-table__status {
display: flex;
align-items: center;
justify-content: center;
gap: 0.6rem;
padding: 1.75rem 1rem;
color: var(--wrn-color-text-muted);
font-size: 0.85rem;
text-align: center;
}
.wrn-table__status--error {
color: var(--wrn-color-danger);
}
.wrn-table__spinner {
width: 0.95rem;
height: 0.95rem;
border: 2px solid color-mix(in srgb, currentColor 30%, transparent);
border-top-color: currentColor;
border-radius: 50%;
animation: wrn-table-spin 700ms linear infinite;
}
@keyframes wrn-table-spin {
to {
transform: rotate(360deg);
}
}
/* Dim the rows while a refresh is in flight rather than tearing them out:
a table that empties on every keystroke is far harder to read. */
.wrn-table[data-loading="true"] .wrn-table__table tbody {
opacity: 0.45;
}
/* Keeps the first column readable while the rest scrolls sideways. */
.wrn-table[data-sticky-first="true"] .wrn-table__table th:first-child,
.wrn-table[data-sticky-first="true"] .wrn-table__table td:first-child {
position: sticky;
left: 0;
z-index: 2;
background: var(--wrn-color-surface);
}
.wrn-table[data-sticky-first="true"] .wrn-table__table thead th:first-child {
z-index: 3;
}
.wrn-table__empty {
margin: 0;
padding: 1.75rem 1rem;
color: var(--wrn-color-text-muted);
font-size: 0.85rem;
text-align: center;
}
.wrn-table__footer {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.75rem;
}
.wrn-table__range {
margin: 0;
color: var(--wrn-color-text-muted);
font-size: 0.8rem;
}
.wrn-table__pager {
display: flex;
align-items: center;
gap: 0.5rem;
}
.wrn-table__page-size {
display: inline-flex;
align-items: center;
gap: 0.4rem;
color: var(--wrn-color-text-muted);
font-size: 0.78rem;
}
.wrn-table__page-dropdown {
display: inline-flex;
}
/*
* padding is reset explicitly: an app-level button { padding: ... } rule
* outranks the browser default and would crush the icon inside these
* fixed-size controls.
*/
.wrn-table__page-button {
appearance: none;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
padding: 0;
width: 2rem;
height: 2rem;
color: var(--wrn-color-text);
background: var(--wrn-color-surface-soft);
border: 1px solid var(--wrn-color-border);
border-radius: 0.55rem;
cursor: pointer;
}
.wrn-table__page-button:hover:not(:disabled) {
color: var(--table-accent);
border-color: color-mix(in srgb, var(--table-accent) 40%, var(--wrn-color-border));
}
.wrn-table__page-button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.wrn-table__page-button svg {
flex: 0 0 auto;
}
.wrn-table__page-numbers {
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.wrn-table__page-number {
appearance: none;
min-width: 2rem;
height: 2rem;
padding: 0 0.4rem;
color: var(--wrn-color-text-muted);
background: transparent;
border: 1px solid transparent;
border-radius: 0.55rem;
font: inherit;
font-size: 0.78rem;
font-variant-numeric: tabular-nums;
cursor: pointer;
}
.wrn-table__page-number:hover {
color: var(--wrn-color-text);
background: var(--wrn-color-surface-soft);
}
.wrn-table__page-number[data-active="true"] {
color: var(--wrn-color-primary-contrast);
background: var(--table-accent);
border-color: var(--table-accent);
}
.wrn-table__page-indicator {
color: var(--wrn-color-text-muted);
font-size: 0.78rem;
font-variant-numeric: tabular-nums;
}
/*
* Below the breakpoint each row becomes its own card and every cell grows
* a label from its column, so the data stays readable without a horizontal
* scrollbar on a phone.
*/
@media (max-width: 639px) {
.wrn-table__scroll {
overflow-x: visible;
border: 0;
}
.wrn-table__table,
.wrn-table__table tbody,
.wrn-table__table tr,
.wrn-table__table td {
display: block;
width: 100%;
}
.wrn-table__table thead {
display: none;
}
/*
* Each row becomes a card. These are the exact tokens Card paints its
* surface with (.wrn-next__card-panel in ui.css) rather than a lookalike
* of my own, so a stacked row and a real Card cannot drift apart.
*
* The Card component itself cannot be mounted per row: component tags
* are resolved once, server side, while these rows are cloned in the
* browser from a single template -- every row would share one mount.
*/
.wrn-table__table tbody tr {
margin-bottom: 0.7rem;
padding: 0.5rem 0.25rem;
background: var(--wrn-color-surface);
border: 1px solid var(--wrn-color-border);
border-radius: var(--wrn-radius-md);
box-shadow: var(--wrn-shadow-1);
}
.wrn-table[data-striped="true"] .wrn-table__table tbody tr:nth-child(even) {
background: var(--wrn-color-surface);
}
.wrn-table[data-striped] .wrn-table__table tbody tr[data-selected="true"] {
background: color-mix(in srgb, var(--table-accent) 15%, var(--wrn-color-surface));
border-color: color-mix(in srgb, var(--table-accent) 45%, var(--wrn-color-border));
}
.wrn-table__table td {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
white-space: normal;
text-align: right;
}
.wrn-table__table td::before {
content: attr(data-label);
color: var(--wrn-color-text-muted);
font-size: 0.72rem;
font-weight: 650;
text-transform: uppercase;
}
.wrn-table__select-cell {
width: auto;
}
.wrn-table__footer {
justify-content: center;
}
.wrn-table__actions {
width: 100%;
}
/* The page-size menu is anchored to a control near the bottom of the
screen; it must be able to render outside the footer box. */
.wrn-table__footer,
.wrn-table__pager {
overflow: visible;
}
}
/* Shared component foundation. */
.wrn-component {
--wrn-component-color: var(--wrn-color-primary);
--wrn-component-scale: 1;
margin: 0;
color: var(--wrn-color-text);
font-family: var(--wrn-font-sans, inherit);
}
.wrn-component--color-primary {
--wrn-component-color: var(--wrn-color-primary);
}
.wrn-component--color-secondary {
--wrn-component-color: var(--wrn-color-secondary);
}
.wrn-component--color-success {
--wrn-component-color: var(--wrn-color-success);
}
.wrn-component--color-warning {
--wrn-component-color: var(--wrn-color-warning);
}
.wrn-component--color-danger {
--wrn-component-color: var(--wrn-color-danger);
}
.wrn-component--color-info {
--wrn-component-color: var(--wrn-color-info);
}
.wrn-component--size-xs {
--wrn-component-scale: 0.78;
}
.wrn-component--size-sm {
--wrn-component-scale: 0.88;
}
.wrn-component--size-default,
.wrn-component--size-md {
--wrn-component-scale: 1;
}
.wrn-component--size-lg {
--wrn-component-scale: 1.14;
}
.wrn-component--size-xl {
--wrn-component-scale: 1.28;
}
.wrn-component:not(.wrn-btn) {
font-size: calc(1em * var(--wrn-component-scale));
}
.wrn-component :is(a, button, input, select, textarea):focus-visible {
outline-color: var(--wrn-component-color);
}
.wrn-component p {
margin: 0;
color: var(--wrn-color-muted);
line-height: 1.6;
}
}
}