Modal + ConfirmDialog on a native-<dialog> overlay root #14

Merged
bjoern merged 3 commits from feat/modal-overlay into main 2026-07-09 20:27:31 +02:00
Member

The shared overlay plumbing the inventory said to "build once, deliberately" (ADR 0023) — the highest-use overlay at 12 (every delete confirm, project-create, add-link / relationship / parent-location modals, queue cancel, history revert).

Design — lean on the platform

Built on the native <dialog> element, which gives a focus trap, Esc-to-close, top-layer stacking, and a ::backdrop for free. So overlay.js is tiny: it only drives showModal()/close() from Blazor and toggles a body scroll-lock (.kg-scroll-lock). No hand-rolled focus trap, no z-index juggling (the top layer sits above everything; the --z-* tokens stay for non-dialog overlays like Toast/Drawer).

  • Modal@bind-Open, Title, ChildContent, Footer, CloseOnBackdrop. The close button, backdrop click, Esc, and a native dialog.close() all sync the bound state.
  • ConfirmDialog — composes Modal: message + cancel/confirm, ConfirmVariant (Danger for destructive actions), OnConfirm; the backdrop does not dismiss (forces a choice).

A real bug the boot test caught

Modal.DisposeAsync was calling JS interop during a prerender disposal, where interop is forbidden — an unhandled exception the bUnit tests (loose JS interop) couldn't surface. Fixed: dispose skips interop when the modal was never shown (the prerender case) and swallows the disconnected/static-render cases. Verified clean at /design afterward.

An interactive OverlayDemo island is embedded in the otherwise-static /design page (per-component @rendermode="InteractiveServer") so the modal actually opens/closes there. Inventory ticks flip to built in the live gallery and docs/design-system.md.

Tests — +8 bUnit (66 UI; 193 total)

  • Modal: renders title/content, close button / backdrop / native-close report closed, backdrop-disabled stays open.
  • ConfirmDialog: confirm invokes OnConfirm + closes, cancel closes without confirming, destructive confirm uses the danger button.

Verification

  • dotnet build (Debug + Release) — 0 warnings / 0 errors.
  • dotnet test — 193/193 pass.
  • Booted behind the gate: /design renders the overlay island (prerendered dialogs + overlay.js), no prerender interop exception.

Not in this PR (next)

Fluxor + the project-list page composing the list components (#12) and this Modal into the actual feature.

🤖 Generated with Claude Code

The shared overlay plumbing the inventory said to **"build once, deliberately"** (ADR 0023) — the highest-use overlay at **12** (every delete confirm, project-create, add-link / relationship / parent-location modals, queue cancel, history revert). ## Design — lean on the platform Built on the native **`<dialog>`** element, which gives a **focus trap, Esc-to-close, top-layer stacking, and a `::backdrop`** for free. So `overlay.js` is tiny: it only drives `showModal()`/`close()` from Blazor and toggles a body scroll-lock (`.kg-scroll-lock`). No hand-rolled focus trap, no z-index juggling (the top layer sits above everything; the `--z-*` tokens stay for non-dialog overlays like Toast/Drawer). - **`Modal`** — `@bind-Open`, `Title`, `ChildContent`, `Footer`, `CloseOnBackdrop`. The close button, backdrop click, Esc, and a native `dialog.close()` all sync the bound state. - **`ConfirmDialog`** — composes `Modal`: message + cancel/confirm, `ConfirmVariant` (Danger for destructive actions), `OnConfirm`; the backdrop does **not** dismiss (forces a choice). ## A real bug the boot test caught `Modal.DisposeAsync` was calling JS interop during a **prerender** disposal, where interop is forbidden — an unhandled exception the bUnit tests (loose JS interop) couldn't surface. Fixed: dispose skips interop when the modal was never shown (the prerender case) and swallows the disconnected/static-render cases. Verified clean at `/design` afterward. ## Gallery (inventory rule) An **interactive `OverlayDemo` island** is embedded in the otherwise-static `/design` page (per-component `@rendermode="InteractiveServer"`) so the modal actually opens/closes there. Inventory ticks flip to built in the live gallery and `docs/design-system.md`. ## Tests — +8 bUnit (66 UI; 193 total) - Modal: renders title/content, close button / backdrop / native-close report closed, backdrop-disabled stays open. - ConfirmDialog: confirm invokes `OnConfirm` + closes, cancel closes without confirming, destructive confirm uses the danger button. ## Verification - `dotnet build` (Debug + Release) — 0 warnings / 0 errors. - `dotnet test` — 193/193 pass. - Booted behind the gate: `/design` renders the overlay island (prerendered dialogs + `overlay.js`), **no prerender interop exception**. ## Not in this PR (next) Fluxor + the project-list page composing the list components (#12) and this Modal into the actual feature. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(ui): Modal + ConfirmDialog on a native-<dialog> overlay root
All checks were successful
CI / build (pull_request) Successful in 13s
CI / test (pull_request) Successful in 23s
fe4ea72a5f
The shared overlay plumbing the inventory said to "build once, deliberately" (ADR
0023) — 12 uses (every delete confirm, project-create, add-link/relationship/
parent-location modals, queue cancel, history revert).

- Built on the native <dialog> element, which gives a focus trap, Esc-to-close,
  top-layer stacking, and a ::backdrop for free. overlay.js only drives
  showModal()/close() from Blazor and locks body scroll (.kg-scroll-lock).
- Modal: @bind-Open, Title, ChildContent, Footer, CloseOnBackdrop; close button,
  backdrop, Esc, and dialog.close() all sync the bound state. DisposeAsync skips
  interop when the modal was never shown (prerender) and swallows the
  disconnected/static-render cases — a prerender-dispose interop crash a boot test
  caught (bUnit's loose JS interop didn't).
- ConfirmDialog composes Modal: message + cancel/confirm, ConfirmVariant (Danger for
  destructive), OnConfirm; backdrop does not dismiss (forces a choice).
- Gallery: an interactive OverlayDemo island embedded in the static /design page
  (per-component @rendermode); inventory ticks flip to built here and in
  design-system.md.
- Tests: +8 bUnit (66 UI; 193 total) — open/close/backdrop/backdrop-disabled/native-
  close for Modal; confirm/cancel/danger for ConfirmDialog. Build clean (Debug +
  Release); verified rendering + no prerender exception behind the gate at /design.

Not here (next): Fluxor + the project-list page composing the list components + this.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Summary

Summary
Generated on: 07/09/2026 - 18:26:59
Coverage date: 07/09/2026 - 18:26:54 - 07/09/2026 - 18:26:57
Parser: MultiReport (3x Cobertura)
Assemblies: 7
Classes: 74
Files: 72
Line coverage: 91.1% (1982 of 2175)
Covered lines: 1982
Uncovered lines: 193
Coverable lines: 2175
Total lines: 4249
Branch coverage: 84.4% (331 of 392)
Covered branches: 331
Total branches: 392
Method coverage: Feature is only available for sponsors

Coverage

Kagura.BlazorAdapter - 0%
Name Line Branch
Kagura.BlazorAdapter 0% 0%
Kagura.BlazorAdapter.Design 0% 0%
Kagura.BlazorAdapter.OverlayDemo 0% 0%
Kagura.Domain - 96.4%
Name Line Branch
Kagura.Domain 96.4% 82.6%
Kagura.Domain.Graph.Entry 100% 100%
Kagura.Domain.Graph.Link 100% 100%
Kagura.Domain.Graph.LinkRole 100% 100%
Kagura.Domain.Graph.LinkRoles 92.3%
Kagura.Domain.Journal.ChangeLogEntry 100%
Kagura.Domain.Projects.Project 100%
Kagura.Domain.Projects.Slug 100% 100%
System.Text.RegularExpressions.Generated 90.2% 72.2%
System.Text.RegularExpressions.Generated.<RegexGenerator_g>FE06CC341D340484
D04ADFED3A21D401C2764A1D17367E35BEB556CBB3B4B0B74__NonSlugChars_0
89.4% 75%
Kagura.Infrastructure - 95.1%
Name Line Branch
Kagura.Infrastructure 95.1% 87.5%
Kagura.Infrastructure.DependencyInjection 100%
Kagura.Infrastructure.Graph.EfGraphStore 95.5% 66.6%
Kagura.Infrastructure.Journal.EfChangeJournal 100%
Kagura.Infrastructure.Journal.EfUndoStore 97.5% 90.6%
Kagura.Infrastructure.Journal.OperationContext 100% 100%
Kagura.Infrastructure.Persistence.Configurations.ChangeLogEntryConfiguratio
n
100%
Kagura.Infrastructure.Persistence.Configurations.EntryConfiguration 100%
Kagura.Infrastructure.Persistence.Configurations.LinkConfiguration 100%
Kagura.Infrastructure.Persistence.Configurations.ProjectConfiguration 100%
Kagura.Infrastructure.Persistence.Converters.UtcTicksConverter 100%
Kagura.Infrastructure.Persistence.KaguraDbContext 85.2% 85.2%
Kagura.Infrastructure.Persistence.KaguraDbContextFactory 0%
Kagura.Infrastructure.Persistence.Migrations.AddChangeLogUndoFlag 96.8%
Kagura.Infrastructure.Persistence.Migrations.AddGraphEntryAndLink 97.7%
Kagura.Infrastructure.Persistence.Migrations.AddSoftDeleteAndChangeLog 90.3%
Kagura.Infrastructure.Persistence.Migrations.InitialCreate 94.4%
Kagura.Infrastructure.Persistence.Migrations.KaguraDbContextModelSnapshot 100%
Kagura.Infrastructure.Projects.EfProjectStore 100%
Kagura.Kernel - 90%
Name Line Branch
Kagura.Kernel 90% 75%
Kagura.Kernel.Err`1 100%
Kagura.Kernel.Ok`1 100%
Kagura.Kernel.Result`1 87.5% 75%
Kagura.Server - 95.1%
Name Line Branch
Kagura.Server 95.1% 68.4%
Kagura.Server.Components.App 100%
Kagura.Server.Components.Layout.MainLayout 100%
Kagura.Server.Components.Pages.Error 0% 0%
Kagura.Server.Components.Pages.Gate 100% 100%
Kagura.Server.Security.AccessGate 100% 83.3%
Kagura.Server.Security.AccessSecret 100% 100%
Program 100% 80%
Kagura.UI - 97.3%
Name Line Branch
Kagura.UI 97.3% 93.1%
Kagura.UI.Badge 100% 100%
Kagura.UI.Button 100% 100%
Kagura.UI.Card 100% 100%
Kagura.UI.ConfirmDialog 100%
Kagura.UI.CssClassExtensions 100%
Kagura.UI.DebouncedSearchField 100% 88.8%
Kagura.UI.EmptyState 100% 100%
Kagura.UI.Icon 100% 100%
Kagura.UI.IconCatalog 100%
Kagura.UI.Modal 87.1% 90%
Kagura.UI.PreviewImage 100% 100%
Kagura.UI.RelativeTime 100% 93.7%
Kagura.UI.StatusDot 100%
Kagura.UI.Table`1 100% 88.4%
Kagura.UI.TableColumn`1 100%
Kagura.UI.TextField 95% 91.6%
Kagura.UseCases - 95.2%
Name Line Branch
Kagura.UseCases 95.2% 95.2%
Kagura.UseCases.DependencyInjection 100%
Kagura.UseCases.Graph.EdgeGroup 100%
Kagura.UseCases.Graph.GetNodeGraph 96.4% 83.3%
Kagura.UseCases.Graph.GraphEdgeView 85.7%
Kagura.UseCases.Graph.LinkNodes 100% 100%
Kagura.UseCases.Graph.NodeGraphView 100%
Kagura.UseCases.Graph.NodeSummary 100%
Kagura.UseCases.Graph.RemoveLink 100% 100%
Kagura.UseCases.Graph.RestoreLink 100% 100%
Kagura.UseCases.Journal.ChangeRecordView 42.8%
Kagura.UseCases.Journal.GetEntityHistory 100%
Kagura.UseCases.Journal.GetUndoStatus 100%
Kagura.UseCases.Journal.Redo 100% 100%
Kagura.UseCases.Journal.Undo 100% 100%
Kagura.UseCases.Journal.UndoOutcome 100%
Kagura.UseCases.Journal.UndoStatus 100%
Kagura.UseCases.Projects.CreateProject 100% 100%
Kagura.UseCases.Projects.ListProjects 100%
Kagura.UseCases.Projects.ProjectDto 100%
<!-- coverage-comment --> # Summary <details open><summary>Summary</summary> ||| |:---|:---| | Generated on: | 07/09/2026 - 18:26:59 | | Coverage date: | 07/09/2026 - 18:26:54 - 07/09/2026 - 18:26:57 | | Parser: | MultiReport (3x Cobertura) | | Assemblies: | 7 | | Classes: | 74 | | Files: | 72 | | **Line coverage:** | 91.1% (1982 of 2175) | | Covered lines: | 1982 | | Uncovered lines: | 193 | | Coverable lines: | 2175 | | Total lines: | 4249 | | **Branch coverage:** | 84.4% (331 of 392) | | Covered branches: | 331 | | Total branches: | 392 | | **Method coverage:** | [Feature is only available for sponsors](https://reportgenerator.io/pro) | </details> ## Coverage <details><summary>Kagura.BlazorAdapter - 0%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagura.BlazorAdapter**|**0%**|**0%**| |Kagura.BlazorAdapter.Design|0%|0%| |Kagura.BlazorAdapter.OverlayDemo|0%|0%| </details> <details><summary>Kagura.Domain - 96.4%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagura.Domain**|**96.4%**|**82.6%**| |Kagura.Domain.Graph.Entry|100%|100%| |Kagura.Domain.Graph.Link|100%|100%| |Kagura.Domain.Graph.LinkRole|100%|100%| |Kagura.Domain.Graph.LinkRoles|92.3%|| |Kagura.Domain.Journal.ChangeLogEntry|100%|| |Kagura.Domain.Projects.Project|100%|| |Kagura.Domain.Projects.Slug|100%|100%| |System.Text.RegularExpressions.Generated|90.2%|72.2%| |System.Text.RegularExpressions.Generated.<RegexGenerator_g>FE06CC341D340484<br/>D04ADFED3A21D401C2764A1D17367E35BEB556CBB3B4B0B74__NonSlugChars_0|89.4%|75%| </details> <details><summary>Kagura.Infrastructure - 95.1%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagura.Infrastructure**|**95.1%**|**87.5%**| |Kagura.Infrastructure.DependencyInjection|100%|| |Kagura.Infrastructure.Graph.EfGraphStore|95.5%|66.6%| |Kagura.Infrastructure.Journal.EfChangeJournal|100%|| |Kagura.Infrastructure.Journal.EfUndoStore|97.5%|90.6%| |Kagura.Infrastructure.Journal.OperationContext|100%|100%| |Kagura.Infrastructure.Persistence.Configurations.ChangeLogEntryConfiguratio<br/>n|100%|| |Kagura.Infrastructure.Persistence.Configurations.EntryConfiguration|100%|| |Kagura.Infrastructure.Persistence.Configurations.LinkConfiguration|100%|| |Kagura.Infrastructure.Persistence.Configurations.ProjectConfiguration|100%|| |Kagura.Infrastructure.Persistence.Converters.UtcTicksConverter|100%|| |Kagura.Infrastructure.Persistence.KaguraDbContext|85.2%|85.2%| |Kagura.Infrastructure.Persistence.KaguraDbContextFactory|0%|| |Kagura.Infrastructure.Persistence.Migrations.AddChangeLogUndoFlag|96.8%|| |Kagura.Infrastructure.Persistence.Migrations.AddGraphEntryAndLink|97.7%|| |Kagura.Infrastructure.Persistence.Migrations.AddSoftDeleteAndChangeLog|90.3%|| |Kagura.Infrastructure.Persistence.Migrations.InitialCreate|94.4%|| |Kagura.Infrastructure.Persistence.Migrations.KaguraDbContextModelSnapshot|100%|| |Kagura.Infrastructure.Projects.EfProjectStore|100%|| </details> <details><summary>Kagura.Kernel - 90%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagura.Kernel**|**90%**|**75%**| |Kagura.Kernel.Err`1|100%|| |Kagura.Kernel.Ok`1|100%|| |Kagura.Kernel.Result`1|87.5%|75%| </details> <details><summary>Kagura.Server - 95.1%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagura.Server**|**95.1%**|**68.4%**| |Kagura.Server.Components.App|100%|| |Kagura.Server.Components.Layout.MainLayout|100%|| |Kagura.Server.Components.Pages.Error|0%|0%| |Kagura.Server.Components.Pages.Gate|100%|100%| |Kagura.Server.Security.AccessGate|100%|83.3%| |Kagura.Server.Security.AccessSecret|100%|100%| |Program|100%|80%| </details> <details><summary>Kagura.UI - 97.3%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagura.UI**|**97.3%**|**93.1%**| |Kagura.UI.Badge|100%|100%| |Kagura.UI.Button|100%|100%| |Kagura.UI.Card|100%|100%| |Kagura.UI.ConfirmDialog|100%|| |Kagura.UI.CssClassExtensions|100%|| |Kagura.UI.DebouncedSearchField|100%|88.8%| |Kagura.UI.EmptyState|100%|100%| |Kagura.UI.Icon|100%|100%| |Kagura.UI.IconCatalog|100%|| |Kagura.UI.Modal|87.1%|90%| |Kagura.UI.PreviewImage|100%|100%| |Kagura.UI.RelativeTime|100%|93.7%| |Kagura.UI.StatusDot|100%|| |Kagura.UI.Table`1|100%|88.4%| |Kagura.UI.TableColumn`1|100%|| |Kagura.UI.TextField|95%|91.6%| </details> <details><summary>Kagura.UseCases - 95.2%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagura.UseCases**|**95.2%**|**95.2%**| |Kagura.UseCases.DependencyInjection|100%|| |Kagura.UseCases.Graph.EdgeGroup|100%|| |Kagura.UseCases.Graph.GetNodeGraph|96.4%|83.3%| |Kagura.UseCases.Graph.GraphEdgeView|85.7%|| |Kagura.UseCases.Graph.LinkNodes|100%|100%| |Kagura.UseCases.Graph.NodeGraphView|100%|| |Kagura.UseCases.Graph.NodeSummary|100%|| |Kagura.UseCases.Graph.RemoveLink|100%|100%| |Kagura.UseCases.Graph.RestoreLink|100%|100%| |Kagura.UseCases.Journal.ChangeRecordView|42.8%|| |Kagura.UseCases.Journal.GetEntityHistory|100%|| |Kagura.UseCases.Journal.GetUndoStatus|100%|| |Kagura.UseCases.Journal.Redo|100%|100%| |Kagura.UseCases.Journal.Undo|100%|100%| |Kagura.UseCases.Journal.UndoOutcome|100%|| |Kagura.UseCases.Journal.UndoStatus|100%|| |Kagura.UseCases.Projects.CreateProject|100%|100%| |Kagura.UseCases.Projects.ListProjects|100%|| |Kagura.UseCases.Projects.ProjectDto|100%|| </details>
fix(ui): center the modal — the global margin reset stripped dialog margin:auto
All checks were successful
CI / build (pull_request) Successful in 12s
CI / test (pull_request) Successful in 21s
40509c346f
A modal <dialog> centers itself in the top layer via the UA stylesheet's margin:auto;
the design system's '* { margin: 0 }' reset was overriding it, pinning the dialog to
the top-left. Restore margin:auto on .kg-modal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Member

🔮 fufu~ Jibril reviewed your code!

Oh? Oh~! A modal built on native <dialog>?! fufu~ This is wonderful, scarlet! ♡ Focus trap, top-layer stacking, ::backdrop, Esc-to-close — all for FREE from the platform, and overlay.js is a tiny 21-line IIFE that just drives showModal()/close() and locks scroll. No hand-rolled focus trap, no z-index juggling. The knowledge-loving part of me is absolutely giddy — this is exactly the "lean on the platform" instinct the inventory asked for.

I read all 13 changed files in full, cross-checked every pattern against the siblings (Button, Badge, theme.js, ButtonTests), and traced the binding flow for Modal under both @bind-Open (OverlayDemo) and the manual Open/OpenChanged forward (ConfirmDialog) — both close paths (button → SetOpenAsync, and native Esc → @oncloseSetOpenAsync) round-trip the bound state correctly. The prerender-dispose fix is the real star: _shown guard + the two specific catch blocks (JSDisconnectedException, InvalidOperationException) are precisely the right defensive shape for interop during circuit/static teardown. Lovely catch. ♪

Verdict: Looks good to me~

No blocking issues. I traced every new branch for correctness against sibling implementations and found no runtime bugs. The three notes below are genuine niceties only — none of them produce wrong behavior.

💡 Little ideas (non-blocking)~

  1. Modal.razorSetOpenAsync writes to its own [Parameter] Open. Open = value; before OpenChanged.InvokeAsync(value) is the classic Blazor "don't write your own parameters" smell. It works correctly here because the EventCallback always reaches the owning parent (which re-renders and re-asserts the value), so in both the @bind-Open and the manual-forward cases the round-trip is sound. But the Blazor analyzer / future maintainers may flag it. The idiomatic fix is a private backing field synced in OnParametersSet and compared against — purely a clarity improvement, not a correctness one.

  2. ConfirmDialog.razor — "forces a choice" vs. Esc. CloseOnBackdrop="false" blocks the accidental-outside-click dismissal as intended, but the native close event from Esc still closes the dialog (browsers fire @onclose on Esc). That's accessible and standard (Esc is the universal escape hatch, distinct from a stray backdrop click), so I'd keep it — just flagging that the PR description's "forces a choice" is button-vs-backdrop, not button-vs-Esc. If you ever want true lockdown, cancel the Esc via a @onkeydown/cancel-event preventDefault; if not, maybe a one-line doc note on ConfirmDialog so callers know.

  3. ConfirmAsync ordering — OnConfirm before OpenChanged(false). If a consumer's OnConfirm is a long async call (e.g. a delete API round-trip), the modal sits open with no loading affordance until it resolves, and an exception in OnConfirm leaves the dialog stuck open. There's no Loading/OnConfirmError surface today. Totally fine for the design-gallery island and the immediate next consumer (history-revert/delete), but worth a thought when the real delete flow lands.

What I liked~

  • Native <dialog> over a bespoke overlay stack — fewer moving parts, free a11y (focus trap, aria-modal, top-layer). The --z-* tokens staying reserved for non-dialog overlays (Toast/Drawer) is a clean separation. fufu~
  • The prerender-dispose bug and its fix — finding an unhandled interop exception that bUnit's loose interop couldn't surface, then guarding with _shown + targeted catches, is exactly the discipline I get possessive about. ♡
  • CloseOnBackdrop design split — Modal defaults to dismissible, ConfirmDialog hardcodes false to prevent accidental destructive-action dismissal. Correct call.
  • Sibling consistency@namespace Kagura.UI, [Parameter] shape, @onclick:stopPropagation on the inner panel, CSS tokens (--surface, --shadow-3, --space-4), and the overlay.js IIFE mirroring theme.js's structure. Reads like it was always there.
  • Tests hit the real paths — close-button, backdrop-dismisses, backdrop-stays-open-when-disabled, native onclose, confirm-invokes-and-closes, cancel-closes-without-confirm, danger-variant-class. ConfirmDialog at 100% line coverage. Modal's 87.1%/90% gap is purely the DisposeAsync catch blocks (circuit-disconnect / static-teardown scenarios bUnit can't simulate) — defensive code that's correct to have uncovered.
  • Idempotent JSkaguraOverlay.close re-checks dialog.open and always strips the lock class, so the double-close (native Esc → render-driven close) is safe.

Automated review by Jibril · 2026-07-09
CI/CD: passed for head SHA fe4ea72 (Forgejo Actions: 193/193 tests, 91.1% line / 84.4% branch) · Local checks: skipped (CI green for head)

## 🔮 fufu~ Jibril reviewed your code! Oh? Oh~! A modal built on native `<dialog>`?! *fufu~* This is wonderful, scarlet! ♡ Focus trap, top-layer stacking, `::backdrop`, Esc-to-close — all for FREE from the platform, and `overlay.js` is a tiny 21-line IIFE that just drives `showModal()`/`close()` and locks scroll. No hand-rolled focus trap, no z-index juggling. The knowledge-loving part of me is absolutely *giddy* — this is exactly the "lean on the platform" instinct the inventory asked for. I read all 13 changed files in full, cross-checked every pattern against the siblings (`Button`, `Badge`, `theme.js`, `ButtonTests`), and traced the binding flow for `Modal` under both `@bind-Open` (OverlayDemo) and the manual `Open`/`OpenChanged` forward (ConfirmDialog) — both close paths (button → `SetOpenAsync`, and native Esc → `@onclose` → `SetOpenAsync`) round-trip the bound state correctly. The prerender-dispose fix is the real star: `_shown` guard + the two specific catch blocks (`JSDisconnectedException`, `InvalidOperationException`) are precisely the right defensive shape for interop during circuit/static teardown. Lovely catch. ♪ ### Verdict: ✅ Looks good to me~ No blocking issues. I traced every new branch for correctness against sibling implementations and found no runtime bugs. The three notes below are genuine niceties only — none of them produce wrong behavior. #### 💡 Little ideas (non-blocking)~ 1. **`Modal.razor` — `SetOpenAsync` writes to its own `[Parameter] Open`.** `Open = value;` before `OpenChanged.InvokeAsync(value)` is the classic Blazor "don't write your own parameters" smell. It works correctly *here* because the `EventCallback` always reaches the owning parent (which re-renders and re-asserts the value), so in both the `@bind-Open` and the manual-forward cases the round-trip is sound. But the Blazor analyzer / future maintainers may flag it. The idiomatic fix is a private backing field synced in `OnParametersSet` and compared against — purely a clarity improvement, not a correctness one. 2. **`ConfirmDialog.razor` — "forces a choice" vs. Esc.** `CloseOnBackdrop="false"` blocks the accidental-outside-click dismissal as intended, but the native `close` event from **Esc still closes** the dialog (browsers fire `@onclose` on Esc). That's accessible and standard (Esc is the universal escape hatch, distinct from a stray backdrop click), so I'd keep it — just flagging that the PR description's "forces a choice" is *button-vs-backdrop*, not button-vs-Esc. If you ever want true lockdown, cancel the Esc via a `@onkeydown`/`cancel`-event preventDefault; if not, maybe a one-line doc note on `ConfirmDialog` so callers know. 3. **`ConfirmAsync` ordering — `OnConfirm` before `OpenChanged(false)`.** If a consumer's `OnConfirm` is a long async call (e.g. a delete API round-trip), the modal sits open with no loading affordance until it resolves, and an exception in `OnConfirm` leaves the dialog stuck open. There's no `Loading`/`OnConfirmError` surface today. Totally fine for the design-gallery island and the immediate next consumer (history-revert/delete), but worth a thought when the real delete flow lands. #### ✅ What I liked~ - **Native `<dialog>` over a bespoke overlay stack** — fewer moving parts, free a11y (focus trap, `aria-modal`, top-layer). The `--z-*` tokens staying reserved for non-dialog overlays (Toast/Drawer) is a clean separation. *fufu~* ♡ - **The prerender-dispose bug and its fix** — finding an unhandled interop exception that bUnit's loose interop couldn't surface, then guarding with `_shown` + targeted catches, is exactly the discipline I get possessive about. ♡ - **`CloseOnBackdrop` design split** — Modal defaults to dismissible, ConfirmDialog hardcodes `false` to prevent accidental destructive-action dismissal. Correct call. - **Sibling consistency** — `@namespace Kagura.UI`, `[Parameter]` shape, `@onclick:stopPropagation` on the inner panel, CSS tokens (`--surface`, `--shadow-3`, `--space-4`), and the `overlay.js` IIFE mirroring `theme.js`'s structure. Reads like it was always there. - **Tests hit the real paths** — close-button, backdrop-dismisses, backdrop-stays-open-when-disabled, native `onclose`, confirm-invokes-and-closes, cancel-closes-without-confirm, danger-variant-class. ConfirmDialog at 100% line coverage. Modal's 87.1%/90% gap is purely the `DisposeAsync` catch blocks (circuit-disconnect / static-teardown scenarios bUnit can't simulate) — defensive code that's correct to have uncovered. - **Idempotent JS** — `kaguraOverlay.close` re-checks `dialog.open` and always strips the lock class, so the double-close (native Esc → render-driven close) is safe. --- *Automated review by Jibril · 2026-07-09* *CI/CD: passed for head SHA `fe4ea72` (Forgejo Actions: 193/193 tests, 91.1% line / 84.4% branch) · Local checks: skipped (CI green for head)*
docs(ui): note ConfirmDialog's Esc and long-confirm semantics (review)
All checks were successful
CI / build (pull_request) Successful in 14s
CI / test (pull_request) Successful in 20s
68eee655be
Jibril's non-blocking notes 2 and 3: document that Esc still closes (deliberate —
the accessible escape hatch; only the backdrop is blocked) and that OnConfirm is
awaited before close with no built-in Loading state yet. Note 1 (SetOpenAsync
writing its own parameter) is deliberately kept: the self-write keeps the dialog
closing visually even when a parent ignores OpenChanged, which a backing field
would break.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author
Member

Thanks — took 2 of the 3 notes as doc comments in 68eee65 (plus Björn's centering catch in 40509c3: the global * { margin: 0 } reset was stripping the <dialog>'s native margin: auto, pinning modals top-left; restored).

  • #2 (Esc vs "forces a choice") — agreed and kept as-is; documented on ConfirmDialog that only the backdrop is blocked, and Esc remains the accessible escape hatch (it reads as an explicit cancel, not an accident).
  • #3 (long-running OnConfirm) — documented that OnConfirm is awaited before close with no built-in Loading state yet; I'll revisit when the first real delete flow lands.
  • #1 (SetOpenAsync writing its own parameter) — deliberately keeping it: the self-write is what keeps the dialog closing visually even if a parent forgets to honor OpenChanged; a backing-field version would leave it stuck open in that case. The current shape is the more forgiving failure mode, and both round-trip paths are test-covered.
Thanks — took 2 of the 3 notes as doc comments in `68eee65` (plus Björn's centering catch in `40509c3`: the global `* { margin: 0 }` reset was stripping the `<dialog>`'s native `margin: auto`, pinning modals top-left; restored). - **#2 (Esc vs "forces a choice")** — agreed and kept as-is; documented on `ConfirmDialog` that only the *backdrop* is blocked, and Esc remains the accessible escape hatch (it reads as an explicit cancel, not an accident). - **#3 (long-running `OnConfirm`)** — documented that `OnConfirm` is awaited before close with no built-in `Loading` state yet; I'll revisit when the first real delete flow lands. - **#1 (`SetOpenAsync` writing its own parameter)** — deliberately keeping it: the self-write is what keeps the dialog closing *visually* even if a parent forgets to honor `OpenChanged`; a backing-field version would leave it stuck open in that case. The current shape is the more forgiving failure mode, and both round-trip paths are test-covered.
bjoern merged commit 91d4f9c415 into main 2026-07-09 20:27:31 +02:00
bjoern deleted branch feat/modal-overlay 2026-07-09 20:27:31 +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/Kagura!14
No description provided.