Replace per-row checkbox + numeric Order inputs with a drag-to-reorder list of selected overlays plus a native <select> for adding more. Native HTML5 DnD; no library, no JS-disabled fallback. Server contract unchanged (overlay_ids in DOM order; existing fallback_position branch absorbs the omitted overlay_position_<id> fields). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
8.5 KiB
L4D2 Blueprint Overlay Picker Design
Goal: Replace the checkbox + numeric-Order table on the blueprint detail page with a drag-to-reorder list and a single dropdown to add overlays. Drag-and-drop is the primary reorder mechanic; per-row Order text inputs are removed.
Approval status: User-approved. No companion implementation plan — small surface, implemented directly.
Context
templates/blueprint_detail.html:14-28 currently renders one HTML table for the blueprint's overlays. Each row carries a Use checkbox, a numeric Order text input, and the overlay name. To enable an overlay you check it; to reorder you type integers into per-row text fields. Adding a new overlay between existing ones means renumbering everything below it by hand.
This spec replaces that table with a single ordered list of selected overlays plus a <select> dropdown for adding more. Drag-to-reorder is the only reorder interaction. A ✕ button on each row removes it (returning it to the dropdown). Picking an entry from the dropdown appends it to the list (and removes it from the dropdown).
The change is intentionally scoped small: no two-panel layout, no filter widget, no touch / keyboard reorder support, no JS-disabled fallback. The native <select> element supplies typeahead-by-letter and keyboard navigation for free, which covers the no-drag path. The page is desktop-primary.
Locked Decisions
- Single ordered list of selected overlays only. No second pane. The "available" set lives in the
<select>. Adding via dropdown is one click; removing via ✕ is one click; reordering is one drag. - Native HTML5 drag-and-drop. No vendored library, no polyfill. Touch-screen drag is unsupported on Android and rough on iOS — accepted because the page is desktop-primary. Add and remove still work on touch via the
<select>and the<button>. - JS-required UI. If JS does not load, the page is unusable. No degradation to the old checkbox table.
- Server contract unchanged. Each list row owns one
<input type="hidden" name="overlay_ids" value="{id}">. Form-submission order = DOM order. The existingordered_overlay_ids_from_formhandler inroutes/blueprint_routes.pyalready falls back to enumerate index when nooverlay_position_<id>field is present, so it accepts the new shape with no Python edit. - Dropdown re-sorted alphabetically on remove. When ✕ removes a row, the corresponding
<option>is sorted-inserted back into the<select>(case-insensitive name compare). The dropdown stays predictable. - Drop-indicator visual. A 2px focus-color bar drawn via
box-shadow … inseton the row under the cursor: top-bar = "drop will land before this row", bottom-bar = "drop will land after this row". The hover side is computed by comparingevent.clientYto the row's vertical midpoint. - Drop on empty space inside the list = append. Drop directly on the dragged row (or with no movement) = no-op. Escape during drag triggers
dragend, which clears all visual classes. - Out of scope: keyboard reorder, ARIA live announcements, touch DnD polyfill, server-side cleanup of the now-unused
overlay_position_<id>form-field path.
Architecture
GET /blueprints/<id>
page_routes.blueprint_page
├─▶ selected_overlays (ordered by BlueprintOverlay.position)
└─▶ available_overlays = all_overlays \ selected_overlays
(alphabetical)
templates/blueprint_detail.html
<ol data-overlay-list> ← drag target, hidden inputs
<li data-overlay-id draggable> × ⋮⋮ name </li>
…
</ol>
<select data-overlay-add> ← add path
<option>Pick a name…</option>
<option value=overlay.id>name</option> ← available_overlays
…
</select>
static/js/blueprint-overlay-picker.js
├─ dragstart/over/leave/drop/end → reorder DOM under [data-overlay-list]
├─ click [data-action="remove"] → remove row + sorted-insert <option>
├─ change [data-overlay-add] → append <li>, remove <option>
└─ refreshEmpty() → toggle [data-overlay-empty][hidden]
POST /blueprints/<id>
form-encoded body: overlay_ids=<id>&overlay_ids=<id>&… (in DOM order)
blueprint_routes.update_blueprint_form
→ ordered_overlay_ids_from_form (existing; fallback_position branch)
→ replace_blueprint_overlays (existing)
Form-contract details
The new template emits one hidden input per selected row, colocated as a child of the <li>:
<li data-overlay-id="3" draggable="true">
<span class="overlay-picker-handle">⋮⋮</span>
<span class="overlay-picker-name">workshop_maps</span>
<button type="button" data-action="remove">×</button>
<input type="hidden" name="overlay_ids" value="3">
</li>
Browser form serialization preserves DOM order across multiple inputs that share a name. Werkzeug's request.form.getlist("overlay_ids") returns them in submission order. ordered_overlay_ids_from_form then assigns each id its enumerate-index position via the fallback_position branch (lines 19-31 of routes/blueprint_routes.py) and feeds the result to replace_blueprint_overlays.
The JSON path (POST /blueprints with application/json) already takes overlay_ids list order at line 64 of the same file — this spec does not affect it.
UI / UX details
- Empty state. When no overlays are selected, a
[data-overlay-empty]paragraph reads "No overlays selected. Pick one below to add." JS toggles itshiddenattribute on every list mutation. - Drag handle. Visual only (
⋮⋮glyph). The whole row isdraggable="true"; the user does not have to grab the handle specifically. - Drop indicator math. During
dragover, computeevent.clientY < rect.top + rect.height/2; that boolean picksdrop-before(bar at top) vsdrop-after(bar at bottom). Ondrop, read which class is set andinsertBeforeorinsertBefore(…, target.nextSibling)accordingly. - Sorted insert on remove. Walk
<select>children comparingoption.dataset.overlayName(lowercased) against the removed name;insertBeforethe new option ahead of the first option whose name sorts later, or append if none. - Reset select after add. Set
select.value = ""so the placeholder reappears after each add.
Files
| Path | Change |
|---|---|
l4d2web/routes/page_routes.py |
Compute available_overlays; pass to template. |
l4d2web/templates/blueprint_detail.html |
Replace overlay table with <ol> + <select>; add <script defer>. |
l4d2web/static/css/components.css |
Append .overlay-picker-* rules. Reuse existing tokens. |
l4d2web/static/js/blueprint-overlay-picker.js |
New IIFE. ~150 LOC. |
l4d2web/tests/test_blueprints.py |
Two new GET-page assertions. |
l4d2web/tests/test_pages.py |
Update test_blueprint_detail_has_ordered_overlay_form to match new shape. |
Verification
Manual browser flow (/blueprints/<id>):
- Initial render shows the saved selection in saved order; dropdown holds the rest. No console errors.
- Drag a row up/down. Focus-colored bar appears at the top or bottom of the hover-target row (depending on which half is hovered). On drop, the row moves; hidden inputs reflect the new order.
- Click ✕ on a row. Row vanishes; the same name appears in the dropdown in alphabetical position.
- Pick from the dropdown. New row appears at the end of the list; the option leaves the dropdown; the placeholder is reselected.
- Save the blueprint, reload. Order survives the round-trip.
- Press Escape mid-drag. Drop indicators clear; source row regains opacity; nothing moved.
Test commands:
pytest l4d2web/tests/test_blueprints.py -q
pytest l4d2web/tests -q
Out of scope / future follow-ups
- Drop the
overlay_position_<id>server-side path. Once no client emits those fields,ordered_overlay_ids_from_formcollapses to[int(v) for v in request.form.getlist("overlay_ids")]. Testtest_form_update_preserves_ordered_overlays_and_multiline_fields(l4d2web/tests/test_blueprints.py:220) gets simplified accordingly. - Touch-friendly DnD. Vendor a polyfill (
drag-drop-touch) or rewrite the picker on pointer events if mobile editing becomes a real use case. - Keyboard reorder. Space-to-grab + arrow-keys + ARIA live announcements. Currently only add/remove are keyboard-accessible.
- Filter on the selected list. Not needed at v1's overlay counts; revisit if blueprints commonly carry 20+ overlays.