Grid / TreeGrid
An interactive two-dimensional widget — data tables, spreadsheets-lite, file trees with columns, calendars — with roving cell focus, row selection, sorting and expandable rows.
Sortable data grid
The WAI-ARIA Data Grid pattern on a real <table> (As="table" /
"thead" / "tbody" / "tr" / "th" / "td").
The grid is a single tab stop: arrow keys move between cells, Home / End jump to the ends of the row,
Ctrl + Home / End to the corners, PageUp / PageDown move by PageSize rows. Column headers are
cells too — click one or press Enter on it and its OnActivate toggles the sort, which
Sort announces as aria-sort.
| Planet | Type | Diameter | Moons |
|---|---|---|---|
| Earth | Terrestrial | 12,756 km | 1 |
| Jupiter | Gas giant | 142,984 km | 95 |
| Mars | Terrestrial | 6,792 km | 2 |
| Mercury | Terrestrial | 4,879 km | 0 |
| Neptune | Ice giant | 49,528 km | 16 |
| Saturn | Gas giant | 120,536 km | 146 |
| Uranus | Ice giant | 51,118 km | 28 |
| Venus | Terrestrial | 12,104 km | 0 |
<div class="datagrid-frame">
<BitBhGrid As="table" aria-label="Planets" class="datagrid">
<BitBhGridRowGroup As="thead">
<BitBhGridRow As="tr" IsHeaderRow="true">
<BitBhGridCell As="th" Header="true" class="datagrid-sortable"
Sort="@(SortOf("name"))" OnActivate="@(() => SortBy("name"))">
Planet<span class="datagrid-sort" aria-hidden="true">▲</span>
</BitBhGridCell>
<BitBhGridCell As="th" Header="true" class="datagrid-sortable"
Sort="@(SortOf("type"))" OnActivate="@(() => SortBy("type"))">
Type<span class="datagrid-sort" aria-hidden="true">▲</span>
</BitBhGridCell>
<BitBhGridCell As="th" Header="true" class="datagrid-sortable num"
Sort="@(SortOf("diameter"))" OnActivate="@(() => SortBy("diameter"))">
Diameter<span class="datagrid-sort" aria-hidden="true">▲</span>
</BitBhGridCell>
<BitBhGridCell As="th" Header="true" class="datagrid-sortable num"
Sort="@(SortOf("moons"))" OnActivate="@(() => SortBy("moons"))">
Moons<span class="datagrid-sort" aria-hidden="true">▲</span>
</BitBhGridCell>
</BitBhGridRow>
</BitBhGridRowGroup>
<BitBhGridRowGroup As="tbody">
@foreach (var planet in SortedPlanets)
{
<BitBhGridRow As="tr" @key="planet.Name" Key="@planet.Name">
<BitBhGridCell As="th" RowHeader="true">@planet.Name</BitBhGridCell>
<BitBhGridCell As="td">@planet.Type</BitBhGridCell>
<BitBhGridCell As="td" class="num">@planet.DiameterKm.ToString("N0") km</BitBhGridCell>
<BitBhGridCell As="td" class="num">@planet.Moons</BitBhGridCell>
</BitBhGridRow>
}
</BitBhGridRowGroup>
</BitBhGrid>
</div>
@code {
private sealed record Planet(string Name, string Type, int DiameterKm, int Moons);
private readonly Planet[] planets =
[
new("Mercury", "Terrestrial", 4_879, 0),
new("Venus", "Terrestrial", 12_104, 0),
new("Earth", "Terrestrial", 12_756, 1),
new("Mars", "Terrestrial", 6_792, 2),
new("Jupiter", "Gas giant", 142_984, 95),
new("Saturn", "Gas giant", 120_536, 146),
new("Uranus", "Ice giant", 51_118, 28),
new("Neptune", "Ice giant", 49_528, 16),
];
private string sortColumn = "name";
private BitBhSortDirection sortDirection = BitBhSortDirection.Ascending;
// The sorted column announces aria-sort; the others pass None.
private BitBhSortDirection SortOf(string column) => sortColumn == column ? sortDirection : BitBhSortDirection.None;
private void SortBy(string column)
{
sortDirection = sortColumn == column && sortDirection == BitBhSortDirection.Ascending
? BitBhSortDirection.Descending
: BitBhSortDirection.Ascending;
sortColumn = column;
}
private IEnumerable<Planet> SortedPlanets
{
get
{
Func<Planet, object> key = sortColumn switch
{
"type" => p => p.Type,
"diameter" => p => p.DiameterKm,
"moons" => p => p.Moons,
_ => p => p.Name,
};
return sortDirection == BitBhSortDirection.Descending ? planets.OrderByDescending(key) : planets.OrderBy(key);
}
}
}/* All styles are consumer-authored — the library ships zero CSS */
.datagrid-demo { display: flex; flex-direction: column; gap: 0.75rem; width: 100%; }
.datagrid-output { font-size: 0.875rem; color: #71717a; }
.datagrid-output strong { color: #18181b; font-weight: 600; }
.datagrid-frame {
width: 100%;
overflow-x: auto;
background: #fff;
border: 1px solid #e4e4e7;
border-radius: 0.5rem;
}
.datagrid {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
color: #18181b;
}
.datagrid th,
.datagrid td {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid #f4f4f5;
text-align: start;
white-space: nowrap;
outline: none;
}
.datagrid thead th {
background: #fafafa;
border-bottom-color: #e4e4e7;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #71717a;
}
.datagrid tbody th { font-weight: 600; } /* row headers */
.datagrid tbody tr:last-child > * { border-bottom: 0; }
.datagrid .num { text-align: end; font-variant-numeric: tabular-nums; }
/* Cells are the focus targets: one tab stop, arrow keys move it. */
.datagrid th:focus-visible,
.datagrid td:focus-visible { outline: 2px solid #2563eb; outline-offset: -2px; }
/* BitBhGridCell Sort="…" emits aria-sort="ascending|descending". */
.datagrid-sortable { cursor: pointer; user-select: none; }
.datagrid-sortable:hover { color: #18181b; }
.datagrid th[aria-sort] { color: #18181b; }
.datagrid-sort {
display: inline-block;
margin-inline-start: 0.25rem;
color: #2563eb;
opacity: 0;
transition: transform 150ms ease, opacity 150ms ease;
}
.datagrid-sortable:hover .datagrid-sort { opacity: 0.35; }
.datagrid th[aria-sort] .datagrid-sort { opacity: 1; }
.datagrid th[aria-sort="descending"] .datagrid-sort { transform: rotate(180deg); }Multi-row selection
SelectionMode="BitBhGridSelectionMode.Multiple" with @bind-SelectedKeys (every
data row needs a Key). Click toggles a row and Shift + click selects a range; Space toggles
the focused row, Shift + Space or Shift + Up / Down extend the selection, Ctrl + A selects every row.
Rows get aria-selected, so the check boxes are pure CSS; the header box uses
OnActivate to select or clear everything.
| Invoice | Customer | Status | Amount | |
|---|---|---|---|---|
| INV-1042 | Acme Corp | Paid | $1,250.00 | |
| INV-1043 | Globex | Pending | $830.50 | |
| INV-1044 | Initech | Overdue | $4,200.00 | |
| INV-1045 | Umbrella | Paid | $615.75 | |
| INV-1046 | Hooli | Pending | $2,980.00 | |
| INV-1047 | Stark Industries | Paid | $12,400.00 |
<div class="datagrid-toolbar">
<span><strong>@selectedInvoices.Count</strong> selected · total <strong>@FormatMoney(SelectedTotal)</strong></span>
<button type="button" class="datagrid-button" disabled="@(selectedInvoices.Count == 0)"
@onclick="ClearInvoices">Clear selection</button>
</div>
<div class="datagrid-frame">
<BitBhGrid As="table" aria-label="Invoices" class="datagrid datagrid-selectable"
SelectionMode="BitBhGridSelectionMode.Multiple"
@bind-SelectedKeys="selectedInvoices">
<BitBhGridRowGroup As="thead">
<BitBhGridRow As="tr" IsHeaderRow="true">
<BitBhGridCell As="th" Header="true" class="datagrid-check-cell" OnActivate="ToggleAllInvoices">
<BitBhVisuallyHidden>Select all</BitBhVisuallyHidden>
<span class="datagrid-check" data-checked="@AllInvoicesState" aria-hidden="true"></span>
</BitBhGridCell>
<BitBhGridCell As="th" Header="true">Invoice</BitBhGridCell>
<BitBhGridCell As="th" Header="true">Customer</BitBhGridCell>
<BitBhGridCell As="th" Header="true">Status</BitBhGridCell>
<BitBhGridCell As="th" Header="true" class="num">Amount</BitBhGridCell>
</BitBhGridRow>
</BitBhGridRowGroup>
<BitBhGridRowGroup As="tbody">
@foreach (var invoice in invoices)
{
<BitBhGridRow As="tr" @key="invoice.Id" Key="@invoice.Id">
<BitBhGridCell As="td" class="datagrid-check-cell">
<span class="datagrid-check" aria-hidden="true"></span>
</BitBhGridCell>
<BitBhGridCell As="td">@invoice.Id</BitBhGridCell>
<BitBhGridCell As="td">@invoice.Customer</BitBhGridCell>
<BitBhGridCell As="td">
<span class="datagrid-badge" data-status="@invoice.Status.ToLowerInvariant()">@invoice.Status</span>
</BitBhGridCell>
<BitBhGridCell As="td" class="num">@FormatMoney(invoice.Amount)</BitBhGridCell>
</BitBhGridRow>
}
</BitBhGridRowGroup>
</BitBhGrid>
</div>
@code {
private sealed record Invoice(string Id, string Customer, string Status, decimal Amount);
private readonly Invoice[] invoices =
[
new("INV-1042", "Acme Corp", "Paid", 1_250.00m),
new("INV-1043", "Globex", "Pending", 830.50m),
new("INV-1044", "Initech", "Overdue", 4_200.00m),
new("INV-1045", "Umbrella", "Paid", 615.75m),
new("INV-1046", "Hooli", "Pending", 2_980.00m),
new("INV-1047", "Stark Industries", "Paid", 12_400.00m),
];
private IReadOnlyCollection<string> selectedInvoices = [];
private decimal SelectedTotal => invoices.Where(i => selectedInvoices.Contains(i.Id)).Sum(i => i.Amount);
private string AllInvoicesState =>
selectedInvoices.Count == 0 ? "false" : selectedInvoices.Count == invoices.Length ? "true" : "mixed";
private void ToggleAllInvoices() =>
selectedInvoices = selectedInvoices.Count == invoices.Length ? [] : invoices.Select(i => i.Id).ToArray();
private void ClearInvoices() => selectedInvoices = [];
private static string FormatMoney(decimal amount) =>
"$" + amount.ToString("N2", System.Globalization.CultureInfo.InvariantCulture);
}/* All styles are consumer-authored — the library ships zero CSS */
.datagrid-demo { display: flex; flex-direction: column; gap: 0.75rem; width: 100%; }
.datagrid-output { font-size: 0.875rem; color: #71717a; }
.datagrid-output strong { color: #18181b; font-weight: 600; }
.datagrid-frame {
width: 100%;
overflow-x: auto;
background: #fff;
border: 1px solid #e4e4e7;
border-radius: 0.5rem;
}
.datagrid {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
color: #18181b;
}
.datagrid th,
.datagrid td {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid #f4f4f5;
text-align: start;
white-space: nowrap;
outline: none;
}
.datagrid thead th {
background: #fafafa;
border-bottom-color: #e4e4e7;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #71717a;
}
.datagrid tbody th { font-weight: 600; } /* row headers */
.datagrid tbody tr:last-child > * { border-bottom: 0; }
.datagrid .num { text-align: end; font-variant-numeric: tabular-nums; }
/* Cells are the focus targets: one tab stop, arrow keys move it. */
.datagrid th:focus-visible,
.datagrid td:focus-visible { outline: 2px solid #2563eb; outline-offset: -2px; }
/* Rows carry aria-selected="true|false" (and data-selected) when selectable. */
.datagrid-selectable tbody tr { cursor: pointer; user-select: none; }
.datagrid-selectable tbody tr:hover { background: #fafafa; }
.datagrid tbody tr[aria-selected="true"] { background: #eff6ff; }
.datagrid tbody tr[aria-selected="true"]:hover { background: #dbeafe; }
.datagrid-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
min-height: 2rem;
font-size: 0.875rem;
color: #71717a;
}
.datagrid-toolbar strong { color: #18181b; font-weight: 600; }
.datagrid-button {
padding: 0.35rem 0.75rem;
border: 1px solid #e4e4e7;
border-radius: 0.375rem;
background: #fff;
font: inherit;
font-size: 0.8rem;
font-weight: 500;
color: #18181b;
cursor: pointer;
}
.datagrid-button:hover { background: #f4f4f5; }
.datagrid-button:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }
.datagrid-button:disabled { opacity: 0.5; cursor: not-allowed; }
.datagrid .datagrid-check-cell { width: 2.5rem; }
.datagrid thead .datagrid-check-cell { cursor: pointer; }
/* Row boxes follow the row's aria-selected; the header box its data-checked. */
.datagrid-check {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
border: 1.5px solid #a1a1aa;
border-radius: 0.25rem;
background: #fff;
color: #fff;
font-size: 0.7rem;
font-weight: 700;
line-height: 1;
vertical-align: middle;
}
.datagrid tr[aria-selected="true"] .datagrid-check,
.datagrid-check[data-checked="true"],
.datagrid-check[data-checked="mixed"] { background: #2563eb; border-color: #2563eb; }
.datagrid tr[aria-selected="true"] .datagrid-check::after,
.datagrid-check[data-checked="true"]::after { content: "✓"; }
.datagrid-check[data-checked="mixed"]::after { content: "–"; }
.datagrid-badge {
display: inline-block;
padding: 0.1rem 0.5rem;
border-radius: 999px;
background: #f4f4f5;
color: #52525b;
font-size: 0.75rem;
font-weight: 500;
}
.datagrid-badge[data-status="paid"] { background: #dcfce7; color: #166534; }
.datagrid-badge[data-status="pending"] { background: #fef9c3; color: #854d0e; }
.datagrid-badge[data-status="overdue"] { background: #fee2e2; color: #991b1b; }Row activation
OnRowActivate fires with the row's Key when the user presses Enter on any of its
cells or double-clicks it — open a message, navigate to a record, start editing. Here
BitBhGridSelectionMode.Single marks the current row, and opening a message marks it as read.
| From | Subject | Received |
|---|---|---|
| Ada Lovelace | Notes on the Analytical Engine | 09:24 |
| Grace Hopper | Found a bug (an actual moth) | 08:51 |
| Alan Turing | Can machines think? | Yesterday |
| Margaret Hamilton | Priority displays | Mon |
Press Enter on a row, or double-click it, to open the message.
<div class="datagrid-frame">
<BitBhGrid As="table" aria-label="Inbox" class="datagrid datagrid-selectable"
SelectionMode="BitBhGridSelectionMode.Single"
@bind-SelectedKeys="selectedMessage"
OnRowActivate="OpenMessage">
<BitBhGridRowGroup As="thead">
<BitBhGridRow As="tr" IsHeaderRow="true">
<BitBhGridCell As="th" Header="true">From</BitBhGridCell>
<BitBhGridCell As="th" Header="true">Subject</BitBhGridCell>
<BitBhGridCell As="th" Header="true" class="num">Received</BitBhGridCell>
</BitBhGridRow>
</BitBhGridRowGroup>
<BitBhGridRowGroup As="tbody">
@foreach (var message in messages)
{
<BitBhGridRow As="tr" @key="message.Id" Key="@message.Id"
data-unread="@(!readMessages.Contains(message.Id))">
<BitBhGridCell As="td"><span class="datagrid-dot" aria-hidden="true"></span>@message.From</BitBhGridCell>
<BitBhGridCell As="td">@message.Subject</BitBhGridCell>
<BitBhGridCell As="td" class="num">@message.Received</BitBhGridCell>
</BitBhGridRow>
}
</BitBhGridRowGroup>
</BitBhGrid>
</div>
@if (OpenedMessage is { } opened)
{
<div class="datagrid-preview">
<strong>@opened.Subject</strong>
From @opened.From — @opened.Body
</div>
}
else
{
<p class="datagrid-output">Press Enter on a row, or double-click it, to open the message.</p>
}
@code {
private sealed record Message(string Id, string From, string Subject, string Received, string Body);
private readonly Message[] messages =
[
new("m1", "Ada Lovelace", "Notes on the Analytical Engine", "09:24", "I have attached my notes — see note G."),
new("m2", "Grace Hopper", "Found a bug (an actual moth)", "08:51", "Taped it into the logbook for posterity."),
new("m3", "Alan Turing", "Can machines think?", "Yesterday", "Let us play the imitation game this Friday."),
new("m4", "Margaret Hamilton", "Priority displays", "Mon", "The guidance computer recovered beautifully."),
];
private IReadOnlyCollection<string> selectedMessage = [];
private readonly HashSet<string> readMessages = ["m3"];
private string? openedMessageId;
private Message? OpenedMessage => messages.FirstOrDefault(m => m.Id == openedMessageId);
// Enter on any cell of the row, or a double click, passes the row's Key.
private void OpenMessage(string id)
{
openedMessageId = id;
readMessages.Add(id);
}
}/* All styles are consumer-authored — the library ships zero CSS */
.datagrid-demo { display: flex; flex-direction: column; gap: 0.75rem; width: 100%; }
.datagrid-output { font-size: 0.875rem; color: #71717a; }
.datagrid-output strong { color: #18181b; font-weight: 600; }
.datagrid-frame {
width: 100%;
overflow-x: auto;
background: #fff;
border: 1px solid #e4e4e7;
border-radius: 0.5rem;
}
.datagrid {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
color: #18181b;
}
.datagrid th,
.datagrid td {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid #f4f4f5;
text-align: start;
white-space: nowrap;
outline: none;
}
.datagrid thead th {
background: #fafafa;
border-bottom-color: #e4e4e7;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #71717a;
}
.datagrid tbody th { font-weight: 600; } /* row headers */
.datagrid tbody tr:last-child > * { border-bottom: 0; }
.datagrid .num { text-align: end; font-variant-numeric: tabular-nums; }
/* Cells are the focus targets: one tab stop, arrow keys move it. */
.datagrid th:focus-visible,
.datagrid td:focus-visible { outline: 2px solid #2563eb; outline-offset: -2px; }
/* Rows carry aria-selected="true|false" (and data-selected) when selectable. */
.datagrid-selectable tbody tr { cursor: pointer; user-select: none; }
.datagrid-selectable tbody tr:hover { background: #fafafa; }
.datagrid tbody tr[aria-selected="true"] { background: #eff6ff; }
.datagrid tbody tr[aria-selected="true"]:hover { background: #dbeafe; }
.datagrid tr[data-unread] td { font-weight: 600; }
.datagrid-dot {
display: inline-block;
width: 0.5rem;
height: 0.5rem;
margin-inline-end: 0.5rem;
border-radius: 50%;
background: #2563eb;
vertical-align: middle;
}
.datagrid tr:not([data-unread]) .datagrid-dot { visibility: hidden; }
.datagrid-preview {
padding: 0.875rem 1rem;
border: 1px solid #e4e4e7;
border-radius: 0.5rem;
background: #fafafa;
font-size: 0.875rem;
color: #52525b;
}
.datagrid-preview strong { display: block; margin-bottom: 0.25rem; color: #18181b; }TreeGrid
TreeGrid="true" renders role="treegrid". Give each row its Level,
HasChildren and ParentKey: rows under a collapsed folder get the
hidden attribute and are skipped by the keyboard. On a row's first cell, Right expands it
(then moves into the row) and Left collapses it (then jumps to the parent row). The chevron is a
BitBhGridRowToggle: a mouse shortcut, hidden from assistive tech, that toggles the row (synced through @bind-ExpandedKeys); indentation comes from the
--bit-bh-grid-level variable on each row.
| Name | Kind | Size |
|---|---|---|
| 📂project | Folder | 502.1 KB |
| 📂src | Folder | 23.6 KB |
| 📄app.ts | TypeScript | 13.9 KB |
| 📄router.ts | TypeScript | 6.7 KB |
| 📄styles.css | Stylesheet | 3.1 KB |
| 📁assets | Folder | 472.8 KB |
| 📄logo.svg | SVG image | 2 KB |
| 📄hero.png | PNG image | 470.8 KB |
| 📄package.json | JSON | 1.2 KB |
| 📄README.md | Markdown | 4.4 KB |
| 📄notes.txt | Text | 1 KB |
Selected: (none)
<div class="datagrid-frame">
<BitBhGrid As="table" TreeGrid="true" aria-label="Project files" class="datagrid datagrid-selectable"
SelectionMode="BitBhGridSelectionMode.Single"
@bind-SelectedKeys="selectedEntry"
@bind-ExpandedKeys="expandedFolders">
<BitBhGridRowGroup As="thead">
<BitBhGridRow As="tr" IsHeaderRow="true">
<BitBhGridCell As="th" Header="true">Name</BitBhGridCell>
<BitBhGridCell As="th" Header="true">Kind</BitBhGridCell>
<BitBhGridCell As="th" Header="true" class="num">Size</BitBhGridCell>
</BitBhGridRow>
</BitBhGridRowGroup>
<BitBhGridRowGroup As="tbody">
@foreach (var entry in fileSystem)
{
<BitBhGridRow As="tr" @key="entry.Path" Key="@entry.Path"
Level="entry.Level" ParentKey="@entry.Parent" HasChildren="entry.IsFolder">
<BitBhGridCell As="td">
<span class="treegrid-name">
@if (entry.IsFolder)
{
<BitBhGridRowToggle class="treegrid-toggle">▸</BitBhGridRowToggle>
<span>@(expandedFolders.Contains(entry.Path) ? "📂" : "📁")</span>
}
else
{
<span class="treegrid-spacer"></span>
<span>📄</span>
}
@entry.Name
</span>
</BitBhGridCell>
<BitBhGridCell As="td">@entry.Kind</BitBhGridCell>
<BitBhGridCell As="td" class="num">@FormatSize(SizeOf(entry))</BitBhGridCell>
</BitBhGridRow>
}
</BitBhGridRowGroup>
</BitBhGrid>
</div>
<p class="datagrid-output">Selected: <strong>@(selectedEntry.FirstOrDefault() ?? "(none)")</strong></p>
@code {
private sealed record FsEntry(string Path, string Name, string Kind, long Bytes, int Level, string? Parent, bool IsFolder);
// Flattened in document (depth-first) order.
private readonly FsEntry[] fileSystem =
[
new("project", "project", "Folder", 0, 1, null, true),
new("project/src", "src", "Folder", 0, 2, "project", true),
new("project/src/app.ts", "app.ts", "TypeScript", 14_236, 3, "project/src", false),
new("project/src/router.ts", "router.ts", "TypeScript", 6_812, 3, "project/src", false),
new("project/src/styles.css", "styles.css", "Stylesheet", 3_154, 3, "project/src", false),
new("project/assets", "assets", "Folder", 0, 2, "project", true),
new("project/assets/logo.svg", "logo.svg", "SVG image", 2_048, 3, "project/assets", false),
new("project/assets/hero.png", "hero.png", "PNG image", 482_113, 3, "project/assets", false),
new("project/package.json", "package.json", "JSON", 1_240, 2, "project", false),
new("project/README.md", "README.md", "Markdown", 4_502, 2, "project", false),
new("notes.txt", "notes.txt", "Text", 1_024, 1, null, false),
];
private IReadOnlyCollection<string> selectedEntry = [];
private IReadOnlyCollection<string> expandedFolders = ["project", "project/src"];
private long SizeOf(FsEntry entry) =>
entry.IsFolder ? fileSystem.Where(e => e.Parent == entry.Path).Sum(e => SizeOf(e)) : entry.Bytes;
private static string FormatSize(long bytes) => bytes switch
{
< 1024 => $"{bytes} B",
< 1024 * 1024 => $"{bytes / 1024.0:0.#} KB",
_ => $"{bytes / (1024.0 * 1024):0.#} MB",
};
}/* All styles are consumer-authored — the library ships zero CSS */
.datagrid-demo { display: flex; flex-direction: column; gap: 0.75rem; width: 100%; }
.datagrid-output { font-size: 0.875rem; color: #71717a; }
.datagrid-output strong { color: #18181b; font-weight: 600; }
.datagrid-frame {
width: 100%;
overflow-x: auto;
background: #fff;
border: 1px solid #e4e4e7;
border-radius: 0.5rem;
}
.datagrid {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
color: #18181b;
}
.datagrid th,
.datagrid td {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid #f4f4f5;
text-align: start;
white-space: nowrap;
outline: none;
}
.datagrid thead th {
background: #fafafa;
border-bottom-color: #e4e4e7;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #71717a;
}
.datagrid tbody th { font-weight: 600; } /* row headers */
.datagrid tbody tr:last-child > * { border-bottom: 0; }
.datagrid .num { text-align: end; font-variant-numeric: tabular-nums; }
/* Cells are the focus targets: one tab stop, arrow keys move it. */
.datagrid th:focus-visible,
.datagrid td:focus-visible { outline: 2px solid #2563eb; outline-offset: -2px; }
/* Rows carry aria-selected="true|false" (and data-selected) when selectable. */
.datagrid-selectable tbody tr { cursor: pointer; user-select: none; }
.datagrid-selectable tbody tr:hover { background: #fafafa; }
.datagrid tbody tr[aria-selected="true"] { background: #eff6ff; }
.datagrid tbody tr[aria-selected="true"]:hover { background: #dbeafe; }
/* Rows carry aria-level, data-level and --bit-bh-grid-level; rows under a
collapsed parent get the hidden attribute. */
.treegrid-name {
display: flex;
align-items: center;
gap: 0.375rem;
padding-inline-start: calc((var(--bit-bh-grid-level, 1) - 1) * 1.25rem);
}
.treegrid-toggle {
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
border-radius: 0.25rem;
color: #a1a1aa;
font-size: 0.75rem;
cursor: pointer;
transition: transform 150ms ease;
}
.treegrid-toggle:hover { background: #e4e4e7; color: #18181b; }
.datagrid tr[data-state="open"] .treegrid-toggle { transform: rotate(90deg); }
.treegrid-spacer { flex: none; width: 1.25rem; }Div-based grid (calendar)
BitBhGrid, BitBhGridRowGroup, BitBhGridRow and BitBhGridCell render
<div>s with role="grid" / "rowgroup" / "row" /
"gridcell" by default, so any layout works — here each row is a 7-column CSS grid. Arrow keys
move by day and week, Home / End go to the start / end of the week, and Enter or a click picks a date
through each cell's OnActivate. aria-selected, aria-current and
aria-disabled on the cells are plain attributes set by the page.
Picked: (none)
<div class="calendar-card">
<div id="grid-calendar-title" class="calendar-title">@MonthStart.ToString("MMMM yyyy", System.Globalization.CultureInfo.InvariantCulture)</div>
<BitBhGrid aria-labelledby="grid-calendar-title" class="calendar">
<BitBhGridRowGroup>
<BitBhGridRow IsHeaderRow="true" class="calendar-row">
@foreach (var (shortName, longName) in weekdays)
{
<BitBhGridCell Header="true" class="calendar-weekday">
<abbr title="@longName">@shortName</abbr>
</BitBhGridCell>
}
</BitBhGridRow>
</BitBhGridRowGroup>
<BitBhGridRowGroup class="calendar-weeks">
@foreach (var week in Weeks)
{
<BitBhGridRow class="calendar-row">
@foreach (var day in week)
{
<BitBhGridCell class="calendar-day"
aria-selected="@(day == pickedDate ? "true" : null)"
aria-current="@(day == today ? "date" : null)"
aria-disabled="@(day.Month != MonthStart.Month ? "true" : null)"
OnActivate="() => PickDate(day)">@day.Day</BitBhGridCell>
}
</BitBhGridRow>
}
</BitBhGridRowGroup>
</BitBhGrid>
</div>
<p class="datagrid-output">
Picked: <strong>@(pickedDate?.ToString("dddd, MMMM d", System.Globalization.CultureInfo.InvariantCulture) ?? "(none)")</strong>
</p>
@code {
private readonly DateOnly today = DateOnly.FromDateTime(DateTime.Today);
private DateOnly? pickedDate;
private readonly (string Short, string Long)[] weekdays =
[
("Su", "Sunday"), ("Mo", "Monday"), ("Tu", "Tuesday"), ("We", "Wednesday"),
("Th", "Thursday"), ("Fr", "Friday"), ("Sa", "Saturday"),
];
private DateOnly MonthStart => new(today.Year, today.Month, 1);
// Whole weeks (Sunday → Saturday) covering the current month.
private IEnumerable<DateOnly[]> Weeks
{
get
{
var monthEnd = MonthStart.AddMonths(1).AddDays(-1);
for (var start = MonthStart.AddDays(-(int)MonthStart.DayOfWeek); start <= monthEnd; start = start.AddDays(7))
{
var weekStart = start;
yield return Enumerable.Range(0, 7).Select(i => weekStart.AddDays(i)).ToArray();
}
}
}
// Days of the neighbouring months are aria-disabled and can't be picked.
private void PickDate(DateOnly day)
{
if (day.Month == MonthStart.Month) pickedDate = day;
}
}/* All styles are consumer-authored — the library ships zero CSS */
.datagrid-demo { display: flex; flex-direction: column; gap: 0.75rem; width: 100%; }
.datagrid-output { font-size: 0.875rem; color: #71717a; }
.datagrid-output strong { color: #18181b; font-weight: 600; }
.calendar-card {
display: inline-flex;
flex-direction: column;
align-self: flex-start;
gap: 0.5rem;
padding: 0.75rem;
background: #fff;
border: 1px solid #e4e4e7;
border-radius: 0.5rem;
font-size: 0.875rem;
color: #18181b;
}
.calendar-title { font-weight: 600; }
/* BitBhGrid, BitBhGridRowGroup and BitBhGridRow render plain <div>s by default. */
.calendar,
.calendar-weeks { display: flex; flex-direction: column; gap: 0.25rem; }
.calendar-row {
display: grid;
grid-template-columns: repeat(7, 2.25rem);
gap: 0.25rem;
}
.calendar-weekday,
.calendar-day {
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.375rem;
outline: none;
}
.calendar-weekday { height: 2rem; font-size: 0.75rem; font-weight: 600; color: #71717a; }
.calendar-weekday abbr { text-decoration: none; }
.calendar-day { height: 2.25rem; cursor: pointer; user-select: none; }
.calendar-day:hover { background: #f4f4f5; }
.calendar-weekday:focus-visible,
.calendar-day:focus-visible { outline: 2px solid #2563eb; outline-offset: 2px; }
/* aria-current / aria-selected / aria-disabled are set by the page on each cell. */
.calendar-day[aria-current="date"] { font-weight: 700; box-shadow: inset 0 0 0 1px #93c5fd; }
.calendar-day[aria-selected="true"] { background: #2563eb; color: #fff; }
.calendar-day[aria-disabled="true"] { color: #d4d4d8; cursor: default; background: transparent; }