Merge remote-tracking branch 'gh/main' into fix/e0ex-decimal-default
This commit is contained in:
+140
-11
@@ -17,7 +17,10 @@ import {
|
||||
indexAction,
|
||||
indicator,
|
||||
isFatal,
|
||||
MENU_ARROW,
|
||||
menuKey,
|
||||
NOTICE_KEY,
|
||||
pageItemAction,
|
||||
noticeOpen,
|
||||
noticeState,
|
||||
noticeText,
|
||||
@@ -41,6 +44,7 @@ const el = {
|
||||
views: document.getElementById("views"),
|
||||
tiles: document.getElementById("tiles"),
|
||||
pages: document.getElementById("pages"),
|
||||
pageMenu: document.getElementById("page-menu"),
|
||||
mode: document.getElementById("mode"),
|
||||
minibuffer: document.getElementById("minibuffer"),
|
||||
hint: document.getElementById("hint"),
|
||||
@@ -233,6 +237,9 @@ function repaint() {
|
||||
}
|
||||
if (model.revision === painted) return;
|
||||
painted = model.revision;
|
||||
// The bar is about to be rebuilt, so the chip this menu is anchored to is
|
||||
// gone. Closing is the honest answer; re-anchoring would be a guess.
|
||||
closePageMenu();
|
||||
|
||||
el.mode.textContent = model.mode;
|
||||
// The mode's own hint, off `help::hint_line`. The page spells no keys
|
||||
@@ -328,22 +335,94 @@ function paintHelp(model) {
|
||||
* (improvise-p7lh). An empty list is what says there are none. */
|
||||
function paintPages(model) {
|
||||
const entries = pageEntries(model);
|
||||
pageAxes = entries;
|
||||
el.pages.hidden = entries.length === 0;
|
||||
el.pages.replaceChildren(
|
||||
...entries.map((entry) => {
|
||||
const span = document.createElement("span");
|
||||
span.className = "page-axis";
|
||||
// Only a Page axis is navigable; a Filter one constrains without being
|
||||
// something `[`/`]` can move (gotcha 27).
|
||||
span.classList.toggle("navigable", entry.navigable);
|
||||
span.textContent = entry.position
|
||||
? `${entry.text} (${entry.position})`
|
||||
: entry.text;
|
||||
return span;
|
||||
el.pages.replaceChildren(...entries.map(pageChip));
|
||||
}
|
||||
|
||||
/** One page chip. A navigable axis is a <button> carrying the arrow, because
|
||||
* it opens a menu; a Filter axis is a <span> with neither (improvise-rw6l). */
|
||||
function pageChip(entry) {
|
||||
const label = entry.position
|
||||
? `${entry.text} (${entry.position})`
|
||||
: entry.text;
|
||||
if (!entry.navigable) {
|
||||
// Only a Page axis is navigable; a Filter one constrains without being
|
||||
// something `[`/`]` or a menu can move (gotcha 27).
|
||||
const span = document.createElement("span");
|
||||
span.className = "page-axis";
|
||||
span.textContent = label;
|
||||
return span;
|
||||
}
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "page-axis navigable";
|
||||
button.dataset.axis = String(entry.index);
|
||||
button.setAttribute("aria-haspopup", "listbox");
|
||||
const text = document.createElement("span");
|
||||
text.textContent = label;
|
||||
const arrow = document.createElement("span");
|
||||
arrow.className = "page-arrow";
|
||||
arrow.setAttribute("aria-hidden", "true");
|
||||
arrow.textContent = MENU_ARROW;
|
||||
button.append(text, arrow);
|
||||
return button;
|
||||
}
|
||||
|
||||
// The open page menu: which axis it belongs to and which row the keyboard is
|
||||
// on. `null` is closed, exactly as `editing` is.
|
||||
let pageAxes = [];
|
||||
let pageMenu = null;
|
||||
|
||||
function openPageMenu(axis, anchor) {
|
||||
const entry = pageAxes[axis];
|
||||
// A Filter axis carries no items, so there is nothing to open — the chip is
|
||||
// not a button either, and both answers come off the same `cycle`.
|
||||
if (!entry || entry.items.length === 0) return;
|
||||
const active = entry.items.findIndex((item) => item.selected);
|
||||
pageMenu = { axis, active: active === -1 ? 0 : active };
|
||||
const box = anchor.getBoundingClientRect();
|
||||
el.pageMenu.style.left = `${Math.round(box.left)}px`;
|
||||
el.pageMenu.style.top = `${Math.round(box.bottom + 2)}px`;
|
||||
paintPageMenu();
|
||||
}
|
||||
|
||||
function closePageMenu() {
|
||||
pageMenu = null;
|
||||
el.pageMenu.hidden = true;
|
||||
el.pageMenu.replaceChildren();
|
||||
}
|
||||
|
||||
function paintPageMenu() {
|
||||
if (!pageMenu) return;
|
||||
const entry = pageAxes[pageMenu.axis];
|
||||
el.pageMenu.hidden = false;
|
||||
el.pageMenu.replaceChildren(
|
||||
...entry.items.map((item) => {
|
||||
const row = document.createElement("button");
|
||||
row.type = "button";
|
||||
row.className = "page-item";
|
||||
row.setAttribute("role", "option");
|
||||
// `selected` is which item the DOCUMENT is fixed to; `active` is where
|
||||
// the keyboard is. They start together and part on the first arrow key.
|
||||
row.setAttribute("aria-selected", String(item.selected));
|
||||
row.classList.toggle("selected", item.selected);
|
||||
row.classList.toggle("active", item.item === pageMenu.active);
|
||||
row.dataset.axis = String(entry.index);
|
||||
row.dataset.item = String(item.item);
|
||||
row.textContent = item.selected ? `✓ ${item.label}` : ` ${item.label}`;
|
||||
return row;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Pick an item and close. The value that comes back is the server's: nothing
|
||||
* here moves the page bar, exactly as an editor does not move a cell. */
|
||||
function pickPageItem(axis, item) {
|
||||
closePageMenu();
|
||||
send(client.click_page_item(axis, item));
|
||||
}
|
||||
|
||||
/** The grid, painted from a plan in which every decision is already made
|
||||
* (improvise-anr9, and `shell.js::gridPlan`). Nothing below decides anything:
|
||||
* which labels repeat and are therefore blank, how many header levels there
|
||||
@@ -586,6 +665,31 @@ function delegateIndex(root, selector, act) {
|
||||
});
|
||||
}
|
||||
|
||||
// The page bar and its menu are two surfaces and ONE meaning: an action with
|
||||
// an axis and maybe an item, decided in `shell.js` (improvise-rw6l, b8ot).
|
||||
for (const root of [el.pages, el.pageMenu]) {
|
||||
root.addEventListener("click", (event) => {
|
||||
const node = event.target.closest("[data-axis]");
|
||||
if (!node || !root.contains(node)) return;
|
||||
const action = pageItemAction(node.dataset);
|
||||
if (!action) return;
|
||||
// A live editor closes first, before the click is composed — the order
|
||||
// the grid's own handler keeps, and for the same reason.
|
||||
if (editing) editing.close(false);
|
||||
if (action.kind === "pick") pickPageItem(action.axis, action.item);
|
||||
else if (pageMenu && pageMenu.axis === action.axis) closePageMenu();
|
||||
else openPageMenu(action.axis, node);
|
||||
});
|
||||
}
|
||||
|
||||
// A click anywhere else dismisses it. `pointerdown` rather than `click`, so a
|
||||
// press that lands on the grid closes this before the grid acts on it.
|
||||
window.addEventListener("pointerdown", (event) => {
|
||||
if (!pageMenu) return;
|
||||
if (el.pages.contains(event.target) || el.pageMenu.contains(event.target)) return;
|
||||
closePageMenu();
|
||||
});
|
||||
|
||||
delegateIndex(el.categories, "li[data-index]", (i) => client.click_category(i));
|
||||
delegateIndex(el.views, "li[data-index]", (i) => client.click_view(i));
|
||||
delegateIndex(el.formulas, "li[data-index]", (i) => client.click_formula(i));
|
||||
@@ -617,6 +721,25 @@ el.grid.addEventListener("mousedown", (event) => {
|
||||
// The keyboard. `Client::on_key_event` folds locally where it can; what it
|
||||
// cannot answer is sent and does nothing, by design (context/code/clients.md).
|
||||
|
||||
/** The menu's own keys, decided in `shell.js`. Every one of them is claimed:
|
||||
* an unhandled key must not reach the grid behind the menu. */
|
||||
function pageMenuKeydown(e) {
|
||||
// Ctrl and Alt combinations are the browser's, exactly as below, so the menu
|
||||
// neither acts on one nor claims it — `Ctrl-j` is not `j` (improvise-rw6l).
|
||||
if (e.ctrlKey || e.altKey) return;
|
||||
const items = pageAxes[pageMenu.axis].items;
|
||||
const action = menuKey(e.key, { count: items.length, active: pageMenu.active });
|
||||
if (action.kind === "close") closePageMenu();
|
||||
else if (action.kind === "pick") pickPageItem(pageMenu.axis, action.item);
|
||||
else if (action.kind === "move") {
|
||||
pageMenu.active = action.item;
|
||||
paintPageMenu();
|
||||
}
|
||||
// Every unmodified key is CLAIMED, `ignore` included: the grid is behind
|
||||
// the menu and must not also act on it.
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", (e) => {
|
||||
// The inline editor owns the keyboard while it is open. It stops
|
||||
// propagation, so this is belt and braces for a focus we did not predict.
|
||||
@@ -624,6 +747,12 @@ window.addEventListener("keydown", (e) => {
|
||||
// BrowserKey drops anything held with the platform meta key precisely so
|
||||
// Cmd-R does not arrive as a bare `r`; do not claim it here either.
|
||||
if (e.metaKey) return;
|
||||
// …and the page menu owns the keyboard too, which is the third state of
|
||||
// that kind: `hjkl` reaching the grid would scroll it out from under it.
|
||||
if (pageMenu) {
|
||||
pageMenuKeydown(e);
|
||||
return;
|
||||
}
|
||||
// The notice's <summary> is a real control and owns Enter and Space while
|
||||
// it has focus. Claiming them here would call `preventDefault` on the
|
||||
// browser's own disclosure toggle, so a keyboard visitor could focus it and
|
||||
|
||||
@@ -108,6 +108,13 @@
|
||||
-->
|
||||
<div id="pages" aria-label="Page axes" hidden></div>
|
||||
|
||||
<!--
|
||||
The page menu (improvise-rw6l). It lives OUTSIDE #pages because that
|
||||
element's children are replaced on every repaint, and a menu rebuilt
|
||||
under the pointer is a menu that closes itself.
|
||||
-->
|
||||
<div id="page-menu" role="listbox" aria-label="Page items" hidden></div>
|
||||
|
||||
<main>
|
||||
<aside id="categories" aria-label="Categories"></aside>
|
||||
<aside id="formulas" aria-label="Formulas" hidden></aside>
|
||||
|
||||
+54
-1
@@ -88,7 +88,8 @@ export function frameCostOf(model) {
|
||||
/** The page bar: one entry per category fixing which records are visible. The
|
||||
* Page/Filter split is `cycle`, decided in Rust — gotcha 27, clients.md. */
|
||||
export function pageEntries(model) {
|
||||
return model.page_coords.map((axis) => ({
|
||||
return model.page_coords.map((axis, index) => ({
|
||||
index,
|
||||
// The TUI's own spelling (`src/ui/grid.rs`); only the spelling is local,
|
||||
// exactly as `indicator`'s is.
|
||||
text: `${axis.cat} = ${axis.selected}`,
|
||||
@@ -96,9 +97,61 @@ export function pageEntries(model) {
|
||||
// navigable: there is no position to show and no key that would move it.
|
||||
position: axis.cycle ? `${axis.cycle.at + 1}/${axis.cycle.count}` : "",
|
||||
navigable: Boolean(axis.cycle),
|
||||
// The items a menu offers, RELAYED from `cycle` — the one home of the
|
||||
// Page/Filter split, so a Filter axis offers none (improvise-rw6l).
|
||||
items: axis.cycle
|
||||
? axis.cycle.items.map((label, item) => ({
|
||||
item,
|
||||
label,
|
||||
selected: item === axis.cycle.at,
|
||||
}))
|
||||
: [],
|
||||
}));
|
||||
}
|
||||
|
||||
/** The down-arrow. Present exactly where there is a menu to open, which is
|
||||
* `navigable` and never "is this on the page bar" (gotcha 27). */
|
||||
export const MENU_ARROW = "▾";
|
||||
|
||||
/** What a delegated click on the page bar or its menu MEANS. `null` where the
|
||||
* node names neither, which is every gap between the chips. */
|
||||
export function pageItemAction(dataset) {
|
||||
const axis = whole(dataset.axis);
|
||||
if (axis === null) return null;
|
||||
const item = whole(dataset.item);
|
||||
// A chip carries an axis and no item: it OPENS the menu. A menu row carries
|
||||
// both: it picks. One attribute apart, so nothing infers from structure.
|
||||
return item === null ? { kind: "open", axis } : { kind: "pick", axis, item };
|
||||
}
|
||||
|
||||
/** What a key does while the page menu is open. The menu owns the keyboard
|
||||
* exactly as the inline editor does — `context/code/clients.md`. */
|
||||
export function menuKey(key, { count, active }) {
|
||||
if (count === 0) return { kind: "close" };
|
||||
switch (key) {
|
||||
case "Escape":
|
||||
case "Tab":
|
||||
return { kind: "close" };
|
||||
case "Enter":
|
||||
case " ":
|
||||
return { kind: "pick", item: active };
|
||||
case "ArrowDown":
|
||||
case "j":
|
||||
return { kind: "move", item: (active + 1) % count };
|
||||
case "ArrowUp":
|
||||
case "k":
|
||||
return { kind: "move", item: (active - 1 + count) % count };
|
||||
case "Home":
|
||||
return { kind: "move", item: 0 };
|
||||
case "End":
|
||||
return { kind: "move", item: count - 1 };
|
||||
// Everything else is SWALLOWED rather than passed on: a menu that let
|
||||
// `hjkl` reach the grid would scroll the thing it is anchored to.
|
||||
default:
|
||||
return { kind: "ignore" };
|
||||
}
|
||||
}
|
||||
|
||||
/** The grid, as a description: every decision made, nothing created. The page
|
||||
* decides nothing here and neither does this — which labels repeat, how many
|
||||
* header levels there are and which columns share a group all arrive
|
||||
|
||||
+68
-2
@@ -269,12 +269,78 @@ header h1 {
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
/* A Page axis can be moved with `[`/`]`; a Filter one constrains without
|
||||
being navigable (gotcha 27), which is why only one of them lights up. */
|
||||
/* A Page axis can be moved with `[`/`]` and picked from a menu; a Filter one
|
||||
constrains without being navigable (gotcha 27), so only one of them lights
|
||||
up and only one of them is a button at all. */
|
||||
#pages .navigable {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
display: inline-flex;
|
||||
gap: 0.3rem;
|
||||
align-items: center;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#pages .navigable:hover {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
/* The arrow says a menu is there. It follows `navigable`, never "is this on
|
||||
the page bar" — an arrow over nothing to pick is worse than none. */
|
||||
#pages .page-arrow {
|
||||
font-size: 1.15em;
|
||||
line-height: 1;
|
||||
margin-left: -0.05rem;
|
||||
}
|
||||
|
||||
/* The menu itself. Absolutely positioned from the chip it was opened on, and
|
||||
outside #pages so a repaint cannot rebuild it under the pointer. */
|
||||
#page-menu[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#page-menu {
|
||||
position: fixed;
|
||||
z-index: 20;
|
||||
min-width: 9rem;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
padding: 0.2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--raised);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
#page-menu .page-item {
|
||||
text-align: left;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
background: none;
|
||||
color: var(--fg);
|
||||
font: inherit;
|
||||
font-size: 0.82rem;
|
||||
white-space: pre;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* `selected` is what the document is fixed to; `active` is where the keyboard
|
||||
is. They start together and part on the first arrow key. */
|
||||
#page-menu .page-item.selected {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#page-menu .page-item.active,
|
||||
#page-menu .page-item:hover {
|
||||
background: var(--selected);
|
||||
}
|
||||
|
||||
/* ── The frame: panels and grid ──────────────────────────────────────── */
|
||||
|
||||
@@ -75,6 +75,9 @@ test("the page still reaches the client through a handful of calls", () => {
|
||||
"click_category",
|
||||
"click_cell",
|
||||
"click_formula",
|
||||
// improvise-rw6l: picking an item off a page axis. The page holds no
|
||||
// `Basis`, so composing `SetPageSelection` cannot happen above this.
|
||||
"click_page_item",
|
||||
"click_tile",
|
||||
"click_view",
|
||||
"on_frame",
|
||||
|
||||
@@ -4,7 +4,15 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { cellAction, cellAttrs, gridPlan, indexAction, pageEntries } from "../shell.js";
|
||||
import {
|
||||
cellAction,
|
||||
cellAttrs,
|
||||
gridPlan,
|
||||
indexAction,
|
||||
menuKey,
|
||||
pageEntries,
|
||||
pageItemAction,
|
||||
} from "../shell.js";
|
||||
|
||||
const cell = (text, extra = {}) => ({
|
||||
text,
|
||||
@@ -85,7 +93,13 @@ test("an index surface reads its row back, and nothing else", () => {
|
||||
// moved it in silence. The bar is what says which quarter this is.
|
||||
test("the page bar names the category and the item it is fixed to", () => {
|
||||
const [entry] = pageEntries({
|
||||
page_coords: [{ cat: "Quarter", selected: "Q2", cycle: { at: 1, count: 4 } }],
|
||||
page_coords: [
|
||||
{
|
||||
cat: "Quarter",
|
||||
selected: "Q2",
|
||||
cycle: { at: 1, count: 4, items: ["Q1", "Q2", "Q3", "Q4"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.equal(entry.text, "Quarter = Q2");
|
||||
assert.equal(entry.position, "2/4", "one-based, as a user counts");
|
||||
@@ -106,3 +120,75 @@ test("a filter axis is shown, and is not offered as something to cycle", () => {
|
||||
test("no fixed categories is an empty bar, not a blank one", () => {
|
||||
assert.deepEqual(pageEntries({ page_coords: [] }), []);
|
||||
});
|
||||
|
||||
// improvise-rw6l: the arrow and the menu follow `cycle`, which is the Page /
|
||||
// Filter split decided in Rust — never "is there an entry on the bar".
|
||||
test("a page axis offers its items, and a filter axis offers none", () => {
|
||||
const [page, filter] = pageEntries({
|
||||
page_coords: [
|
||||
{
|
||||
cat: "Quarter",
|
||||
selected: "Q3",
|
||||
cycle: { at: 2, count: 3, items: ["Q1", "Q2", "Q3"] },
|
||||
},
|
||||
{ cat: "Region", selected: "North", cycle: null },
|
||||
],
|
||||
});
|
||||
assert.deepEqual(page.items, [
|
||||
{ item: 0, label: "Q1", selected: false },
|
||||
{ item: 1, label: "Q2", selected: false },
|
||||
{ item: 2, label: "Q3", selected: true },
|
||||
]);
|
||||
assert.equal(page.index, 0, "the axis names itself by its place on the bar");
|
||||
assert.deepEqual(
|
||||
filter.items,
|
||||
[],
|
||||
"a Filter axis constrains without being navigable: nothing to pick",
|
||||
);
|
||||
assert.equal(filter.index, 1);
|
||||
});
|
||||
|
||||
// One meaning across two surfaces (improvise-b8ot): a chip carries an axis, a
|
||||
// menu row carries an axis AND an item. Nothing infers from structure.
|
||||
test("a chip opens the menu and a menu row picks, one attribute apart", () => {
|
||||
assert.deepEqual(pageItemAction({ axis: "1" }), { kind: "open", axis: 1 });
|
||||
assert.deepEqual(pageItemAction({ axis: "1", item: "0" }), {
|
||||
kind: "pick",
|
||||
axis: 1,
|
||||
item: 0,
|
||||
});
|
||||
assert.equal(pageItemAction({}), null, "the gaps between the chips are not a chip");
|
||||
assert.equal(pageItemAction({ axis: "" }), null);
|
||||
assert.equal(pageItemAction({ axis: "-1" }), null);
|
||||
assert.deepEqual(
|
||||
pageItemAction({ axis: "0", item: "" }),
|
||||
{ kind: "open", axis: 0 },
|
||||
"an empty item attribute is NO item, where Number('') would say 0",
|
||||
);
|
||||
});
|
||||
|
||||
// The menu owns the keyboard while it is open, so every key is answered and
|
||||
// nothing falls through to the grid it is anchored to.
|
||||
test("the menu answers its own keys and swallows the rest", () => {
|
||||
const at = (active) => ({ count: 3, active });
|
||||
assert.deepEqual(menuKey("ArrowDown", at(0)), { kind: "move", item: 1 });
|
||||
assert.deepEqual(menuKey("j", at(2)), { kind: "move", item: 0 }, "and it wraps");
|
||||
assert.deepEqual(menuKey("ArrowUp", at(0)), { kind: "move", item: 2 }, "both ways");
|
||||
assert.deepEqual(menuKey("k", at(1)), { kind: "move", item: 0 });
|
||||
assert.deepEqual(menuKey("Home", at(2)), { kind: "move", item: 0 });
|
||||
assert.deepEqual(menuKey("End", at(0)), { kind: "move", item: 2 });
|
||||
assert.deepEqual(menuKey("Enter", at(1)), { kind: "pick", item: 1 });
|
||||
assert.deepEqual(menuKey(" ", at(1)), { kind: "pick", item: 1 });
|
||||
assert.deepEqual(menuKey("Escape", at(1)), { kind: "close" });
|
||||
assert.deepEqual(menuKey("Tab", at(1)), { kind: "close" });
|
||||
assert.deepEqual(
|
||||
menuKey("h", at(1)),
|
||||
{ kind: "ignore" },
|
||||
"a grid key is SWALLOWED, not passed on — the grid is behind the menu",
|
||||
);
|
||||
assert.deepEqual(
|
||||
menuKey("ArrowDown", { count: 0, active: 0 }),
|
||||
{ kind: "close" },
|
||||
"a menu with nothing in it cannot be moved around",
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user