Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions patterns/data.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,17 @@ func pageSlice(dataset []Item, page, size int) []Item {
return slices.Clone(dataset[start:end])
}

func initialSortableItems() []SortableItem {
return []SortableItem{
{Key: "task-1", Name: "Design wireframes"},
{Key: "task-2", Name: "Write API spec"},
{Key: "task-3", Name: "Implement backend"},
{Key: "task-4", Name: "Build frontend"},
{Key: "task-5", Name: "Write tests"},
{Key: "task-6", Name: "Deploy to staging"},
}
}

// carMakes maps car makes to their model lists. Used by Value Select to
// demonstrate cascading dependent selects.
var carMakes = map[string][]string{
Expand Down Expand Up @@ -246,6 +257,7 @@ func allPatterns() []PatternCategory {
{Name: "Click To Load", Path: "/patterns/lists/click-to-load", Description: "Append-only pagination", Implemented: true},
{Name: "Infinite Scroll", Path: "/patterns/lists/infinite-scroll", Description: "Auto-load on scroll with IntersectionObserver", Implemented: true},
{Name: "Value Select", Path: "/patterns/lists/value-select", Description: "Cascading dependent selects", Implemented: true},
{Name: "Sortable List", Path: "/patterns/lists/sortable", Description: "Drag-and-drop reordering with native HTML5 drag events", Implemented: true},
},
},
{
Expand Down
83 changes: 83 additions & 0 deletions patterns/handlers_lists.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,3 +172,86 @@ func infiniteScrollHandler(baseOpts []livetemplate.Option) http.Handler {
HasMore: true,
}))
}

// --- Sortable List ---

// SortableController holds the list ordering process-wide so it persists across reloads (live multi-tab sync would need Sync()).
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(baseOpts []livetemplate.Option) http.Handler {
opts := append(slices.Clone(baseOpts),
livetemplate.WithParseFiles("templates/layout.tmpl", "templates/lists/sortable.tmpl"),
)
tmpl := livetemplate.Must(livetemplate.New("layout", opts...))
return tmpl.Handle(newSortableController(), livetemplate.AsState(&SortableState{
Title: "Sortable List",
Category: "Lists & Data",
}))
}
3 changes: 2 additions & 1 deletion patterns/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,12 @@ func main() {
mux.Handle("/patterns/forms/file-upload", fileUploadHandler(baseOpts))
mux.Handle("/patterns/forms/preserve-inputs", preserveInputsHandler(baseOpts))

// Category: Lists & Data (#8–#11)
// Category: Lists & Data
mux.Handle("/patterns/lists/delete-row", deleteRowHandler(baseOpts))
mux.Handle("/patterns/lists/click-to-load", clickToLoadHandler(baseOpts))
mux.Handle("/patterns/lists/infinite-scroll", infiniteScrollHandler(baseOpts))
mux.Handle("/patterns/lists/value-select", valueSelectHandler(baseOpts))
mux.Handle("/patterns/lists/sortable", sortableHandler(baseOpts))

// Category: Search & Filtering (#12–#13)
mux.Handle("/patterns/search/active-search", activeSearchHandler(baseOpts))
Expand Down
193 changes: 193 additions & 0 deletions patterns/patterns_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1137,6 +1137,199 @@ func TestValueSelect(t *testing.T) {
})
}

// --- Sortable List ---

func TestSortable(t *testing.T) {
if testing.Short() {
t.Skip("Skipping E2E test in short mode")
}

ctx, cancel, serverPort := setupTest(t)
Comment on lines +1142 to +1147
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR description says TestSortable has “4 subtests” but the implementation includes 5 (Initial_Reset, Reorder_DragForward, Reorder_DragBackward, SelfDrop_NoOp, Reset_RestoresInitialOrder). Consider updating the PR description (or test) so they match.

Copilot uses AI. Check for mistakes.
defer cancel()

url := e2etest.GetChromeTestURL(serverPort) + "/patterns/lists/sortable"

// CDP Input.dispatchMouseEvent is unreliable for HTML5 DnD in headless
// Docker Chrome, so we dispatch real DragEvent objects with a shared
// DataTransfer instead. This still exercises the full client
// delegation pipeline — not a liveTemplateClient.send() shortcut.
simulateDrag := func(srcKey, tgtKey string) chromedp.Action {
js := fmt.Sprintf(`
(() => {
const src = document.querySelector('#sortable-list li[data-key=%q]');
const tgt = document.querySelector('#sortable-list li[data-key=%q]');
if (!src || !tgt) throw new Error('source or target not found');
const dt = new DataTransfer();
src.dispatchEvent(new DragEvent('dragstart', {bubbles:true, cancelable:true, dataTransfer:dt}));
tgt.dispatchEvent(new DragEvent('dragover', {bubbles:true, cancelable:true, dataTransfer:dt}));
tgt.dispatchEvent(new DragEvent('drop', {bubbles:true, cancelable:true, dataTransfer:dt}));
})()
`, srcKey, tgtKey)
return chromedp.Evaluate(js, nil)
}

// Reset the demo's shared in-memory order at the start. The controller's
// state is process-wide so other tests (or a previous run of this test
// in dev) could leave the list reordered.
t.Run("Initial_Reset", func(t *testing.T) {
err := chromedp.Run(ctx,
chromedp.Navigate(url),
e2etest.WaitForWebSocketReady(5*time.Second),
chromedp.WaitVisible(`#sortable-list`, chromedp.ByQuery),
e2etest.ValidateNoTemplateExpressions("[data-lvt-id]"),
e2etest.WaitForCount(`#sortable-list li[data-key]`, 6, 5*time.Second),
chromedp.Click(`button[name="reset"]`, chromedp.ByQuery),
e2etest.WaitFor(
`document.querySelectorAll('#sortable-list li')[0].dataset.key === 'task-1'`,
5*time.Second,
),
)
if err != nil {
t.Fatalf("Failed to load + reset: %v", err)
}
})

runStandardSubtests(t, ctx, false, "Sortable List — six task items each with a hamburger drag handle, in default order, plus a Reset Order button")

// resetToInitial restores the canonical task-1..task-6 order so each
// reorder subtest starts from a known state and doesn't depend on the
// previous one's outcome.
resetToInitial := chromedp.Tasks{
chromedp.Click(`button[name="reset"]`, chromedp.ByQuery),
e2etest.WaitFor(
`document.querySelectorAll('#sortable-list li')[0].dataset.key === 'task-1' && document.querySelectorAll('#sortable-list li')[5].dataset.key === 'task-6'`,
5*time.Second,
),
}

t.Run("Reorder_DragForward", func(t *testing.T) {
// Initial: [task-1, task-2, task-3, task-4, task-5, task-6]
// Drag task-1 onto task-3 with insert-before-target semantics:
// task-1 is removed from index 0, the post-removal target index of
// task-3 is 1, and task-1 is inserted at index 1.
// Expected: [task-2, task-1, task-3, task-4, task-5, task-6]
var order string
err := chromedp.Run(ctx,
resetToInitial,
simulateDrag("task-1", "task-3"),
e2etest.WaitFor(
`document.querySelectorAll('#sortable-list li')[1].dataset.key === 'task-1'`,
5*time.Second,
),
chromedp.Evaluate(
`Array.from(document.querySelectorAll('#sortable-list li')).map(el => el.dataset.key).join(',')`,
&order,
),
)
if err != nil {
t.Fatalf("Forward drag failed: %v", err)
}
want := "task-2,task-1,task-3,task-4,task-5,task-6"
if order != want {
t.Errorf("Order after forward drag: got %q, want %q", order, want)
}
})

t.Run("Reorder_DragBackward", func(t *testing.T) {
// Initial: [task-1, task-2, task-3, task-4, task-5, task-6]
// Drag task-6 onto task-2: task-6 is removed from index 5, no
// post-removal index adjustment (srcIdx > tgtIdx), task-6 inserted
// at task-2's index 1.
// Expected: [task-1, task-6, task-2, task-3, task-4, task-5]
var order string
err := chromedp.Run(ctx,
resetToInitial,
simulateDrag("task-6", "task-2"),
e2etest.WaitFor(
`document.querySelectorAll('#sortable-list li')[1].dataset.key === 'task-6'`,
5*time.Second,
),
chromedp.Evaluate(
`Array.from(document.querySelectorAll('#sortable-list li')).map(el => el.dataset.key).join(',')`,
&order,
),
)
if err != nil {
t.Fatalf("Backward drag failed: %v", err)
}
want := "task-1,task-6,task-2,task-3,task-4,task-5"
if order != want {
t.Errorf("Order after backward drag: got %q, want %q", order, want)
}
})

t.Run("SelfDrop_NoOp", func(t *testing.T) {
// The controller short-circuits when source == target, so no diff
// is emitted. We can't condition-wait on a state that should NOT
// change, so we wait long enough for any spurious server-side
// reorder to round-trip (~500ms) and assert order is unchanged.
var orderBefore string
if err := chromedp.Run(ctx,
resetToInitial,
chromedp.Evaluate(
`Array.from(document.querySelectorAll('#sortable-list li')).map(el => el.dataset.key).join(',')`,
&orderBefore,
),
); err != nil {
t.Fatalf("Failed to read order before self-drop: %v", err)
}

firstKey := strings.Split(orderBefore, ",")[0]
if err := chromedp.Run(ctx, simulateDrag(firstKey, firstKey)); err != nil {
t.Fatalf("Self-drop dispatch failed: %v", err)
}

// time.Sleep (Go-side) is fine for negative assertions — the
// CLAUDE.md "no chromedp.Sleep" rule is about browser-side waits
// that hide timing bugs in positive assertions. 1s gives loaded
// CI runners headroom for any spurious server-side reorder to
// round-trip and surface in the assertion below.
time.Sleep(1 * time.Second)

var orderAfter string
if err := chromedp.Run(ctx, chromedp.Evaluate(
`Array.from(document.querySelectorAll('#sortable-list li')).map(el => el.dataset.key).join(',')`,
&orderAfter,
)); err != nil {
t.Fatalf("Failed to read order after self-drop: %v", err)
}
if orderAfter != orderBefore {
t.Errorf("Self-drop changed order: was %q, now %q", orderBefore, orderAfter)
}
})

t.Run("Reset_RestoresInitialOrder", func(t *testing.T) {
// Scramble first so Reset has something to undo. Without this
// step the assertion would pass trivially when the list happened
// to already be in initial order.
var order string
err := chromedp.Run(ctx,
resetToInitial,
simulateDrag("task-3", "task-1"),
e2etest.WaitFor(
`document.querySelectorAll('#sortable-list li')[0].dataset.key === 'task-3'`,
5*time.Second,
),
chromedp.Click(`button[name="reset"]`, chromedp.ByQuery),
e2etest.WaitFor(
`document.querySelectorAll('#sortable-list li')[0].dataset.key === 'task-1' && document.querySelectorAll('#sortable-list li')[5].dataset.key === 'task-6'`,
5*time.Second,
),
chromedp.Evaluate(
`Array.from(document.querySelectorAll('#sortable-list li')).map(el => el.dataset.key).join(',')`,
&order,
),
)
if err != nil {
t.Fatalf("Reset failed: %v", err)
}
want := "task-1,task-2,task-3,task-4,task-5,task-6"
if order != want {
t.Errorf("Order after reset: got %q, want %q", order, want)
}
})
}

// --- Pattern #12: Active Search ---

func TestActiveSearch(t *testing.T) {
Expand Down
11 changes: 11 additions & 0 deletions patterns/state_lists.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,14 @@ type ValueSelectState struct {
Make string
Model string
}

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

type SortableItem struct {
Key string
Name string
}
25 changes: 25 additions & 0 deletions patterns/templates/lists/sortable.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{{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}}
Loading