docs(modals): URL-addressable modals design (pilot: file editor)
Spec for the swift3-style ?modal=<path> pattern: same route renders full page or layoutless fragment based on an HX-Modal header, ~50-line JS module owns URL+history, HTMX owns fetch+swap, native <dialog> owns show/hide. Pilot migrates the file editor's open/render flow only — save flow stays AJAX. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2942467cfd
commit
fcab4b0b72
1 changed files with 236 additions and 0 deletions
|
|
@ -0,0 +1,236 @@
|
||||||
|
# URL-Addressable Modals — Design (pilot: file editor)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Modals in left4me today are inline `<dialog>` elements pre-rendered into every
|
||||||
|
page that needs them, opened/closed via JS only. There is no URL state, no
|
||||||
|
deep linking, no "share this view" affordance, and content is populated by
|
||||||
|
client-side state — for the file editor specifically,
|
||||||
|
`l4d2web/l4d2web/static/js/files-overlay.js` fills an empty
|
||||||
|
`<dialog id="files-editor-modal">` template (at
|
||||||
|
`l4d2web/l4d2web/templates/overlay_detail.html:165-228`) from data already on the page.
|
||||||
|
|
||||||
|
We want the swift3 (Rails) pattern: clicking a modal trigger appends
|
||||||
|
`?modal=<path>` to the current URL, the modal content is server-rendered from
|
||||||
|
that path with a layoutless template, and the same URL works as a standalone
|
||||||
|
full page on refresh or direct visit. Underlying-page identity is preserved,
|
||||||
|
which matters here because overlay pages have live-updating regions (build
|
||||||
|
status polled every 2 s, console transcripts) we don't want to lose when
|
||||||
|
someone shares a link with a modal open.
|
||||||
|
|
||||||
|
**Pilot scope:** the file editor only. Its open/render flow migrates to
|
||||||
|
URL-addressable; its save flow stays AJAX (no `<form>`, save button is
|
||||||
|
`type="button"`, current code in `files-overlay.js` reads
|
||||||
|
`window.__filesEditor.getValue()` and POSTs directly). Other inline
|
||||||
|
`<dialog>`s (rename, delete, new folder, conflict-resolution) stay as they
|
||||||
|
are. The new system is additive and lives at a different attribute selector
|
||||||
|
(`[data-modal]`) than the existing inline-dialog triggers
|
||||||
|
(`[data-modal-open]` / `[data-modal-close]`), so they coexist without
|
||||||
|
collision.
|
||||||
|
|
||||||
|
## Architecture — Approach C (Hybrid)
|
||||||
|
|
||||||
|
Three layers with clear boundaries:
|
||||||
|
|
||||||
|
1. **Custom JS module (~50 lines)** owns: click intercept on `[data-modal]`,
|
||||||
|
`?modal=<path>` URL composition, `history.pushState`, `popstate` handling,
|
||||||
|
`<dialog>` open/close, initial-load bootstrap (parse `?modal=` from URL on
|
||||||
|
`DOMContentLoaded`).
|
||||||
|
2. **HTMX (already loaded)** owns: the fetch, response swap into
|
||||||
|
`#modal-content`, loading indicator, error display, swap events. The JS
|
||||||
|
module triggers HTMX via `htmx.ajax('GET', path, …)`.
|
||||||
|
3. **Jinja layout switch** owns: rendering the same route as a full page or a
|
||||||
|
layoutless fragment based on a custom `HX-Modal: 1` request header.
|
||||||
|
Deliberately **not** HTMX's built-in `HX-Request`, which fires on every
|
||||||
|
HTMX request including the existing 2-second build-status poll — using it
|
||||||
|
would misclassify polls as modal renders.
|
||||||
|
|
||||||
|
### Why C and not A (HTMX-native) or B (port swift3 verbatim)
|
||||||
|
|
||||||
|
- **A** depends on `HX-Push-Url` response headers and server-side URL
|
||||||
|
composition using `Referer`. That's fragile on POST redirects and on
|
||||||
|
first-load bootstrap — the server doesn't reliably know the "underlying"
|
||||||
|
URL.
|
||||||
|
- **B** reimplements fetch/swap/loading/error machinery HTMX already provides
|
||||||
|
(~150 lines), gaining only HTMX-independence (hypothetical value).
|
||||||
|
- **C** isolates the one thing HTMX *can't* do well (compose a URL whose path
|
||||||
|
stays put while a query param records the modal target) into the smallest
|
||||||
|
possible module, and lets HTMX do the rest. URL composition is local,
|
||||||
|
deterministic, and obviously correct.
|
||||||
|
|
||||||
|
C is also a stepping stone to A if we ever decide server-side URL composition
|
||||||
|
is preferable; going A → C means rewriting URL state.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### 1. `modal-router.js` (new, ~50 lines)
|
||||||
|
|
||||||
|
Two attribute systems coexist deliberately:
|
||||||
|
|
||||||
|
- `[data-modal]` on links — the new URL-addressable system (this module)
|
||||||
|
- `[data-modal-open]` / `[data-modal-close]` on buttons — the existing inline
|
||||||
|
dialog triggers in `l4d2web/l4d2web/static/js/modal.js` (unchanged, keeps
|
||||||
|
working)
|
||||||
|
|
||||||
|
Module responsibilities:
|
||||||
|
|
||||||
|
```text
|
||||||
|
click on a[data-modal] (left-click only, no modifier keys)
|
||||||
|
→ preventDefault
|
||||||
|
→ openModal(href)
|
||||||
|
|
||||||
|
openModal(path)
|
||||||
|
→ url.searchParams.set('modal', path)
|
||||||
|
→ history.pushState({modal: path}, '', url)
|
||||||
|
→ fetchAndShow(path)
|
||||||
|
|
||||||
|
fetchAndShow(path)
|
||||||
|
→ htmx.ajax('GET', path, {target:'#modal-content', headers:{'HX-Modal':'1'}})
|
||||||
|
→ on success: document.getElementById('modal-container').showModal()
|
||||||
|
→ guard against stale responses (track currentModalPath token; discard
|
||||||
|
swaps whose target path no longer matches)
|
||||||
|
|
||||||
|
closeModal()
|
||||||
|
→ dialog.close()
|
||||||
|
→ url.searchParams.delete('modal')
|
||||||
|
→ history.pushState({}, '', url)
|
||||||
|
→ (deferred — pilot doesn't require it) optional refresh of underlying page
|
||||||
|
content via htmx.ajax
|
||||||
|
|
||||||
|
popstate
|
||||||
|
→ re-read ?modal= from URL
|
||||||
|
→ if set: fetchAndShow; if absent: dialog.close()
|
||||||
|
|
||||||
|
DOMContentLoaded
|
||||||
|
→ if ?modal= present: fetchAndShow
|
||||||
|
|
||||||
|
click on [data-modal-dismiss]
|
||||||
|
→ closeModal()
|
||||||
|
|
||||||
|
dialog 'cancel' event (Esc key, native)
|
||||||
|
→ closeModal() (so the URL syncs)
|
||||||
|
```
|
||||||
|
|
||||||
|
Links-only (not buttons): preserves the "works without JS" property — a
|
||||||
|
plain `<a href>` navigates to the full-page version naturally if the script
|
||||||
|
fails to load.
|
||||||
|
|
||||||
|
### 2. Persistent modal slot in `base.html`
|
||||||
|
|
||||||
|
```html
|
||||||
|
<dialog id="modal-container" class="modal modal-wide">
|
||||||
|
<div id="modal-content"></div>
|
||||||
|
</dialog>
|
||||||
|
```
|
||||||
|
|
||||||
|
Native `<dialog>` chosen over swift3's CSS-class approach because it:
|
||||||
|
|
||||||
|
- gets free focus trap, Esc-to-close, backdrop click behavior
|
||||||
|
- matches the existing modal markup pattern in left4me
|
||||||
|
- the existing CSS at `l4d2web/l4d2web/static/css/components.css:114-166`
|
||||||
|
already styles `dialog.modal`
|
||||||
|
|
||||||
|
### 3. Jinja layout switch via context processor
|
||||||
|
|
||||||
|
```python
|
||||||
|
# l4d2web/l4d2web/app.py (or wherever the Flask app is constructed)
|
||||||
|
@app.context_processor
|
||||||
|
def inject_base_layout():
|
||||||
|
is_modal = request.headers.get('HX-Modal') == '1'
|
||||||
|
return {'base_layout': '_modal_partial.html' if is_modal else 'base.html'}
|
||||||
|
```
|
||||||
|
|
||||||
|
```jinja
|
||||||
|
{# templates/_modal_partial.html — single line #}
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
|
||||||
|
{# Any page template (e.g. the new editor route) #}
|
||||||
|
{% extends base_layout %}
|
||||||
|
{% block content %}…the page content…{% endblock %}
|
||||||
|
```
|
||||||
|
|
||||||
|
Same route, two render modes, zero per-route changes after a template is
|
||||||
|
updated to use `{% extends base_layout %}`.
|
||||||
|
|
||||||
|
### 4. New server route: file editor as a real page
|
||||||
|
|
||||||
|
The dominant cost of the pilot. The current editor markup is empty and
|
||||||
|
populated by `files-overlay.js`. For URL deep-linking to mean anything on
|
||||||
|
refresh, the editor route must server-render the markup with content
|
||||||
|
pre-filled.
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /overlays/<overlay_id>/files/edit?path=<rel_path>
|
||||||
|
→ load file by path from the overlay's filesystem
|
||||||
|
→ render templates/overlay_file_editor.html with values pre-filled:
|
||||||
|
- filename input value
|
||||||
|
- <textarea data-editor-language="auto">{{ content }}</textarea>
|
||||||
|
- byte count, modification time
|
||||||
|
- download href
|
||||||
|
→ if request.headers['HX-Modal'] == '1':
|
||||||
|
extends _modal_partial.html → returns the dialog body fragment only
|
||||||
|
else:
|
||||||
|
extends base.html → returns full standalone page (overlay nav + editor)
|
||||||
|
```
|
||||||
|
|
||||||
|
This template is a **lift-and-shift** of `overlay_detail.html:165-228` into
|
||||||
|
its own template file, with content variables substituted in instead of empty
|
||||||
|
placeholders.
|
||||||
|
|
||||||
|
`files-overlay.js` becomes simpler: the "open editor" code path now becomes
|
||||||
|
just `location.href = <edit-url>` (which the modal-router intercepts and
|
||||||
|
turns into the modal flow). Save flow is unchanged — still calls the
|
||||||
|
existing AJAX POST endpoint reading `window.__filesEditor.getValue()`.
|
||||||
|
|
||||||
|
### 5. CodeMirror re-init after HTMX swap
|
||||||
|
|
||||||
|
`l4d2web/l4d2web/static/js/editor.js` currently mounts CM6 at
|
||||||
|
`DOMContentLoaded` only (see the `if (document.readyState === "loading")`
|
||||||
|
branch at the end of the file). After the editor HTML is swapped in via
|
||||||
|
HTMX, the new `<textarea data-editor-language>` is in the DOM but unmounted.
|
||||||
|
Fix:
|
||||||
|
|
||||||
|
- Refactor `editor.js` to expose `window.__editor.initEditors(root)` covering
|
||||||
|
the existing init logic, parameterized by a DOM root (defaulting to
|
||||||
|
`document`).
|
||||||
|
- Add an `htmx:afterSwap` listener: if `event.target.id === 'modal-content'`,
|
||||||
|
call `window.__editor.initEditors(event.target)`.
|
||||||
|
- Add a cleanup hook: when the modal closes, call `controller.destroy()` on
|
||||||
|
the CM6 instance to free listeners (the editor bundle should already
|
||||||
|
expose this on the controller; verify at implementation time).
|
||||||
|
|
||||||
|
## Critical files to modify
|
||||||
|
|
||||||
|
| Path | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `l4d2web/l4d2web/static/js/modal-router.js` | new — the ~50-line module |
|
||||||
|
| `l4d2web/l4d2web/static/js/editor.js` | expose `initEditors(root)`, add `htmx:afterSwap` listener |
|
||||||
|
| `l4d2web/l4d2web/static/js/files-overlay.js` | change "open editor" path to set `location.href` (intercepted) instead of populating the inline dialog |
|
||||||
|
| `l4d2web/l4d2web/templates/base.html` | add persistent `#modal-container` slot; include `modal-router.js` |
|
||||||
|
| `l4d2web/l4d2web/templates/_modal_partial.html` | new — single-line layout |
|
||||||
|
| `l4d2web/l4d2web/templates/overlay_file_editor.html` | new — lifted from `overlay_detail.html:165-228` with content variables |
|
||||||
|
| `l4d2web/l4d2web/templates/overlay_detail.html` | remove the inline `<dialog id="files-editor-modal">` (it is now a route) |
|
||||||
|
| `l4d2web/l4d2web/routes/files_routes.py` | add `GET /overlays/<id>/files/edit` |
|
||||||
|
| `l4d2web/l4d2web/app.py` (or app-factory location) | add the `inject_base_layout` context processor |
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
End-to-end checks (manual + Chromium):
|
||||||
|
|
||||||
|
1. **Direct link works as full page.** Open `/overlays/<id>/files/edit?path=server.cfg` in a new tab. Editor renders standalone with content. Save works (AJAX).
|
||||||
|
2. **Modal open from overlay.** From `/overlays/<id>`, click a file's edit link. URL becomes `/overlays/<id>?modal=/overlays/<id>/files/edit?path=server.cfg`. Modal opens with content. CM6 mounted, language detected from filename, byte count correct.
|
||||||
|
3. **Refresh in modal state.** With modal open, hit refresh. Same modal opens, same content, on the same underlying overlay page (which has resumed its 2-second build-status polling).
|
||||||
|
4. **Share URL.** Copy the modal-state URL, open in incognito. Lands on the overlay page with modal already open.
|
||||||
|
5. **Back button.** With modal open, click back. URL loses `?modal=`, modal closes, underlying page intact (no flicker, polling continues).
|
||||||
|
6. **Forward button.** After back, click forward. Modal re-opens with the right file.
|
||||||
|
7. **Esc to close.** Native `<dialog>` `cancel` event fires; URL clears.
|
||||||
|
8. **Race on rapid clicks.** Click edit on file A, immediately click edit on file B. Modal ends in file B's state.
|
||||||
|
9. **No HTMX poll misclassification.** Open dev tools network panel during a modal session. Build-status polls do not carry `HX-Modal: 1` and do not trigger modal-router code.
|
||||||
|
10. **Existing inline dialogs unaffected.** Rename, delete, new-folder, conflict-resolution modals still open from their `data-modal-open` triggers.
|
||||||
|
|
||||||
|
## Out of scope (follow-ups)
|
||||||
|
|
||||||
|
- Migrating the editor save flow from AJAX to `hx-post` + `HX-Redirect` (a real `<form>`)
|
||||||
|
- Migrating other modals (rename, delete, new-folder, conflict) to the URL-addressable pattern
|
||||||
|
- Server-side URL composition (Approach A migration) — only if a concrete need emerges
|
||||||
|
- `[data-modal]` on non-`<a>` elements (buttons that trigger modals without an `href`) — kept links-only for now to preserve the "works without JS" property
|
||||||
Loading…
Reference in a new issue