Skip to content

Commit f267dc4

Browse files
adnaanclaude
andauthored
feat(toast): migrate to trigger-attribute ephemeral component (#290)
* feat(toast): migrate to trigger-attribute pattern (client-side ephemeral component) Server now emits a lightweight hidden <span data-toast-trigger data-pending='[...]'> instead of full toast HTML. Client reads data-pending after each DOM update and manages toast DOM entirely client-side. - container.tmpl: render trigger span only; data-pending omitted when no messages - toast.go: add TakePendingJSON() with hasNewMessages flag + renderedJSON cache to handle LiveTemplate's double-render (HTML pass + diff-tree pass per action) - bundle: rebuilt from client feat commit Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(components): embed CSS in component templates (toast + modal) Each component template now renders a <style> block alongside its trigger/content. Because these templates are included on every server render, morphdom preserves the CSS across all DOM patches — no JS injection needed. - toast/container.tmpl: add <style> with [data-lvt-toast-stack/item/content] rules - modal/confirm.tmpl: add <style> with full confirm-dialog layout rules - modal/default.tmpl: add <style> with overlay + button-width overrides - modal/sheet.tmpl: add <style> with overlay + button-width overrides - bundle: rebuilt (injectToastStyles removed, -1.1kb) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: handle json.Marshal error in TakePendingJSON, update toast tests Addresses bot review: explicit error handling in TakePendingJSON, update template tests for trigger-only output, add TakePendingJSON idempotency tests. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: use max-width for toast stack to avoid overflow on narrow viewports Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: address claude review — Position support, drain docs, aria-live confirmation 1. Emit data-position on trigger span so client directive positions the toast stack per Container.Position (top-right, bottom-left, etc.) 2. Document that TakePendingJSON drains Messages (Count()/HasMessages() return zero after template execution — by design) 3. Add comment explaining intentional CSS duplication across modal templates 4. aria-live="polite" confirmed present in client directive (getOrCreateToastStack) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 53d6d40 commit f267dc4

6 files changed

Lines changed: 190 additions & 83 deletions

File tree

components/modal/templates/confirm.tmpl

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,13 @@
11
{{define "lvt:modal:confirm:v1"}}
2+
{{/* CSS is intentionally duplicated across modal templates (confirm/default/sheet).
3+
Each template must include its own <style> because LiveTemplate's DOM diffing
4+
removes <style> elements not present in the server-rendered HTML. */}}
5+
<style>
6+
[data-modal] { position: fixed; inset: 0; z-index: 40; display: flex; align-items: center; justify-content: center; padding: 1rem; background: rgba(0,0,0,0.5); }
7+
[data-modal] > div:last-child > div { background: var(--pico-background-color); border-radius: var(--pico-border-radius); padding: 1.5rem 2rem; max-width: 28rem; width: 100%; box-shadow: 0 20px 60px rgba(0,0,0,0.3); }
8+
[data-modal] > div:last-child > div > div:last-child { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 1.5rem; }
9+
[data-modal] button { width: auto; margin: 0; }
10+
</style>
211
{{if .Open}}
312
<div class="{{.Styles.Root}}" data-modal="{{.ID}}" role="alertdialog" aria-modal="true">
413
{{/* Overlay */}}

components/modal/templates/default.tmpl

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
{{define "lvt:modal:default:v1"}}
2+
<style>
3+
[data-modal] { position: fixed; inset: 0; z-index: 40; display: flex; align-items: center; justify-content: center; padding: 1rem; background: rgba(0,0,0,0.5); }
4+
[data-modal] button { width: auto; margin: 0; }
5+
</style>
26
{{if .Open}}
37
<div class="{{.Styles.Root}}" data-modal="{{.ID}}" role="dialog" aria-modal="true">
48
{{/* Overlay */}}

components/modal/templates/sheet.tmpl

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
{{define "lvt:modal:sheet:v1"}}
2+
<style>
3+
[data-modal] { position: fixed; inset: 0; z-index: 40; background: rgba(0,0,0,0.5); }
4+
[data-modal] button { width: auto; margin: 0; }
5+
</style>
26
{{if .Open}}
37
<div class="{{.Styles.Root}}" data-modal="{{.ID}}" role="dialog" aria-modal="true">
48
{{/* Overlay */}}
Lines changed: 17 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,19 @@
11
{{define "lvt:toast:container:v1"}}
2-
{{$c := .}}
3-
<div
4-
class="{{$c.Styles.Container}} {{$c.GetPositionClasses}} {{$c.Styles.ContainerWidth}}"
5-
data-toast-container="{{$c.ID}}"
6-
aria-live="polite"
7-
aria-atomic="true"
8-
>
9-
{{range $c.VisibleMessages}}
10-
<div
11-
class="{{$c.Styles.Toast}} {{$c.GetTypeClasses .Type}}"
12-
role="alert"
13-
data-toast="{{.ID}}"
14-
{{if gt .AutoDismissMS 0}}data-auto-dismiss="{{.AutoDismissMS}}"{{end}}
15-
>
16-
<div class="{{$c.Styles.ToastInner}}">
17-
<div class="{{$c.Styles.ToastLayout}}">
18-
{{if .Icon}}
19-
<div class="{{$c.Styles.IconWrap}}">
20-
{{.Icon}}
21-
</div>
22-
{{end}}
23-
<div class="{{$c.Styles.ContentWrap}}{{if .Icon}} {{$c.Styles.ContentIcon}}{{end}}">
24-
{{if .Title}}
25-
<p class="{{$c.Styles.Title}}">{{.Title}}</p>
26-
{{end}}
27-
{{if .Body}}
28-
<p class="{{$c.Styles.Body}}{{if .Title}} {{$c.Styles.BodyWithTitle}}{{end}}">{{.Body}}</p>
29-
{{end}}
30-
</div>
31-
{{if .Dismissible}}
32-
<div class="{{$c.Styles.DismissWrap}}">
33-
<button
34-
type="button"
35-
class="{{$c.Styles.DismissBtn}}"
36-
lvt-click="dismiss_toast_{{$c.ID}}"
37-
lvt-data-toast="{{.ID}}"
38-
aria-label="Dismiss"
39-
>
40-
<svg class="{{$c.Styles.DismissIcon}}" viewBox="0 0 20 20" fill="currentColor">
41-
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
42-
</svg>
43-
</button>
44-
</div>
45-
{{end}}
46-
</div>
47-
</div>
48-
</div>
49-
{{end}}
50-
</div>
2+
{{- $c := . -}}
3+
{{- $pending := $c.TakePendingJSON -}}
4+
<style>
5+
[data-lvt-toast-stack] { position: fixed; z-index: 50; display: flex; flex-direction: column; gap: .5rem; max-width: 360px; width: calc(100vw - 2rem); pointer-events: none; top: 1rem; right: 1rem; }
6+
[data-lvt-toast-item] { background: var(--pico-card-background-color, #fff); border: 1px solid var(--pico-muted-border-color, #ddd); border-radius: var(--pico-border-radius, .25rem); padding: .75rem 1rem; box-shadow: 0 4px 12px rgba(0,0,0,.15); pointer-events: auto; display: flex; align-items: flex-start; gap: .75rem; }
7+
[data-lvt-toast-content] { flex: 1; }
8+
[data-lvt-toast-content] strong { display: block; margin-bottom: .1rem; }
9+
[data-lvt-toast-content] p { margin: 0; }
10+
[data-lvt-toast-item] > button { margin: 0; padding: .25rem; width: auto; min-width: auto; background: transparent; border: none; cursor: pointer; color: var(--pico-muted-color, #666); line-height: 1; flex-shrink: 0; }
11+
</style>
12+
<span
13+
data-toast-trigger="{{$c.ID}}"
14+
data-position="{{$c.Position}}"
15+
{{- if $pending}} data-pending='{{$pending}}'{{end}}
16+
hidden
17+
aria-hidden="true"
18+
></span>
5119
{{end}}

components/toast/toast.go

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
package toast
2424

2525
import (
26+
"encoding/json"
2627
"strconv"
2728

2829
"github.com/livetemplate/lvt/components/base"
@@ -53,13 +54,13 @@ const (
5354

5455
// Message represents a single toast notification.
5556
type Message struct {
56-
ID string // Unique identifier for this toast
57-
Title string // Optional title/header
58-
Body string // Main message content
59-
Type Type // Visual style (info, success, warning, error)
60-
Dismissible bool // Whether user can dismiss the toast
61-
Icon string // Optional icon (HTML or class name)
62-
AutoDismissMS int // Auto-dismiss after this many milliseconds (0 = no auto-dismiss)
57+
ID string `json:"id"`
58+
Title string `json:"title"`
59+
Body string `json:"body"`
60+
Type Type `json:"type"`
61+
Dismissible bool `json:"dismissible"`
62+
Icon string `json:"icon,omitempty"`
63+
AutoDismissMS int `json:"dismissMS"`
6364
}
6465

6566
// Container holds and manages multiple toast notifications.
@@ -78,6 +79,13 @@ type Container struct {
7879

7980
// Counter for generating unique IDs
8081
Counter int `json:"counter"`
82+
83+
// hasNewMessages is set to true when Add is called, cleared after first drain.
84+
// renderedJSON caches the drained JSON so TakePendingJSON is idempotent within
85+
// a single render cycle (LiveTemplate evaluates dynamic expressions twice per
86+
// update: once for HTML rendering, once for the diff tree).
87+
hasNewMessages bool
88+
renderedJSON string
8189
}
8290

8391
// New creates a toast container.
@@ -112,6 +120,8 @@ func (c *Container) Add(msg Message) {
112120
}
113121

114122
c.Messages = append(c.Messages, msg)
123+
c.hasNewMessages = true
124+
c.renderedJSON = "" // Invalidate render cache
115125

116126
// Trim to MaxVisible if set
117127
if c.MaxVisible > 0 && len(c.Messages) > c.MaxVisible {
@@ -157,6 +167,40 @@ func (c *Container) DismissAll() {
157167
c.Messages = make([]Message, 0)
158168
}
159169

170+
// TakePendingJSON returns JSON-encoded pending messages and clears the queue.
171+
// Safe to call multiple times per render cycle: the first call drains the queue
172+
// and caches the result; subsequent calls within the same cycle return the cached
173+
// value. This handles LiveTemplate's double-evaluation pattern where dynamic
174+
// expressions are evaluated once for HTML rendering and once for the diff tree.
175+
// Returns an empty string when there are no pending messages.
176+
//
177+
// Note: the first call drains c.Messages. After template execution completes,
178+
// Count() and HasMessages() will return zero even though toasts are displaying
179+
// client-side. This is by design — the server's role ends once the client
180+
// receives the pending JSON.
181+
func (c *Container) TakePendingJSON() string {
182+
if c.hasNewMessages {
183+
// New messages since last drain — marshal, drain, and cache.
184+
if len(c.Messages) == 0 {
185+
c.hasNewMessages = false
186+
return ""
187+
}
188+
b, err := json.Marshal(c.Messages)
189+
if err != nil {
190+
c.hasNewMessages = false
191+
return ""
192+
}
193+
c.renderedJSON = string(b)
194+
c.Messages = make([]Message, 0)
195+
c.hasNewMessages = false
196+
return c.renderedJSON
197+
}
198+
// No new messages — return cached result from this render cycle, then clear.
199+
result := c.renderedJSON
200+
c.renderedJSON = ""
201+
return result
202+
}
203+
160204
// Count returns the number of active toasts.
161205
func (c *Container) Count() int {
162206
return len(c.Messages)

components/toast/toast_test.go

Lines changed: 105 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -342,20 +342,22 @@ func TestTemplateRendering(t *testing.T) {
342342
}
343343

344344
html := buf.String()
345-
if !strings.Contains(html, `data-toast-container="notifications"`) {
346-
t.Error("expected data-toast-container attribute")
345+
if !strings.Contains(html, `data-toast-trigger="notifications"`) {
346+
t.Error("expected data-toast-trigger attribute")
347347
}
348-
if !strings.Contains(html, `aria-live="polite"`) {
349-
t.Error("expected aria-live attribute")
348+
if !strings.Contains(html, `hidden`) {
349+
t.Error("expected hidden attribute on trigger span")
350350
}
351-
if !strings.Contains(html, "Success!") {
352-
t.Error("expected toast title in output")
351+
if !strings.Contains(html, `aria-hidden="true"`) {
352+
t.Error("expected aria-hidden attribute on trigger span")
353353
}
354-
if !strings.Contains(html, "Your changes have been saved.") {
355-
t.Error("expected toast body in output")
354+
// Pending messages should be serialized in data-pending attribute
355+
if !strings.Contains(html, `data-pending='[`) {
356+
t.Error("expected data-pending attribute with JSON messages")
356357
}
357-
if !strings.Contains(html, `lvt-click="dismiss_toast_notifications"`) {
358-
t.Error("expected dismiss button with lvt-click")
358+
// html/template escapes quotes inside attributes as &#34;
359+
if !strings.Contains(html, `&#34;title&#34;:&#34;Success!&#34;`) {
360+
t.Errorf("expected toast title in data-pending JSON, got:\n%s", html)
359361
}
360362
}
361363

@@ -376,17 +378,16 @@ func TestUnstyledTemplateRendering(t *testing.T) {
376378
}
377379

378380
html := buf.String()
379-
// Unstyled version should use BEM-style class names, not Tailwind
380-
if strings.Contains(html, "fixed z-50") {
381-
t.Error("unstyled template should not have Tailwind classes")
381+
// Trigger-only template is style-agnostic; both styled and unstyled
382+
// render the same hidden span with data-toast-trigger.
383+
if !strings.Contains(html, `data-toast-trigger="test"`) {
384+
t.Error("expected data-toast-trigger attribute")
382385
}
383-
// Should have unstyled BEM classes
384-
if !strings.Contains(html, "lvt-toast") {
385-
t.Error("unstyled template should have BEM-style classes")
386+
if !strings.Contains(html, `hidden`) {
387+
t.Error("expected hidden attribute on trigger span")
386388
}
387-
// But should still have functional attributes
388-
if !strings.Contains(html, `role="alert"`) {
389-
t.Error("unstyled template should have role=alert")
389+
if !strings.Contains(html, `data-pending='[`) {
390+
t.Error("expected data-pending attribute with JSON messages")
390391
}
391392
}
392393

@@ -407,11 +408,14 @@ func TestAutoDismissRendering(t *testing.T) {
407408
}
408409

409410
html := buf.String()
410-
if !strings.Contains(html, `data-auto-dismiss="5000"`) {
411-
t.Error("expected data-auto-dismiss attribute on success toast")
411+
// Auto-dismiss metadata is now embedded in the JSON payload inside data-pending.
412+
// html/template escapes quotes as &#34; inside attribute values.
413+
// The success toast should carry dismissMS in the JSON.
414+
if !strings.Contains(html, `&#34;dismissMS&#34;:5000`) {
415+
t.Errorf("expected dismissMS field in data-pending JSON for success toast, got:\n%s", html)
412416
}
413417

414-
// Error toast should NOT have auto-dismiss
418+
// Error toast should have dismissMS=0 (no auto-dismiss)
415419
c2 := New("test2", WithStyled(true))
416420
c2.AddError("Oops", "Something went wrong")
417421

@@ -422,8 +426,8 @@ func TestAutoDismissRendering(t *testing.T) {
422426
}
423427

424428
html2 := buf.String()
425-
if strings.Contains(html2, "data-auto-dismiss") {
426-
t.Error("error toast should NOT have data-auto-dismiss attribute")
429+
if !strings.Contains(html2, `&#34;dismissMS&#34;:0`) {
430+
t.Errorf("expected dismissMS:0 in data-pending JSON for error toast (no auto-dismiss), got:\n%s", html2)
427431
}
428432
}
429433

@@ -460,8 +464,82 @@ func TestEmptyContainerRendering(t *testing.T) {
460464
}
461465

462466
html := buf.String()
463-
// Should render container even if empty
464-
if !strings.Contains(html, `data-toast-container="test"`) {
465-
t.Error("expected empty container to render")
467+
// Should render trigger span even when empty
468+
if !strings.Contains(html, `data-toast-trigger="test"`) {
469+
t.Error("expected empty container to render trigger span")
470+
}
471+
// No data-pending when no messages
472+
if strings.Contains(html, `data-pending`) {
473+
t.Error("expected no data-pending attribute when container is empty")
474+
}
475+
}
476+
477+
func TestTakePendingJSON_DrainAndCache(t *testing.T) {
478+
c := New("test")
479+
c.AddInfo("Hello", "World")
480+
481+
// First call: drains messages and returns JSON.
482+
json1 := c.TakePendingJSON()
483+
if json1 == "" {
484+
t.Fatal("first call should return non-empty JSON")
485+
}
486+
if !strings.Contains(json1, `"title":"Hello"`) {
487+
t.Errorf("expected title in JSON, got %s", json1)
488+
}
489+
// Messages should be drained after first call.
490+
if c.Count() != 0 {
491+
t.Errorf("expected 0 messages after drain, got %d", c.Count())
492+
}
493+
494+
// Second call (same render cycle): returns cached JSON (idempotent).
495+
json2 := c.TakePendingJSON()
496+
if json2 != json1 {
497+
t.Errorf("second call should return cached JSON;\n got: %s\n want: %s", json2, json1)
498+
}
499+
500+
// Third call: cache cleared, returns empty string.
501+
json3 := c.TakePendingJSON()
502+
if json3 != "" {
503+
t.Errorf("third call should return empty string, got %s", json3)
504+
}
505+
}
506+
507+
func TestTakePendingJSON_NoMessages(t *testing.T) {
508+
c := New("test")
509+
510+
result := c.TakePendingJSON()
511+
if result != "" {
512+
t.Errorf("expected empty string when no messages, got %s", result)
513+
}
514+
}
515+
516+
func TestTakePendingJSON_AddAfterDrain(t *testing.T) {
517+
c := New("test")
518+
c.AddSuccess("First", "First message")
519+
520+
// Drain
521+
json1 := c.TakePendingJSON()
522+
if json1 == "" {
523+
t.Fatal("expected non-empty JSON from first drain")
524+
}
525+
526+
// Consume cache
527+
_ = c.TakePendingJSON()
528+
// Clear cache
529+
_ = c.TakePendingJSON()
530+
531+
// Add new message after drain
532+
c.AddError("Second", "Second message")
533+
534+
json4 := c.TakePendingJSON()
535+
if json4 == "" {
536+
t.Fatal("expected non-empty JSON after adding new message")
537+
}
538+
if !strings.Contains(json4, `"title":"Second"`) {
539+
t.Errorf("expected new message in JSON, got %s", json4)
540+
}
541+
// Should not contain the first message
542+
if strings.Contains(json4, `"title":"First"`) {
543+
t.Error("expected first message to be absent after drain")
466544
}
467545
}

0 commit comments

Comments
 (0)