All checks were successful
deploy / deploy (push) Successful in 1m22s
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>
216 lines
6.4 KiB
Markdown
216 lines
6.4 KiB
Markdown
# 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
|
||
|
||
1. **No floating toasts** — fixed-position overlays are removed entirely
|
||
2. **Inline feedback for form saves** — contextual, next to the action
|
||
3. **Banners for page-level outcomes** — document flow, persistent until dismissed
|
||
4. **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.
|
||
|
||
```heex
|
||
<.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:**
|
||
|
||
```elixir
|
||
# 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.
|
||
|
||
```elixir
|
||
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:
|
||
|
||
1. Form has both `action` (controller POST) and `phx-submit` (LiveView)
|
||
2. With JS: LiveView handles the event, shows inline feedback
|
||
3. Without JS: Form POSTs to controller, redirects with flash
|
||
|
||
```heex
|
||
<.form
|
||
for={@form}
|
||
action={~p"/admin/settings/email"}
|
||
method="post"
|
||
phx-change="validate"
|
||
phx-submit="save"
|
||
>
|
||
```
|
||
|
||
The controller sets a flash and redirects:
|
||
|
||
```elixir
|
||
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:
|
||
|
||
1. **Appropriate role:**
|
||
- `role="status"` for info/success (non-urgent)
|
||
- `role="alert"` for errors (urgent)
|
||
|
||
2. **aria-live attribute:**
|
||
- `aria-live="polite"` for info/success
|
||
- `aria-live="assertive"` for errors
|
||
|
||
3. **Dismiss buttons:**
|
||
- Must be actual `<button>` elements
|
||
- Include `aria-label="Close"`
|
||
- Icon must have `aria-hidden="true"`
|
||
|
||
## 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
|
||
```elixir
|
||
# 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
|
||
```elixir
|
||
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
|
||
```elixir
|
||
# 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
|
||
```elixir
|
||
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
|
||
```heex
|
||
<div phx-click={dismiss()}>
|
||
<p>{msg}</p>
|
||
<button>×</button> <!-- button does nothing -->
|
||
</div>
|
||
```
|
||
|
||
**Right:** Click handler on the close button
|
||
```heex
|
||
<div>
|
||
<p>{msg}</p>
|
||
<button phx-click={dismiss()} aria-label="Close">×</button>
|
||
</div>
|
||
```
|