| Task name | Date | Status | Channel |
|---|---|---|---|
| Draft spring trail email | Dec 03 | To do | Email |
| Review GEO citation report | Sep 22 | In Progress | Perplexity |
| Approve gear guide video | Jan 02 | To do | YouTube |
| Reply to camping thread | Nov 08 | In Progress | Reddit |
| Send weekly GA4 report | Apr 14 | Completed | GA4 |
Astro UI
Filter Table.
Status chips that filter a table in place.
DataComponentNo dependencies Beautiful UI, MIT
Install
-
Import the foundation once
foundation.css holds the tokens every component reads.
--- // src/layouts/Layout.astro import "../styles/foundation.css"; --- -
Use it
--- import FilterTable from "../components/viewership/filter-table/FilterTable.astro"; --- <FilterTable />
Props 7
| Prop | Type | Default | Description |
|---|---|---|---|
rows | Record<string, string>[] | [] | One object per row, keyed by column key. |
columns | { key; label; width?; kind?: "title" | "muted" | "date" | "status" }[] | task / date / status / owner | Column order, labels, widths and cell style. |
statuses | { key; label; tone?: "amber" | "cyan" | "green" | "red" | "violet" | "gray" }[] | To do / In Progress / Completed | Filter chips and status pills. Counts come from `rows`. |
statusKey | string | "status" | Row field holding the status key. |
filter | string | "all" | Initial filter. |
caption | string | "Tasks" | Hidden table caption. |
labels | { all?, filterGroup?, result? } | · | Chip and screen reader text. `result` takes {shown}, {total}, {filter}. |
Source 1 file
---
/**
* FilterTable: status chips that filter a table in place. Chip counts come
* from the data.
*
* Ported from Beautiful UI `components/primitives/FilterTable.tsx`
* (MIT, © 2026 Shane Levine).
*
* @example
* <FilterTable rows={tasks} caption="Marketing tasks" />
*
* Events: `vs-filter-change` (detail: { filter, shown, total }) on the root.
*/
import type { HTMLAttributes } from "astro/types";
export type FilterTone = "amber" | "cyan" | "green" | "red" | "violet" | "gray";
export type FilterStatus = { key: string; label: string; tone?: FilterTone };
export type FilterColumn = {
key: string;
label: string;
/** Relative column width. */
width?: number;
/** title = emphasised, muted = secondary ink, date = muted + tabular, status = pill. */
kind?: "title" | "muted" | "date" | "status";
};
export type FilterRow = Record<string, string>;
type Props = HTMLAttributes<"div"> & {
rows?: FilterRow[];
columns?: FilterColumn[];
statuses?: FilterStatus[];
/** Row field that holds the status key. */
statusKey?: string;
/** Initially selected filter ("all" or a status key). */
filter?: string;
/** Visually hidden table caption (also names the scroll region). */
caption?: string;
labels?: {
all?: string;
filterGroup?: string;
/** Announced after filtering. Tokens: {shown}, {total}, {filter}. */
result?: string;
};
};
const DEFAULT_COLUMNS: FilterColumn[] = [
{ key: "task", label: "Task name", width: 1.3, kind: "title" },
{ key: "date", label: "Date", width: 0.6, kind: "date" },
{ key: "status", label: "Status", width: 0.95, kind: "status" },
{ key: "owner", label: "Owner", width: 0.9, kind: "muted" },
];
const DEFAULT_STATUSES: FilterStatus[] = [
{ key: "todo", label: "To do", tone: "amber" },
{ key: "progress", label: "In Progress", tone: "cyan" },
{ key: "done", label: "Completed", tone: "green" },
];
const {
rows = [],
columns = DEFAULT_COLUMNS,
statuses = DEFAULT_STATUSES,
statusKey = "status",
filter = "all",
caption = "Tasks",
labels,
class: className,
...rest
} = Astro.props;
const text = {
all: "All",
filterGroup: "Filter by status",
result: "{filter}: showing {shown} of {total} rows",
...labels,
};
const uid = `vs-ft-${Math.random().toString(36).slice(2, 8)}`;
const statusOf = new Map(statuses.map((s) => [s.key, s]));
const filters = [
{ key: "all", label: text.all, tone: undefined as FilterTone | undefined, count: rows.length },
...statuses.map((s) => ({ ...s, count: rows.filter((r) => r[statusKey] === s.key).length })),
];
const totalWidth = columns.reduce((n, c) => n + (c.width ?? 1), 0);
const shown = (row: FilterRow) => filter === "all" || row[statusKey] === filter;
---
<div class:list={["vs-filter-table", className]} data-status-key={statusKey} data-result={text.result} {...rest}>
<div class="chips" role="group" aria-label={text.filterGroup}>
{filters.map((f) => (
<button type="button" class="chip" aria-pressed={filter === f.key ? "true" : "false"} aria-controls={uid} data-filter={f.key} data-label={f.label}>
{f.tone && <span class:list={["dot", `tone-${f.tone}`]} aria-hidden="true"></span>}
{f.label}
<span class="count">{f.count}</span>
</button>
))}
</div>
<div class="scroll" role="region" aria-label={`${caption}, scrollable`} tabindex="0">
<table id={uid}>
<caption class="sr-only">{caption}</caption>
<colgroup>
{columns.map((c) => <col style={`width: ${(((c.width ?? 1) / totalWidth) * 100).toFixed(3)}%`} />)}
</colgroup>
<thead>
<tr>
{columns.map((c) => <th scope="col">{c.label}</th>)}
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr data-status={row[statusKey]} data-hidden={shown(row) ? undefined : ""} aria-hidden={shown(row) ? undefined : "true"}>
{columns.map((c) => {
const value = row[c.key] ?? "";
const status = c.kind === "status" ? statusOf.get(value) : undefined;
return (
<td>
<div class="ft-collapse">
<div class="clip">
<div class:list={["cell", `cell--${c.kind ?? "muted"}`]}>
{c.kind === "status" ? (
<span class:list={["pill", `tone-${status?.tone ?? "gray"}`]}>{status?.label ?? value}</span>
) : (
<span class="value">{value}</span>
)}
</div>
</div>
</div>
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
<p class="sr-only" role="status" aria-live="polite" data-announce></p>
</div>
<script>
function init(root: HTMLElement) {
const chips = [...root.querySelectorAll<HTMLButtonElement>(".chip")];
const rows = [...root.querySelectorAll<HTMLTableRowElement>("tbody tr")];
const announce = root.querySelector<HTMLElement>("[data-announce]");
const template = root.dataset.result ?? "";
const apply = (key: string, chip: HTMLButtonElement) => {
let shown = 0;
for (const row of rows) {
const visible = key === "all" || row.dataset.status === key;
if (visible) shown++;
row.toggleAttribute("data-hidden", !visible);
if (visible) row.removeAttribute("aria-hidden");
else row.setAttribute("aria-hidden", "true");
}
chips.forEach((c) => c.setAttribute("aria-pressed", String(c === chip)));
if (announce) {
announce.textContent = template
.replace("{shown}", String(shown))
.replace("{total}", String(rows.length))
.replace("{filter}", chip.dataset.label ?? key);
}
root.dispatchEvent(
new CustomEvent("vs-filter-change", { detail: { filter: key, shown, total: rows.length }, bubbles: true }),
);
};
chips.forEach((chip) =>
chip.addEventListener("click", () => {
if (chip.getAttribute("aria-pressed") === "true") return;
apply(chip.dataset.filter ?? "all", chip);
}),
);
}
const boot = () =>
document.querySelectorAll<HTMLElement>(".vs-filter-table:not([data-vs-ready])").forEach((root) => {
root.dataset.vsReady = "";
init(root);
});
boot();
document.addEventListener("astro:page-load", boot);
</script>
<style>
.vs-filter-table {
width: 100%;
max-width: 420px;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
/* status tones */
.tone-amber {
--tag-base: oklch(0.757 0.153 66.401);
}
.tone-cyan {
--tag-base: oklch(0.671 0.118 219.351);
}
.tone-green {
--tag-base: oklch(0.652 0.131 162.865);
}
.tone-red {
--tag-base: oklch(0.65 0.17 24);
}
.tone-violet {
--tag-base: oklch(0.62 0.19 300);
}
.tone-gray {
--tag-base: var(--vs-ink-3);
}
/* filter chips */
.chips {
display: flex;
align-items: center;
gap: 4px;
margin: 0 -4px 4px;
padding: 4px;
overflow-x: auto;
scrollbar-width: none;
}
.chips::-webkit-scrollbar {
display: none;
}
.chip {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 6px;
height: 26px;
padding: 0 10px;
border: none;
border-radius: var(--vs-radius-pill);
background: transparent;
color: var(--vs-ink-2);
font: inherit;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition:
background-color 200ms,
box-shadow 200ms,
color 200ms;
}
.chip:hover {
background: var(--vs-hover);
}
.chip[aria-pressed="true"] {
background: var(--vs-surface);
color: var(--vs-ink);
box-shadow: var(--vs-shadow-btn);
}
.chip:focus-visible {
outline: none;
box-shadow: var(--vs-focus-ring);
}
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--tag-base);
}
.count {
padding: 0 4px;
border-radius: 4px;
font-size: 10.5px;
font-variant-numeric: tabular-nums;
color: var(--vs-ink-3);
transition: background-color 200ms, color 200ms;
}
.chip[aria-pressed="true"] .count {
background: var(--vs-field);
color: var(--vs-ink-2);
}
/* table */
.scroll {
overflow-x: auto;
border-radius: var(--vs-radius-card);
background: var(--vs-surface);
box-shadow: var(--vs-shadow-card);
scrollbar-width: none;
}
.scroll::-webkit-scrollbar {
display: none;
}
.scroll:focus-visible {
outline: none;
box-shadow: var(--vs-shadow-card), var(--vs-focus-ring);
}
table {
width: 100%;
min-width: 420px;
table-layout: fixed;
border-collapse: separate;
border-spacing: 0;
}
th,
td {
padding: 0;
vertical-align: top;
text-align: left;
border-right: 1px solid var(--vs-grid-line);
}
th:last-child,
td:last-child {
border-right: none;
}
th {
padding: 8px 12px;
border-bottom: 1px solid var(--vs-grid-line);
font-size: 12.5px;
font-weight: 500;
color: var(--vs-ink-2);
}
tbody tr {
transition: background-color 100ms;
}
tbody tr:hover:not([data-hidden]) {
background: var(--vs-hover);
}
/* row collapse: grid-template-rows 1fr ↔ 0fr */
.ft-collapse {
display: grid;
grid-template-rows: 1fr;
opacity: 1;
transition:
grid-template-rows 300ms var(--vs-ease-out),
opacity 300ms var(--vs-ease-out);
}
tr[data-hidden] .ft-collapse {
grid-template-rows: 0fr;
opacity: 0;
}
.clip {
min-height: 0;
overflow: hidden;
}
.cell {
display: flex;
align-items: center;
box-sizing: border-box;
min-width: 0;
min-height: 40px;
padding: 8px 12px;
border-bottom: 1px solid var(--vs-grid-line);
font-size: 13px;
color: var(--vs-ink-2);
}
.value {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cell--title .value {
font-weight: 500;
color: var(--vs-ink);
}
.cell--date .value {
font-variant-numeric: tabular-nums;
}
/* status pill */
.pill {
display: inline-flex;
flex-shrink: 0;
align-items: center;
height: 23px;
padding: 0 7px;
border: 1px solid color-mix(in srgb, var(--tag-base) 34%, var(--vs-surface));
border-radius: 8px;
background: color-mix(in srgb, var(--tag-base) 20%, var(--vs-surface));
color: color-mix(in srgb, var(--tag-base) 92%, var(--vs-ink));
font-size: 13px;
font-weight: 500;
white-space: nowrap;
}
:global(:root[data-theme="dark"]) .pill {
background: color-mix(in srgb, var(--tag-base) 34%, var(--vs-surface));
color: color-mix(in srgb, var(--tag-base) 86%, white);
}
@media (prefers-color-scheme: dark) {
:global(:root:not([data-theme="light"])) .pill {
background: color-mix(in srgb, var(--tag-base) 34%, var(--vs-surface));
color: color-mix(in srgb, var(--tag-base) 86%, white);
}
}
</style> Ported from Beautiful UI, MIT. Notices.