Rewrote planning doc as a reference guide with: - decision tree for choosing feedback type - implementation patterns with code examples - accessibility requirements - common mistakes to avoid Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
6.4 KiB
Notification system
Status: Complete
This document describes the notification and feedback patterns used throughout the app. Follow these rules when implementing new features or modifying existing ones.
Core principles
- No floating toasts — fixed-position overlays are removed entirely
- Inline feedback for form saves — contextual, next to the action
- Banners for page-level outcomes — document flow, persistent until dismissed
- Progressive enhancement — works without JS, enhanced with LiveView
Components
<.inline_feedback> — contextual save status
Used for form saves that stay on the same page. Shows status next to the save button.
<.button type="submit">Save</.button>
<.inline_feedback status={@save_status} />
States:
:idle— hidden:saving— spinner + "Saving...":saved— green tick + "Saved":error— red icon + "Something went wrong" (or custom message)
Implementation pattern:
# Mount
assign(socket, :save_status, :idle)
# Validate event — clear status on form change
def handle_event("validate", params, socket) do
{:noreply, assign(socket, save_status: :idle)}
end
# Save event
def handle_event("save", params, socket) do
case Context.save(params) do
{:ok, _} -> {:noreply, assign(socket, :save_status, :saved)}
{:error, _} -> {:noreply, assign(socket, :save_status, :error)}
end
end
When to use: Theme editor, page editor, settings forms, media metadata, product details, navigation editor, newsletter drafts.
<.flash> / <.shop_flash_group> — page-level banners
Used for outcomes that affect the whole page or result from navigation. Renders in document flow (pushes content down), not fixed overlay.
socket
|> put_flash(:info, "Product sync started")
|> push_navigate(to: ~p"/admin/products")
Behaviour:
- Persistent until dismissed (close button)
- Clears automatically on next navigation
- Info messages use
role="status"+aria-live="polite" - Error messages use
role="alert"+aria-live="assertive"
When to use:
- Operation outcomes: "Sync started", "Page deleted", "Campaign sent"
- Auth messages: "Welcome back!", "Logged out"
- Connection/provider status changes
- Any action that navigates to a new page
Decision tree
Is this feedback for a form save that stays on the page?
├─ YES → Use inline_feedback
│ - Add :save_status assign
│ - Clear to :idle on validate
│ - Set to :saved/:error on submit
│
└─ NO → Does the action navigate away or affect the whole page?
├─ YES → Use put_flash (banner)
│ - :info for success
│ - :error for failures
│
└─ NO → Use inline_feedback anyway
(contextual is usually better)
Progressive enhancement
Forms should work without JavaScript. The pattern:
- Form has both
action(controller POST) andphx-submit(LiveView) - With JS: LiveView handles the event, shows inline feedback
- Without JS: Form POSTs to controller, redirects with flash
<.form
for={@form}
action={~p"/admin/settings/email"}
method="post"
phx-change="validate"
phx-submit="save"
>
The controller sets a flash and redirects:
def update(conn, params) do
case Context.save(params) do
{:ok, _} ->
conn
|> put_flash(:info, "Settings saved")
|> redirect(to: ~p"/admin/settings")
{:error, changeset} ->
render(conn, :edit, form: to_form(changeset))
end
end
Result: With JS, user sees instant inline feedback. Without JS, user gets a banner after redirect.
Accessibility requirements
All notification components must include:
-
Appropriate role:
role="status"for info/success (non-urgent)role="alert"for errors (urgent)
-
aria-live attribute:
aria-live="polite"for info/successaria-live="assertive"for errors
-
Dismiss buttons:
- Must be actual
<button>elements - Include
aria-label="Close" - Icon must have
aria-hidden="true"
- Must be actual
Files
- Component:
lib/berrypod_web/components/core_components.ex—inline_feedback/1,flash/1 - Shop flash:
lib/berrypod_web/components/shop_components/content.ex—shop_flash_group/1 - Admin CSS:
assets/css/admin/core.css—.admin-inline-feedback,.admin-banner,.admin-alert - Shop CSS:
assets/css/shop/components.css—.shop-flash
Examples in codebase
| Page | Feedback type | File |
|---|---|---|
| Theme editor | inline_feedback | lib/berrypod_web/live/admin/theme/index.ex |
| Page editor | inline_feedback | lib/berrypod_web/live/admin/pages/editor.ex |
| Settings | inline_feedback | lib/berrypod_web/live/admin/settings.ex |
| Email settings | inline_feedback | lib/berrypod_web/live/admin/email_settings.ex |
| Media library | inline_feedback | lib/berrypod_web/live/admin/media.ex |
| Product show | inline_feedback | lib/berrypod_web/live/admin/product_show.ex |
| Navigation | inline_feedback | lib/berrypod_web/live/admin/navigation.ex |
| Newsletter | inline_feedback | lib/berrypod_web/live/admin/newsletter/campaign_form.ex |
| Provider form | flash (navigates) | lib/berrypod_web/live/admin/providers/form.ex |
| Contact | flash (navigates) | lib/berrypod_web/live/shop/contact.ex |
Common mistakes
Wrong: Using put_flash for a form save that stays on the page
# Don't do this
def handle_event("save", params, socket) do
{:ok, _} = Context.save(params)
{:noreply, put_flash(socket, :info, "Saved")}
end
Right: Using inline feedback
def handle_event("save", params, socket) do
{:ok, _} = Context.save(params)
{:noreply, assign(socket, :save_status, :saved)}
end
Wrong: Forgetting to clear status on validate
# Status stays "Saved" even while user is editing
def handle_event("validate", params, socket) do
{:noreply, assign(socket, :form, to_form(params))}
end
Right: Clear status when form changes
def handle_event("validate", params, socket) do
{:noreply, assign(socket, form: to_form(params), save_status: :idle)}
end
Wrong: Making flash the whole-div clickable
<div phx-click={dismiss()}>
<p>{msg}</p>
<button>×</button> <!-- button does nothing -->
</div>
Right: Click handler on the close button
<div>
<p>{msg}</p>
<button phx-click={dismiss()} aria-label="Close">×</button>
</div>