Drag a row onto another to reorder. Each <li> is draggable and binds native HTML5
dragstart/dragover/drop events with lvt-on:; on drop the client sends the
source and target data-keys as dragSourceKey/dragTargetKey, and the Reorder
handler moves the item in a process-wide slice. The new order persists across
reloads, and Reset Order restores the original sequence.
Sortable List
Drag any row to reorder (mouse only; no keyboard path or visual drop-zone highlighting in this demo). The new order persists across reloads. Use Reset Order to restore the initial sequence.
Use a mouse to drag items. Keyboard reordering is not supported in this demo.
☰ Design wireframes
☰ Write API spec
☰ Implement backend
☰ Build frontend
☰ Write tests
☰ Deploy to staging
Template
draggable="true" plus the three lvt-on: drag bindings make each item a drop
target; data-key is what the handler reads to know what moved where.
{{define "content"}}
<article>
<hgroup>
<h3>Sortable List</h3>
<p><small>Drag any row to reorder (mouse only; no keyboard path or visual drop-zone highlighting in this demo). The new order persists across reloads. Use Reset Order to restore the initial sequence.</small></p>
</hgroup>
<p id="sortable-list-desc" class="visually-hidden">Use a mouse to drag items. Keyboard reordering is not supported in this demo.</p>
<ul id="sortable-list"
aria-label="Reorderable task list"
aria-describedby="sortable-list-desc">
{{range .Items}}
<li data-key="{{.Key}}"
draggable="true"
lvt-on:dragstart=""
lvt-on:dragover=""
lvt-on:drop="reorder">
<span aria-hidden="true">☰</span> {{.Name}}
</li>
{{end}}
</ul>
<form method="POST">
<button name="reset" class="secondary outline">Reset Order</button>
</form>
</article>
{{end}}
Reorder reads dragSourceKey/dragTargetKey, finds both items under a mutex, and
splices the source into the target's position; it always re-reads the shared slice so
the order stays authoritative. Reset restores the initial items.
// SortableController holds the list ordering process-wide so it persists across reloads (live multi-tab sync would need Publish to SelfTopic()).
type SortableController struct {
mu sync.Mutex
items []SortableItem
}
func newSortableController() *SortableController {
return &SortableController{items: initialSortableItems()}
}
func (c *SortableController) snapshot() []SortableItem {
c.mu.Lock()
defer c.mu.Unlock()
return slices.Clone(c.items)
}
func (c *SortableController) Mount(state SortableState, ctx *livetemplate.Context) (SortableState, error) {
state.Items = c.snapshot()
return state, nil
}
// Reorder reads dragSourceKey / dragTargetKey (injected by livetemplate/client from the source/target data-key) and always repopulates state.Items from the locked snapshot — the framework-provided value is per-session and may lag the shared ordering.
func (c *SortableController) Reorder(state SortableState, ctx *livetemplate.Context) (SortableState, error) {
src := ctx.GetString("dragSourceKey")
tgt := ctx.GetString("dragTargetKey")
c.mu.Lock()
defer c.mu.Unlock()
if src == "" || tgt == "" || src == tgt {
state.Items = slices.Clone(c.items)
return state, nil
}
srcIdx, tgtIdx := -1, -1
for i, it := range c.items {
if it.Key == src {
srcIdx = i
}
if it.Key == tgt {
tgtIdx = i
}
if srcIdx >= 0 && tgtIdx >= 0 {
break
}
}
if srcIdx < 0 || tgtIdx < 0 {
state.Items = slices.Clone(c.items)
return state, nil
}
moved := c.items[srcIdx]
c.items = slices.Delete(c.items, srcIdx, srcIdx+1)
if srcIdx < tgtIdx {
tgtIdx--
}
c.items = slices.Insert(c.items, tgtIdx, moved)
state.Items = slices.Clone(c.items)
return state, nil
}
func (c *SortableController) Reset(state SortableState, ctx *livetemplate.Context) (SortableState, error) {
c.mu.Lock()
defer c.mu.Unlock()
c.items = initialSortableItems()
state.Items = slices.Clone(c.items)
return state, nil
}
func sortableHandler() http.Handler {
tmpl := newLayoutTmpl("templates/layout.tmpl", "templates/lists/sortable.tmpl")
return tmpl.Handle(newSortableController(), livetemplate.AsState(&SortableState{
Title: "Sortable List",
Category: "Lists & Data",
}))
}