From bd07c9c7d9a063c948a3345ef31410ae57391c6e Mon Sep 17 00:00:00 2001 From: jamey Date: Sat, 7 Mar 2026 19:01:32 +0000 Subject: [PATCH] separate editor FAB and panel for cleaner animation Split the editor sheet into two distinct elements: - .editor-fab: floating action button, always a pill in the corner - .editor-panel: sliding panel that animates in/out independently This enables proper CSS keyframe animations (slide-up/down on mobile, slide-in/out on desktop) with a closing class for exit transitions. Simplified the JS hook to only handle close behaviour. Co-Authored-By: Claude Opus 4.5 --- assets/css/admin/components.css | 267 +++++++++--------- assets/js/app.js | 40 +-- .../components/shop_components/layout.ex | 104 +++---- lib/berrypod_web/page_renderer.ex | 6 +- .../live/shop/custom_page_test.exs | 6 +- test/berrypod_web/page_editor_hook_test.exs | 56 ++-- 6 files changed, 230 insertions(+), 249 deletions(-) diff --git a/assets/css/admin/components.css b/assets/css/admin/components.css index d4b33a4..a865173 100644 --- a/assets/css/admin/components.css +++ b/assets/css/admin/components.css @@ -2378,94 +2378,19 @@ } /* ══════════════════════════════════════════════════════════════════════════ - Editor sheet (unified bottom/right sheet for page editing) + Editor FAB + Panel (page editing) + + Two separate elements: + - .editor-fab: floating action button, always a pill in the corner + - .editor-panel: sliding panel, animates in/out ══════════════════════════════════════════════════════════════════════════ */ -.editor-sheet { +/* ── Floating action button ── */ +.editor-fab { position: fixed; z-index: 1000; - background: var(--t-surface-base); - box-shadow: var(--t-shadow-lg, 0 8px 24px rgba(0, 0, 0, 0.15)); - transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1); - display: flex; - flex-direction: column; -} - - - -@media (prefers-reduced-motion: reduce) { - .editor-sheet { - transition: none; - } -} - -/* ── Mobile: bottom-anchored ── */ -@media (max-width: 767px) { - /* Collapsed: floating pill button, no panel background */ - .editor-sheet { - left: auto; - right: 16px; - bottom: 16px; - top: auto; - width: auto; - height: auto; - background: transparent; - box-shadow: none; - border: none; - border-radius: 0; - } - - /* Open: full panel from bottom */ - .editor-sheet[data-state="open"] { - left: 0; - right: 0; - bottom: 0; - top: 15dvh; - width: auto; - height: auto; - background: var(--t-surface-base); - box-shadow: var(--t-shadow-lg, 0 8px 24px rgba(0, 0, 0, 0.15)); - border-radius: var(--t-radius-lg, 12px) var(--t-radius-lg, 12px) 0 0; - border: 1px solid var(--t-border-default); - border-bottom: none; - } -} - -/* ── Desktop: right-anchored ── */ -/* ── Desktop: same floating button, side panel when open ── */ -@media (min-width: 768px) { - /* Collapsed: floating pill button, no panel background (same as mobile) */ - .editor-sheet { - left: auto; - right: 16px; - bottom: 16px; - top: auto; - width: auto; - height: auto; - background: transparent; - box-shadow: none; - border: none; - border-radius: 0; - } - - /* Open: side panel from right */ - .editor-sheet[data-state="open"] { - top: 0; - right: 0; - bottom: 0; - width: 420px; - max-width: 90vw; - background: var(--t-surface-base); - box-shadow: var(--t-shadow-lg, 0 8px 24px rgba(0, 0, 0, 0.15)); - border-radius: var(--t-radius-lg, 12px) 0 0 var(--t-radius-lg, 12px); - border: 1px solid var(--t-border-default); - border-right: none; - } -} - - -/* ── Edit button in collapsed state ── */ -.editor-sheet-edit-btn { + right: 16px; + bottom: 16px; display: flex; align-items: center; gap: 0.5rem; @@ -2478,62 +2403,119 @@ font-weight: 500; cursor: pointer; transition: filter 0.15s, box-shadow 0.15s; - /* Soft glow for visibility over any background */ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2), 0 0 0 3px rgba(255, 255, 255, 0.6); } -.editor-sheet-edit-btn:hover { +.editor-fab:hover { filter: brightness(1.1); } -.editor-sheet-edit-btn svg { +.editor-fab svg { width: 16px; height: 16px; flex-shrink: 0; } - -/* ── Dirty indicator ── */ -.editor-sheet-dirty { - display: flex; - align-items: center; - gap: 0.25rem; - color: var(--t-status-warning, oklch(0.75 0.18 85)); - font-size: 0.75rem; - font-weight: 500; -} - -.editor-sheet-dirty-dot { +.editor-fab-dirty { width: 8px; height: 8px; border-radius: 9999px; - background: currentColor; + background: var(--t-status-warning, oklch(0.75 0.18 85)); + flex-shrink: 0; } -/* Desktop collapsed: hide "Unsaved" text, show only dot */ +/* ── Editor panel ── */ +.editor-panel { + position: fixed; + z-index: 1000; + display: flex; + flex-direction: column; + background: var(--t-surface-base); + box-shadow: var(--t-shadow-lg, 0 8px 24px rgba(0, 0, 0, 0.15)); +} + +/* Hidden when collapsed (but not during close animation) */ +.editor-panel[data-state="collapsed"]:not(.closing) { + display: none; +} + +/* ── Mobile: bottom sheet ── */ +@media (max-width: 767px) { + .editor-panel[data-state="open"], + .editor-panel.closing { + left: 0; + right: 0; + bottom: 0; + top: 15dvh; + border-radius: var(--t-radius-lg, 12px) var(--t-radius-lg, 12px) 0 0; + border: 1px solid var(--t-border-default); + border-bottom: none; + } + + .editor-panel[data-state="open"] { + animation: editor-panel-slide-up 0.3s cubic-bezier(0.32, 0.72, 0, 1) both; + } + + .editor-panel.closing { + animation: editor-panel-slide-down 0.25s cubic-bezier(0.32, 0.72, 0, 1) both; + } +} + +@keyframes editor-panel-slide-up { + from { opacity: 0; transform: translateY(24px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes editor-panel-slide-down { + from { opacity: 1; transform: translateY(0); } + to { opacity: 0; transform: translateY(24px); } +} + +/* ── Desktop: side panel ── */ @media (min-width: 768px) { - .editor-sheet[data-state="collapsed"] .editor-sheet-dirty span:not(.editor-sheet-dirty-dot) { - /* Visually hidden but accessible to screen readers */ - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; + .editor-panel[data-state="open"], + .editor-panel.closing { + top: 0; + right: 0; + bottom: 0; + width: 420px; + max-width: 90vw; + border-radius: var(--t-radius-lg, 12px) 0 0 var(--t-radius-lg, 12px); + border: 1px solid var(--t-border-default); + border-right: none; } - .editor-sheet[data-state="collapsed"] .editor-sheet-dirty { - position: relative; + .editor-panel[data-state="open"] { + animation: editor-panel-slide-in 0.3s cubic-bezier(0.32, 0.72, 0, 1) both; + } + + .editor-panel.closing { + animation: editor-panel-slide-out 0.25s cubic-bezier(0.32, 0.72, 0, 1) both; } } -/* ── Sheet header (when expanded) ── */ -.editor-sheet-header { +@keyframes editor-panel-slide-in { + from { opacity: 0; transform: translateX(24px); } + to { opacity: 1; transform: translateX(0); } +} + +@keyframes editor-panel-slide-out { + from { opacity: 1; transform: translateX(0); } + to { opacity: 0; transform: translateX(24px); } +} + +/* Disable animations for users who prefer reduced motion */ +@media (prefers-reduced-motion: reduce) { + .editor-panel[data-state="open"], + .editor-panel.closing { + animation: none; + } +} + +/* ── Panel header ── */ +.editor-panel-header { display: flex; align-items: center; justify-content: space-between; @@ -2543,28 +2525,45 @@ gap: 0.5rem; } -.editor-sheet-header-left { +.editor-panel-header-left { display: flex; align-items: center; gap: 0.75rem; min-width: 0; } -.editor-sheet-title { +.editor-panel-title { font-size: 0.875rem; font-weight: 600; color: var(--t-text-primary); } -.editor-sheet-header-actions { +.editor-panel-header-actions { display: flex; align-items: center; gap: 0.25rem; flex-shrink: 0; } -/* ── Sheet content ── */ -.editor-sheet-content { +/* ── Dirty indicator ── */ +.editor-panel-dirty { + display: flex; + align-items: center; + gap: 0.25rem; + color: var(--t-status-warning, oklch(0.75 0.18 85)); + font-size: 0.75rem; + font-weight: 500; +} + +.editor-panel-dirty-dot { + width: 8px; + height: 8px; + border-radius: 9999px; + background: currentColor; +} + +/* ── Panel content ── */ +.editor-panel-content { flex: 1; overflow-y: auto; overscroll-behavior: contain; @@ -2575,18 +2574,8 @@ } } -/* ── Hide content when collapsed ── */ -.editor-sheet[data-state="collapsed"] .editor-sheet-content { - display: none; -} - -/* Collapsed header doesn't need bottom border */ -.editor-sheet[data-state="collapsed"] .editor-sheet-header { - border-bottom: none; -} - -/* ── Page header inside sheet ── */ -.editor-sheet-page-header { +/* ── Page header inside panel ── */ +.editor-panel-page-header { display: flex; align-items: center; justify-content: space-between; @@ -2598,7 +2587,7 @@ } } -.editor-sheet-page-title { +.editor-panel-page-title { font-size: 0.9375rem; font-weight: 600; flex: 1; @@ -2612,26 +2601,26 @@ } } -.editor-sheet-undo-redo { +.editor-panel-undo-redo { display: flex; align-items: center; gap: 0.25rem; flex-shrink: 0; } -/* Picker inside the editor sheet — grid scrolls within a capped height */ -.editor-sheet .block-picker-overlay { +/* Picker inside the editor panel — grid scrolls within a capped height */ +.editor-panel .block-picker-overlay { position: static; background: none; } -.editor-sheet .block-picker { +.editor-panel .block-picker { border-radius: 0; max-height: none; padding: 0; } -.editor-sheet .block-picker-grid { +.editor-panel .block-picker-grid { max-height: 45dvh; overflow-y: auto; } diff --git a/assets/js/app.js b/assets/js/app.js index 018a03f..e964183 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -679,28 +679,25 @@ const DirtyGuard = { } } -// EditorSheet: simple open/collapse sheet for page editing -// Positioning is handled by CSS - JS just handles click-outside and Escape +// EditorSheet: handles click-outside and Escape for the editor panel const EditorSheet = { mounted() { - // Click outside to collapse (works in any mode for preview) - // Use mousedown instead of click to avoid race with LiveView re-renders this._onDocMousedown = (e) => { - if (!this.el.contains(e.target) && this._getState() !== "collapsed") { - this._setState("collapsed") - } + if (this._getState() !== "open") return + const fab = document.querySelector(".editor-fab") + if (this.el.contains(e.target)) return + if (fab && fab.contains(e.target)) return + this._close() } document.addEventListener("mousedown", this._onDocMousedown) - // Escape key to collapse this._onKeydown = (e) => { - if (e.key === "Escape" && this._getState() !== "collapsed") { + if (e.key === "Escape" && this._getState() === "open") { e.preventDefault() - this._setState("collapsed") + this._close() } } document.addEventListener("keydown", this._onKeydown) - }, destroyed() { @@ -712,11 +709,22 @@ const EditorSheet = { return this.el.dataset.state || "collapsed" }, - _setState(state) { - this.el.dataset.state = state - this.el.setAttribute("aria-expanded", state !== "collapsed") - this.pushEvent("editor_set_sheet_state", { state }) - this._announce(state === "collapsed" ? "Editor collapsed" : "Editor expanded") + _close() { + const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches + if (prefersReducedMotion) { + this.el.setAttribute("aria-hidden", "true") + this.pushEvent("editor_set_sheet_state", { state: "collapsed" }) + this._announce("Editor collapsed") + return + } + + this.el.classList.add("closing") + this.el.addEventListener("animationend", () => { + this.el.classList.remove("closing") + this.el.setAttribute("aria-hidden", "true") + this.pushEvent("editor_set_sheet_state", { state: "collapsed" }) + this._announce("Editor collapsed") + }, { once: true }) }, _announce(message) { diff --git a/lib/berrypod_web/components/shop_components/layout.ex b/lib/berrypod_web/components/shop_components/layout.ex index 4734a7d..b0646ea 100644 --- a/lib/berrypod_web/components/shop_components/layout.ex +++ b/lib/berrypod_web/components/shop_components/layout.ex @@ -1066,77 +1066,61 @@ defmodule BerrypodWeb.ShopComponents.Layout do def editor_sheet(assigns) do ~H""" + <%!-- Floating action button: always visible when panel is closed --%> + + + <%!-- Editor panel: slides in/out --%> diff --git a/lib/berrypod_web/page_renderer.ex b/lib/berrypod_web/page_renderer.ex index 044aafe..205a73e 100644 --- a/lib/berrypod_web/page_renderer.ex +++ b/lib/berrypod_web/page_renderer.ex @@ -128,9 +128,9 @@ defmodule BerrypodWeb.PageRenderer do ~H"""
<%!-- Page title and undo/redo --%> -
-

{@page.title}

-
+
+

{@page.title}

+