Sortable List

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">&#9776;</span> {{.Name}}
        </li>
    {{end}}
    </ul>
    <form method="POST">
        <button name="reset" class="secondary outline">Reset Order</button>
    </form>
</article>
{{end}}

sortable.tmpl

Handler & state

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",
	}))
}

handlers_lists.go:186-263

type SortableState struct {
	Title    string
	Category string
	Items    []SortableItem
}

type SortableItem struct {
	Key  string
	Name string
}

state_lists.go:51-61

When to use

For removing rather than reordering rows, see Delete Row.

source: livetemplate/docs · path: examples/patterns/templates/lists/sortable.tmpl