дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
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]>
Цей коміт міститься в:
1 джерело
75773dd889
коміт
309bb48f2c
56 files changed
+913
-32
No files matched your search
@@ -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, []));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Заблокувати користувача