feat(ui): add DataTable and Toaster, drop the legacy Table, fix overlay dialogs DataTable replaces the 20-line Table scaffold entirely: columns, sorting, filtering, pagination, selection, bulk actions, comparison layout, sticky first column, custom HTML cells, and a remote source driven by a `request` output rather than a function prop (props travel as HTML attributes, so a function arrives as its own source text). Toaster replaces the hand-rolled status div: tone icons, actions, hover pause/resume and a progress bar. Overlays audit -- Modal and Drawer declared aria-modal="true" but nothing ever moved focus into the panel, so the @keydown handler on their root never ran and closeOnEscape did nothing. Focus, focus restore, a Tab trap and a body scroll lock now live in the reactive runtime, shared by both. ContextMenu placed pointer menus by subtracting a guessed 340x420 from the viewport, which pushed every menu that was not that size away from the pointer; it now positions at the pointer and lets the anchored clamp pull it back once it can be measured. The reactive runtime size budget moves 150k -> 175k to cover anchored overlays, dialog behaviour, the toaster and the DataTable client half. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> @
1635 lines
51 KiB
Plaintext
1635 lines
51 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 page = 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(page) || 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"
|
|
}
|
|
page = 1
|
|
output.sort({ key: sortKey, direction: sortDirection })
|
|
reload()
|
|
output.change(viewState())
|
|
}
|
|
|
|
client function updateQuery(sourceEvent) {
|
|
query = sourceEvent.target.value
|
|
page = 1
|
|
output.search({ query: query })
|
|
reload()
|
|
output.change(viewState())
|
|
}
|
|
|
|
client function goToPage(next) {
|
|
var target = Math.min(Math.max(1, next), pageCount())
|
|
page = 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
|
|
page = 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 = ""
|
|
page = 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='wire-table {class}'
|
|
@window:wrnexus:datatable:rows='applyRows(event)'
|
|
@window:wrnexus:datatable:error='applyError(event)'
|
|
>
|
|
{#if caption || description || searchable}
|
|
<div class="wire-table__toolbar">
|
|
<div class="wire-table__heading">
|
|
{#if caption}
|
|
<h3 class="wire-table__caption">{caption}</h3>
|
|
{/if}
|
|
{#if description}
|
|
<p class="wire-table__description">{description}</p>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="wire-table__actions" data-show="visibleActions().length">
|
|
<span class="wire-table__selected-count" data-show="selectedCount()">
|
|
{selectedCount()} selected
|
|
</span>
|
|
|
|
<button
|
|
type="button"
|
|
class="wire-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="wire-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="wire-table__scroll">
|
|
<table class="wire-table__table" data-show="layout !== 'comparison'">
|
|
<thead>
|
|
<tr>
|
|
{#if selectable}
|
|
<th class="wire-table__select-cell" scope="col">
|
|
<button
|
|
type="button"
|
|
class="wire-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="wire-table__sort"
|
|
data-show="column.sortable"
|
|
data-active='{column.active}'
|
|
@click='sortBy(column.key)'
|
|
>
|
|
<span>{column.label}</span>
|
|
<svg
|
|
class="wire-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="wire-table__select-cell">
|
|
<button
|
|
type="button"
|
|
class="wire-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="wire-table__table wire-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="wire-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="wire-table__status" data-show="loading">
|
|
<span class="wire-table__spinner" aria-hidden="true"></span>
|
|
<span>{loadingLabel}</span>
|
|
</div>
|
|
|
|
<div class="wire-table__status wire-table__status--error" data-show="loadError">
|
|
<span>{loadError}</span>
|
|
<button type="button" class="wire-table__action" @click='reload()'>
|
|
{retryLabel}
|
|
</button>
|
|
</div>
|
|
|
|
<div
|
|
class="wire-table__status"
|
|
data-show="!loading && !loadError && totalCount() === 0"
|
|
>
|
|
<span>{emptyMessage()}</span>
|
|
<button
|
|
type="button"
|
|
class="wire-table__action"
|
|
data-show="isFiltered()"
|
|
@click='clearSearch()'
|
|
>
|
|
{clearSearchLabel}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="wire-table__footer" data-show="paginated && totalCount() > 0">
|
|
<p class="wire-table__range">{rangeLabel()}</p>
|
|
|
|
<div class="wire-table__pager">
|
|
<span class="wire-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="wire-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="wire-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="wire-table__page-indicator"
|
|
data-show="paginationStyle !== 'numbered'"
|
|
>
|
|
{currentPage()} / {pageCount()}
|
|
</span>
|
|
|
|
<span
|
|
class="wire-table__page-numbers"
|
|
data-show="paginationStyle === 'numbered'"
|
|
>
|
|
<button
|
|
type="button"
|
|
class="wire-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="wire-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 {
|
|
.wire-table {
|
|
--table-accent: var(--wire-color-primary);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.85rem;
|
|
width: 100%;
|
|
min-width: 0;
|
|
max-width: 100%;
|
|
color: var(--wire-color-text);
|
|
}
|
|
|
|
.wire-table[data-color="secondary"] {
|
|
--table-accent: var(--wire-color-secondary);
|
|
}
|
|
|
|
.wire-table[data-color="success"] {
|
|
--table-accent: var(--wire-color-success);
|
|
}
|
|
|
|
.wire-table[data-color="danger"] {
|
|
--table-accent: var(--wire-color-danger);
|
|
}
|
|
|
|
.wire-table[data-color="info"] {
|
|
--table-accent: var(--wire-color-info);
|
|
}
|
|
|
|
.wire-table__toolbar {
|
|
display: flex;
|
|
align-items: flex-end;
|
|
justify-content: space-between;
|
|
flex-wrap: wrap;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.wire-table__heading {
|
|
display: grid;
|
|
gap: 0.2rem;
|
|
min-width: 0;
|
|
}
|
|
|
|
.wire-table__caption {
|
|
margin: 0;
|
|
color: var(--wire-color-text);
|
|
font-size: 0.98rem;
|
|
font-weight: 650;
|
|
}
|
|
|
|
.wire-table__description {
|
|
margin: 0;
|
|
color: var(--wire-color-text-muted);
|
|
font-size: 0.82rem;
|
|
}
|
|
|
|
.wire-table__actions {
|
|
display: flex;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
gap: 0.4rem;
|
|
}
|
|
|
|
.wire-table__selected-count {
|
|
color: var(--wire-color-text-muted);
|
|
font-size: 0.78rem;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
.wire-table__action {
|
|
appearance: none;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.35rem;
|
|
min-height: 2.1rem;
|
|
padding: 0 0.7rem;
|
|
color: var(--wire-color-text);
|
|
background: var(--wire-color-surface-soft);
|
|
border: 1px solid var(--wire-color-border);
|
|
border-radius: var(--wire-radius-sm);
|
|
font: inherit;
|
|
font-size: 0.79rem;
|
|
cursor: pointer;
|
|
transition: border-color 140ms ease, color 140ms ease, background 140ms ease;
|
|
}
|
|
|
|
.wire-table__action:hover {
|
|
color: var(--table-accent);
|
|
border-color: color-mix(in srgb, var(--table-accent) 45%, var(--wire-color-border));
|
|
}
|
|
|
|
.wire-table__action[data-tone="danger"] {
|
|
color: var(--wire-color-danger);
|
|
border-color: color-mix(in srgb, var(--wire-color-danger) 35%, var(--wire-color-border));
|
|
}
|
|
|
|
.wire-table__action[data-tone="danger"]:hover {
|
|
background: color-mix(in srgb, var(--wire-color-danger) 12%, transparent);
|
|
}
|
|
|
|
/* Full gridlines: vertical rules between columns as well as rows. */
|
|
.wire-table[data-gridlines="grid"] .wire-table__table th,
|
|
.wire-table[data-gridlines="grid"] .wire-table__table td {
|
|
border-right: 1px solid color-mix(in srgb, var(--wire-color-border) 60%, transparent);
|
|
}
|
|
|
|
.wire-table[data-gridlines="grid"] .wire-table__table th:last-child,
|
|
.wire-table[data-gridlines="grid"] .wire-table__table td:last-child {
|
|
border-right: 0;
|
|
}
|
|
|
|
.wire-table__search {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.45rem;
|
|
min-height: 2.35rem;
|
|
padding: 0 0.7rem;
|
|
color: var(--wire-color-text-muted);
|
|
background: var(--wire-color-surface-soft);
|
|
border: 1px solid var(--wire-color-border);
|
|
border-radius: 0.7rem;
|
|
}
|
|
|
|
.wire-table__search input {
|
|
width: 12rem;
|
|
max-width: 100%;
|
|
padding: 0;
|
|
color: var(--wire-color-text);
|
|
background: transparent;
|
|
border: 0;
|
|
font: inherit;
|
|
font-size: 0.83rem;
|
|
outline: none;
|
|
}
|
|
|
|
.wire-table__search:focus-within {
|
|
border-color: color-mix(in srgb, var(--table-accent) 45%, var(--wire-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.
|
|
*/
|
|
.wire-table__scroll {
|
|
position: relative;
|
|
width: 100%;
|
|
min-width: 0;
|
|
max-width: 100%;
|
|
overflow-x: auto;
|
|
border: 1px solid var(--wire-color-border);
|
|
border-radius: 0.9rem;
|
|
}
|
|
|
|
.wire-table[data-bordered="false"] .wire-table__scroll {
|
|
border: 0;
|
|
border-radius: 0;
|
|
}
|
|
|
|
.wire-table__table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.wire-table__table th,
|
|
.wire-table__table td {
|
|
padding: 0.7rem 0.85rem;
|
|
text-align: left;
|
|
vertical-align: middle;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.wire-table[data-density="compact"] .wire-table__table th,
|
|
.wire-table[data-density="compact"] .wire-table__table td {
|
|
padding: 0.42rem 0.6rem;
|
|
}
|
|
|
|
.wire-table[data-density="comfortable"] .wire-table__table th,
|
|
.wire-table[data-density="comfortable"] .wire-table__table td {
|
|
padding: 0.95rem 1rem;
|
|
}
|
|
|
|
.wire-table__table th[data-align="center"],
|
|
.wire-table__table td[data-align="center"] {
|
|
text-align: center;
|
|
}
|
|
|
|
.wire-table__table th[data-align="end"],
|
|
.wire-table__table td[data-align="end"] {
|
|
text-align: right;
|
|
}
|
|
|
|
.wire-table__table thead th {
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 1;
|
|
color: var(--wire-color-text-muted);
|
|
background: var(--wire-color-surface-soft);
|
|
border-bottom: 1px solid var(--wire-color-border);
|
|
font-size: 0.76rem;
|
|
font-weight: 650;
|
|
letter-spacing: 0.02em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.wire-table__table tbody tr {
|
|
border-bottom: 1px solid color-mix(in srgb, var(--wire-color-border) 60%, transparent);
|
|
}
|
|
|
|
.wire-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.
|
|
*/
|
|
.wire-table[data-striped="true"] .wire-table__table tbody tr:nth-child(even) {
|
|
background: color-mix(in srgb, var(--wire-color-surface-soft) 55%, transparent);
|
|
}
|
|
|
|
.wire-table[data-striped] .wire-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. */
|
|
.wire-table[data-striped] .wire-table__table tbody tr[data-selected="true"] {
|
|
background: color-mix(in srgb, var(--table-accent) 15%, transparent);
|
|
}
|
|
|
|
.wire-table[data-striped] .wire-table__table tbody tr[data-selected="true"]:hover {
|
|
background: color-mix(in srgb, var(--table-accent) 22%, transparent);
|
|
}
|
|
|
|
.wire-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;
|
|
}
|
|
|
|
.wire-table__sort:hover {
|
|
color: var(--table-accent);
|
|
}
|
|
|
|
.wire-table__sort-icon {
|
|
flex: 0 0 auto;
|
|
opacity: 0.45;
|
|
}
|
|
|
|
.wire-table__sort[data-active="true"] {
|
|
color: var(--table-accent);
|
|
}
|
|
|
|
.wire-table__sort[data-active="true"] .wire-table__sort-icon {
|
|
opacity: 1;
|
|
}
|
|
|
|
.wire-table__select-cell {
|
|
width: 2.6rem;
|
|
text-align: center;
|
|
}
|
|
|
|
.wire-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(--wire-color-surface-raised);
|
|
border: 1px solid var(--wire-color-border);
|
|
border-radius: 0.32rem;
|
|
cursor: pointer;
|
|
transition: background 140ms ease, border-color 140ms ease, color 140ms ease;
|
|
}
|
|
|
|
.wire-table__check[aria-checked="true"] {
|
|
color: var(--wire-color-primary-contrast);
|
|
background: var(--table-accent);
|
|
border-color: var(--table-accent);
|
|
}
|
|
|
|
.wire-table__check:focus-visible {
|
|
outline: none;
|
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--table-accent) 35%, transparent);
|
|
}
|
|
|
|
.wire-table__table--comparison {
|
|
width: auto;
|
|
min-width: 100%;
|
|
}
|
|
|
|
.wire-table__table--comparison th,
|
|
.wire-table__table--comparison td {
|
|
min-width: 7rem;
|
|
}
|
|
|
|
.wire-table__table--comparison th[scope="row"] {
|
|
color: var(--wire-color-text-muted);
|
|
background: var(--wire-color-surface-soft);
|
|
font-size: 0.76rem;
|
|
font-weight: 650;
|
|
text-transform: uppercase;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.wire-table[data-layout="comparison"] .wire-table__table th:first-child,
|
|
.wire-table[data-layout="comparison"] .wire-table__table td:first-child {
|
|
position: sticky;
|
|
left: 0;
|
|
z-index: 2;
|
|
}
|
|
|
|
.wire-table__status {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 0.6rem;
|
|
padding: 1.75rem 1rem;
|
|
color: var(--wire-color-text-muted);
|
|
font-size: 0.85rem;
|
|
text-align: center;
|
|
}
|
|
|
|
.wire-table__status--error {
|
|
color: var(--wire-color-danger);
|
|
}
|
|
|
|
.wire-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: wire-table-spin 700ms linear infinite;
|
|
}
|
|
|
|
@keyframes wire-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. */
|
|
.wire-table[data-loading="true"] .wire-table__table tbody {
|
|
opacity: 0.45;
|
|
}
|
|
|
|
/* Keeps the first column readable while the rest scrolls sideways. */
|
|
.wire-table[data-sticky-first="true"] .wire-table__table th:first-child,
|
|
.wire-table[data-sticky-first="true"] .wire-table__table td:first-child {
|
|
position: sticky;
|
|
left: 0;
|
|
z-index: 2;
|
|
background: var(--wire-color-surface);
|
|
}
|
|
|
|
.wire-table[data-sticky-first="true"] .wire-table__table thead th:first-child {
|
|
z-index: 3;
|
|
}
|
|
|
|
.wire-table__empty {
|
|
margin: 0;
|
|
padding: 1.75rem 1rem;
|
|
color: var(--wire-color-text-muted);
|
|
font-size: 0.85rem;
|
|
text-align: center;
|
|
}
|
|
|
|
.wire-table__footer {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
flex-wrap: wrap;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.wire-table__range {
|
|
margin: 0;
|
|
color: var(--wire-color-text-muted);
|
|
font-size: 0.8rem;
|
|
}
|
|
|
|
.wire-table__pager {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.wire-table__page-size {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
color: var(--wire-color-text-muted);
|
|
font-size: 0.78rem;
|
|
}
|
|
|
|
.wire-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.
|
|
*/
|
|
.wire-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(--wire-color-text);
|
|
background: var(--wire-color-surface-soft);
|
|
border: 1px solid var(--wire-color-border);
|
|
border-radius: 0.55rem;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.wire-table__page-button:hover:not(:disabled) {
|
|
color: var(--table-accent);
|
|
border-color: color-mix(in srgb, var(--table-accent) 40%, var(--wire-color-border));
|
|
}
|
|
|
|
.wire-table__page-button:disabled {
|
|
opacity: 0.45;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.wire-table__page-button svg {
|
|
flex: 0 0 auto;
|
|
}
|
|
|
|
.wire-table__page-numbers {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.25rem;
|
|
}
|
|
|
|
.wire-table__page-number {
|
|
appearance: none;
|
|
min-width: 2rem;
|
|
height: 2rem;
|
|
padding: 0 0.4rem;
|
|
color: var(--wire-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;
|
|
}
|
|
|
|
.wire-table__page-number:hover {
|
|
color: var(--wire-color-text);
|
|
background: var(--wire-color-surface-soft);
|
|
}
|
|
|
|
.wire-table__page-number[data-active="true"] {
|
|
color: var(--wire-color-primary-contrast);
|
|
background: var(--table-accent);
|
|
border-color: var(--table-accent);
|
|
}
|
|
|
|
.wire-table__page-indicator {
|
|
color: var(--wire-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) {
|
|
.wire-table__scroll {
|
|
overflow-x: visible;
|
|
border: 0;
|
|
}
|
|
|
|
.wire-table__table,
|
|
.wire-table__table tbody,
|
|
.wire-table__table tr,
|
|
.wire-table__table td {
|
|
display: block;
|
|
width: 100%;
|
|
}
|
|
|
|
.wire-table__table thead {
|
|
display: none;
|
|
}
|
|
|
|
/*
|
|
* Each row becomes a card. These are the exact tokens Card paints its
|
|
* surface with (.wire-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.
|
|
*/
|
|
.wire-table__table tbody tr {
|
|
margin-bottom: 0.7rem;
|
|
padding: 0.5rem 0.25rem;
|
|
background: var(--wire-color-surface);
|
|
border: 1px solid var(--wire-color-border);
|
|
border-radius: var(--wire-radius-md);
|
|
box-shadow: var(--wire-shadow-1);
|
|
}
|
|
|
|
.wire-table[data-striped="true"] .wire-table__table tbody tr:nth-child(even) {
|
|
background: var(--wire-color-surface);
|
|
}
|
|
|
|
.wire-table[data-striped] .wire-table__table tbody tr[data-selected="true"] {
|
|
background: color-mix(in srgb, var(--table-accent) 15%, var(--wire-color-surface));
|
|
border-color: color-mix(in srgb, var(--table-accent) 45%, var(--wire-color-border));
|
|
}
|
|
|
|
.wire-table__table td {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 1rem;
|
|
white-space: normal;
|
|
text-align: right;
|
|
}
|
|
|
|
.wire-table__table td::before {
|
|
content: attr(data-label);
|
|
color: var(--wire-color-text-muted);
|
|
font-size: 0.72rem;
|
|
font-weight: 650;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.wire-table__select-cell {
|
|
width: auto;
|
|
}
|
|
|
|
.wire-table__footer {
|
|
justify-content: center;
|
|
}
|
|
|
|
.wire-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. */
|
|
.wire-table__footer,
|
|
.wire-table__pager {
|
|
overflow: visible;
|
|
}
|
|
}
|
|
}
|
|
}
|