// // DataTable -- columns, sorting, filtering, pagination and selection over an // array of records. // // // // 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; 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; 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 {
{#if caption || description || searchable}
{#if caption}

{caption}

{/if} {#if description}

{description}

{/if}
{selectedCount()} selected
{#if searchable} {/if}
{/if}
{#if selectable} {/if} {#if selectable} {/if}
{column.label}
{cell.value}
{caption} {heading.label}
{field.label} {cell.value}
{loadingLabel}
{loadError}
{emptyMessage()}
} style { .wire-next--data-map { display: grid; gap: 0.75rem; padding: 1rem; border: 1px solid var(--wire-color-border); border-radius: var(--wire-radius-md); background: var(--wire-color-surface); box-shadow: var(--wire-shadow-1); } .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; } } /* Shared component foundation. */ .wire-component { --wire-component-color: var(--wire-color-primary); --wire-component-scale: 1; margin: 0; color: var(--wire-color-text); font-family: var(--wrn-font-sans, inherit); } .wire-component--color-primary { --wire-component-color: var(--wire-color-primary); } .wire-component--color-secondary { --wire-component-color: var(--wire-color-secondary); } .wire-component--color-success { --wire-component-color: var(--wire-color-success); } .wire-component--color-warning { --wire-component-color: var(--wire-color-warning); } .wire-component--color-danger { --wire-component-color: var(--wire-color-danger); } .wire-component--color-info { --wire-component-color: var(--wire-color-info); } .wire-component--size-xs { --wire-component-scale: 0.78; } .wire-component--size-sm { --wire-component-scale: 0.88; } .wire-component--size-default, .wire-component--size-md { --wire-component-scale: 1; } .wire-component--size-lg { --wire-component-scale: 1.14; } .wire-component--size-xl { --wire-component-scale: 1.28; } .wire-component:not(.wire-btn) { font-size: calc(1em * var(--wire-component-scale)); } .wire-component :is(a, button, input, select, textarea):focus-visible { outline-color: var(--wire-component-color); } .wire-component p { margin: 0; color: var(--wire-color-muted); line-height: 1.6; } } }