From b270dfedb4d41145e17f5ac19a1934448f5fe49d Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 2 May 2026 15:05:09 -0400 Subject: [PATCH] feat(ext/vault): sticky save bar in fullscreen forms The form pane gets a flex column layout: scrollable content above, sticky save bar at bottom. Bar uses translucent fill with backdrop-blur and a 24px gradient fade so content scrolls under it. Save / cancel buttons reuse the form's existing handlers via externalActions flag. --- extension/src/popup/components/item-form.ts | 2 +- extension/src/popup/components/types/login.ts | 4 +- extension/src/vault/vault.css | 36 +++++++++ extension/src/vault/vault.ts | 75 ++++++++++++++++++- 4 files changed, 111 insertions(+), 6 deletions(-) diff --git a/extension/src/popup/components/item-form.ts b/extension/src/popup/components/item-form.ts index f0e72be..0460c7c 100644 --- a/extension/src/popup/components/item-form.ts +++ b/extension/src/popup/components/item-form.ts @@ -41,7 +41,7 @@ export function renderItemForm(app: HTMLElement, mode: 'add' | 'edit'): void { const type: ItemType = existing?.type ?? state.newType ?? 'login'; switch (type) { - case 'login': return login.renderForm(app, mode, existing, { surface: isInTab() ? 'fullscreen' : 'popup' }); + case 'login': return login.renderForm(app, mode, existing, { surface: isInTab() ? 'fullscreen' : 'popup', externalActions: isInTab() }); case 'secure_note': return secureNote.renderForm(app, mode, existing); case 'identity': return identity.renderForm(app, mode, existing); case 'card': return card.renderForm(app, mode, existing); diff --git a/extension/src/popup/components/types/login.ts b/extension/src/popup/components/types/login.ts index f923bb6..f21bef7 100644 --- a/extension/src/popup/components/types/login.ts +++ b/extension/src/popup/components/types/login.ts @@ -358,12 +358,10 @@ export function renderForm( ${renderSectionsEditor(sectionsDraft, sectionsExpanded)} ${isInTab() ? renderAttachmentsDisclosure({ itemId: existing?.id ?? '', attachments: attachmentsDraft, mode: 'edit' }) : ''} - ${externalActions ? '' : ` -
+
- `}
`; diff --git a/extension/src/vault/vault.css b/extension/src/vault/vault.css index 299d3c1..a507dbd 100644 --- a/extension/src/vault/vault.css +++ b/extension/src/vault/vault.css @@ -1605,3 +1605,39 @@ textarea { padding-bottom: 6px; margin-bottom: 12px; } + +/* Phase 2B: sticky save bar + scrollable form pane */ +.form-pane { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} +.form-scroll { + flex: 1; + overflow-y: auto; + padding: 20px 24px; +} +.sticky-save-bar { + position: sticky; + bottom: 0; + background: rgba(17, 22, 30, 0.7); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + border-top: 1px solid var(--border-mid); + padding: 12px 24px; + display: flex; + justify-content: flex-end; + gap: 8px; + z-index: 10; +} +.sticky-save-bar::before { + content: ''; + position: absolute; + top: -24px; + left: 0; + right: 0; + height: 24px; + background: linear-gradient(to top, rgba(17, 22, 30, 0.7), transparent); + pointer-events: none; +} diff --git a/extension/src/vault/vault.ts b/extension/src/vault/vault.ts index f90449e..4f0bb92 100644 --- a/extension/src/vault/vault.ts +++ b/extension/src/vault/vault.ts @@ -415,6 +415,71 @@ async function selectItem(id: ItemId): Promise { } } +// --------------------------------------------------------------------------- +// Platform-aware save hint +// --------------------------------------------------------------------------- + +const isMac = navigator.platform.toLowerCase().includes('mac'); +const SAVE_HINT = isMac ? '⌘+S to save' : 'Ctrl+S to save'; + +// --------------------------------------------------------------------------- +// Fullscreen form wrapper — sticky save bar + scrollable content + header +// --------------------------------------------------------------------------- + +function renderFormWrapped(app: HTMLElement, mode: 'add' | 'edit'): void { + const itemType = state.selectedItem?.type ?? state.newType ?? 'login'; + const typeLabel = itemType.replace('_', ' '); + const titleText = mode === 'add' ? `new ${typeLabel}` : `edit ${typeLabel}`; + const wrapper = document.createElement('div'); + wrapper.className = 'form-pane'; + wrapper.innerHTML = ` +
+
+
${titleText}
+
no changes
+
+
${SAVE_HINT}
+
+
+
+ + +
+ `; + // Remove pane padding so form-pane can fill height cleanly + app.style.padding = '0'; + app.style.overflow = 'hidden'; + app.replaceChildren(wrapper); + + const scrollEl = wrapper.querySelector('#form-scroll') as HTMLElement; + renderItemForm(scrollEl, mode); + + const subEl = wrapper.querySelector('#form-dirty-sub') as HTMLElement; + let isDirty = false; + const markDirty = () => { + if (isDirty) return; + isDirty = true; + subEl.textContent = 'unsaved · esc to cancel'; + }; + const markClean = () => { + isDirty = false; + subEl.textContent = 'no changes'; + }; + scrollEl.addEventListener('input', markDirty, true); + scrollEl.addEventListener('change', markDirty, true); + + wrapper.querySelector('#form-cancel')?.addEventListener('click', () => { + markClean(); + (scrollEl.querySelector('#cancel-btn') as HTMLButtonElement | null)?.click(); + }); + wrapper.querySelector('#form-save')?.addEventListener('click', () => { + markClean(); + (scrollEl.querySelector('#save-btn') as HTMLButtonElement | null)?.click(); + }); +} + +export const __test__ = { renderFormWrapped }; + // --------------------------------------------------------------------------- // Pane rendering — delegates to shared popup components // --------------------------------------------------------------------------- @@ -453,10 +518,16 @@ function renderPane(): void { // set by the type-selection click handler (which calls setState → // renderPane before the URL hash has been updated to include the type). state.newType = (route.type as ItemType) ?? state.newType ?? null; - renderItemForm(pane, 'add'); + // Use the form wrapper (sticky bar + header) when a type is already chosen. + // Without a type the type-selection screen renders — no sticky bar needed. + if (state.newType) { + renderFormWrapped(pane, 'add'); + } else { + renderItemForm(pane, 'add'); + } break; case 'edit': - renderItemForm(pane, 'edit'); + renderFormWrapped(pane, 'edit'); break; case 'trash': renderTrash(pane);