a913390322
Three tickets on one subject: paging.
improvise-rw6l — clicking a page chip opens a menu of that axis's items, with
a down-arrow saying so. Almost nothing had to be derived: `PageAxis::items` was
already projected and `ModelCommand::SetPageSelection` was already on the wire.
The one gap was that `RenderPageAxis` dropped the names, keeping the position
and count improvise-p1ym needed for `[`/`]`. `items` is relayed INSIDE `cycle`
rather than beside it, so there is one predicate and not two: a Filter axis has
no cycle, therefore no arrow, no menu, no items, and composes nothing if a click
reaches it — all four off `PageAxis::cycle`, the one home of gotcha 27's split.
improvise-771e — the browser tile bar said "Quarter Pag". `short_label` is three
characters so the TUI aligns in a fixed-width terminal (gotcha 26); a browser has
no column to fit. `render.rs` asks `plain_label` instead; `short_label` is
untouched. Note what pinned the truncation was `harness/tests/drill.spec.js`,
not `tests/grid_frame_agreement.rs`, which does not mention the tile bar.
improvise-wiew — `[`/`]` move from Normal onto a new `document_keymap` between
the global and the modes whose subject IS the document (Normal, the three
panels, TileSelect; RecordsNormal through Normal). Help and the import wizard
take the global directly. Per the user's ruling: an overlay has a subject of its
own, and these are the first shared keys that MUTATE the document — under the
help overlay that is an invisible change to the grid it is covering.
Boundary crossings to flag, both outside the brief's stated write scope and
neither a wire change:
- `resolve.rs` gains `Click::PageItem` and `lib.rs` a `click_page_item`
binding. Composing the command anywhere else would put a derivation in the
page, which design-principles §5 forbids; `Command`/`ModelCommand` are
untouched.
- `improvise-command/src/command/keymap.rs` gains three tests. The two
existing guards were the mechanism that forced the wiew argument, so they
had to be answered where they live.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
126 lines
5.2 KiB
JavaScript
126 lines
5.2 KiB
JavaScript
// The page bar as a control: the arrow, the menu, and the keys that reach it.
|
|
// The RULES behind each decision are `node --test web/tests` — clients.md.
|
|
|
|
import { test, expect } from "@playwright/test";
|
|
|
|
/// The shipped demo, focused. `#grid` must have focus or the page's keydown
|
|
/// listener never sees a key and every key assertion would pass vacuously.
|
|
async function theDemo(page) {
|
|
await page.goto("/app/index.html");
|
|
await expect(page.locator("#grid table")).toBeVisible({ timeout: 20_000 });
|
|
await page.locator("#grid").focus();
|
|
}
|
|
|
|
const chip = (page) => page.locator("#pages .page-axis");
|
|
const menu = (page) => page.locator("#page-menu");
|
|
|
|
test.describe("a page axis can be picked from a menu", () => {
|
|
// improvise-rw6l. The demo has Quarter on Page, so the chip is navigable and
|
|
// the arrow is the affordance that says a menu is behind it.
|
|
test("the chip carries a down-arrow and opens a menu of every item", async ({
|
|
page,
|
|
}) => {
|
|
await theDemo(page);
|
|
await expect(chip(page)).toHaveText(/Quarter = Q1 \(1\/4\)/);
|
|
// A <button> and not a <span>: the affordance is the element as much as
|
|
// the glyph, and a Filter axis gets neither.
|
|
await expect(chip(page)).toHaveAttribute("aria-haspopup", "listbox");
|
|
await expect(chip(page).locator(".page-arrow")).toHaveText("▾");
|
|
|
|
await expect(menu(page)).toBeHidden();
|
|
await chip(page).click();
|
|
await expect(menu(page)).toBeVisible();
|
|
await expect(menu(page).locator(".page-item")).toHaveText([
|
|
"✓ Q1",
|
|
" Q2",
|
|
" Q3",
|
|
" Q4",
|
|
]);
|
|
// Which item the document is fixed to is MARKED, so the menu says where
|
|
// you are as well as where you may go.
|
|
await expect(menu(page).locator(".page-item.selected")).toHaveText("✓ Q1");
|
|
});
|
|
|
|
// The whole point: the picked item reaches the host and the grid it filters
|
|
// changes. Nothing on the page moves the bar itself.
|
|
test("picking an item moves the page axis and the numbers under it", async ({
|
|
page,
|
|
}) => {
|
|
await theDemo(page);
|
|
const firstCell = page.locator("#grid td").first();
|
|
const q1 = await firstCell.textContent();
|
|
|
|
await chip(page).click();
|
|
await menu(page).getByText("Q4", { exact: false }).click();
|
|
|
|
await expect(chip(page)).toHaveText(/Quarter = Q4 \(4\/4\)/);
|
|
await expect(menu(page)).toBeHidden();
|
|
// Espresso Revenue is 18,400 in Q1 and 20,300 in Q4, so a menu that moved
|
|
// only the label would leave this unchanged.
|
|
await expect(firstCell).not.toHaveText(q1 ?? "");
|
|
await expect(firstCell).toHaveText("20,300");
|
|
});
|
|
|
|
// The menu is operable without a mouse, and while it is open it owns the
|
|
// keyboard: a grid key must not reach the grid behind it.
|
|
test("the menu is driven by the keyboard and does not leak keys to the grid", async ({
|
|
page,
|
|
}) => {
|
|
await theDemo(page);
|
|
await chip(page).click();
|
|
await expect(menu(page)).toBeVisible();
|
|
|
|
// `j` is the grid's own "move down" and here it moves the MENU. If it
|
|
// leaked, the selection under the menu would move as well.
|
|
const selected = page.locator("#grid td.selected");
|
|
const before = await selected.textContent();
|
|
await page.keyboard.press("j");
|
|
await expect(menu(page).locator(".page-item.active")).toHaveText(" Q2");
|
|
await expect(selected).toHaveText(before ?? "");
|
|
|
|
await page.keyboard.press("Enter");
|
|
await expect(menu(page)).toBeHidden();
|
|
await expect(chip(page)).toHaveText(/Quarter = Q2 \(2\/4\)/);
|
|
});
|
|
|
|
test("Escape closes the menu and changes nothing", async ({ page }) => {
|
|
await theDemo(page);
|
|
await chip(page).click();
|
|
await expect(menu(page)).toBeVisible();
|
|
await page.keyboard.press("Escape");
|
|
await expect(menu(page)).toBeHidden();
|
|
await expect(chip(page)).toHaveText(/Quarter = Q1 \(1\/4\)/);
|
|
// And the keyboard is the grid's again: `]` is the page key, so the bar
|
|
// moving here proves the menu gave it back.
|
|
await page.keyboard.press("]");
|
|
await expect(chip(page)).toHaveText(/Quarter = Q2 \(2\/4\)/);
|
|
});
|
|
});
|
|
|
|
test.describe("the page keys follow the document, not the grid", () => {
|
|
// improvise-wiew, end to end: `[`/`]` are on the layer between the global
|
|
// and the modes whose subject IS the document, so a panel pages too.
|
|
test("a panel is still somewhere the page axis can be moved", async ({ page }) => {
|
|
await theDemo(page);
|
|
await expect(chip(page)).toHaveText(/Quarter = Q1/);
|
|
|
|
await page.keyboard.press("C");
|
|
await expect(page.locator("#mode")).toHaveText("CATEGORIES");
|
|
await page.keyboard.press("]");
|
|
await expect(chip(page)).toHaveText(/Quarter = Q2 \(2\/4\)/);
|
|
// The key did its own job and no other: the panel is still open.
|
|
await expect(page.locator("#mode")).toHaveText("CATEGORIES");
|
|
});
|
|
|
|
// …and the help overlay is not, because it covers the grid it would be
|
|
// changing. That is the one case the layer exists to exclude.
|
|
test("the help overlay pages nothing behind itself", async ({ page }) => {
|
|
await theDemo(page);
|
|
await page.keyboard.press("?");
|
|
await expect(page.locator("#help")).toBeVisible();
|
|
await page.keyboard.press("]");
|
|
await expect(page.locator("#help")).toBeVisible();
|
|
await expect(chip(page)).toHaveText(/Quarter = Q1 \(1\/4\)/);
|
|
});
|
|
});
|