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.
This commit is contained in:
@@ -41,7 +41,7 @@ export function renderItemForm(app: HTMLElement, mode: 'add' | 'edit'): void {
|
|||||||
const type: ItemType = existing?.type ?? state.newType ?? 'login';
|
const type: ItemType = existing?.type ?? state.newType ?? 'login';
|
||||||
|
|
||||||
switch (type) {
|
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 'secure_note': return secureNote.renderForm(app, mode, existing);
|
||||||
case 'identity': return identity.renderForm(app, mode, existing);
|
case 'identity': return identity.renderForm(app, mode, existing);
|
||||||
case 'card': return card.renderForm(app, mode, existing);
|
case 'card': return card.renderForm(app, mode, existing);
|
||||||
|
|||||||
@@ -358,12 +358,10 @@ export function renderForm(
|
|||||||
|
|
||||||
${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
|
${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
|
||||||
${isInTab() ? renderAttachmentsDisclosure({ itemId: existing?.id ?? '', attachments: attachmentsDraft, mode: 'edit' }) : ''}
|
${isInTab() ? renderAttachmentsDisclosure({ itemId: existing?.id ?? '', attachments: attachmentsDraft, mode: 'edit' }) : ''}
|
||||||
${externalActions ? '' : `
|
<div class="form-actions" ${externalActions ? 'hidden' : ''}>
|
||||||
<div class="form-actions">
|
|
||||||
<button class="btn" id="cancel-btn">cancel</button>
|
<button class="btn" id="cancel-btn">cancel</button>
|
||||||
<button class="btn btn-primary" id="save-btn">${state.loading ? '<span class="spinner"></span>' : 'save'}</button>
|
<button class="btn btn-primary" id="save-btn">${state.loading ? '<span class="spinner"></span>' : 'save'}</button>
|
||||||
</div>
|
</div>
|
||||||
`}
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -1605,3 +1605,39 @@ textarea {
|
|||||||
padding-bottom: 6px;
|
padding-bottom: 6px;
|
||||||
margin-bottom: 12px;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -415,6 +415,71 @@ async function selectItem(id: ItemId): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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 = `
|
||||||
|
<div class="fullscreen-form-header">
|
||||||
|
<div>
|
||||||
|
<div class="title">${titleText}</div>
|
||||||
|
<div class="sub" id="form-dirty-sub">no changes</div>
|
||||||
|
</div>
|
||||||
|
<div class="hint">${SAVE_HINT}</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-scroll" id="form-scroll"></div>
|
||||||
|
<div class="sticky-save-bar">
|
||||||
|
<button class="btn-secondary" id="form-cancel">cancel</button>
|
||||||
|
<button class="btn-primary" id="form-save">save</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
// 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
|
// Pane rendering — delegates to shared popup components
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -453,10 +518,16 @@ function renderPane(): void {
|
|||||||
// set by the type-selection click handler (which calls setState →
|
// set by the type-selection click handler (which calls setState →
|
||||||
// renderPane before the URL hash has been updated to include the type).
|
// renderPane before the URL hash has been updated to include the type).
|
||||||
state.newType = (route.type as ItemType) ?? state.newType ?? null;
|
state.newType = (route.type as ItemType) ?? state.newType ?? null;
|
||||||
|
// 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');
|
renderItemForm(pane, 'add');
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 'edit':
|
case 'edit':
|
||||||
renderItemForm(pane, 'edit');
|
renderFormWrapped(pane, 'edit');
|
||||||
break;
|
break;
|
||||||
case 'trash':
|
case 'trash':
|
||||||
renderTrash(pane);
|
renderTrash(pane);
|
||||||
|
|||||||
Reference in New Issue
Block a user