replace admin rail with unified bottom sheet editor
All checks were successful
deploy / deploy (push) Successful in 1m30s

- add editor sheet component anchored bottom (mobile) / right (desktop)
- admin cog moves to header, always visible for admins
- remove Done button from editor header, keep only Save
- add editor_at_defaults tracking to disable Reset when at defaults
- sheet collapses on click outside or Escape, stays in edit mode
- dirty indicator + beforeunload warning for unsaved changes
- keyboard shortcuts: Ctrl+Z undo, Ctrl+Shift+Z redo
- WCAG compliant: aria-expanded, live region, focus management

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
jamey
2026-03-07 09:30:07 +00:00
parent dbcecc7878
commit f4f036b84b
12 changed files with 1232 additions and 474 deletions

View File

@@ -47,7 +47,7 @@ defmodule BerrypodWeb.CoreComponents do
id={@id}
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
role="alert"
class="admin-toast"
class="admin-banner"
{@rest}
>
<div class={[
@@ -70,6 +70,39 @@ defmodule BerrypodWeb.CoreComponents do
"""
end
@doc """
Renders inline status feedback next to a button or form section.
## Examples
<.inline_feedback status={@save_status} />
<.inline_feedback status={@save_status} message={@save_error} />
"""
attr :status, :atom, values: [:idle, :saving, :saved, :error], default: :idle
attr :message, :string, default: nil
def inline_feedback(assigns) do
~H"""
<span
:if={@status != :idle}
class={["admin-inline-feedback", "admin-inline-feedback-#{@status}"]}
role={@status == :error && "alert"}
>
<.icon :if={@status == :saving} name="hero-arrow-path" class="size-4 motion-safe:animate-spin" />
<.icon :if={@status == :saved} name="hero-check" class="size-4" />
<.icon :if={@status == :error} name="hero-exclamation-circle" class="size-4" />
<span>{feedback_text(@status, @message)}</span>
</span>
"""
end
defp feedback_text(:saving, _), do: "Saving..."
defp feedback_text(:saved, nil), do: "Saved"
defp feedback_text(:saved, msg), do: msg
defp feedback_text(:error, nil), do: "Something went wrong"
defp feedback_text(:error, msg), do: msg
defp feedback_text(:idle, _), do: nil
@doc """
Renders a button with navigation support.

View File

@@ -35,13 +35,13 @@ defmodule BerrypodWeb.Layouts do
def app(assigns) do
~H"""
<.flash_group flash={@flash} />
<main class="app-main">
<div class="app-container">
{render_slot(@inner_block)}
</div>
</main>
<.flash_group flash={@flash} />
"""
end

View File

@@ -18,9 +18,10 @@
</.link>
</header>
<.flash_group flash={@flash} />
<%!-- page content --%>
<main class="admin-main">
<.flash_group flash={@flash} />
<div class="admin-container">
{@inner_content}
</div>

View File

@@ -128,9 +128,6 @@ defmodule BerrypodWeb.ShopComponents.Layout do
mode={@mode}
cart_count={@cart_count}
is_admin={@is_admin}
editing={@editing}
editor_current_path={@editor_current_path}
editor_sidebar_open={@editor_sidebar_open}
header_nav_items={@header_nav_items}
/>
@@ -167,11 +164,12 @@ defmodule BerrypodWeb.ShopComponents.Layout do
search_open={@search_open}
/>
<.mobile_bottom_nav
<.mobile_nav_drawer
:if={!@error_page}
active_page={@active_page}
mode={@mode}
items={@header_nav_items}
categories={assigns[:categories] || []}
/>
</div>
"""
@@ -513,6 +511,111 @@ defmodule BerrypodWeb.ShopComponents.Layout do
"""
end
@doc """
Renders the mobile navigation drawer.
A slide-out drawer containing the main navigation links for mobile users.
Triggered by the hamburger menu button in the header.
"""
attr :active_page, :string, required: true
attr :mode, :atom, default: :live
attr :items, :list, default: []
attr :categories, :list, default: []
def mobile_nav_drawer(assigns) do
~H"""
<div
id="mobile-nav-drawer"
class="mobile-nav-drawer"
phx-hook="MobileNavDrawer"
>
<div
class="mobile-nav-backdrop"
phx-click={Phoenix.LiveView.JS.dispatch("close-mobile-nav", to: "#mobile-nav-drawer")}
>
</div>
<nav class="mobile-nav-panel" aria-label="Main navigation">
<div class="mobile-nav-header">
<span class="mobile-nav-title">Menu</span>
<button
type="button"
class="mobile-nav-close"
phx-click={Phoenix.LiveView.JS.dispatch("close-mobile-nav", to: "#mobile-nav-drawer")}
aria-label="Close menu"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<ul class="mobile-nav-links">
<li :for={item <- @items}>
<%= if @mode == :preview do %>
<a
href="#"
phx-click="change_preview_page"
phx-value-page={item["slug"]}
class="mobile-nav-link"
aria-current={@active_page in (item["active_slugs"] || [item["slug"]]) && "page"}
>
{item["label"]}
</a>
<% else %>
<.link
navigate={item["href"]}
class="mobile-nav-link"
aria-current={@active_page in (item["active_slugs"] || [item["slug"]]) && "page"}
phx-click={Phoenix.LiveView.JS.dispatch("close-mobile-nav", to: "#mobile-nav-drawer")}
>
{item["label"]}
</.link>
<% end %>
</li>
</ul>
<%= if @categories != [] do %>
<div class="mobile-nav-section">
<span class="mobile-nav-section-title">Shop by category</span>
<ul class="mobile-nav-links">
<li :for={category <- @categories}>
<%= if @mode == :preview do %>
<a
href="#"
phx-click="change_preview_page"
phx-value-page="collection"
class="mobile-nav-link"
>
{category.name}
</a>
<% else %>
<.link
navigate={"/collections/#{category.slug}"}
class="mobile-nav-link"
phx-click={
Phoenix.LiveView.JS.dispatch("close-mobile-nav", to: "#mobile-nav-drawer")
}
>
{category.name}
</.link>
<% end %>
</li>
</ul>
</div>
<% end %>
</nav>
</div>
"""
end
@doc """
Renders the shop footer with newsletter signup and links.
@@ -662,9 +765,6 @@ defmodule BerrypodWeb.ShopComponents.Layout do
attr :mode, :atom, default: :live
attr :cart_count, :integer, default: 0
attr :is_admin, :boolean, default: false
attr :editing, :boolean, default: false
attr :editor_current_path, :string, default: nil
attr :editor_sidebar_open, :boolean, default: true
attr :header_nav_items, :list, default: []
def shop_header(assigns) do
@@ -674,6 +774,27 @@ defmodule BerrypodWeb.ShopComponents.Layout do
<div style={header_background_style(@theme_settings, @header_image)} />
<% end %>
<%!-- Hamburger menu button (mobile only) --%>
<button
type="button"
class="header-hamburger"
phx-click={Phoenix.LiveView.JS.dispatch("open-mobile-nav", to: "#mobile-nav-drawer")}
aria-label="Open menu"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="3" y1="6" x2="21" y2="6"></line>
<line x1="3" y1="12" x2="21" y2="12"></line>
<line x1="3" y1="18" x2="21" y2="18"></line>
</svg>
</button>
<div class="shop-logo">
<.logo_content
theme_settings={@theme_settings}
@@ -697,48 +818,14 @@ defmodule BerrypodWeb.ShopComponents.Layout do
</nav>
<div class="shop-actions">
<%!-- Pencil icon: enters edit mode, or re-opens sidebar if already editing --%>
<.link
:if={@is_admin && !@editing && @editor_current_path}
patch={"#{@editor_current_path}?edit=true"}
class="header-icon-btn"
aria-label="Edit page"
>
<.edit_pencil_svg />
</.link>
<button
:if={@is_admin && @editing && !@editor_sidebar_open}
phx-click="editor_toggle_sidebar"
class="header-icon-btn"
aria-label="Show editor sidebar"
>
<.edit_pencil_svg />
</button>
<%!-- Admin cog: always visible for admins, links to admin dashboard --%>
<.link
:if={@is_admin}
href="/admin"
class="header-icon-btn"
aria-label="Admin"
aria-label="Admin dashboard"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
/>
</svg>
<.admin_cog_svg />
</.link>
<a
href="/search"
@@ -950,4 +1037,197 @@ defmodule BerrypodWeb.ShopComponents.Layout do
defp open_cart_drawer_js do
Phoenix.LiveView.JS.push("open_cart_drawer")
end
# ── Editor sheet ────────────────────────────────────────────────────
@doc """
Renders the unified editor sheet for page editing.
The sheet is anchored to the bottom edge on mobile (<768px) and the right edge
on desktop (≥768px). It has three states on mobile (collapsed, partial, full)
and two states on desktop (collapsed, open).
## Attributes
* `editing` - Whether edit mode is active.
* `editor_dirty` - Whether there are unsaved changes.
* `editor_sheet_state` - Current state (:collapsed, :partial, :full, or :open).
## Slots
* `inner_block` - The editor content (block list, settings, etc.).
"""
attr :editing, :boolean, default: false
attr :editor_dirty, :boolean, default: false
attr :editor_sheet_state, :atom, default: :collapsed
attr :editor_save_status, :atom, default: :idle
slot :inner_block
def editor_sheet(assigns) do
~H"""
<aside
id="editor-sheet"
class="editor-sheet"
role="region"
aria-label="Page editor"
aria-expanded={to_string(@editor_sheet_state != :collapsed)}
data-state={@editor_sheet_state}
data-editing={to_string(@editing)}
phx-hook="EditorSheet"
>
<%!-- Header: content varies by state and editing mode --%>
<div class="editor-sheet-header">
<%= if @editor_sheet_state == :collapsed and not @editing do %>
<%!-- Not editing, collapsed: show Edit button to enter edit mode --%>
<button
type="button"
phx-click="editor_toggle_editing"
class="editor-sheet-edit-btn"
>
<.edit_pencil_svg />
<span>Edit page</span>
</button>
<% end %>
<%= if @editor_sheet_state == :collapsed and @editing do %>
<%!-- Editing but collapsed: show button to expand sheet (for previewing) --%>
<button
type="button"
phx-click="editor_set_sheet_state"
phx-value-state="open"
class="editor-sheet-edit-btn"
>
<.edit_pencil_svg />
<span>Show editor</span>
</button>
<span :if={@editor_dirty} class="editor-sheet-dirty" aria-live="polite">
<span class="editor-sheet-dirty-dot" aria-hidden="true" />
<span>Unsaved</span>
</span>
<% end %>
<%= if @editor_sheet_state != :collapsed do %>
<div class="editor-sheet-header-left">
<span class="editor-sheet-title">Page editor</span>
<span :if={@editor_dirty} class="editor-sheet-dirty" aria-live="polite">
<span class="editor-sheet-dirty-dot" aria-hidden="true" />
<span>Unsaved</span>
</span>
</div>
<div class="editor-sheet-header-actions">
<button
:if={@editor_save_status == :saved}
type="button"
class="admin-btn admin-btn-sm admin-btn-ghost"
disabled
>
Saved ✓
</button>
<button
:if={@editor_save_status != :saved}
type="button"
phx-click="editor_save"
class={["admin-btn admin-btn-sm", @editor_dirty && "admin-btn-primary"]}
disabled={!@editor_dirty}
>
Save
</button>
</div>
<% end %>
</div>
<%!-- Content area (hidden when collapsed) --%>
<div class="editor-sheet-content">
{render_slot(@inner_block)}
</div>
</aside>
<%!-- Live region for screen reader announcements --%>
<div id="editor-live-region" class="sr-only" aria-live="polite" aria-atomic="true" />
"""
end
# ── Admin rail (deprecated) ────────────────────────────────────────
@doc """
Renders the admin rail with edit and admin icons.
This thin vertical bar appears on the left edge of the page for logged-in admins.
The edit button toggles the page editor, and the cog links to the admin dashboard.
"""
attr :editing, :boolean, default: false
attr :editor_dirty, :boolean, default: false
attr :editor_sidebar_open, :boolean, default: true
slot :editor_sidebar
slot :inner_block, required: true
def admin_rail(assigns) do
~H"""
<div
class="admin-rail-layout"
data-editing={to_string(@editing)}
data-sidebar-open={to_string(@editor_sidebar_open)}
>
<aside class="admin-rail" aria-label="Admin tools">
<button
type="button"
phx-click="editor_toggle_editing"
class={["admin-rail-btn", @editing && "admin-rail-btn-active"]}
aria-label={if @editing, do: "Close editor", else: "Edit page"}
aria-pressed={to_string(@editing)}
>
<.edit_pencil_svg />
<span
:if={@editing && @editor_dirty}
class="admin-rail-dirty-dot"
aria-label="Unsaved changes"
/>
</button>
<.link href="/admin" class="admin-rail-btn" aria-label="Admin dashboard">
<.admin_cog_svg />
</.link>
</aside>
<aside :if={@editing} class="admin-rail-sidebar" aria-label="Page editor">
{render_slot(@editor_sidebar)}
</aside>
<%!-- Backdrop to close sidebar on mobile --%>
<div
:if={@editing && @editor_sidebar_open}
class="admin-rail-backdrop"
phx-click="editor_toggle_sidebar"
aria-hidden="true"
/>
<div class="admin-rail-content">
{render_slot(@inner_block)}
</div>
</div>
"""
end
def admin_cog_svg(assigns) do
~H"""
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
/>
</svg>
"""
end
end