feat(toast): migrate to trigger-attribute ephemeral component#290
feat(toast): migrate to trigger-attribute ephemeral component#290
Conversation
…ral 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>
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>
PR Review:
|
There was a problem hiding this comment.
Pull request overview
This PR migrates the toast component from server-rendered HTML to a lightweight “trigger attribute” pattern that hands off rendering to a client-side toast directive, and adds inline CSS blocks to modal templates to make component styling self-contained.
Changes:
- Replace the toast container’s server-rendered markup with a hidden trigger element that carries pending toast messages as JSON.
- Add
Container.TakePendingJSON()with caching to be idempotent across LiveTemplate’s double-render evaluation. - Add inline
<style>blocks to modal templates (confirm,default,sheet) to provide baseline overlay/button styling.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| components/toast/toast.go | Adds JSON marshaling + idempotent drain method (TakePendingJSON) for pending toasts. |
| components/toast/templates/container.tmpl | Switches to trigger-only markup and embeds pending messages as a JSON payload in a data attribute. |
| components/modal/templates/sheet.tmpl | Adds inline CSS rules for modal overlay/button behavior. |
| components/modal/templates/default.tmpl | Adds inline CSS rules for centered modal overlay/button behavior. |
| components/modal/templates/confirm.tmpl | Adds inline CSS for confirm modal presentation and action layout. |
components/toast/toast.go
Outdated
| c.hasNewMessages = false | ||
| return "" | ||
| } | ||
| b, _ := json.Marshal(c.Messages) |
There was a problem hiding this comment.
json.Marshal errors are being ignored here. Even if it "shouldn't fail" today, silently dropping/returning invalid JSON would make toast rendering flaky and very hard to debug if Message evolves (or if Type ever gains custom marshaling). Handle the error explicitly (e.g., keep the queue intact and return empty/diagnostic output) instead of discarding it.
| b, _ := json.Marshal(c.Messages) | |
| b, err := json.Marshal(c.Messages) | |
| if err != nil { | |
| // On marshal failure, keep the queue intact and avoid updating the cache. | |
| // Return empty output so callers do not receive invalid JSON. | |
| return "" | |
| } |
| // TakePendingJSON returns JSON-encoded pending messages and clears the queue. | ||
| // Safe to call multiple times per render cycle: the first call drains the queue | ||
| // and caches the result; subsequent calls within the same cycle return the cached | ||
| // value. This handles LiveTemplate's double-evaluation pattern where dynamic | ||
| // expressions are evaluated once for HTML rendering and once for the diff tree. | ||
| // Returns an empty string when there are no pending messages. | ||
| func (c *Container) TakePendingJSON() string { | ||
| if c.hasNewMessages { | ||
| // New messages since last drain — marshal, drain, and cache. |
There was a problem hiding this comment.
TakePendingJSON introduces new draining/idempotency behavior but there are no unit tests covering the double-evaluation contract (first call drains + caches; second call returns same JSON; subsequent calls return empty; queue is cleared). Since this change is specifically to satisfy LiveTemplate’s two-pass rendering, please add focused tests to prevent regressions.
| {{- $c := . -}} | ||
| {{- $pending := $c.TakePendingJSON -}} | ||
| <style> | ||
| [data-lvt-toast-stack] { position: fixed; top: 1rem; right: 1rem; z-index: 50; display: flex; flex-direction: column; gap: .5rem; width: 360px; pointer-events: none; } |
There was a problem hiding this comment.
This template now hard-codes fixed top-right positioning and toast appearance via [data-lvt-toast-*] CSS, but the component API still exposes WithPosition, WithStyled, and the style-adapter classes ($c.Styles.*, $c.GetPositionClasses). As-is, those options no longer affect rendering, which is a behavioral breaking change for existing users. Consider either emitting adapter-driven classes/position info as data-attributes for the client directive, or removing/deprecating the unused options and related style code.
| [data-lvt-toast-stack] { position: fixed; top: 1rem; right: 1rem; z-index: 50; display: flex; flex-direction: column; gap: .5rem; width: 360px; pointer-events: none; } | |
| [data-lvt-toast-stack] { display: flex; flex-direction: column; gap: .5rem; width: 360px; pointer-events: none; } |
| <span | ||
| data-toast-trigger="{{$c.ID}}" | ||
| {{- if $pending}} data-pending='{{$pending}}'{{end}} | ||
| hidden | ||
| aria-hidden="true" | ||
| ></span> |
There was a problem hiding this comment.
The existing toast template unit tests assert server-rendered markup (data-toast-container, aria-live, role=alert, data-auto-dismiss, dismiss lvt-click, etc.). With the new trigger-only markup (data-toast-trigger/data-pending), those tests will fail and should be updated to validate the new contract (including JSON payload shape and idempotent draining).
| <style> | ||
| [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); } | ||
| [data-modal] button { width: auto; margin: 0; } | ||
| </style> |
There was a problem hiding this comment.
Adding inline CSS here bypasses the existing style-adapter system (.Styles.Root, .Styles.Overlay, etc.) and can override Tailwind/unstyled adapter behavior via cascade order. If the goal is to make modals work without any app CSS, consider moving these rules into the style adapters (tailwind/unstyled) or scoping them to a specific "pico"/"unstyled" mode, rather than applying generic [data-modal] rules unconditionally.
| <style> | |
| [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); } | |
| [data-modal] button { width: auto; margin: 0; } | |
| </style> |
| [data-modal] { position: fixed; inset: 0; z-index: 40; background: rgba(0,0,0,0.5); } | ||
| [data-modal] button { width: auto; margin: 0; } | ||
| </style> | ||
| {{if .Open}} | ||
| <div class="{{.Styles.Root}}" data-modal="{{.ID}}" role="dialog" aria-modal="true"> |
There was a problem hiding this comment.
Same concern as the default modal: these unscoped [data-modal] rules apply globally and may override the style-adapter classes used in the template. This can make Tailwind/unstyled styling inconsistent and harder to customize. Prefer keeping layout/styling in the adapter-generated class sets, or scope the inline rules to an explicit mode/namespace.
| [data-modal] { position: fixed; inset: 0; z-index: 40; background: rgba(0,0,0,0.5); } | |
| [data-modal] button { width: auto; margin: 0; } | |
| </style> | |
| {{if .Open}} | |
| <div class="{{.Styles.Root}}" data-modal="{{.ID}}" role="dialog" aria-modal="true"> | |
| [data-modal-sheet] { position: fixed; inset: 0; z-index: 40; background: rgba(0,0,0,0.5); } | |
| [data-modal-sheet] button { width: auto; margin: 0; } | |
| </style> | |
| {{if .Open}} | |
| <div class="{{.Styles.Root}}" data-modal="{{.ID}}" data-modal-sheet role="dialog" aria-modal="true"> |
| [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); } | ||
| [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); } | ||
| [data-modal] > div:last-child > div > div:last-child { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 1.5rem; } | ||
| [data-modal] button { width: auto; margin: 0; } |
There was a problem hiding this comment.
This inline CSS uses structural selectors like [data-modal] > div:last-child > div / ... > div:last-child that are tightly coupled to the current DOM shape and will break if the template structure changes (even innocuous wrapper divs). It also sets background: var(--pico-background-color) with no fallback, which can result in a transparent dialog when Pico variables aren’t present. Prefer targeting a stable class (e.g., the panel class) and include a safe fallback color.
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>
PR Review:
|
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PR Review:
|
…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>
|
Code Review This is a clean architectural shift moving toast rendering fully client-side, making the server a thin JSON-over-attribute pipeline. The TakePendingJSON state machine is well-designed for the double-evaluation constraint. A few items worth addressing: Potential Issue: Single-Quote Attribute and JSON Content container.tmpl uses single-quote delimiters for the data-pending attribute. Go html/template does escape single quotes to HTML entities in single-quoted attribute contexts, so browser consumption via element.dataset.pending is safe. However there is no test covering a message with a single quote in Title or Body (e.g. a value containing an apostrophe). Worth adding a test to confirm the HTML entity roundtrip works correctly before JSON.parse on the client. CSS Position Is Hardcoded Despite data-position Being Passed The style block hardcodes top:1rem; right:1rem, but data-position is also emitted on the trigger span, presumably for the client directive (livetemplate/client#42) to override dynamically. A brief comment explaining the hardcoded values are just defaults would help future readers understand why both exist. Silent JSON Marshal Failure Drops Messages In TakePendingJSON, if json.Marshal fails, messages are silently dropped with no signal. The branch is effectively unreachable for this struct, but worth a short comment marking it as such to aid debugging if it ever triggers unexpectedly. Dismiss() and DismissAll() Are Now No-Ops After Drain The PR notes this is intentional, but it is a silent behavioral change. Existing server-side code calling DismissAll() after TakePendingJSON has already drained the queue will silently have no effect. Worth documenting in the DismissAll/Dismiss doc comments so callers understand this limitation. Modal CSS Injects Global Selectors Per Render Each render pass in confirm.tmpl, default.tmpl, and sheet.tmpl injects a style block targeting the global [data-modal] selector. Multiple simultaneous modals would duplicate these style blocks in the DOM. Not a functional problem but worth a follow-up issue. Minor Nit: dismissMS vs AutoDismissMS Naming The JSON tag dismissMS diverges from the Go field name AutoDismissMS. Since the struct was not previously serialized to JSON there is no backwards-compat concern, but the name mismatch may cause confusion. Low priority if the client contract is already established. Strengths:
Overall a solid PR. The main items to consider before merging are the apostrophe/entity-escaping roundtrip test and the Dismiss/DismissAll documentation update. |
Pick up trigger-attribute toast template + component CSS + TakePendingJSON from merged livetemplate/lvt#290. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…46) * feat: remove redundant examples, merge component patterns into todos (#42) - Delete todos-progressive (Tier 1 CRUD patterns covered by todos) - Delete profile-progressive (form/validation patterns covered by todos-progressive) - Delete todos-components (standalone); merge modal + toast into todos - todos now demonstrates: CRUD, auth, pagination, delete confirmation modal, toast notifications - Add delete-via-modal E2E test to todos_test.go - Update test-all.sh, README.md, CLAUDE.md to reflect 8 remaining examples Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: improve toast and modal UI/UX for unstyled component mode With SetStyled(false), all class="" attributes are empty — component layout depends entirely on structural CSS selectors. Toast fixes: - Apply display:flex at ToastInner and ToastLayout levels so the dismiss button sits at the right edge (not stacked below) - Set explicit width/height on SVG so the × dismiss icon is visible - Scope button selector to [data-toast-container] to avoid conflicts Modal fixes: - Style the panel div with background, border-radius, padding, shadow so the dialog has a proper card container on the overlay - Right-align action buttons with flex + gap + margin-top Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(todos): add toast auto-dismiss and click-outside JS Uses a MutationObserver to watch [data-toast-container] for new alert elements and schedules setTimeout based on data-auto-dismiss attribute value. A document click handler dismisses all visible toasts when clicking outside the container. Both send dismiss_toast_notifications action to the server to remove the toast from state, keeping client and server in sync. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(todos): migrate to client-side toast + component CSS ownership - controller.go: remove DismissToastNotifications() (client handles dismissal) - todos.tmpl: remove server-rendered toast HTML and custom modal/toast CSS; component templates now own their own <style> blocks; keep .visually-hidden - todos_test.go: assert on [data-lvt-toast-item] (client DOM) not server HTML - test-all.sh: use local go.work when present (dev mode with local module overrides) - CLAUDE.md: update reference examples + add ephemeral-components guide link Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test: verify toast has position:fixed (not in document flow) Add computed-style assertion to Delete_via_confirmation_modal subtest: checks that [data-lvt-toast-stack] has position=fixed, plus dumps all <style> elements, computed CSS, and stack HTML for debugging. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: address Copilot review — remove deleted examples from CI, hide modal on empty DeleteID - test.yml: remove todos-progressive, profile-progressive, todos-components from CI matrix (directories deleted in this PR) - controller.go: hide confirmation modal when DeleteID is empty so user isn't stuck with an open modal Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: bump lvt to v0.0.0-20260402031847-f267dc4e1614 Pick up trigger-attribute toast template + component CSS + TakePendingJSON from merged livetemplate/lvt#290. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
- components/toast/templates/container.tmpl: remove $c/$pending variable
declarations and use . directly with {{with .TakePendingJSON}}. The
LiveTemplate tree builder does not track local variable assignments
($c := .) across template boundaries, causing 'undefined variable $c'
errors on every action, which silently broke all WebSocket DOM updates
(table rows never appeared after form submissions).
- e2e/embedded_browser_test.go: update TestToastAutoDismiss selectors from
[data-toast][data-auto-dismiss] / [data-toast] to [data-lvt-toast-item]
to match the new client-side ephemeral toast pattern from PR #290.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* chore: improve release.sh with robustness fixes from livetemplate
- Add GOWORK=off to go test and go build to avoid workspace interference
- Add optional git-chglog support in generate_changelog with fallback
- Warn (not error) in check_prerequisites if git-chglog is missing
- Gracefully skip changelog when no previous tag found (warn+skip)
- Use dynamic branch detection in publish_github (drop hardcoded 'main')
- Add error handling on git push with rebase hint
- Guard against detached HEAD state in main before proceeding
- Pull latest with --rebase from remote before starting release
- Improve success output next steps (verify, announce)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix: address bot review comments on release.sh
- Gate git-chglog on both binary AND .chglog/config.yml presence (Copilot)
- Remove 2>/dev/null from git-chglog call so errors are visible (Claude)
- Warn in check_prerequisites when git-chglog installed but config missing
- Pass $branch from main() to publish_github() for consistency (Claude)
- Only git add CHANGELOG.md if it exists, preventing failure on first release
with no previous tag and no git-chglog config (Claude edge case)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix: address second round of bot review feedback
- git-chglog failure now aborts release with exit 1 to avoid stale changelog (Claude)
- Provide explicit manual recovery command on push failure (Claude)
- Replace platform-specific brew hint with cross-platform URL (Claude)
- Fix misleading log message: distinguish 'not installed' from 'config missing' (Claude)
- Simplify check_prerequisites: remove redundant .chglog/config.yml check (Claude)
- Log current HEAD after git pull --rebase for visibility (Claude)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix: resolve pre-existing e2e test failures from toast migration
- components/toast/templates/container.tmpl: remove $c/$pending variable
declarations and use . directly with {{with .TakePendingJSON}}. The
LiveTemplate tree builder does not track local variable assignments
($c := .) across template boundaries, causing 'undefined variable $c'
errors on every action, which silently broke all WebSocket DOM updates
(table rows never appeared after form submissions).
- e2e/embedded_browser_test.go: update TestToastAutoDismiss selectors from
[data-toast][data-auto-dismiss] / [data-toast] to [data-lvt-toast-item]
to match the new client-side ephemeral toast pattern from PR #290.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Summary
<span data-toast-trigger data-pending='[...]'>. Add<style>block with positioning CSS directly in the template (survives morphdom patches).TakePendingJSON()withhasNewMessagesflag +renderedJSONcache for idempotent double-render (LiveTemplate evaluates expressions twice per action: HTML pass + diff-tree pass).<style>blocks toconfirm.tmpl,default.tmpl,sheet.tmpl— component CSS belongs with the component, not in consuming apps.Depends on: livetemplate/client#42 (toast directive)
Test plan
todos/E2E: toast appears withposition:fixed; top:1rem; right:1remtest-all.sh🤖 Generated with Claude Code