feat: the page bar becomes a control, and [/] reach the modes about the document
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
This commit is contained in:
+74
-4
@@ -1630,10 +1630,80 @@ now spelled twice — `src/ui/grid.rs` and `web/shell.js::gridPlan` — which fa
|
||||
the `indicator()` precedent (the collapse glyph is spelled in both ends while
|
||||
the *fact* is shared) and because the alternative is a protocol-level string
|
||||
table for one word; `invariant(a_ui_string_is_keyed_by_a_command_never_by_a_key)`
|
||||
is where it belongs if a second such string ever appears. And the page bar is
|
||||
**display-only**: it shows the cycle position so `[`/`]` have an affordance, but
|
||||
clicking an entry does nothing, because composing a page change from a click
|
||||
needs a `click_page` on the wasm surface that does not exist yet.
|
||||
is where it belongs if a second such string ever appears.
|
||||
|
||||
### The page bar is a control (improvise-rw6l)
|
||||
|
||||
It was display-only until improvise-rw6l, and this file said so, naming the
|
||||
missing `click_page` on the wasm surface as the reason. That is now built, and
|
||||
it cost less than the note implied: `PageAxis::items` was already projected and
|
||||
`ModelCommand::SetPageSelection` was already on the wire. The only gap was that
|
||||
`RenderPageAxis` **dropped the names** — it carried a position and a count,
|
||||
which is everything improvise-p1ym needed for `[`/`]` and nothing a menu needs.
|
||||
|
||||
**The widening puts `items` inside `cycle`, not beside it.** That is the whole
|
||||
design: one predicate, not two. A Filter axis has no `cycle`, so it has no
|
||||
items, no down-arrow, is painted as a `<span>` rather than a `<button>`, and
|
||||
composes nothing if a click somehow reaches it — four answers off the one home
|
||||
gotcha 27 already named. The tempting alternative, a separate `items` field on
|
||||
`RenderPageAxis`, gives the page a second thing to test and a way to disagree
|
||||
with itself.
|
||||
|
||||
**The arrow follows `navigable`, never "is this on the page bar".** A Filter
|
||||
axis *is* on the bar and must get no arrow: offering a menu that cannot do
|
||||
anything is worse than offering none, because the user spends a click finding
|
||||
out. A fixture with only a Page axis cannot see that distinction, which is why
|
||||
every test of it — Rust, `node --test`, and the two in the harness — carries a
|
||||
Filter axis beside the Page one wherever the shape allows.
|
||||
|
||||
**The menu lives outside `#pages`**, in its own `#page-menu` element. `#pages`
|
||||
has its children replaced on every repaint, and a menu rebuilt under the pointer
|
||||
is a menu that closes itself mid-click. Being outside also means a repaint is
|
||||
free to *close* it explicitly, which is the honest answer: the chip it was
|
||||
anchored to has just been rebuilt, and re-anchoring would be a guess.
|
||||
|
||||
**Two surfaces, one meaning.** improvise-b8ot's ruling is one delegated handler
|
||||
per surface on a stable root, operands off data attributes, no structural
|
||||
inference — so the chip carries `data-axis` and a menu row carries `data-axis`
|
||||
*and* `data-item`, and `shell.js::pageItemAction` is one function over both.
|
||||
That single attribute is the entire disambiguation. The case that has to be got
|
||||
right is an **empty** `data-item`: `Number("")` is `0`, so a naive read turns
|
||||
"no item" into "item zero", and clicking the chip of an axis already on its
|
||||
first item would silently re-pick it instead of opening anything.
|
||||
|
||||
**The menu owns the keyboard while it is open, which is the third state of the
|
||||
kind `editing` already was.** `app.js`'s window `keydown` handler returns early
|
||||
for `editing`; it now returns early for `pageMenu` too, into `menuKey`. That is
|
||||
the same mechanism rather than a new one, and the reason is specific: the menu
|
||||
hangs over the grid, so letting `hjkl` through would scroll the thing it is
|
||||
pointing at out from under it. `menuKey` therefore answers **every** key, with
|
||||
`ignore` as the default — an unlisted key is swallowed, not passed on — and
|
||||
`count: 0` closes rather than wrapping modulo zero, because a repaint can empty
|
||||
an axis under an open menu.
|
||||
|
||||
`active` and `selected` are two different facts and both are drawn: `selected`
|
||||
is what the *document* is fixed to (a check mark), `active` is where the
|
||||
keyboard is. They start together and part on the first arrow key.
|
||||
|
||||
### The tile bar says "Page", not "Pag" (improvise-771e)
|
||||
|
||||
`Axis::short_label` returns three characters so the TUI's tile bar aligns in a
|
||||
fixed-width terminal (gotcha 26); `"·"` for `None` is an answer there and not a
|
||||
truncation. A browser has no column to fit, so the abbreviation buys nothing and
|
||||
reads to a user as a rendering defect — it was spotted in PR #120's screenshots
|
||||
as "Quarter Pag". `render.rs` now asks `plain_label`, which is the other
|
||||
vocabulary already on `Axis`: the decision stays in the shared layer and only
|
||||
the *choice* of vocabulary is local, exactly the `AxisColor → ratatui::Color`
|
||||
shape §5 blesses. `short_label` is untouched, because the TUI's width is
|
||||
load-bearing.
|
||||
|
||||
**Where the truncation was actually pinned is the useful part.** The ticket says
|
||||
to check `tests/grid_frame_agreement.rs` first, in case it fixes the tile bar's
|
||||
vocabulary. It does not mention the tile bar at all. What held "Quarter Pag" in
|
||||
place was `harness/tests/drill.spec.js`, which asserts the tile's text exactly —
|
||||
a Playwright spec in a directory the ticket never names, asserting the bug as
|
||||
its expected value. A rendered string is pinned wherever anything reads the
|
||||
screen, so the search is for the *string*, not for the function that makes it.
|
||||
|
||||
### The keyboard
|
||||
|
||||
|
||||
@@ -1487,6 +1487,69 @@ agreement, and it is what `no_mode_binds_a_panel_toggle_of_its_own` now keeps.
|
||||
is the thing being removed, and two equal-but-separate globals would satisfy
|
||||
any value comparison.
|
||||
|
||||
### The layer below it: `document_keymap` (improvise-wiew)
|
||||
|
||||
The global above is **all seventeen modes**, and that answer is unchanged. What
|
||||
changed is that a second shared layer now sits between it and some of them.
|
||||
|
||||
The user asked for `[`/`]` "probably in the global keymap", and then answered
|
||||
the question that left open himself: *"the help display (and import wizard,
|
||||
etc.) shouldn't respond to these keys, but the 'normal' modes like
|
||||
categories/tiles/formulas/etc. should."*
|
||||
|
||||
The distinction is **is the document the subject right now**, and it is genuinely
|
||||
a new one rather than the global's argument applied again. Every key the global
|
||||
binds — `?`, `:`, the three panel toggles — is a **UI** action: it opens a room,
|
||||
it does not change the document. `page-next`/`page-prev` resolve to
|
||||
`ModelCommand::SetPageSelection`, which is `EffectKind::Model(LayoutGeometry)`.
|
||||
They are the first shared keys that *mutate*, and under the Help overlay that is
|
||||
a mutation the user cannot see, because the overlay is covering the grid it
|
||||
just moved.
|
||||
|
||||
So the chain is three deep:
|
||||
|
||||
```
|
||||
global ? : and the panel toggles — every mode
|
||||
└── document [ ] — Normal, the three
|
||||
├── Normal panels, TileSelect; RecordsNormal
|
||||
│ └── RecordsNormal through Normal
|
||||
├── FormulaPanel / CategoryPanel / ViewPanel
|
||||
└── TileSelect
|
||||
├── Help global only
|
||||
├── ImportWizard global only
|
||||
└── the text-entry modes and SearchMode, unchanged
|
||||
```
|
||||
|
||||
**The name is the guard.** Nothing in the code stops the next person parenting
|
||||
Help to this layer; what stops them is that it is called `document_keymap` and
|
||||
not `common` or `shared`, so its name answers "should my new mode inherit it?"
|
||||
A name describing *who* inherits a layer invites everything to join, and the
|
||||
distinction dies within two waves.
|
||||
|
||||
**RecordsNormal inherits it once, through Normal.** It already parents Normal,
|
||||
so it reaches the layer transitively; parenting it directly as well would be a
|
||||
second edge with nothing keeping the two in step. That is asserted on the chain
|
||||
identity rather than on behaviour, because a duplicated edge answers `page-next`
|
||||
correctly on the day it is added and only diverges later.
|
||||
|
||||
**The layer owes the same Char-only constraint as the global**, and for the
|
||||
identical reason: it is reached by *fallthrough*, so a non-Char binding on it
|
||||
would fire while a descendant's `AnyChar` was typing. No descendant binds
|
||||
`AnyChar` today — which is exactly why the constraint is written down as a test
|
||||
rather than left to the arrangement. An unconstrained shared layer is where the
|
||||
next "just put it in the shared one" lands.
|
||||
|
||||
**Two things that cost nothing and were checked anyway.** Text entry is
|
||||
untouched: the fallback order is `exact → Char(NONE) → AnyChar → Any → parent`,
|
||||
so a text-entry mode's `AnyChar` answers `[` before any parent is consulted, and
|
||||
typing a bracket into a category name still types it. And `prefix_keymaps_are_flat`
|
||||
is about the *sub-keymaps a prefix opens*, not about inheritance depth, so a
|
||||
third level of parents violates nothing it asserts.
|
||||
|
||||
The browser follows for free: `resolve.rs` dispatches on the command **name**,
|
||||
so every mode that now reaches `page-next` composes it in a browser too, with no
|
||||
second table to update. That is the whole point of one shared keymap.
|
||||
|
||||
### `TextEntry` — the spine, welded
|
||||
|
||||
Every text-entry mode has the same spine: characters append to one named
|
||||
|
||||
@@ -3362,3 +3362,99 @@ pin the palette, which is not the property.
|
||||
Inject the fault by moving the `td:not(.editable)` block back below
|
||||
`td.error`, or by restoring either `color: var(--fg)`; each makes exactly the
|
||||
corresponding assertion fail.
|
||||
|
||||
## The page bar as a control (improvise-rw6l, improvise-771e)
|
||||
|
||||
The user, pointing at the deployed page: *"The page items here (e.g. Quarter)
|
||||
should open a menu when clicked so you can pick a different option. Also have a
|
||||
down-arrow indicating there's a menu."*
|
||||
|
||||
Almost nothing had to be derived for it. `PageAxis::items` was already
|
||||
projected, `ModelCommand::SetPageSelection` was already on the wire, and the one
|
||||
gap was that `RenderPageAxis` **threw the names away**: it kept a position and a
|
||||
count, which is all improvise-p1ym needed for `[`/`]`. Widening
|
||||
`RenderPageCycle` with `items` is a pass-through, and the items sit *inside*
|
||||
`cycle` rather than beside it so there is exactly one predicate — a Filter axis
|
||||
has no `cycle`, therefore no arrow, no menu and no items, all off the one home
|
||||
`PageAxis::cycle` already was (gotcha 27).
|
||||
|
||||
### render::tests::a_navigable_page_axis_relays_the_items_it_may_be_fixed_to
|
||||
|
||||
Fault injected: `items: Vec::new()` in place of `items.to_vec()`. The menu then
|
||||
paints empty on a real document and nothing in the browser can see it, because
|
||||
the down-arrow and the chip's `<button>`-ness both follow `cycle`, not the item
|
||||
count. The Filter row in the same test is what stops the fix being "just always
|
||||
send `axis.items`".
|
||||
|
||||
### render::tests::a_page_tile_reads_as_the_whole_word
|
||||
|
||||
improvise-771e: the browser tile bar drew "Quarter Pag". `Axis::short_label`
|
||||
returns three characters so the TUI's tile bar aligns in a fixed-width terminal
|
||||
(gotcha 26); a browser has no column to fit, so the abbreviation buys nothing
|
||||
and reads as a rendering defect. `plain_label` is the other vocabulary already
|
||||
on `Axis`, so the decision stays in the shared layer and only the *choice* of
|
||||
vocabulary is local — the `AxisColor → ratatui::Color` shape §5 blesses.
|
||||
|
||||
**The fixture is the whole test.** The two vocabularies differ on exactly two
|
||||
of five axes — `Page` ("Pag") and `None` ("·") — so `client_with_panels`, whose
|
||||
tiles are `Row` and `None`, could see the `None` half and **not** the `Page`
|
||||
half the ticket is about. This test projects a `Page` tile and a `Column` one
|
||||
beside it: the second is what says this is a change of vocabulary rather than a
|
||||
rewrite, since `Col` is spelled the same either way.
|
||||
|
||||
**What actually pinned the truncation was not where the ticket said to look.**
|
||||
improvise-771e says to check `tests/grid_frame_agreement.rs` before changing
|
||||
anything. It does not mention the tile bar at all. The assertion that held
|
||||
"Quarter Pag" in place was `harness/tests/drill.spec.js`, which asserts the tile
|
||||
text exactly, twice — a Playwright spec, in a directory the ticket never names,
|
||||
asserting the bug as the expected value. The lesson is the search, not the file:
|
||||
a *rendered string* is pinned wherever anything reads the screen, so the grep is
|
||||
for the string, not for the function that produces it.
|
||||
|
||||
### a page axis offers its items, and a filter axis offers none
|
||||
|
||||
`web/tests/dispatch.test.js`. `pageEntries` relays `cycle.items` and gives a
|
||||
Filter axis `[]`. Fault injected: `items: axis.cycle ? ... : axis.items`, i.e.
|
||||
falling back to the raw list — the Filter row then offers items nothing can act
|
||||
on, which is the "a menu that cannot do anything" case the ticket names as
|
||||
worse than no menu at all.
|
||||
|
||||
### a chip opens the menu and a menu row picks, one attribute apart
|
||||
|
||||
improvise-b8ot's shape: one delegated handler per surface on a stable root,
|
||||
operands off data attributes, and **no structural inference**. A chip carries
|
||||
`data-axis`; a menu row carries `data-axis` *and* `data-item`. That single
|
||||
difference is the whole disambiguation, so `pageItemAction` is one function over
|
||||
both surfaces and `app.js` registers the same listener on each.
|
||||
|
||||
The row worth having is `{ axis: "0", item: "" }` → `open`. `Number("")` is `0`,
|
||||
so a naive read turns an absent item into item zero — which picks Q1 whenever
|
||||
the user clicks the chip for an axis already on Q1, i.e. silently does nothing,
|
||||
which is indistinguishable from the menu failing to open.
|
||||
|
||||
### the menu answers its own keys and swallows the rest
|
||||
|
||||
The menu is the **third** state of the kind `editing` already was: `app.js`'s
|
||||
window `keydown` handler returns early for it. That is the same treatment and
|
||||
not a new mechanism, and the reason is the same — the menu is anchored to a
|
||||
chip above the grid, so `hjkl` reaching the grid would scroll the thing the menu
|
||||
is pointing at out from under it.
|
||||
|
||||
`menuKey` therefore answers **every** key, with `ignore` as the default rather
|
||||
than a fallthrough: an unlisted key is swallowed, not passed on. The
|
||||
`count: 0` row is a real state, not defensive padding — a repaint can empty the
|
||||
axis under an open menu — and it closes rather than wrapping modulo zero.
|
||||
|
||||
### the menu is driven by the keyboard and does not leak keys to the grid
|
||||
|
||||
`harness/tests/pages.spec.js`, and the assertion that earns the slow test is
|
||||
the **negative** one: `j` moves the menu's active row *and* leaves
|
||||
`#grid td.selected` where it was. A spec that only checked the menu moved would
|
||||
pass with the key leaking, because both handlers would have run.
|
||||
|
||||
### the help overlay pages nothing behind itself
|
||||
|
||||
improvise-wiew's product call, executed. The overlay is open, `]` is pressed,
|
||||
and the page bar still reads `Quarter = Q1 (1/4)`. Its sibling — a panel open,
|
||||
`]` pressed, bar moves to Q2 *and the panel is still open* — is what stops the
|
||||
first one passing because the key does nothing anywhere.
|
||||
|
||||
@@ -821,3 +821,90 @@ Faults injected against the fixed code:
|
||||
duplication improvise-636d removes, and it is invisible to every check that
|
||||
asks what a key *does*.
|
||||
|
||||
|
||||
## `src/keymap.rs` — the layer between the global and the modes (improvise-wiew)
|
||||
|
||||
The user asked for `[`/`]` "probably in the global keymap", and then ruled on
|
||||
the question that ask left open: *"maybe we need an intermediate keymap: the
|
||||
help display (and import wizard, etc.) shouldn't respond to these keys, but the
|
||||
'normal' modes like categories/tiles/formulas/etc. should."*
|
||||
|
||||
So the distinction is **is the document the subject right now**, and it is a new
|
||||
distinction rather than the global's. Every key the global already binds — `?`,
|
||||
`:`, the three panel toggles — is a **UI** action. `page-next`/`page-prev`
|
||||
resolve to `ModelCommand::SetPageSelection`, which is
|
||||
`EffectKind::Model(LayoutGeometry)`: the first key on a shared layer that
|
||||
*changes the document*. Under the Help overlay that is a real mutation the user
|
||||
cannot see, because the overlay is covering the grid it just moved.
|
||||
|
||||
`document_keymap` is therefore a third level: `global → document → {Normal, the
|
||||
three panels, TileSelect}`, with Help, ImportWizard and the text-entry modes
|
||||
taking `global` directly. RecordsNormal reaches it **through Normal**, which it
|
||||
already parents — parenting it directly as well would be a second edge to keep
|
||||
in step, and `records_normal_inherits_the_document_layer_once_through_normal`
|
||||
asserts the chain is exactly four long.
|
||||
|
||||
Two things about the name. It is `document_keymap` and not `common`/`shared`
|
||||
deliberately: the name is the only thing that tells the next person whether a
|
||||
new mode should parent it, and a name that describes *who inherits it* invites
|
||||
everything to join. And the layer is **derived** in every test off
|
||||
`Normal.parent()` rather than exported, so a test cannot restate a membership
|
||||
the table disagrees with.
|
||||
|
||||
### the_page_keys_reach_the_document_modes_and_no_overlay
|
||||
|
||||
The six document modes answer `]`/`[` with `page-next`/`page-prev`; Help
|
||||
resolves **neither at all** (`lookup` is `None`, not "shadowed into something
|
||||
harmless"), ImportWizard answers `handle-wizard-key` through its `Any`, and
|
||||
CategoryAdd answers `append-char` — so typing a bracket into a name still types
|
||||
it, which is the fallback order (`AnyChar` before the parent) doing its job.
|
||||
|
||||
Fault injected: parenting Help to `document_keymap` instead of to `global`.
|
||||
`]` in Help then resolves to `page-next` and the test fails. Note the assertion
|
||||
that discriminates is the Help one — the six positive rows pass either way.
|
||||
|
||||
### records_normal_inherits_the_document_layer_once_through_normal
|
||||
|
||||
Fault injected: `Keymap::with_parent(document.clone())` for RecordsNormal in
|
||||
place of `with_parent(normal)`. The test above **still passes** — RecordsNormal
|
||||
answers `page-next` either way — and this one fails on the parent identity.
|
||||
That pair is the reason it is a separate test: a duplicated inheritance edge is
|
||||
invisible to every behavioural assertion on the day it is added, and only shows
|
||||
up the first time Normal and the layer disagree.
|
||||
|
||||
### the_document_layer_binds_only_unmodified_char_keys
|
||||
|
||||
The sibling of `the_global_binds_only_unmodified_char_keys`, and it is owed for
|
||||
exactly the same reason: the layer is reached by **fallthrough**, so a non-Char
|
||||
binding on it would fire while a descendant mode's `AnyChar` was typing. No
|
||||
descendant binds `AnyChar` today, which is precisely why the constraint has to
|
||||
be written down rather than relied on — an unconstrained new layer is where the
|
||||
next "just put it in the shared one" lands.
|
||||
|
||||
### resolve::tests::picking_a_page_item_moves_that_axis_and_only_that_axis
|
||||
|
||||
improvise-rw6l. `Click::PageItem { axis, item }` is the browser's "the user
|
||||
picked straight off the bar", and the property that separates it from `]` is
|
||||
that it names **one** axis: `page_cycle` is an odometer that restates every
|
||||
navigable axis and carries between them, so on a two-axis fixture `]` from
|
||||
`Year=2024, Quarter=Q4` emits both `Year=2025` and `Quarter=Q1`. The click emits
|
||||
`Quarter=Q1` alone. A fixture with one page axis cannot tell the two apart.
|
||||
|
||||
The basis it claims is `state.basis.claim([])` — the same claim `page_cycle`
|
||||
makes, because `SetPageSelection` names no record (gotcha 43). It is written as
|
||||
that call rather than as the `published()` fixture so the test says *which*
|
||||
claim it means; the fixture carries a record tier this command must not state.
|
||||
|
||||
### resolve::tests::a_filter_axis_offers_no_item_to_pick
|
||||
|
||||
Gotcha 27, from the click side. A Filter axis is on the page bar and carries no
|
||||
items, so `PageAxis::cycle` is `None` and the click composes nothing — the same
|
||||
one home the arrow and the menu follow. The two out-of-range rows beside it
|
||||
(an axis past the end of the bar, an item past the end of the list) are there
|
||||
because the page names both by index, so a stale bar under a repaint is the
|
||||
realistic way either arrives.
|
||||
|
||||
Fault injected: resolving `items` off `axis.items` directly instead of through
|
||||
`cycle()`. The Filter arm then indexes an empty `Some(vec![])` for a Page axis
|
||||
with no items — and, more to the point, the `items.is_some()` spelling is the
|
||||
one gotcha 27 says is *not* the split.
|
||||
|
||||
@@ -831,6 +831,114 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The layer between the global and the modes whose subject is the
|
||||
/// document. Derived off Normal's parent, never restated (improvise-wiew).
|
||||
fn document_layer(ks: &KeymapSet) -> Arc<Keymap> {
|
||||
ks.maps()
|
||||
.get(&ModeKey::Normal)
|
||||
.expect("Normal has a keymap")
|
||||
.parent()
|
||||
.expect("Normal inherits the document layer")
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn chain_of(km: &Arc<Keymap>) -> Vec<Arc<Keymap>> {
|
||||
let mut chain = vec![km.clone()];
|
||||
while let Some(parent) = chain.last().expect("non-empty").parent() {
|
||||
chain.push(parent.clone());
|
||||
}
|
||||
chain
|
||||
}
|
||||
|
||||
/// improvise-wiew: `[`/`]` page the DOCUMENT, so they reach every mode
|
||||
/// whose subject IS the document — and no overlay with one of its own.
|
||||
#[test]
|
||||
fn the_page_keys_reach_the_document_modes_and_no_overlay() {
|
||||
let ks = KeymapSet::default_keymaps();
|
||||
for mode in [
|
||||
ModeKey::Normal,
|
||||
ModeKey::RecordsNormal,
|
||||
ModeKey::CategoryPanel,
|
||||
ModeKey::ViewPanel,
|
||||
ModeKey::FormulaPanel,
|
||||
ModeKey::TileSelect,
|
||||
] {
|
||||
assert_eq!(
|
||||
bound(&ks, &mode, KeyCode::Char(']')).0,
|
||||
"page-next",
|
||||
"{mode:?} is about the document, so it pages"
|
||||
);
|
||||
assert_eq!(bound(&ks, &mode, KeyCode::Char('[')).0, "page-prev");
|
||||
}
|
||||
|
||||
// Help draws over the grid it would be mutating, so the keys must not
|
||||
// reach it at all — not merely be shadowed into something harmless.
|
||||
let help = ks.maps().get(&ModeKey::Help).expect("Help has a keymap");
|
||||
for key in [KeyCode::Char('['), KeyCode::Char(']')] {
|
||||
assert!(
|
||||
help.lookup(key, KeyModifiers::NONE).is_none(),
|
||||
"{key:?} resolves in Help; it would page the document under the overlay"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
bound(&ks, &ModeKey::ImportWizard, KeyCode::Char(']')).0,
|
||||
"handle-wizard-key",
|
||||
"the wizard swallows everything, which is its own subject answering"
|
||||
);
|
||||
assert_eq!(
|
||||
bound(&ks, &ModeKey::CategoryAdd, KeyCode::Char('[')).0,
|
||||
"append-char",
|
||||
"and typing a bracket into a name still types it"
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-wiew: RecordsNormal reaches the layer THROUGH Normal. Parented
|
||||
/// directly as well it would inherit twice and drift on the next change.
|
||||
#[test]
|
||||
fn records_normal_inherits_the_document_layer_once_through_normal() {
|
||||
let ks = KeymapSet::default_keymaps();
|
||||
let document = document_layer(&ks);
|
||||
let normal = ks.maps().get(&ModeKey::Normal).unwrap().clone();
|
||||
let rn = ks.maps().get(&ModeKey::RecordsNormal).unwrap();
|
||||
assert!(
|
||||
Arc::ptr_eq(rn.parent().expect("RecordsNormal has a parent"), &normal),
|
||||
"RecordsNormal must parent Normal, not the layer directly"
|
||||
);
|
||||
let chain = chain_of(rn);
|
||||
assert_eq!(
|
||||
chain.iter().filter(|km| Arc::ptr_eq(km, &document)).count(),
|
||||
1,
|
||||
"the layer appears exactly once in RecordsNormal's chain"
|
||||
);
|
||||
assert_eq!(chain.len(), 4, "rn -> normal -> document -> global");
|
||||
}
|
||||
|
||||
/// The layer is reached by fallthrough exactly as the global is, so it owes
|
||||
/// the same shape — improvise-636d's argument, one level down.
|
||||
#[test]
|
||||
fn the_document_layer_binds_only_unmodified_char_keys() {
|
||||
let ks = KeymapSet::default_keymaps();
|
||||
let document = document_layer(&ks);
|
||||
assert!(
|
||||
!Arc::ptr_eq(&document, &root_of(&document)),
|
||||
"the layer is not the global: it must have the global as its parent"
|
||||
);
|
||||
for (pattern, binding) in document.bindings() {
|
||||
let KeyPattern::Key(code, mods) = pattern else {
|
||||
panic!("the layer binds {pattern:?}; only exact Char keys are shadowable");
|
||||
};
|
||||
assert!(
|
||||
matches!(code, KeyCode::Char(_)),
|
||||
"the layer binds {code:?}, which AnyChar cannot shadow while typing"
|
||||
);
|
||||
assert_eq!(*mods, KeyModifiers::NONE, "{code:?} is held with {mods:?}");
|
||||
assert!(
|
||||
matches!(binding, Binding::Cmd { .. }),
|
||||
"{code:?}: a prefix here would break prefix_keymaps_are_flat"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The keys the global binds, read off the global rather than restated —
|
||||
/// a second list here would be the duplication improvise-636d removed.
|
||||
fn global_keys(global: &Keymap) -> Vec<KeyCode> {
|
||||
|
||||
@@ -403,6 +403,16 @@ fn global_keymap() -> Keymap {
|
||||
km
|
||||
}
|
||||
|
||||
/// The keys every mode answers **whose subject is the document itself**, and
|
||||
/// no overlay with one of its own: `context/code/protocol.md` §6.
|
||||
fn document_keymap(global: Arc<Keymap>) -> Keymap {
|
||||
let none = KeyModifiers::NONE;
|
||||
let mut km = Keymap::with_parent(global);
|
||||
km.bind(KeyCode::Char(']'), none, "page-next");
|
||||
km.bind(KeyCode::Char('['), none, "page-prev");
|
||||
km
|
||||
}
|
||||
|
||||
/// Bind every panel's toggle key. One call site -- the global -- so a new panel
|
||||
/// reaches every mode from one `Panel::ALL` entry.
|
||||
fn bind_panel_toggles(km: &mut Keymap) {
|
||||
@@ -499,7 +509,10 @@ impl Keymaps {
|
||||
// ── Normal mode ──────────────────────────────────────────────────
|
||||
// The global every mode falls through to (improvise-636d).
|
||||
let global = Arc::new(global_keymap());
|
||||
let mut normal = Keymap::with_parent(global.clone());
|
||||
// The layer between: the modes whose subject is the document. Help
|
||||
// and the wizard take `global` directly — §6 (improvise-wiew).
|
||||
let document = Arc::new(document_keymap(global.clone()));
|
||||
let mut normal = Keymap::with_parent(document.clone());
|
||||
|
||||
// Navigation
|
||||
for (key, dr, dc) in [
|
||||
@@ -611,9 +624,8 @@ impl Keymaps {
|
||||
);
|
||||
normal.bind(KeyCode::Char('N'), none, "search-or-category-add");
|
||||
|
||||
// Page navigation
|
||||
normal.bind(KeyCode::Char(']'), none, "page-next");
|
||||
normal.bind(KeyCode::Char('['), none, "page-prev");
|
||||
// Page navigation is the global's, exactly as `?` and `:` are: a
|
||||
// mode that re-bound it would be the asymmetry §6 removed.
|
||||
|
||||
// Group / hide
|
||||
normal.bind(KeyCode::Char('z'), none, "toggle-group-under-cursor");
|
||||
@@ -709,7 +721,7 @@ impl Keymaps {
|
||||
set.insert(
|
||||
ModeKey::FormulaPanel,
|
||||
Arc::new(
|
||||
PanelKeymap::new(Panel::Formula, global.clone())
|
||||
PanelKeymap::new(Panel::Formula, document.clone())
|
||||
.bind_args(
|
||||
KeyCode::Char('a'),
|
||||
"enter-mode",
|
||||
@@ -733,7 +745,7 @@ impl Keymaps {
|
||||
set.insert(
|
||||
ModeKey::CategoryPanel,
|
||||
Arc::new(
|
||||
PanelKeymap::new(Panel::Category, global.clone())
|
||||
PanelKeymap::new(Panel::Category, document.clone())
|
||||
.bind(KeyCode::Enter, "filter-to-item")
|
||||
.bind(KeyCode::Char(' '), "cycle-axis-at-cursor")
|
||||
.bind_args(
|
||||
@@ -751,7 +763,7 @@ impl Keymaps {
|
||||
set.insert(
|
||||
ModeKey::ViewPanel,
|
||||
Arc::new(
|
||||
PanelKeymap::new(Panel::View, global.clone())
|
||||
PanelKeymap::new(Panel::View, document.clone())
|
||||
.bind(KeyCode::Enter, "switch-view-at-cursor")
|
||||
.bind(KeyCode::Char('n'), "create-and-switch-view")
|
||||
.bind(KeyCode::Char('o'), "create-and-switch-view")
|
||||
@@ -761,7 +773,7 @@ impl Keymaps {
|
||||
);
|
||||
|
||||
// ── Tile select ──────────────────────────────────────────────────
|
||||
let mut ts = Keymap::with_parent(global.clone());
|
||||
let mut ts = Keymap::with_parent(document.clone());
|
||||
ts.bind_args(KeyCode::Esc, none, "enter-mode", vec!["normal".into()]);
|
||||
ts.bind_args(KeyCode::Tab, none, "enter-mode", vec!["normal".into()]);
|
||||
ts.bind_args(KeyCode::Left, none, "move-tile-cursor", vec!["-1".into()]);
|
||||
|
||||
@@ -84,6 +84,9 @@ pub enum Click {
|
||||
Tile(usize),
|
||||
/// A grid cell, in **viewport** coordinates as the page painted it.
|
||||
Cell { row: usize, col: usize },
|
||||
/// An item of a page axis, both named by index: `axis` into
|
||||
/// `GridCache::page_coords`, `item` into that axis's own item list.
|
||||
PageItem { axis: usize, item: usize },
|
||||
}
|
||||
|
||||
/// One rule for all the widgets: **go there, then do what `Enter` does
|
||||
@@ -97,6 +100,7 @@ pub fn click(state: &ClientState, target: Click) -> Vec<Command> {
|
||||
Click::FormulaRow(index) => click_formula_row(state, index),
|
||||
Click::Tile(index) => click_tile(state, index),
|
||||
Click::Cell { row, col } => click_cell(state, row, col),
|
||||
Click::PageItem { axis, item } => click_page_item(state, axis, item),
|
||||
};
|
||||
resolved.unwrap_or_default()
|
||||
}
|
||||
@@ -720,6 +724,22 @@ fn page_cycle(state: &ClientState, way: Odometer) -> Option<Vec<Command>> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The user picked an item off a page axis directly. `PageAxis::cycle` is
|
||||
/// asked, so a Filter axis composes nothing (gotcha 27).
|
||||
fn click_page_item(state: &ClientState, axis: usize, item: usize) -> Option<Vec<Command>> {
|
||||
let page = state.grid.page_coords.get(axis)?;
|
||||
let (items, _) = page.cycle()?;
|
||||
let picked = items.get(item)?;
|
||||
Some(vec![Command::Model {
|
||||
basis: state.basis.claim([])?,
|
||||
cmd: ModelCommand::SetPageSelection {
|
||||
view: state.view.active_view.clone(),
|
||||
category: page.cat.clone(),
|
||||
item: picked.clone(),
|
||||
},
|
||||
}])
|
||||
}
|
||||
|
||||
/// Which way `CommitAndAdvance` moves after writing.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum Advance {
|
||||
@@ -2715,6 +2735,52 @@ mod tests {
|
||||
assert_eq!(moved.len(), 2, "the two Page axes still move: {moved:?}");
|
||||
}
|
||||
|
||||
/// improvise-rw6l: picking straight off the bar, which is what a menu
|
||||
/// does. It names ONE axis, so the odometer's carry must not appear.
|
||||
#[test]
|
||||
fn picking_a_page_item_moves_that_axis_and_only_that_axis() {
|
||||
let state = paged();
|
||||
let commands = click(&state, Click::PageItem { axis: 1, item: 0 });
|
||||
assert_eq!(
|
||||
commands,
|
||||
vec![Command::Model {
|
||||
// The claim `]` makes for the same document: this command
|
||||
// names no record, so the record tier is omitted (gotcha 43).
|
||||
basis: state.basis.claim([]).expect("the basis is published"),
|
||||
cmd: ModelCommand::SetPageSelection {
|
||||
view: state.view.active_view.clone(),
|
||||
category: "Quarter".into(),
|
||||
item: "Q1".into(),
|
||||
},
|
||||
}],
|
||||
"Q4 to Q1 directly: Year is untouched, where `]` would have carried into it"
|
||||
);
|
||||
}
|
||||
|
||||
/// A Filter axis is on the bar and offers nothing to pick (gotcha 27).
|
||||
/// Offering a menu that cannot act is worse than offering none.
|
||||
#[test]
|
||||
fn a_filter_axis_offers_no_item_to_pick() {
|
||||
let mut state = paged();
|
||||
state.grid.page_coords.push(crate::PageAxis {
|
||||
cat: "Region".into(),
|
||||
selected: "East".into(),
|
||||
items: None,
|
||||
});
|
||||
assert!(
|
||||
click(&state, Click::PageItem { axis: 2, item: 0 }).is_empty(),
|
||||
"a Filter axis has no item list, so there is nothing a click could mean"
|
||||
);
|
||||
assert!(
|
||||
click(&state, Click::PageItem { axis: 9, item: 0 }).is_empty(),
|
||||
"and an axis that is not on the bar is not one either"
|
||||
);
|
||||
assert!(
|
||||
click(&state, Click::PageItem { axis: 0, item: 9 }).is_empty(),
|
||||
"nor an item past the end of the list the bar was painted from"
|
||||
);
|
||||
}
|
||||
|
||||
/// Enter in `Editing` is `[commit-cell-edit editing, clear-buffer edit]`
|
||||
/// and every part shows up here — including the odd-looking trailing clear.
|
||||
#[test]
|
||||
|
||||
@@ -139,6 +139,13 @@ impl Client {
|
||||
self.clicked(Click::Tile(index))
|
||||
}
|
||||
|
||||
/// The user picked item `item` off page axis `axis`, both indexing what
|
||||
/// the bar was painted from. A Filter axis composes nothing (gotcha 27).
|
||||
#[wasm_bindgen]
|
||||
pub fn click_page_item(&mut self, axis: usize, item: usize) -> Result<Option<String>, JsError> {
|
||||
self.clicked(Click::PageItem { axis, item })
|
||||
}
|
||||
|
||||
/// The user clicked a grid cell at a **viewport** position: move the
|
||||
/// selection there. Opening an editor is the page's own affair.
|
||||
#[wasm_bindgen]
|
||||
|
||||
@@ -162,12 +162,15 @@ pub struct RenderPageAxis {
|
||||
pub cycle: Option<RenderPageCycle>,
|
||||
}
|
||||
|
||||
/// A position in a navigable page axis, as a named pair: two bare counts read
|
||||
/// as "how many page axes are there".
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize)]
|
||||
/// A position in a navigable page axis, and what it may be moved to. `items`
|
||||
/// is RELAYED, never derived here — `context/code/clients.md`.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
|
||||
pub struct RenderPageCycle {
|
||||
pub at: usize,
|
||||
pub count: usize,
|
||||
/// Every item this axis can be fixed to, in the document's own order, so
|
||||
/// a page may offer them. `count` is `items.len()` and stays for `[`/`]`.
|
||||
pub items: Vec<String>,
|
||||
}
|
||||
|
||||
/// One row of the category panel. `item_count` and `expanded` are a category
|
||||
@@ -206,8 +209,8 @@ pub struct RenderFormulaRow {
|
||||
pub selected: bool,
|
||||
}
|
||||
|
||||
/// One tile: a category the view may move between axes, with `Axis`'s own
|
||||
/// short label (`"·"` for None is an answer, not a truncation — gotcha 26).
|
||||
/// One tile: a category the view may move between axes, with `Axis`'s PLAIN
|
||||
/// label — the three-column one is the TUI's (improvise-771e, clients.md).
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
|
||||
pub struct RenderTile {
|
||||
pub category: String,
|
||||
@@ -330,6 +333,7 @@ fn page_axes(state: &ClientState) -> Vec<RenderPageAxis> {
|
||||
cycle: axis.cycle().map(|(items, at)| RenderPageCycle {
|
||||
at,
|
||||
count: items.len(),
|
||||
items: items.to_vec(),
|
||||
}),
|
||||
})
|
||||
.collect()
|
||||
@@ -383,7 +387,7 @@ fn tile_row(state: &ClientState) -> Vec<RenderTile> {
|
||||
.enumerate()
|
||||
.map(|(i, tile)| RenderTile {
|
||||
category: tile.category.clone(),
|
||||
axis: tile.axis.short_label(),
|
||||
axis: tile.axis.plain_label(),
|
||||
selected: selecting && i == state.view.tile_cat_idx,
|
||||
})
|
||||
.collect()
|
||||
@@ -829,8 +833,8 @@ mod tests {
|
||||
let normal = client.model();
|
||||
assert_eq!(normal.tiles.len(), 2);
|
||||
assert_eq!(
|
||||
normal.tiles[1].axis, "·",
|
||||
"None is an answer, not a truncation"
|
||||
normal.tiles[1].axis, "None",
|
||||
"the browser has no column to fit, so the word is the answer"
|
||||
);
|
||||
assert!(
|
||||
!normal.tiles.iter().any(|t| t.selected),
|
||||
@@ -992,7 +996,11 @@ mod tests {
|
||||
let bar = client.model().page_coords;
|
||||
assert_eq!(
|
||||
bar[0].cycle,
|
||||
Some(RenderPageCycle { at: 2, count: 3 }),
|
||||
Some(RenderPageCycle {
|
||||
at: 2,
|
||||
count: 3,
|
||||
items: vec!["Q1".into(), "Q2".into(), "Q3".into()],
|
||||
}),
|
||||
"a Page axis says where `[`/`]` currently sit"
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -1005,6 +1013,61 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-rw6l: the names were projected and the browser dropped them,
|
||||
/// so the bar could say WHERE the cycle sits and not WHAT it may move to.
|
||||
#[test]
|
||||
fn a_navigable_page_axis_relays_the_items_it_may_be_fixed_to() {
|
||||
let mut client = init();
|
||||
client
|
||||
.state_mut()
|
||||
.fold(&Command::Project(Projection::Grid(GridUpdate::PageCoords(
|
||||
vec![
|
||||
page_axis("Quarter", "Q3", Some(vec!["Q1", "Q2", "Q3"])),
|
||||
page_axis("Region", "North", None),
|
||||
],
|
||||
))));
|
||||
let bar = client.model().page_coords;
|
||||
let cycle = bar[0].cycle.as_ref().expect("a Page axis cycles");
|
||||
assert_eq!(
|
||||
cycle.items,
|
||||
vec!["Q1", "Q2", "Q3"],
|
||||
"the document's own order, relayed — a menu offers these and the page derives none of them"
|
||||
);
|
||||
assert_eq!(cycle.at, 2, "and which of them it is fixed to now");
|
||||
assert!(
|
||||
bar[1].cycle.is_none(),
|
||||
"a Filter axis offers NO items: there is nothing a menu could pick (gotcha 27)"
|
||||
);
|
||||
}
|
||||
|
||||
/// improvise-771e: `short_label` is three columns so the TUI's tile bar
|
||||
/// aligns; a browser has no column to fit and read "Pag" as a bug.
|
||||
#[test]
|
||||
fn a_page_tile_reads_as_the_whole_word() {
|
||||
let mut client = init();
|
||||
client
|
||||
.state_mut()
|
||||
.fold(&Command::Project(Projection::TileBar(vec![
|
||||
TileEntry {
|
||||
category: "Quarter".into(),
|
||||
axis: Axis::Page,
|
||||
},
|
||||
TileEntry {
|
||||
category: "Region".into(),
|
||||
axis: Axis::Column,
|
||||
},
|
||||
])));
|
||||
let tiles = client.model().tiles;
|
||||
assert_eq!(
|
||||
tiles[0].axis, "Page",
|
||||
"the ONE label the two vocabularies disagree on among the visible axes"
|
||||
);
|
||||
assert_eq!(
|
||||
tiles[1].axis, "Col",
|
||||
"and the ones they agree on are unchanged, so this is a choice of vocabulary and not a rewrite"
|
||||
);
|
||||
}
|
||||
|
||||
/// The totals strip is relayed, never summed here: the total runs over
|
||||
/// rows this viewport scrolled past (gotcha 44). improvise-p7lh.
|
||||
#[test]
|
||||
|
||||
@@ -47,7 +47,7 @@ test.describe("every field the render model projects reaches the screen", () =>
|
||||
await expect(page.locator("#status")).toContainText("in-page worker");
|
||||
|
||||
// `tiles`: drawn on every frame, as the TUI draws them. The axis label is
|
||||
// `Axis::short_label` — "Pag", and "·" for None, an answer not a stub.
|
||||
// `Axis::plain_label` here; the three-column one is the TUI's (771e).
|
||||
await expect(page.locator("#tiles .tile")).toHaveCount(5);
|
||||
await expect(page.locator("#tiles .tile").filter({ hasText: "Product" })).toHaveText(
|
||||
"Product Row",
|
||||
@@ -151,7 +151,7 @@ test.describe("every field the render model projects reaches the screen", () =>
|
||||
|
||||
// `web/demo.improv` is `Quarter: page, Q1` — the fact this bead is about.
|
||||
await expect(page.locator("#pages")).toBeVisible();
|
||||
await expect(page.locator("#pages .page-axis")).toHaveText("Quarter = Q1 (1/4)");
|
||||
await expect(page.locator("#pages .page-axis")).toHaveText(/Quarter = Q1 \(1\/4\)/);
|
||||
// Page is navigable where Filter is not (gotcha 27), and that is what
|
||||
// says `[`/`]` will move it.
|
||||
await expect(page.locator("#pages .page-axis")).toHaveClass(/navigable/);
|
||||
@@ -167,7 +167,7 @@ test.describe("every field the render model projects reaches the screen", () =>
|
||||
|
||||
// BOTH move, and the numbers are Q2's: a bar that showed a stale quarter
|
||||
// over fresh numbers is the exact lie this bead names.
|
||||
await expect(page.locator("#pages .page-axis")).toHaveText("Quarter = Q2 (2/4)", {
|
||||
await expect(page.locator("#pages .page-axis")).toHaveText(/Quarter = Q2 \(2\/4\)/, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(page.locator("#grid tfoot td").first()).toHaveText("66,400");
|
||||
|
||||
@@ -17,14 +17,14 @@ async function anAggregateGrid(page) {
|
||||
await page.keyboard.press("Shift+T");
|
||||
const tiles = page.locator("#tiles .tile");
|
||||
const quarter = tiles.filter({ hasText: "Quarter" });
|
||||
await expect(quarter).toHaveText("Quarter Pag");
|
||||
await expect(quarter).toHaveText("Quarter Page");
|
||||
const names = await tiles.allTextContents();
|
||||
for (let i = 0; i < names.findIndex((t) => t.startsWith("Quarter")); i++) {
|
||||
await page.keyboard.press("ArrowRight");
|
||||
}
|
||||
await expect(quarter).toHaveClass(/selected/);
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(quarter).toHaveText("Quarter ·", { timeout: 10_000 });
|
||||
await expect(quarter).toHaveText("Quarter None", { timeout: 10_000 });
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
// The precondition, asserted rather than assumed: nothing is editable now,
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
// 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\)/);
|
||||
});
|
||||
});
|
||||
+136
-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,10 +721,31 @@ 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) {
|
||||
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();
|
||||
}
|
||||
// Ctrl and Alt combinations stay the browser's, exactly as below.
|
||||
if (!e.ctrlKey && !e.altKey) 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.
|
||||
if (editing) return;
|
||||
// …and so does the page menu, which is the third state of that kind: it is
|
||||
// anchored to the grid, so letting `hjkl` through would scroll it away.
|
||||
if (pageMenu) {
|
||||
pageMenuKeydown(e);
|
||||
return;
|
||||
}
|
||||
// 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;
|
||||
|
||||
@@ -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 ──────────────────────────────────────── */
|
||||
|
||||
@@ -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