feat(plugins): clickable column headers with sort events for plugin tables (#1785)

A plugin opts a table into click-to-sort by sending it as view body other(kind "table") with its sort; a header click comes back as view-event other(kind "sort", {column, descending}), so no released WIT type changes. The SDK builds and decodes both (Body::sortable_table, ViewEvent::sort). Headers are keyboard-operable, carry aria-sort and show the reported order with an arrow.

Co-authored-by: Inf1mum <[email protected]>
Цей коміт міститься в:
Vadim LazukтаInf1mum authored and GitHub committed 2026-11-07 21:09:18 +08:00
1 джерело 75773dd889
коміт 309bb48f2c
56 files changed
+913 -32

No files matched your search

+11 -1
Переглянути файл
@@ -7708,6 +7708,12 @@ export type SyntaxNode = {
hasError: boolean,
};
/** A sortable table's order: the column and the direction. */
export type TableSort = {
column: number,
descending: boolean,
};
/** A long-running background task shown in the status bar. */
export type TaskId = number;
@@ -8067,7 +8073,7 @@ export type ViewButton = {
};
/** A user action in a view. */
export type ViewEvent = { kind: "button"; id: string } | { kind: "activate"; key: string } | { kind: "select"; key: string } | { kind: "expand"; key: string } | { kind: "collapse"; key: string } | { kind: "field"; key: string; value: string } | { kind: "cell"; row: number; column: number; value: string } | { kind: "insertRow"; before: number } | { kind: "deleteRows"; rows: number[] } | { kind: "message"; text: string };
export type ViewEvent = { kind: "button"; id: string } | { kind: "activate"; key: string } | { kind: "select"; key: string } | { kind: "expand"; key: string } | { kind: "collapse"; key: string } | { kind: "field"; key: string; value: string } | { kind: "cell"; row: number; column: number; value: string } | { kind: "insertRow"; before: number } | { kind: "deleteRows"; rows: number[] } | { kind: "message"; text: string } | { kind: "sort"; column: number; descending: boolean };
export type ViewField = {
key: string,
@@ -8088,6 +8094,10 @@ export type ViewTable = {
firstRow: number,
rows: string[][],
editable: boolean,
/** The plugin opted its headers in to click-to-sort (a `table` body through `other`). */
sortable: boolean,
/** The sort the plugin orders by (none: no arrow yet). */
sort: TableSort | null,
};
export type ViewTreeItem = {
+83 -1
Переглянути файл
@@ -400,7 +400,15 @@ describe("PluginView", () => {
toolbar: [],
body: {
kind: "table",
value: { columns: ["name", "city"], totalRows: 1, firstRow: 0, rows: [["Ada", city]], editable: true },
value: {
columns: ["name", "city"],
totalRows: 1,
firstRow: 0,
rows: [["Ada", city]],
editable: true,
sortable: false,
sort: null,
},
},
status: null,
});
@@ -419,6 +427,80 @@ describe("PluginView", () => {
expect(await screen.findByText("Paris")).toBeInTheDocument();
});
it("sorts a sortable table from its column headers, inverting on a second click", async () => {
const events: ViewEvent[] = [];
let sort: { column: number; descending: boolean } | null = null;
const table = (): View => ({
toolbar: [],
body: {
kind: "table",
value: {
columns: ["ext", "files"],
totalRows: 1,
firstRow: 0,
rows: [["rs", "2"]],
editable: false,
sortable: true,
sort,
},
},
status: null,
});
setPluginsBackend(backendWith(table, events));
const user = userEvent.setup();
const { container } = render(<PluginView pluginId="ext" view="counts" />);
const header = await screen.findByRole("columnheader", { name: "files" });
await user.click(header);
await waitFor(() => expect(events).toContainEqual({ kind: "sort", column: 1, descending: false }));
sort = { column: 1, descending: false };
bumpView("ext", "counts");
await waitFor(() =>
expect(screen.getByRole("columnheader", { name: "files" })).toHaveAttribute("aria-sort", "ascending"),
);
expect(container.querySelector('[aria-sort="ascending"] svg')).not.toBeNull();
await user.click(screen.getByRole("columnheader", { name: "files" }));
await waitFor(() => expect(events).toContainEqual({ kind: "sort", column: 1, descending: true }));
sort = { column: 1, descending: true };
bumpView("ext", "counts");
await waitFor(() =>
expect(screen.getByRole("columnheader", { name: "files" })).toHaveAttribute("aria-sort", "descending"),
);
// Another column starts over at ascending.
await user.click(screen.getByRole("columnheader", { name: "ext" }));
await waitFor(() => expect(events).toContainEqual({ kind: "sort", column: 0, descending: false }));
expect(events.filter((e) => e.kind === "sort")).toHaveLength(3);
});
it("keeps plain table headers inert", async () => {
const events: ViewEvent[] = [];
const table = (): View => ({
toolbar: [],
body: {
kind: "table",
value: {
columns: ["name", "city"],
totalRows: 1,
firstRow: 0,
rows: [["Ada", "London"]],
editable: false,
sortable: false,
sort: null,
},
},
status: null,
});
setPluginsBackend(backendWith(table, events));
const user = userEvent.setup();
render(<PluginView pluginId="csv" view="table" />);
await user.click(await screen.findByRole("columnheader", { name: "city" }));
expect(events).toHaveLength(0);
});
it("keeps an HTML body loaded across theme switches and posts the new theme to it", async () => {
const html: View = { toolbar: [], body: { kind: "html", value: "<p>chart</p>" }, status: null };
setPluginsBackend(backendWith(() => html, []));
+17
Переглянути файл
@@ -162,6 +162,23 @@
border-right: 1px solid var(--border);
}
/* A sortable table's header: clickable, the sorted one marked by an arrow. */
.headerCell {
cursor: pointer;
user-select: none;
}
.headerCell:hover,
.headerCell:focus-visible {
background: var(--bg-hover);
}
.sortIcon {
display: inline-block;
margin-left: 4px;
vertical-align: -2px;
}
.tableEmpty {
padding: 32px 12px;
color: var(--fg-muted);
+31 -5
Переглянути файл
@@ -384,6 +384,13 @@ function TableBody({
if (table.editable) showContextMenu(e, items);
};
// A sortable table (`table` through the plugin API's `other` body case):
// the plugin sorts its own rows, so a header click only asks it to — the
// first click ascending, a second on the same column reversed.
const sort = table.sortable ? table.sort : null;
const sortColumn = (column: number) =>
send({ kind: "sort", column, descending: sort?.column === column ? !sort.descending : false });
const rows: React.ReactNode[] = [];
for (let r = Math.max(first, table.firstRow); r < Math.min(shownEnd, loadedEnd); r++) {
const cells = table.rows[r - table.firstRow] ?? [];
@@ -446,11 +453,30 @@ function TableBody({
<div className={styles.tableCanvas} style={{ height: (table.totalRows + 1) * ROW_HEIGHT }}>
<div className={`${styles.tableRow} ${styles.tableHeader}`} role="row" aria-rowindex={1}>
<div className={styles.rowNumber} />
{table.columns.map((c, i) => (
<div key={i} role="columnheader" className={styles.cell} title={c}>
{c}
</div>
))}
{table.columns.map((c, i) => {
const sorted = sort?.column === i;
return (
<div
key={i}
role="columnheader"
className={table.sortable ? `${styles.cell} ${styles.headerCell}` : styles.cell}
title={c}
aria-sort={sorted ? (sort.descending ? "descending" : "ascending") : undefined}
tabIndex={table.sortable ? 0 : undefined}
onClick={table.sortable ? () => sortColumn(i) : undefined}
onKeyDown={
table.sortable
? (e) => (e.key === "Enter" || e.key === " ") && (e.preventDefault(), sortColumn(i))
: undefined
}
>
{c}
{sorted && (
<Icon name={sort.descending ? "chevron-down" : "chevron-up"} size={12} className={styles.sortIcon} />
)}
</div>
);
})}
</div>
{rows}
</div>