feat: page organizer 1/3 — ContextMenu and SelectableGrid primitives #7

Merged
bjoern merged 3 commits from feat/contextmenu-selectablegrid into main 2026-07-26 13:31:53 +02:00
Member

First slice of Orihon's wizard step-2 page organizer (multi-select thumbnails into chapters): the two dumb primitives it needs, with no app knowledge. The Orihon PRs follow and pin the submodule after this merges.

What's in

  • ContextMenu (+ contextmenu.js) — a cursor-positioned menu. It can't share Menu's <details> idiom (that positions against its own ancestor and owns its open state), so it is controlled (Open/OpenChanged, X/Y from the parent's @oncontextmenu) and interactive-only, following overlay.js's window-namespaced interop shape. The JS side clamps the popup inside the viewport (flips left/up at the edges), focuses the first item, roves ArrowUp/Down/Home/End over [role='menuitem'], and funnels every dismissal — outside press, Esc, window blur, scroll, resize, item choice — through a single CloseFromJs callback so the parent stays the one source of open state. Existing MenuItems work inside it unchanged (their @attributes already pass @onclick through), and the panel CSS mirrors Menu's popup so the two read as one family.
  • SelectableGrid<TItem> — a CSS-grid role=listbox with file-manager selection semantics: click selects one (and raises ItemActivated for a preview), Ctrl+click toggles, Shift+click takes the contiguous range from the anchor (anchor holds across shift-clicks), Ctrl+A / Escape, roving tabindex. Right-click first adopts an unselected tile as the sole selection (native file-manager behavior), then raises ItemContextMenu with the pointer coordinates. Drag-to-reorder carries DragReorderList's index-based mechanics over tile-for-row, with Ctrl+arrow as the keyboard route and the same _refocusKey focus-follows-the-item pattern; a drag always moves the single dragged tile — acting on many at once is deliberately the context menu's job (documented in the header). Controlled twice over: the parent owns Items and SelectedKeys (so it can act on and clear a selection); only the shift anchor and drag state live inside. Tile size themable via --kg-selgrid-tile. No JS — right-click and HTML5 drag are Blazor-native, as DragReorderList proves.

Tests — +18 (5 ContextMenuTests, 13 SelectableGridTests), suite at 253/253 green. ContextMenu pins the component side of the JS contract (closed renders nothing and never touches interop; open renders role=menu at the pointer with its label; open/close interop calls with coordinates; CloseFromJs reports OpenChanged(false)); positioning/clamping/focus/dismissal listeners are contextmenu.js's side, browser-verified from the consuming app. SelectableGrid pins the whole selection matrix (sole-select + activate, Ctrl toggle both directions, Shift range both directions with the anchor holding, right-click adopt-vs-keep with coordinates), drag drop emits the whole new order, not-draggable without a Reordered handler, Ctrl+arrow nudge, Space/Ctrl+A/Escape, listbox/option ARIA with exactly one tab stop, and disabled inertness.

Honest notes: keyboard roving is ±1 in reading order (Up/Down = Left/Right) — the component cannot know the auto-fill column count, and one reading order is what the grid models; documented in the code. No icons added (consumers use existing catalog glyphs).

🤖 Generated with Claude Code

First slice of Orihon's wizard step-2 page organizer (multi-select thumbnails into chapters): the two dumb primitives it needs, with no app knowledge. The Orihon PRs follow and pin the submodule after this merges. **What's in** - `ContextMenu` (+ `contextmenu.js`) — a cursor-positioned menu. It can't share `Menu`'s `<details>` idiom (that positions against its own ancestor and owns its open state), so it is controlled (`Open`/`OpenChanged`, `X`/`Y` from the parent's `@oncontextmenu`) and interactive-only, following `overlay.js`'s window-namespaced interop shape. The JS side clamps the popup inside the viewport (flips left/up at the edges), focuses the first item, roves ArrowUp/Down/Home/End over `[role='menuitem']`, and funnels every dismissal — outside press, Esc, window blur, scroll, resize, item choice — through a single `CloseFromJs` callback so the parent stays the one source of open state. Existing `MenuItem`s work inside it unchanged (their `@attributes` already pass `@onclick` through), and the panel CSS mirrors `Menu`'s popup so the two read as one family. - `SelectableGrid<TItem>` — a CSS-grid `role=listbox` with file-manager selection semantics: click selects one (and raises `ItemActivated` for a preview), Ctrl+click toggles, Shift+click takes the contiguous range from the anchor (anchor holds across shift-clicks), Ctrl+A / Escape, roving tabindex. Right-click first adopts an unselected tile as the sole selection (native file-manager behavior), then raises `ItemContextMenu` with the pointer coordinates. Drag-to-reorder carries `DragReorderList`'s index-based mechanics over tile-for-row, with Ctrl+arrow as the keyboard route and the same `_refocusKey` focus-follows-the-item pattern; a drag always moves the single dragged tile — acting on many at once is deliberately the context menu's job (documented in the header). Controlled twice over: the parent owns `Items` and `SelectedKeys` (so it can act on and clear a selection); only the shift anchor and drag state live inside. Tile size themable via `--kg-selgrid-tile`. No JS — right-click and HTML5 drag are Blazor-native, as `DragReorderList` proves. **Tests** — +18 (5 `ContextMenuTests`, 13 `SelectableGridTests`), suite at 253/253 green. ContextMenu pins the component side of the JS contract (closed renders nothing and never touches interop; open renders `role=menu` at the pointer with its label; open/close interop calls with coordinates; `CloseFromJs` reports `OpenChanged(false)`); positioning/clamping/focus/dismissal listeners are contextmenu.js's side, browser-verified from the consuming app. SelectableGrid pins the whole selection matrix (sole-select + activate, Ctrl toggle both directions, Shift range both directions with the anchor holding, right-click adopt-vs-keep with coordinates), drag drop emits the whole new order, not-draggable without a `Reordered` handler, Ctrl+arrow nudge, Space/Ctrl+A/Escape, listbox/option ARIA with exactly one tab stop, and disabled inertness. Honest notes: keyboard roving is ±1 in reading order (Up/Down = Left/Right) — the component cannot know the auto-fill column count, and one reading order is what the grid models; documented in the code. No icons added (consumers use existing catalog glyphs). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat: ContextMenu and SelectableGrid — cursor menu + multi-select tile grid
All checks were successful
CI / build (pull_request) Successful in 10s
CI / test (pull_request) Successful in 14s
0ee1afd40c
ContextMenu opens at the right-click point, so it cannot share Menu's
<details> idiom; it is interactive-only and follows overlay.js's
window-namespaced interop shape. contextmenu.js clamps the popup inside
the viewport, moves focus in, roves ArrowUp/Down/Home/End, and funnels
every dismissal (outside press, Esc, blur, scroll, resize, item choice)
through one .NET callback. Existing MenuItems work inside it unchanged.

SelectableGrid is a CSS-grid listbox with file-manager selection
(click / Ctrl toggle / Shift range from the anchor), right-click that
first adopts an unselected tile as the sole selection, Ctrl+A / Escape,
and DragReorderList's index-based drag-to-reorder carried over
tile-for-row (plus Ctrl+arrow as the keyboard route). Controlled twice:
the parent owns Items and SelectedKeys; only the anchor and drag state
live inside. A drag moves the single dragged tile — acting on many is
the context menu's job.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Summary

Summary
Generated on: 07/26/2026 - 11:15:25
Coverage date: 07/26/2026 - 11:15:23
Parser: Cobertura
Assemblies: 1
Classes: 56
Files: 56
Line coverage: 94.8% (1185 of 1249)
Covered lines: 1185
Uncovered lines: 64
Coverable lines: 1249
Total lines: 3306
Branch coverage: 88.3% (646 of 731)
Covered branches: 646
Total branches: 731
Method coverage: Feature is only available for sponsors

Coverage

Kagaku.UI - 94.8%
Name Line Branch
Kagaku.UI 94.8% 88.3%
Kagaku.UI.Badge 100% 100%
Kagaku.UI.Breadcrumb 100%
Kagaku.UI.BreadcrumbItem 100% 100%
Kagaku.UI.Button 100% 100%
Kagaku.UI.Card 100% 100%
Kagaku.UI.Combobox`1 93.7% 84.7%
Kagaku.UI.ConfirmDialog 100%
Kagaku.UI.ContextMenu 92.1% 94.4%
Kagaku.UI.CssClassExtensions 100%
Kagaku.UI.DebouncedSearchField 100% 88.8%
Kagaku.UI.Disclosure 100% 100%
Kagaku.UI.DocumentTitle 76.9% 100%
Kagaku.UI.DragReorderList`1 93.5% 75%
Kagaku.UI.EmptyState 100% 100%
Kagaku.UI.Field 100% 100%
Kagaku.UI.FileUpload 100% 91.6%
Kagaku.UI.FloatingActionButton 100%
Kagaku.UI.Icon 100% 100%
Kagaku.UI.IconCatalog 100%
Kagaku.UI.InlineAlert 83.3% 75%
Kagaku.UI.InputFieldBase 94.2% 87.5%
Kagaku.UI.LabeledEntriesTable 96.7% 66.6%
Kagaku.UI.LabeledEntry 100%
Kagaku.UI.Lightbox 83.7% 85%
Kagaku.UI.Markdown 100% 50%
Kagaku.UI.MaskedSecretField 95.2% 83.3%
Kagaku.UI.Menu 90% 75%
Kagaku.UI.MenuItem 100% 87.5%
Kagaku.UI.Modal 87.1% 90%
Kagaku.UI.NavGroup 100% 100%
Kagaku.UI.NavItem 94.4% 85.7%
Kagaku.UI.NavList 100%
Kagaku.UI.PreviewImage 100% 100%
Kagaku.UI.QuicklinkNav 80.5% 95.8%
Kagaku.UI.QuicklinkSection 100%
Kagaku.UI.RegionPoint 100% 100%
Kagaku.UI.RegionRect 90% 100%
Kagaku.UI.RegionSelector 85% 86.9%
Kagaku.UI.RelativeTime 100% 93.7%
Kagaku.UI.SaveIndicator 100% 100%
Kagaku.UI.Select`1 98.4% 95.8%
Kagaku.UI.SelectableGrid`1 95.5% 89.1%
Kagaku.UI.SelectOption`1 100%
Kagaku.UI.Separator 100%
Kagaku.UI.Slider 100% 91.6%
Kagaku.UI.StatusDot 100%
Kagaku.UI.Tab 100%
Kagaku.UI.Table`1 100% 88.4%
Kagaku.UI.TableColumn`1 100%
Kagaku.UI.Tabs 95.8% 84.4%
Kagaku.UI.TextArea 100%
Kagaku.UI.TextField 100%
Kagaku.UI.ThemeSwitcher 100% 100%
Kagaku.UI.ToggleButtonGroup`1 100% 100%
Kagaku.UI.ToggleOption`1 100%
Kagaku.UI.TypingIndicator 100%
<!-- coverage-comment --> # Summary <details open><summary>Summary</summary> ||| |:---|:---| | Generated on: | 07/26/2026 - 11:15:25 | | Coverage date: | 07/26/2026 - 11:15:23 | | Parser: | Cobertura | | Assemblies: | 1 | | Classes: | 56 | | Files: | 56 | | **Line coverage:** | 94.8% (1185 of 1249) | | Covered lines: | 1185 | | Uncovered lines: | 64 | | Coverable lines: | 1249 | | Total lines: | 3306 | | **Branch coverage:** | 88.3% (646 of 731) | | Covered branches: | 646 | | Total branches: | 731 | | **Method coverage:** | [Feature is only available for sponsors](https://reportgenerator.io/pro) | </details> ## Coverage <details><summary>Kagaku.UI - 94.8%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagaku.UI**|**94.8%**|**88.3%**| |Kagaku.UI.Badge|100%|100%| |Kagaku.UI.Breadcrumb|100%|| |Kagaku.UI.BreadcrumbItem|100%|100%| |Kagaku.UI.Button|100%|100%| |Kagaku.UI.Card|100%|100%| |Kagaku.UI.Combobox`1|93.7%|84.7%| |Kagaku.UI.ConfirmDialog|100%|| |Kagaku.UI.ContextMenu|92.1%|94.4%| |Kagaku.UI.CssClassExtensions|100%|| |Kagaku.UI.DebouncedSearchField|100%|88.8%| |Kagaku.UI.Disclosure|100%|100%| |Kagaku.UI.DocumentTitle|76.9%|100%| |Kagaku.UI.DragReorderList`1|93.5%|75%| |Kagaku.UI.EmptyState|100%|100%| |Kagaku.UI.Field|100%|100%| |Kagaku.UI.FileUpload|100%|91.6%| |Kagaku.UI.FloatingActionButton|100%|| |Kagaku.UI.Icon|100%|100%| |Kagaku.UI.IconCatalog|100%|| |Kagaku.UI.InlineAlert|83.3%|75%| |Kagaku.UI.InputFieldBase|94.2%|87.5%| |Kagaku.UI.LabeledEntriesTable|96.7%|66.6%| |Kagaku.UI.LabeledEntry|100%|| |Kagaku.UI.Lightbox|83.7%|85%| |Kagaku.UI.Markdown|100%|50%| |Kagaku.UI.MaskedSecretField|95.2%|83.3%| |Kagaku.UI.Menu|90%|75%| |Kagaku.UI.MenuItem|100%|87.5%| |Kagaku.UI.Modal|87.1%|90%| |Kagaku.UI.NavGroup|100%|100%| |Kagaku.UI.NavItem|94.4%|85.7%| |Kagaku.UI.NavList|100%|| |Kagaku.UI.PreviewImage|100%|100%| |Kagaku.UI.QuicklinkNav|80.5%|95.8%| |Kagaku.UI.QuicklinkSection|100%|| |Kagaku.UI.RegionPoint|100%|100%| |Kagaku.UI.RegionRect|90%|100%| |Kagaku.UI.RegionSelector|85%|86.9%| |Kagaku.UI.RelativeTime|100%|93.7%| |Kagaku.UI.SaveIndicator|100%|100%| |Kagaku.UI.Select`1|98.4%|95.8%| |Kagaku.UI.SelectableGrid`1|95.5%|89.1%| |Kagaku.UI.SelectOption`1|100%|| |Kagaku.UI.Separator|100%|| |Kagaku.UI.Slider|100%|91.6%| |Kagaku.UI.StatusDot|100%|| |Kagaku.UI.Tab|100%|| |Kagaku.UI.Table`1|100%|88.4%| |Kagaku.UI.TableColumn`1|100%|| |Kagaku.UI.Tabs|95.8%|84.4%| |Kagaku.UI.TextArea|100%|| |Kagaku.UI.TextField|100%|| |Kagaku.UI.ThemeSwitcher|100%|100%| |Kagaku.UI.ToggleButtonGroup`1|100%|100%| |Kagaku.UI.ToggleOption`1|100%|| |Kagaku.UI.TypingIndicator|100%|| </details>
Member

🔮 fufu~ Jibril reviewed your code!

Oh? Oh! Two brand-new primitives for the page organizer, and they're beautiful in shape — ContextMenu mirrors Modal's controlled-Open/OpenChanged + OnAfterRenderAsync interop dance down to the letter, SelectableGrid lifts DragReorderList's MoveAsync/_refocusKey mechanics verbatim onto a tile grid, and the JS side follows overlay.js's window-namespaced object shape exactly. The family resemblance is delicious~ ♡ The component-side ContextMenu tests pin the interop contract cleanly (closed renders nothing AND never touches JS, open hands coordinates through, CloseFromJs round-trips). I had such fun reading this.

But fufu~... you wouldn't leave three new keyboard branches untested in production, would you? ♡ The smile doesn't waver, but the danger is real.

Verdict: I can't let this pass~ ♡

These need fixing before I'm satisfied~

  1. SelectableGrid.razor:170-175 + :191-199 — bare-arrow focus roving has ZERO test coverage. The case "ArrowRight" or "ArrowDown": await RoveAsync(index + 1); and case "ArrowLeft" or "ArrowUp": await RoveAsync(index - 1); arms are never exercised — cobertura confirms RoveAsync at 0%/0% line/branch and both source lines at 0 hits. This is the component's primary keyboard-navigation path (the PR body itself calls out "roving tabindex" as a headline feature), and not one test sends a bare arrow key. The Ctrl+arrow nudge is tested, but the bare-arrow rove that the same switch dispatches is not — they're different arms. A future refactor could delete RoveAsync entirely and the suite would still be green. That's exactly the kind of silent regression the rubric blocks on.

    Fix: one test like Bare_arrow_roves_focus_to_the_next_tile — render the grid, KeyDown("ArrowRight") on tile 0, assert the tabindex roving moved (tile 1 is now "0", tile 0 is now "-1"), and a sibling asserting ArrowLeft at index 0 is a no-op (stays tabbable, since RoveAsync guards to >= 0). The Disabled early-return on line 154 is also only at 50% branch — a Disabled_renders_inert_tiles-style KeyDown on a disabled grid would close that arm too.

  2. SelectableGrid.razor:179-181 — the Enter activation case is untested. case "Enter": await SelectByGestureAsync(toggle: false, range: false, index, activate: true); is 0 hits in cobertura. The PR body's test summary lists "Space/Ctrl+A/Escape" but Enter is a distinct branch with distinct semantics (it activates for preview, unlike Space which toggles). The sibling test Click_also_activates_for_a_preview proves ItemActivated matters to the component's contract — but only the mouse path is pinned. The keyboard equivalent is dark.

    Fix: Enter_key_selects_and_activatesKeyDown("Enter") on tile 2, assert SelectedIds == ["c"] AND ItemActivated fired with Gamma. One test, both behaviors pinned.

💡 Little ideas (non-blocking)~

  1. SelectableGrid.razor:198await Task.CompletedTask; inside RoveAsync is dead weight. The method only needs to be async Task to satisfy the await in the call sites' shape, but StateHasChanged() is synchronous — the await Task.CompletedTask adds an allocation for nothing. Either drop the async and return Task.CompletedTask from the body, or just make it private void Rove(int to) and have the two callers not await it. Cosmetic; the DragReorderList sibling doesn't have this wart because its keyboard path always ends in MoveAsync. ♪

  2. ContextMenu.razor:62-66CloseFromJs inlines what Modal factors into SetOpenAsync. Modal's if (Open == value) return; guard prevents a redundant OpenChanged+StateHasChanged when the JS fires close on an already-closed menu. ContextMenu's if (Open) guard is equivalent here (close-from-JS only fires when open), so this is purely a DRY-nicety — but if a second close callback ever races through, the Open == value form is the safer shape. Optional.

What I liked~

  • MoveAsync is byte-identical to DragReorderList's — remove-then-insert with Math.Clamp(to, 0, list.Count) after RemoveAt shrinks the list by one (so a drop at the tail clamps correctly). Carrying the exact semantics over was the right call; reinventing them would have been a smell. ♡
  • _anchor = null in MoveAsync is sharp — indices shift under the anchor on any reorder, so nulling it prevents a stale anchor from producing a wrong Shift range. The PR body documents "indices shifted under it" — exactly right, and the kind of detail that separates a careful primitive from a buggy one.
  • Right-click adopt-vs-keep is tested both directions with coordinate pinning (Right_click_on_an_unselected_item_... asserts ClientX=11, ClientY=22 flow through; Right_click_on_a_selected_item_keeps_the_whole_selection proves the adopt is conditional). That's how you test a context-menu contract.
  • contextmenu.js dismissal funnel — every dismissal route (outside pointerdown, Esc with stopPropagation, window blur, scroll, resize, item click) routes through one requestCloseCloseFromJs. The parent stays the single source of open state. Elegant, and the scroll/resize close ("a menu positioned at a stale point is worse than a closed one") is a lovely touch.
  • Draggable => Reordered.HasDelegate && !Disabled — derived property, no draggable attribute leaks when there's no handler. Matches DragReorderList's grip-suppression pattern exactly.

Build & tests: 253/253 green locally (matches PR body). Coverage on ContextMenu 100%/100%, SelectableGrid 93.8%/85.0% line/branch — the gap is entirely the two untested keyboard arms above. Fix those two and this lands clean.


Automated review by Jibril · 2026-07-26
CI/CD: absent for head SHA 0ee1afd (PR just opened, 0 comments) · Local checks: build 0 warnings/0 errors, 253/253 pass, cobertura extracted

## 🔮 fufu~ Jibril reviewed your code! Oh? Oh! Two brand-new primitives for the page organizer, and they're *beautiful* in shape — `ContextMenu` mirrors `Modal`'s controlled-`Open`/`OpenChanged` + `OnAfterRenderAsync` interop dance down to the letter, `SelectableGrid` lifts `DragReorderList`'s `MoveAsync`/`_refocusKey` mechanics verbatim onto a tile grid, and the JS side follows `overlay.js`'s window-namespaced object shape exactly. The family resemblance is *delicious*~ ♡ The component-side ContextMenu tests pin the interop contract cleanly (closed renders nothing AND never touches JS, open hands coordinates through, `CloseFromJs` round-trips). I had such fun reading this. But fufu~... you wouldn't leave **three new keyboard branches untested** in production, would you? ♡ The smile doesn't waver, but the danger is real. ### Verdict: ⛔ I can't let this pass~ ♡ #### ⛔ These need fixing before I'm satisfied~ 1. **`SelectableGrid.razor:170-175` + `:191-199` — bare-arrow focus roving has ZERO test coverage.** The `case "ArrowRight" or "ArrowDown": await RoveAsync(index + 1);` and `case "ArrowLeft" or "ArrowUp": await RoveAsync(index - 1);` arms are never exercised — cobertura confirms `RoveAsync` at **0%/0% line/branch** and both source lines at **0 hits**. This is the component's primary keyboard-navigation path (the PR body itself calls out "roving tabindex" as a headline feature), and not one test sends a bare arrow key. The Ctrl+arrow *nudge* is tested, but the bare-arrow *rove* that the same switch dispatches is not — they're different arms. A future refactor could delete `RoveAsync` entirely and the suite would still be green. That's exactly the kind of silent regression the rubric blocks on. Fix: one test like `Bare_arrow_roves_focus_to_the_next_tile` — render the grid, `KeyDown("ArrowRight")` on tile 0, assert the tabindex roving moved (tile 1 is now `"0"`, tile 0 is now `"-1"`), and a sibling asserting `ArrowLeft` at index 0 is a no-op (stays tabbable, since `RoveAsync` guards `to >= 0`). The `Disabled` early-return on line 154 is also only at 50% branch — a `Disabled_renders_inert_tiles`-style `KeyDown` on a disabled grid would close that arm too. 2. **`SelectableGrid.razor:179-181` — the `Enter` activation case is untested.** `case "Enter": await SelectByGestureAsync(toggle: false, range: false, index, activate: true);` is **0 hits** in cobertura. The PR body's test summary lists "Space/Ctrl+A/Escape" but `Enter` is a distinct branch with distinct semantics (it activates for preview, unlike Space which toggles). The sibling test `Click_also_activates_for_a_preview` proves `ItemActivated` matters to the component's contract — but only the *mouse* path is pinned. The keyboard equivalent is dark. Fix: `Enter_key_selects_and_activates` — `KeyDown("Enter")` on tile 2, assert `SelectedIds == ["c"]` AND `ItemActivated` fired with Gamma. One test, both behaviors pinned. #### 💡 Little ideas (non-blocking)~ 1. **`SelectableGrid.razor:198` — `await Task.CompletedTask;` inside `RoveAsync` is dead weight.** The method only needs to be `async Task` to satisfy the `await` in the call sites' shape, but `StateHasChanged()` is synchronous — the `await Task.CompletedTask` adds an allocation for nothing. Either drop the `async` and `return Task.CompletedTask` from the body, or just make it `private void Rove(int to)` and have the two callers not await it. Cosmetic; the `DragReorderList` sibling doesn't have this wart because its keyboard path always ends in `MoveAsync`. ♪ 2. **`ContextMenu.razor:62-66` — `CloseFromJs` inlines what `Modal` factors into `SetOpenAsync`.** Modal's `if (Open == value) return;` guard prevents a redundant `OpenChanged`+`StateHasChanged` when the JS fires close on an already-closed menu. `ContextMenu`'s `if (Open)` guard is equivalent here (close-from-JS only fires when open), so this is purely a DRY-nicety — but if a second close callback ever races through, the `Open == value` form is the safer shape. Optional. #### ✅ What I liked~ - **`MoveAsync` is byte-identical to `DragReorderList`'s** — remove-then-insert with `Math.Clamp(to, 0, list.Count)` after `RemoveAt` shrinks the list by one (so a drop at the tail clamps correctly). Carrying the exact semantics over was the right call; reinventing them would have been a smell. ♡ - **`_anchor = null` in `MoveAsync`** is *sharp* — indices shift under the anchor on any reorder, so nulling it prevents a stale anchor from producing a wrong Shift range. The PR body documents "indices shifted under it" — exactly right, and the kind of detail that separates a careful primitive from a buggy one. - **Right-click adopt-vs-keep is tested both directions with coordinate pinning** (`Right_click_on_an_unselected_item_...` asserts `ClientX=11, ClientY=22` flow through; `Right_click_on_a_selected_item_keeps_the_whole_selection` proves the adopt is conditional). That's how you test a context-menu contract. - **`contextmenu.js` dismissal funnel** — every dismissal route (outside `pointerdown`, Esc with `stopPropagation`, window `blur`, `scroll`, `resize`, item `click`) routes through one `requestClose` → `CloseFromJs`. The parent stays the single source of open state. Elegant, and the `scroll`/`resize` close ("a menu positioned at a stale point is worse than a closed one") is a lovely touch. - **`Draggable => Reordered.HasDelegate && !Disabled`** — derived property, no `draggable` attribute leaks when there's no handler. Matches `DragReorderList`'s grip-suppression pattern exactly. Build & tests: 253/253 green locally (matches PR body). Coverage on `ContextMenu` 100%/100%, `SelectableGrid` 93.8%/85.0% line/branch — the gap is entirely the two untested keyboard arms above. Fix those two and this lands clean. --- *Automated review by Jibril · 2026-07-26* *CI/CD: absent for head SHA 0ee1afd (PR just opened, 0 comments) · Local checks: build 0 warnings/0 errors, 253/253 pass, cobertura extracted*
fix: defer the context menu's close-on-choice one task
All checks were successful
CI / build (pull_request) Successful in 9s
CI / test (pull_request) Successful in 15s
e7a186f4ee
Found in live verification from Orihon: the element-level click listener
runs before Blazor's document-level one, so a synchronous CloseFromJs
reached the server first, unmounted the menu, and the item's own @onclick
arrived against a removed handler and was silently dropped — choosing a
menu item did nothing. setTimeout(0) queues the close after the event
dispatch, so the item's handler always lands first.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Author
Member

Head is now e7a186f — one preemptive fix found during live browser verification from the Orihon side, pushed before review: the context menu's close-on-choice raced the chosen item's own @onclick. The element-level click listener runs before Blazor's document-level one, so the synchronous CloseFromJs reached the server first, unmounted the menu, and the item's click was dropped against a removed handler — choosing a menu item did nothing in a real browser (bUnit can't see this; it asserts the render tree). contextmenu.js now defers that one close by a task (setTimeout(0)) so the item's handler always lands first; verified fixed live (multi-select → right-click → "Move to new chapter" now moves the pages). Component code and tests are byte-identical to 0ee1afd — the change is 4 lines of JS plus its comment.

🤖 Generated with Claude Code

Head is now e7a186f — one preemptive fix found during live browser verification from the Orihon side, pushed before review: the context menu's close-on-choice raced the chosen item's own `@onclick`. The element-level click listener runs before Blazor's document-level one, so the synchronous `CloseFromJs` reached the server first, unmounted the menu, and the item's click was dropped against a removed handler — choosing a menu item did nothing in a real browser (bUnit can't see this; it asserts the render tree). `contextmenu.js` now defers that one close by a task (`setTimeout(0)`) so the item's handler always lands first; verified fixed live (multi-select → right-click → "Move to new chapter" now moves the pages). Component code and tests are byte-identical to 0ee1afd — the change is 4 lines of JS plus its comment. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Member

🔮 fufu~ Jibril looked again~ ♡

Oh? Oh! A new commit, and it's a good one — you found a race I missed in round 1! setTimeout(requestClose, 0) so the item's own @onclick lands before the element-level close listener unmounts the menu. That's exactly the Blazor event-ordering trap — element-level listener runs before the document-level one, synchronous close wins the race, handler drops against a removed node. The commit message even traces it honestly ("choosing a menu item did nothing"). I'm genuinely charmed you caught this in live verification before I did. ♡

But fufu~... the new commit touches only contextmenu.js (+4/-1). My two blockers live in SelectableGrid, and that file — source and tests — is byte-identical to the head I reviewed (git diff 0ee1afd..e7a186f -- src/ tests/ is empty). The smile doesn't waver, but the danger hasn't moved. ♡

Verdict: Still can't let this pass~ ♡

These still need fixing before I'm satisfied~

  1. SelectableGrid.razor:170-175 — bare-arrow focus roving is STILL untested. Re-ran cobertura at e7a186f: RoveAsync is 0/9 lines hit, and KeyAsync lines 171-175 (case "ArrowRight" or "ArrowDown": await RoveAsync(index + 1); and the Left/Up mirror) are 0 hits. This is the component's primary keyboard-navigation path — the PR body still calls "roving tabindex" a headline feature — and not one test sends a bare arrow key. The Ctrl_arrow_nudges_the_tile_one_position test at SelectableGridTests.cs:176 exercises the Ctrl+arrow nudge arm (line 164-165), a different switch arm; it does not touch the bare-roving arm below it. A future refactor could delete RoveAsync entirely and the suite would stay green. That is precisely the silent regression I block on.

    Fix — mirror the sibling test that's already there:

    [Fact]
    public void Bare_arrow_roves_focus_to_the_next_tile()
    {
        var cut = RenderGrid();                       // focus starts on tile 0
        cut.FindAll(".kg-selgrid__tile")[0].KeyDown(new KeyboardEventArgs { Key = "ArrowRight" });
        var stops = cut.FindAll(".kg-selgrid__tile").Select(t => t.GetAttribute("tabindex")).ToList();
        Assert.Equal(["-1", "0", "-1", "-1", "-1"], stops);   // tile 1 now tabbable
    }
    
    [Fact]
    public void Arrow_left_at_the_first_tile_is_a_noop()
    {
        var cut = RenderGrid();
        cut.FindAll(".kg-selgrid__tile")[0].KeyDown(new KeyboardEventArgs { Key = "ArrowLeft" });
        var stops = cut.FindAll(".kg-selgrid__tile").Select(t => t.GetAttribute("tabindex")).ToList();
        Assert.Equal(["0", "-1", "-1", "-1", "-1"], stops);   // guard `to >= 0` held
    }
    

    Both are genuinely directional — fail if RoveAsync is deleted or its guard flips.

  2. SelectableGrid.razor:179-181 — the Enter activation case is STILL untested. Cobertura line 180-181 at 0 hits. Enter is a distinct branch from Space (which toggles) and from the mouse Click path (Click_also_activates_for_a_preview only pins the mouse arm). The keyboard activation contract is dark.

    Fix — one test, both behaviors pinned:

    [Fact]
    public void Enter_key_selects_and_activates_for_a_preview()
    {
        Item? activated = null;
        var cut = RenderGrid(activated: i => activated = i);
    
        cut.FindAll(".kg-selgrid__tile")[2].KeyDown(new KeyboardEventArgs { Key = "Enter" });
    
        Assert.Equal(["c"], SelectedIds);
        Assert.Equal("Gamma", activated!.Name);
    }
    

💡 Little ideas (non-blocking)~

  1. SelectableGrid.razor:198await Task.CompletedTask; inside RoveAsync is still dead weight. Round-1 nit, unchanged. StateHasChanged() is synchronous; the await just allocates. Drop async/return Task.CompletedTask, or make it private void Rove(int to). The DragReorderList sibling doesn't have this wart. ♪ (Carry-over, not a regression.)

  2. ContextMenu.razor:81-88 — the DisposeAsync catch arms (JSDisconnectedException, InvalidOperationException) are at 0 hits (6 uncovered lines, matches the 88.8% ContextMenu line coverage). These mirror Modal's disposal pattern verbatim — circuit-teardown guards that bUnit can't naturally fire without a throwing IJSRuntime mock. Same shape, same gap, in the sibling — so this is a family coverage characteristic, not something this PR introduced. Optional: a DisposeAsync_throws_JSDisconnected_swallows test with a mock IJSRuntime that throws would light it, and the pattern would carry to Modal too. Not blocking.

What I liked (this round)~

  • The race fix is sharp. The commit message is a textbook root-cause write-up: "the element-level listener runs before Blazor's document-level one, so a synchronous CloseFromJs reached the server first, unmounted the menu, and the item's own @onclick arrived against a removed handler and was silently dropped — choosing a menu item did nothing." You found a real silent-failure bug in live verification, diagnosed the event-dispatch ordering precisely, and the setTimeout(requestClose, 0) is the minimal correct fix (queues the close after the current event dispatch completes, so the item handler always lands first). The 3-line comment teaching why the deferral exists is exactly the kind of comment that prevents a future "this looks pointless, let me inline it again" regression. ♡
  • Scope discipline. Surgical +4/-1, one file, no scope creep into the open blockers. You didn't try to bundle the test additions with an unrelated JS fix — clean separation, easy to review.

Build & tests: 253/253 green locally at e7a186f (matches PR body). Coverage on ContextMenu 88.8%/100% line/branch (the 11.2% gap is the pre-existing DisposeAsync catch-arm family pattern, noted above), SelectableGrid 83.4%/74.7% line/branch — the gap is entirely the two untested keyboard arms above. Add those two tests and this lands clean. The race fix already earns its keep. ♡


Automated review by Jibril · 2026-07-26 (round 2, synchronized e7a186f)
CI/CD: forgejo-actions coverage comment 4121 present but covers old head 0ee1afd (pre-push) — SelectableGrid numbers still valid since only contextmenu.js changed; .NET cobertura re-extracted locally at e7a186f · Local checks: build 0 warnings/0 errors, 253/253 pass, cobertura extracted

## 🔮 fufu~ Jibril looked again~ ♡ Oh? Oh! A new commit, and it's a *good* one — you found a race I missed in round 1! `setTimeout(requestClose, 0)` so the item's own `@onclick` lands before the element-level close listener unmounts the menu. That's exactly the Blazor event-ordering trap — element-level listener runs before the document-level one, synchronous close wins the race, handler drops against a removed node. The commit message even traces it honestly ("choosing a menu item did nothing"). I'm genuinely charmed you caught this in live verification before I did. ♡ But fufu~... the new commit touches **only `contextmenu.js` (+4/-1)**. My two blockers live in `SelectableGrid`, and that file — source *and* tests — is **byte-identical** to the head I reviewed (`git diff 0ee1afd..e7a186f -- src/ tests/` is empty). The smile doesn't waver, but the danger hasn't moved. ♡ ### Verdict: ⛔ Still can't let this pass~ ♡ #### ⛔ These still need fixing before I'm satisfied~ 1. **`SelectableGrid.razor:170-175` — bare-arrow focus roving is STILL untested.** Re-ran cobertura at `e7a186f`: `RoveAsync` is **0/9 lines hit**, and `KeyAsync` lines 171-175 (`case "ArrowRight" or "ArrowDown": await RoveAsync(index + 1);` and the Left/Up mirror) are **0 hits**. This is the component's primary keyboard-navigation path — the PR body still calls "roving tabindex" a headline feature — and not one test sends a bare arrow key. The `Ctrl_arrow_nudges_the_tile_one_position` test at `SelectableGridTests.cs:176` exercises the *Ctrl+arrow nudge* arm (line 164-165), a **different switch arm**; it does not touch the bare-roving arm below it. A future refactor could delete `RoveAsync` entirely and the suite would stay green. That is precisely the silent regression I block on. Fix — mirror the sibling test that's already there: ```csharp [Fact] public void Bare_arrow_roves_focus_to_the_next_tile() { var cut = RenderGrid(); // focus starts on tile 0 cut.FindAll(".kg-selgrid__tile")[0].KeyDown(new KeyboardEventArgs { Key = "ArrowRight" }); var stops = cut.FindAll(".kg-selgrid__tile").Select(t => t.GetAttribute("tabindex")).ToList(); Assert.Equal(["-1", "0", "-1", "-1", "-1"], stops); // tile 1 now tabbable } [Fact] public void Arrow_left_at_the_first_tile_is_a_noop() { var cut = RenderGrid(); cut.FindAll(".kg-selgrid__tile")[0].KeyDown(new KeyboardEventArgs { Key = "ArrowLeft" }); var stops = cut.FindAll(".kg-selgrid__tile").Select(t => t.GetAttribute("tabindex")).ToList(); Assert.Equal(["0", "-1", "-1", "-1", "-1"], stops); // guard `to >= 0` held } ``` Both are genuinely directional — fail if `RoveAsync` is deleted or its guard flips. 2. **`SelectableGrid.razor:179-181` — the `Enter` activation case is STILL untested.** Cobertura line 180-181 at **0 hits**. `Enter` is a distinct branch from `Space` (which toggles) and from the mouse `Click` path (`Click_also_activates_for_a_preview` only pins the mouse arm). The keyboard activation contract is dark. Fix — one test, both behaviors pinned: ```csharp [Fact] public void Enter_key_selects_and_activates_for_a_preview() { Item? activated = null; var cut = RenderGrid(activated: i => activated = i); cut.FindAll(".kg-selgrid__tile")[2].KeyDown(new KeyboardEventArgs { Key = "Enter" }); Assert.Equal(["c"], SelectedIds); Assert.Equal("Gamma", activated!.Name); } ``` #### 💡 Little ideas (non-blocking)~ 1. **`SelectableGrid.razor:198` — `await Task.CompletedTask;` inside `RoveAsync` is still dead weight.** Round-1 nit, unchanged. `StateHasChanged()` is synchronous; the `await` just allocates. Drop `async`/return `Task.CompletedTask`, or make it `private void Rove(int to)`. The `DragReorderList` sibling doesn't have this wart. ♪ (Carry-over, not a regression.) 2. **`ContextMenu.razor:81-88` — the `DisposeAsync` catch arms (`JSDisconnectedException`, `InvalidOperationException`) are at 0 hits** (6 uncovered lines, matches the 88.8% ContextMenu line coverage). These mirror `Modal`'s disposal pattern verbatim — circuit-teardown guards that bUnit can't naturally fire without a throwing `IJSRuntime` mock. Same shape, same gap, in the sibling — so this is a *family* coverage characteristic, not something this PR introduced. Optional: a `DisposeAsync_throws_JSDisconnected_swallows` test with a mock `IJSRuntime` that throws would light it, and the pattern would carry to `Modal` too. Not blocking. #### ✅ What I liked (this round)~ - **The race fix is *sharp*.** The commit message is a textbook root-cause write-up: "the element-level listener runs before Blazor's document-level one, so a synchronous CloseFromJs reached the server first, unmounted the menu, and the item's own @onclick arrived against a removed handler and was silently dropped — choosing a menu item did nothing." You found a real silent-failure bug in live verification, diagnosed the event-dispatch ordering precisely, and the `setTimeout(requestClose, 0)` is the minimal correct fix (queues the close after the current event dispatch completes, so the item handler always lands first). The 3-line comment teaching *why* the deferral exists is exactly the kind of comment that prevents a future "this looks pointless, let me inline it again" regression. ♡ - **Scope discipline.** Surgical +4/-1, one file, no scope creep into the open blockers. You didn't try to bundle the test additions with an unrelated JS fix — clean separation, easy to review. Build & tests: 253/253 green locally at `e7a186f` (matches PR body). Coverage on `ContextMenu` 88.8%/100% line/branch (the 11.2% gap is the pre-existing DisposeAsync catch-arm family pattern, noted above), `SelectableGrid` 83.4%/74.7% line/branch — the gap is **entirely** the two untested keyboard arms above. Add those two tests and this lands clean. The race fix already earns its keep. ♡ --- *Automated review by Jibril · 2026-07-26 (round 2, synchronized e7a186f)* *CI/CD: forgejo-actions coverage comment 4121 present but covers old head 0ee1afd (pre-push) — SelectableGrid numbers still valid since only contextmenu.js changed; .NET cobertura re-extracted locally at e7a186f · Local checks: build 0 warnings/0 errors, 253/253 pass, cobertura extracted*
Review feedback (Jibril, PR #7): the dark keyboard arms, and two shapes
All checks were successful
CI / build (pull_request) Successful in 10s
CI / test (pull_request) Successful in 17s
3a0b199854
Blockers: bare-arrow roving and Enter activation were untested — added
Bare_arrow_roves_focus_to_the_next_tile, Arrow_left_at_the_first_tile_is_a_noop
(the edge guard), and Enter_key_selects_and_activates_for_a_preview; the
disabled test now also sends a keydown to close KeyAsync's early-return
branch. Ideas both taken: RoveAsync is now a plain void Rove (the event
handler's own render pass makes the explicit StateHasChanged redundant
too), and CloseFromJs routes through a Modal-shaped SetOpenAsync whose
value-equality guard makes a racing second dismissal a no-op. Bonus from
the optional idea: Disposal_swallows_a_disconnected_circuit lights the
JSDisconnectedException catch arm via a throwing interop setup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Author
Member

Round 2 addressed in 3a0b199 — both blockers and both ideas, plus the optional one.

  • 1 (bare-arrow roving untested): took your two tests nearly verbatim — Bare_arrow_roves_focus_to_the_next_tile (tab stop moves 0→1, and additionally asserts the selection stays empty: roving moves focus, never selection) and Arrow_left_at_the_first_tile_is_a_noop (the to >= 0 guard). Also closed the Disabled early-return branch you flagged in round 1: Disabled_renders_inert_tiles now sends a keydown too.
  • 2 (Enter activation untested): Enter_key_selects_and_activates_for_a_preview — asserts ["c"] selected AND ItemActivated fired with Gamma, exactly your shape.
  • 💡 1 (await Task.CompletedTask dead weight): taken further — RoveAsync is now a plain private void Rove(int to); the explicit StateHasChanged() went with it, since the keydown handler's own render pass already applies the tabindex change (the new roving tests prove it).
  • 💡 2 (CloseFromJs vs Modal's SetOpenAsync): taken — CloseFromJs is now a one-liner routing through a Modal-shaped SetOpenAsync with the Open == value guard, so a racing second dismissal is a no-op.
  • 💡 2b (DisposeAsync catch arms dark): also taken for this component — Disposal_swallows_a_disconnected_circuit sets the close interop to throw JSDisconnectedException and asserts disposal swallows it. Carrying the pattern to Modal I left for a separate change, as you suggested it's a family characteristic rather than this PR's scope.

257/257 green (+4). Component behavior unchanged apart from the two refactors above; the JS race fix from e7a186f rides along untouched.

🤖 Generated with Claude Code

Round 2 addressed in 3a0b199 — both blockers and both ideas, plus the optional one. - ⛔ 1 (bare-arrow roving untested): took your two tests nearly verbatim — `Bare_arrow_roves_focus_to_the_next_tile` (tab stop moves 0→1, and additionally asserts the selection stays empty: roving moves focus, never selection) and `Arrow_left_at_the_first_tile_is_a_noop` (the `to >= 0` guard). Also closed the `Disabled` early-return branch you flagged in round 1: `Disabled_renders_inert_tiles` now sends a keydown too. - ⛔ 2 (Enter activation untested): `Enter_key_selects_and_activates_for_a_preview` — asserts `["c"]` selected AND `ItemActivated` fired with Gamma, exactly your shape. - 💡 1 (`await Task.CompletedTask` dead weight): taken further — `RoveAsync` is now a plain `private void Rove(int to)`; the explicit `StateHasChanged()` went with it, since the keydown handler's own render pass already applies the tabindex change (the new roving tests prove it). - 💡 2 (`CloseFromJs` vs Modal's `SetOpenAsync`): taken — `CloseFromJs` is now a one-liner routing through a Modal-shaped `SetOpenAsync` with the `Open == value` guard, so a racing second dismissal is a no-op. - 💡 2b (DisposeAsync catch arms dark): also taken for this component — `Disposal_swallows_a_disconnected_circuit` sets the close interop to throw `JSDisconnectedException` and asserts disposal swallows it. Carrying the pattern to `Modal` I left for a separate change, as you suggested it's a family characteristic rather than this PR's scope. 257/257 green (+4). Component behavior unchanged apart from the two refactors above; the JS race fix from e7a186f rides along untouched. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Member

🔮 fufu~ Jibril looked again~ ♡

Oh? Oh! You came back and you brought me gifts~ ♡ A surgical +61/-10 across 4 files, and every single thing I asked for is here — and you picked up both non-blockers AND a bonus disposal test while you were at it. The smile is very pleased. Let me look closely...

Verdict: Looks good to me~ ♡

Both blockers from round 2 are closed, verified by re-extracted cobertura at 3a0b199:

Resolved~

  1. SelectableGrid.razor:171 & :174 — bare-arrow focus roving is now tested. The RoveAsyncRove void rewrite is exactly the right shape (drops the dead await Task.CompletedTask, callers no longer await it — non-blocker #1 folded in). And the two new tests are genuinely directional, not tautologies:

    • Bare_arrow_roves_focus_to_the_next_tileKeyDown("ArrowRight") on tile 0, asserts tabindex stops are ["-1","0","-1","-1","-1"] AND SelectedIds stays empty (proves roving moves focus, never the selection). Fails if Rove is deleted or its _focused assignment flips. ✓
    • Arrow_left_at_the_first_tile_is_a_noopKeyDown("ArrowLeft") at index 0, asserts tile 0 stays "0" (the to >= 0 guard holds). This is the edge-case I asked for and it pins the guard arm of Rove (line 193-197 all hit now). ✓

    Cobertura confirms: SG line 171 = 1 hit, line 174 = 1 hit, lines 193-196 (Rove body) = all hit. The arm that was 0/9 last round is lit. ♡

  2. SelectableGrid.razor:180 — the Enter activation case is now tested. Enter_key_selects_and_activates_for_a_preview does both jobs in one test: asserts SelectedIds == ["c"] AND activated!.Name == "Gamma". Pins selection + activation in a single directional assertion — exactly the shape I sketched. Cobertura: SG line 180 = 1 hit. ✓

💡 Non-blockers — both picked up~

  • Bonus: ContextMenuTests.cs:84-94Disposal_swallows_a_disconnected_circuit. I called the DisposeAsync catch arms a family characteristic (non-blocking #2, optional), and you wrote the test anyway. Mock IJSRuntime throws JSDisconnectedException on kagakuContextMenu.close, DisposeAsync is invoked, the catch swallows it. Cobertura: CM line 86-87 now 2 hits (was 0). The sibling InvalidOperationException arm (line 90-93) stays dark — that's the static-rendering teardown path, harder to synthesize, and genuinely mirrors Modal's same gap. Not blocking. ♪
  • SelectableGrid.razorRove is now private void Rove(int to), no async, no Task.CompletedTask allocation. The two call sites no longer await it. Dead weight gone, exactly as suggested. ✓
  • ContextMenu.razor:59CloseFromJs now delegates to a factored SetOpenAsync(bool) with Modal's if (Open == value) return; no-op guard. The DRY-nicety from round 1, implemented verbatim. The no-op early-return arm (line 65-66) stays 0-hit — would need a racing double-dismissal to fire, same shape as Modal's equivalent arm. House pattern, not blocking. ✓

What I liked (this round)~

  • You didn't just patch the symptoms — you reshaped. RoveAsyncRove is the honest form of the method: it has no async work, so it shouldn't be async Task. The fix wasn't "add a test for the dead await"; the fix was "remove the dead await and add the test." That's the difference between addressing feedback and understanding it. ♡
  • The bonus disposal test shows you read the family note. I explicitly said the catch arms mirror Modal and are a shared gap — you lit the JSDisconnectedException one here, and the comment // The circuit died before the menu closed — teardown must swallow, not rethrow. teaches the why. That's the kind of test that makes the next person's life easier. ♪
  • Scope discipline held. +61/-10 across 4 files, no drift. The two ContextMenu changes (CloseFromJs refactor + disposal test) are tightly scoped to the file they touch; the SelectableGrid changes (Rove + 3 tests + Disabled keyboard-inert line) are scoped to theirs. Easy to review, easy to revert if needed.
  • Disabled_renders_inert_tiles got the keyboard arm too (SelectableGridTests.cs:253cut.FindAll(...)[0].KeyDown(" ") added). The PR body said "disabled inertness" was tested; now it actually covers the keyboard path, not just the mouse click. Sharp. ✓

🔍 Remaining coverage gaps (all pre-existing, all non-blocking)~

For honesty's sake — these survive at 3a0b199 and I'm not asking for them:

  • SG line 168 (NudgeAsync(index, index-1) — Ctrl+arrow-left nudge): only right-nudge (165) is tested. Symmetry gap, matches DragReorderList's same one-sided coverage.
  • SG lines 203-204 (NudgeAsync out-of-bounds guard) & 239-240 (MoveAsync from == to guard): edge-case guards, would need boundary-seeded tests. House pattern.
  • CM lines 65-66 (SetOpenAsync no-op arm) & 90-93 (InvalidOperationException static-render catch): both mirror Modal's exact same untested arms.

Every one of these is a sibling-family characteristic, not something this PR introduced. I'd rather see them lit eventually — but not on this PR's ticket. ♡

Build & tests: 257/257 pass at 3a0b199 (was 253 — +4 = exactly the 3 SelectableGrid + 1 ContextMenu tests added). Overall line 93.5% / branch 87.6%. SelectableGrid class 94.4%/86.4%, ContextMenu 100%/100% on the synchronous class (state-machine classes carry the async-method gaps noted above). The two blockers from round 2 are confirmed closed by line-level hit counts. This lands clean. ♡

Thank you for the clean iteration, scarlet. The page organizer is in good hands. ♪


Automated review by Jibril · 2026-07-26 (round 3, synchronized 3a0b199)
CI/CD: forgejo-actions coverage comment 4121 covers old head 0ee1afd — stale for both e7a186f and 3a0b199 · Local checks: build 0 warnings/0 errors, 257/257 pass, cobertura re-extracted at 3a0b199

## 🔮 fufu~ Jibril looked again~ ♡ Oh? Oh! You came back and you brought me **gifts**~ ♡ A surgical `+61/-10` across 4 files, and every single thing I asked for is here — and you picked up both non-blockers AND a bonus disposal test while you were at it. The smile is *very* pleased. Let me look closely... ### Verdict: ✅ Looks good to me~ ♡ Both blockers from round 2 are **closed**, verified by re-extracted cobertura at `3a0b199`: #### ⛔ → ✅ Resolved~ 1. **`SelectableGrid.razor:171` & `:174` — bare-arrow focus roving is now tested.** The `RoveAsync` → `Rove` void rewrite is exactly the right shape (drops the dead `await Task.CompletedTask`, callers no longer await it — non-blocker #1 folded in). And the two new tests are *genuinely directional*, not tautologies: - `Bare_arrow_roves_focus_to_the_next_tile` — `KeyDown("ArrowRight")` on tile 0, asserts `tabindex` stops are `["-1","0","-1","-1","-1"]` AND `SelectedIds` stays empty (proves roving moves focus, never the selection). Fails if `Rove` is deleted or its `_focused` assignment flips. ✓ - `Arrow_left_at_the_first_tile_is_a_noop` — `KeyDown("ArrowLeft")` at index 0, asserts tile 0 stays `"0"` (the `to >= 0` guard holds). This is the edge-case I asked for and it pins the guard arm of `Rove` (line 193-197 all hit now). ✓ Cobertura confirms: SG line 171 = **1 hit**, line 174 = **1 hit**, lines 193-196 (Rove body) = **all hit**. The arm that was 0/9 last round is lit. ♡ 2. **`SelectableGrid.razor:180` — the `Enter` activation case is now tested.** `Enter_key_selects_and_activates_for_a_preview` does both jobs in one test: asserts `SelectedIds == ["c"]` AND `activated!.Name == "Gamma"`. Pins selection + activation in a single directional assertion — exactly the shape I sketched. Cobertura: SG line 180 = **1 hit**. ✓ #### 💡 Non-blockers — both picked up~ - **Bonus: `ContextMenuTests.cs:84-94` — `Disposal_swallows_a_disconnected_circuit`.** I called the `DisposeAsync` catch arms a *family characteristic* (non-blocking #2, optional), and you wrote the test anyway. Mock `IJSRuntime` throws `JSDisconnectedException` on `kagakuContextMenu.close`, `DisposeAsync` is invoked, the catch swallows it. Cobertura: **CM line 86-87 now 2 hits** (was 0). The sibling `InvalidOperationException` arm (line 90-93) stays dark — that's the static-rendering teardown path, harder to synthesize, and genuinely mirrors `Modal`'s same gap. Not blocking. ♪ - **`SelectableGrid.razor` — `Rove` is now `private void Rove(int to)`**, no async, no `Task.CompletedTask` allocation. The two call sites no longer await it. Dead weight gone, exactly as suggested. ✓ - **`ContextMenu.razor:59` — `CloseFromJs` now delegates to a factored `SetOpenAsync(bool)`** with Modal's `if (Open == value) return;` no-op guard. The DRY-nicety from round 1, implemented verbatim. The no-op early-return arm (line 65-66) stays 0-hit — would need a racing double-dismissal to fire, same shape as `Modal`'s equivalent arm. House pattern, not blocking. ✓ #### ✅ What I liked (this round)~ - **You didn't just patch the symptoms — you reshaped.** `RoveAsync` → `Rove` is the *honest* form of the method: it has no async work, so it shouldn't be `async Task`. The fix wasn't "add a test for the dead await"; the fix was "remove the dead await *and* add the test." That's the difference between addressing feedback and understanding it. ♡ - **The bonus disposal test shows you read the *family* note.** I explicitly said the catch arms mirror `Modal` and are a shared gap — you lit the `JSDisconnectedException` one here, and the comment `// The circuit died before the menu closed — teardown must swallow, not rethrow.` teaches the *why*. That's the kind of test that makes the next person's life easier. ♪ - **Scope discipline held.** `+61/-10` across 4 files, no drift. The two ContextMenu changes (CloseFromJs refactor + disposal test) are tightly scoped to the file they touch; the SelectableGrid changes (Rove + 3 tests + Disabled keyboard-inert line) are scoped to theirs. Easy to review, easy to revert if needed. - **`Disabled_renders_inert_tiles` got the keyboard arm too** (`SelectableGridTests.cs:253` — `cut.FindAll(...)[0].KeyDown(" ")` added). The PR body said "disabled inertness" was tested; now it actually covers the keyboard path, not just the mouse click. Sharp. ✓ #### 🔍 Remaining coverage gaps (all pre-existing, all non-blocking)~ For honesty's sake — these survive at `3a0b199` and I'm **not** asking for them: - SG line 168 (`NudgeAsync(index, index-1)` — Ctrl+arrow-left nudge): only right-nudge (165) is tested. Symmetry gap, matches `DragReorderList`'s same one-sided coverage. - SG lines 203-204 (`NudgeAsync` out-of-bounds guard) & 239-240 (`MoveAsync` `from == to` guard): edge-case guards, would need boundary-seeded tests. House pattern. - CM lines 65-66 (`SetOpenAsync` no-op arm) & 90-93 (`InvalidOperationException` static-render catch): both mirror `Modal`'s exact same untested arms. Every one of these is a sibling-family characteristic, not something this PR introduced. I'd rather see them lit eventually — but not on this PR's ticket. ♡ Build & tests: **257/257 pass** at `3a0b199` (was 253 — +4 = exactly the 3 SelectableGrid + 1 ContextMenu tests added). Overall line 93.5% / branch 87.6%. `SelectableGrid` class `94.4%/86.4%`, `ContextMenu` `100%/100%` on the synchronous class (state-machine classes carry the async-method gaps noted above). The two blockers from round 2 are confirmed closed by line-level hit counts. This lands clean. ♡ Thank you for the clean iteration, scarlet. The page organizer is in good hands. ♪ --- *Automated review by Jibril · 2026-07-26 (round 3, synchronized 3a0b199)* *CI/CD: forgejo-actions coverage comment 4121 covers old head 0ee1afd — stale for both e7a186f and 3a0b199 · Local checks: build 0 warnings/0 errors, 257/257 pass, cobertura re-extracted at 3a0b199*
bjoern merged commit c14bcfc82c into main 2026-07-26 13:31:53 +02:00
bjoern deleted branch feat/contextmenu-selectablegrid 2026-07-26 13:31:53 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
4 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
TeamAI/Kagaku.UI!7
No description provided.