RankedTable

RankedTable is a ranked table. Each row carries a label and several value columns. A sortable column header is a button: click it and the rows re-rank. The bars behind the rows follow the active sort, so the ranking and its magnitudes read together. It is the multi-column sibling of RankedList and shares its chrome: the card header, the category tabs, the search input, the fading scroller. It sits directly inside Card. The card owns the fixed height.

import RankedTable from "@haloid/azucar/ui/ranked-table";

Columns and sorting

Every category carries its own rows and its own columns. The first column is the label column: it flexes and truncates. Every other column is a value column: right aligned, tabular numerals, fixed width. The default width is 5.5rem. Set width with any CSS length when a column needs more room. A column with sortBy is sortable. A column without it is static. defaultSort picks the opening sort and must name a sortable column that exists in every category.

Click a column header to re-sort. The arrow marks the active column and points along the direction. Click Records: the rows re-rank and the bars re-fit to the new metric. Click Facility and the bars disappear: a text sort has no magnitude to draw.

<Card className="flex h-90 w-120 flex-col overflow-hidden">
<RankedTable
  ariaLabel="Facilities ranked by power gap"
  header="Facilities"
  defaultSort={{ columnKey: "gap", direction: "desc" }}
  rowKey={(row) => row.id}
  categories={[
    {
      key: "facilities",
      label: "Facilities",
      rows: [
        { id: "tor5", name: "TOR5", records: 367, gap: 134.3 },
        { id: "atl2", name: "ATL2", records: 298, gap: 88.1 },
        { id: "dfw1", name: "DFW1", records: 512, gap: 61.4 },
        { id: "phx4", name: "PHX4", records: 120, gap: 22.9 },
        { id: "sea3", name: "SEA3", records: 84, gap: 8.2 },
      ],
      columns: [
        {
          key: "name",
          header: "Facility",
          cell: (row) => row.name,
          sortBy: (row) => row.name,
        },
        {
          key: "records",
          header: "Records",
          cell: (row) => row.records,
          sortBy: (row) => row.records,
        },
        {
          key: "gap",
          header: "Power gap",
          cell: (row) => row.gap + " kW",
          sortBy: (row) => row.gap,
          width: "96px",
        },
      ],
    },
  ]}
/>
</Card>

The Card page shows the two-category form of this table. This page keeps one category so the columns stay the subject.

Search

search: true requires searchBy. The props are a discriminated union: search without searchBy does not compile, and searchBy alone does not either. The input mounts in the card header collapsed to an icon, alongside the tabs when the table has more than one category. Matching is a case-insensitive substring test over the searchBy text. A query with no hits shows a centered No matches.

This demo mirrors the real consumer, minus its second category. The header is a function of state: while a query is active it reports the match count, otherwise the category total. The category carries an extra noun field and the header function reads it. Type a few letters: the rows filter, and the qualifier follows the match count.

const valueColumns = [
{
  key: "issues",
  header: "Issues",
  cell: (row) => row.issues,
  sortBy: (row) => row.issues,
},
{
  key: "net",
  header: "Net kW",
  cell: (row) => (row.net > 0 ? "+" : "") + row.net + " kW",
  sortBy: (row) => Math.abs(row.net),
  width: "112px",
},
];

const customers = [
{ name: "Equinix Managed Services", issues: 34, net: -212.4 },
{ name: "AT&T Global Services", issues: 41, net: 154.6 },
{ name: "Shopify Commerce Ltd", issues: 12, net: -88 },
{ name: "Rogers Communications", issues: 19, net: 61.2 },
{ name: "Telus Enterprise", issues: 8, net: -44.8 },
{ name: "Bell Canada Business", issues: 15, net: 32.5 },
{ name: "OVHcloud NA", issues: 6, net: -18.9 },
{ name: "Hut 8 Mining Corp", issues: 4, net: 9.6 },
];

const label = (header) => ({
key: "name",
header,
cell: (row) => row.name,
});

const categories = [
{
  key: "customer",
  label: "Customer",
  noun: "customers",
  rows: customers,
  columns: [label("Customer"), ...valueColumns],
},
];

function qualifier(category, search) {
const total = category.rows.length + " " + category.noun;
if (search !== null && search.query.trim() !== "") {
  return search.matches + " of " + total;
}
return "across " + total;
}

render(
<Card className="flex h-90 w-140 flex-col overflow-hidden">
  <RankedTable
    header="Data issues"
    headerMuted={({ category, search }) => qualifier(category, search)}
    categories={categories}
    defaultSort={{ columnKey: "net", direction: "desc" }}
    rowKey={(row) => row.name}
    search
    searchBy={(row) => row.name}
    ariaLabel="Data issues ranked by absolute power impact"
  />
</Card>,
);

The sorting model

sortBy does two jobs. It orders the rows, and its numeric values size the bars. One accessor for both, so the bars always agree with the ranking.

The direction rules are fixed. A new column opens descending when its values are numbers and ascending when they are strings. A click on the active column flips it. There is no unsorted state. The table always has an active sorting.

Bars normalize against the maximum across all rows of the category, not only the visible ones, so they hold still while a search filters. A 2% floor keeps near-zero rows readable as ranked rows. A text sort draws no bars. A column whose sortBy returns a mix of numbers and strings draws none either.

Signed values need a decision: does the ranking follow the number, or its size? Show the signed number in cell and decide in sortBy. The real card sorts by Math.abs(row.net_kw), so a large negative gap outranks a smaller positive one. The search demo above does the same: -212.4 kW ranks first. Sort by the signed value when the direction itself is the story.

Bars against a target

bar fixes the bar per row. It returns a magnitude and an optional target, such as a contract or a cap. The sort then stops driving the bars: a click on a column re-ranks the rows, and every bar keeps its meaning. The scale is the largest value or target in the category, so the longest bar always fits.

With a target, a faint track runs to the target and the fill stops at the value. Past the target, the fill runs to the seam and the excess renders in the measure's tone: good by default, or bad, warn, info, neutral for another meaning. A 1px ticker marks the seam. The excess never draws narrower than 12px, so a value a hair over its target still reads as over. Return null for a row that draws no bar. Sort by Cap in the demo: the rows reorder and the bars stay put.

<Card className="flex h-90 w-120 flex-col overflow-hidden">
<RankedTable
  ariaLabel="Steps against their caps"
  header="Steps"
  defaultSort={{ columnKey: "value", direction: "desc" }}
  rowKey={(row) => row.id}
  bar={(row) => ({ value: row.value, target: row.cap })}
  categories={[
    {
      key: "steps",
      label: "Steps",
      rows: [
        { id: "a", name: "Step A", value: 12, cap: 10 },
        { id: "b", name: "Step B", value: 7, cap: 10 },
        { id: "c", name: "Step C", value: 9.5, cap: 8 },
        { id: "d", name: "Step D", value: 3, cap: 6 },
      ],
      columns: [
        { key: "name", header: "Step", cell: (row) => row.name },
        {
          key: "value",
          header: "Value",
          cell: (row) => row.value,
          sortBy: (row) => row.value,
        },
        {
          key: "cap",
          header: "Cap",
          cell: (row) => row.cap,
          sortBy: (row) => row.cap,
        },
      ],
    },
  ]}
/>
</Card>

Grouped rows

Use getChildren to make a parent row expandable. Return an empty array for an independent row. The caller supplies the parent values. The table does not calculate totals.

Sorting moves each parent with its children. Children keep their supplied order and use the same columns. Parent and child bars share one scale. The table supports one level of children. Use defaultExpandedKeys to open groups in the initial category.

A parent click expands the group. Child rows retain their links or actions. For parent rows, expansion takes precedence over href and onRowClick. Groups support keyboard activation, accessible disclosure state, and reduced motion.

Use renderDetails to give leaf rows their own disclosure panel, including children within a group. Return null for a leaf without details. Details take precedence over links and actions. Group rows show their children instead of a detail panel. detailsLayout="full-bleed" removes the panel's horizontal inset.

Search checks parents and children. A child match shows its parent with the matching children. A parent match shows all its children. Search opens matching groups without changing their totals. The match count measures top-level rows. Clearing search restores the earlier expansion state. Search keeps leaf details closed unless the user opens them.

This example groups two steps under one parent.

<Card className="flex h-80 w-140 flex-col overflow-hidden">
<RankedTable
  header="Steps"
  ariaLabel="Grouped steps"
  defaultSort={{ columnKey: "value", direction: "desc" }}
  rowKey={(row) => row.id}
  getChildren={(row) => row.children ?? []}
  defaultExpandedKeys={["group"]}
  renderDetails={(row) => row.children ? null : <Text>Details for {row.name}</Text>}
  search
  searchBy={(row) => row.name}
  bar={(row) => row.cap === undefined ? null : { value: row.value, target: row.cap }}
  categories={[{
    key: "steps",
    label: "Steps",
    rows: [
      {
        id: "group", name: "Group A ยท 2 steps", value: 18, cap: 20,
        children: [
          { id: "a", name: "Step A", value: 12 },
          { id: "b", name: "Step B", value: 6 },
        ],
      },
      { id: "c", name: "Step C", value: 8, cap: 10 },
    ],
    columns: [
      { key: "name", header: "Step", cell: (row) => row.name },
      { key: "value", header: "Value", cell: (row) => row.value, sortBy: (row) => row.value },
      { key: "cap", header: "Cap", cell: (row) => row.cap ?? "Shared" },
    ],
  }]}
/>
</Card>

In the product

The consumer today is the discrepancy ranking card on the reconciliation overview: apps/north/src/components/pages/reconciliation-overview/discrepancy-ranking-card.tsx. It ranks data issues by absolute power impact, across two categories with different nouns, with search, and with every row linked to the queue page. Condensed:

const VALUE_COLUMNS: RankedTableColumn<DiscrepancyRow>[] = [
{
key: "count",
header: "Issues",
cell: (row) => row.count,
sortBy: (row) => row.count,
},
{
key: "net_kw",
header: "Net kW",
cell: (row) => formatSignedKw(row.net_kw),
// magnitude, not the signed value: ranking concentrates biggest impact
sortBy: (row) => Math.abs(row.net_kw),
width: "7rem",
},
];
interface CategoryExtras {
// plural noun for the header qualifier
noun: string;
}
<Card className="flex h-[520px] flex-col overflow-hidden">
<RankedTable<DiscrepancyRow, CategoryExtras>
header="Data issues"
headerMuted={({ category, search }) => headerQualifier(category, search)}
categories={categories}
defaultSort={{ columnKey: "net_kw", direction: "desc" }}
rowKey={(row) => row.name}
href={() => "/reconciliation/queue"}
search
searchBy={(row) => row.name}
ariaLabel="Discrepancies ranked by absolute power impact"
/>
</Card>;

href renders every row as a real link with the hover shade, so new-tab and copy-link work. The demos on this page skip it: a link row navigates. The generics are explicit because Row does not infer through the nested category generic. Write <RankedTable<MyRow>>, or <RankedTable<MyRow, MyExtras>> when categories carry extra header fields.

Use onRowClick for an action within the current page. Rows without a link render as buttons with keyboard support. When a row has an href, its link takes precedence over onRowClick.

Branch circuit monitoring groups cabinets by their shared license. Parent rows show combined load, licensed capacity, headroom, and status. Child rows show cabinet measurements and keep their links to the customer. Each license contributes capacity once. Separate licenses retain separate compliance calculations. Physical rows expand into their cabinets. Customer pages group shared licenses and preserve each cabinet's usage history.

API

// public types: @haloid/azucar/ui/ranked-table/types (import type only)
type SortValue = number | string;
type BarTone = "bad" | "warn" | "good" | "info" | "neutral"; // @haloid/azucar/ui/ranked-shared/types
interface RankedTableColumn<Row> {
key: string;
header?: React.ReactNode; // omitted: empty header cell, tracks still align
cell: (row: Row) => React.ReactNode;
sortBy?: (row: Row) => SortValue; // opt-in; numeric values also drive the bars
width?: string; // CSS length for value columns; default "5.5rem"
}
interface RankedTableCategory<Row> {
key: string;
label: React.ReactNode; // the category tab
rows: Row[]; // any order; the active sorting ranks them
columns: RankedTableColumn<Row>[]; // first entry is the label column
}
interface Sorting {
columnKey: string;
direction: "desc" | "asc";
}
// RankedTable<Row, Extra = unknown> (default export)
type Props<Row, Extra> = {
categories: (Extra & RankedTableCategory<Row>)[]; // a single entry renders no tabs
defaultSort: Sorting; // must name a sortable column in every category
defaultCategory?: string; // a category key; defaults to the first
rowKey: (row: Row) => string;
href?: (row: Row) => string | null; // present: rows render as links; null skips one row
onRowClick?: (row: Row) => void; // rows without a link render as action buttons
getChildren?: (row: Row) => Row[]; // one level; parent summaries remain intact during sorting and search
defaultExpandedKeys?: string[]; // row keys in the initial category
renderDetails?: (row: Row) => React.ReactNode; // leaf disclosure content; null skips a row
detailsLayout?: "inset" | "full-bleed"; // default: inset
sizing?: "fill" | "content"; // default: fill
bar?: (row: Row) => { value: number; target?: number; tone?: BarTone } | null; // fixed per row, independent of the sort; tone colors the over part, "good" by default
header?:
| React.ReactNode
| ((state: {
category: Extra & RankedTableCategory<Row>;
sorting: Sorting | null; // never null for the table
search: { query: string; matches: number } | null;
}) => React.ReactNode);
headerMuted?: TableHeader<Row, Extra>; // the quiet clause after the title, same shapes as header
ariaLabel?: string;
} & (
| { search: true; searchBy: (row: Row) => string }
| { search?: false; searchBy?: never }
);

Sorting, the active category, the query, and expansion are internal state. Expansion persists by category and row key. Search has separate expansion state. There is no controlled form yet.

The component fills the height its parent gives it. The parent owns the fixed height. The real card uses h-[520px] on the Card shell. The demos here use h-90. Without a fixed height the scroller has nothing to scroll inside. Use sizing="content" to grow with expanded rows and details. This mode removes the internal scroll container.

One trap: the card header renders only when header or headerMuted is set or the table has more than one category. The search input lives in that header, so a single-category table with search and no header shows no input. Give a searchable table a header.