Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: the agents page is one column of cards that open in place

The roster loses its second column and its table view. Selecting a card opens
its details under it as four folded sections: overview, settings, accounts and
the terminal. The status, message and wanted rows, which the card header
already shows, are gone from the details. The terminal page's details dialog
keeps the flat list.
This commit is contained in:
iris 2026-11-01 01:21:43 +01:00
commit 2eaa02e892
3 changed files with 555 additions and 541 deletions

View file

@ -26,8 +26,8 @@ export function Card({
children,
class: extraClass,
}: {
/** Highlight styling — the caller decides what "selected" means (the
* row shown in a detail panel, say); this component just renders it. */
/** Highlight styling — the caller decides what "selected" means (an
* opened row, say); this component just renders it. */
selected?: boolean;
/** Present → a real interactive card (role="button", keyboard-
* activatable). Absent → a plain static container, same border/

View file

@ -21,14 +21,19 @@ import {
MultiselectFilter,
type MultiselectFilterState,
} from "../multiselect-filter/MultiselectFilter.js";
import { Table, useTableFilters, type TableColumn } from "../table/Table.js";
import {
Table,
useTableFilters,
type FilterColumn,
type TableColumn,
} from "../table/Table.js";
import "./FilterableView.css";
function isFilterable<T>(c: TableColumn<T>): boolean {
function isFilterable<T>(c: FilterColumn<T>): boolean {
return c.filterValue !== undefined || c.filterValues !== undefined;
}
function columnLabel<T>(c: TableColumn<T>): string {
function columnLabel<T>(c: FilterColumn<T>): string {
return typeof c.header === "string" ? c.header : c.key;
}
@ -46,7 +51,7 @@ function FilterBar<T>({
activeFilters,
multiselectOptionsFor,
}: {
columns: TableColumn<T>[];
columns: FilterColumn<T>[];
getFilter: (key: string) => MultiselectFilterState & { value: string };
updateFilter: (
key: string,
@ -55,7 +60,7 @@ function FilterBar<T>({
toggleFilterValue: (key: string, value: string) => void;
resetFilters: () => void;
activeFilters: unknown[];
multiselectOptionsFor: (c: TableColumn<T>) => string[];
multiselectOptionsFor: (c: FilterColumn<T>) => string[];
}) {
const filterable = columns.filter(isFilterable);
if (filterable.length === 0) return null;
@ -104,30 +109,30 @@ function FilterBar<T>({
);
}
export function FilterableView<T>({
columns,
rows,
rowKey,
storageKey,
view,
renderCard,
emptyMessage,
}: {
columns: TableColumn<T>[];
rows: T[];
rowKey: (row: T) => string;
// Shared 1:1 with `Table`'s own filter persistence — pass the same
// key `<Table>` gets when it's the other `view`, so cards↔table never
// lose or hide an active filter (see this file's own comment).
storageKey: string;
view: "cards" | "table";
renderCard: (row: T) => ComponentChildren;
// Shown when `rows` itself is empty. A filter narrowing a non-empty
// `rows` to zero visible rows gets its own fixed message instead
// (same as `Table`'s own "no rows match the current filter") — the
// two cases mean different things, same as `Table`'s own split.
emptyMessage?: ComponentChildren;
}) {
// `columns` says what each view needs of them: a table draws every cell, so it
// takes full `TableColumn`s; cards only filter, so they take `FilterColumn`s
// (a column minus its cell renderer).
type ViewColumns<T> =
| { view: "cards"; columns: FilterColumn<T>[] }
| { view: "table"; columns: TableColumn<T>[] };
export function FilterableView<T>(
props: ViewColumns<T> & {
rows: T[];
rowKey: (row: T) => string;
// Shared 1:1 with `Table`'s own filter persistence — pass the same
// key `<Table>` gets when it's the other `view`, so cards↔table never
// lose or hide an active filter (see this file's own comment).
storageKey: string;
renderCard: (row: T) => ComponentChildren;
// Shown when `rows` itself is empty. A filter narrowing a non-empty
// `rows` to zero visible rows gets its own fixed message instead
// (same as `Table`'s own "no rows match the current filter") — the
// two cases mean different things, same as `Table`'s own split.
emptyMessage?: ComponentChildren;
},
) {
const { columns, rows, rowKey, storageKey, renderCard, emptyMessage } = props;
const {
visibleRows,
activeFilters,
@ -138,10 +143,10 @@ export function FilterableView<T>({
multiselectOptionsFor,
} = useTableFilters(columns, rows, storageKey);
if (view === "table") {
if (props.view === "table") {
return (
<Table
columns={columns}
columns={props.columns}
rows={rows}
rowKey={rowKey}
storageKey={storageKey}

View file

@ -27,6 +27,12 @@ import { clampLeft } from "./popover-position.js";
import { forExistingColumns } from "./table-filters.js";
import "./Table.css";
/**
* What filtering and sorting read from a column, without how its cell draws:
* for a list that has no table to draw them in (`FilterableView` as cards).
*/
export type FilterColumn<T> = Omit<TableColumn<T>, "render">;
export interface TableColumn<T> {
key: string;
// `ComponentChildren`, not `string` — a plain string still satisfies
@ -130,7 +136,7 @@ function isFilterActive(f: ColumnFilterState | undefined): boolean {
return f !== undefined && (f.value !== "" || f.values.length > 0);
}
function isFilterable<T>(c: TableColumn<T>): boolean {
function isFilterable<T>(c: FilterColumn<T>): boolean {
return c.filterValue !== undefined || c.filterValues !== undefined;
}
@ -154,7 +160,7 @@ function compareValues(a: string | number, b: string | number): number {
// layout renders its own trigger against the same `getFilter`/
// `updateFilter`/`toggleFilterValue`/`multiselectOptionsFor` this returns.
export function useTableFilters<T>(
columns: TableColumn<T>[],
columns: FilterColumn<T>[],
rows: T[],
storageKey: string,
) {
@ -203,7 +209,7 @@ export function useTableFilters<T>(
// `visibleRows`, matching how a spreadsheet's column filters don't
// hide each other's choices. A row contributes every one of its own
// values, not just one.
function multiselectOptionsFor(c: TableColumn<T>): string[] {
function multiselectOptionsFor(c: FilterColumn<T>): string[] {
if (!c.filterValues) return [];
const values = new Set<string>();
for (const row of rows) for (const v of c.filterValues(row)) values.add(v);