From a1a4537c833bab1e7cf7d511a1801de7ceb4bf86 Mon Sep 17 00:00:00 2001 From: Matt Edholm Date: Fri, 8 May 2026 16:25:11 -0400 Subject: [PATCH] fix(home): remove confirmation is now a centered modal popup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inline-expand version (within the bottom sheet) was awkward — the sheet's content shifted around and the destructive button visually inherited the same layout as Save. Switched to a centered overlay modal teleported to : - Backdrop with semi-transparent dark + subtle blur, click-to-cancel. - Card scales up slightly on enter, fades out on leave. - Two-button row: Cancel (neutral) and Yes, remove (red). - alertdialog role for screen readers. The Remove button stays in the sheet so the entry point is unchanged; only the confirmation surface moves out of the sheet's flow. Tests updated for : HomeView.test.ts queries document directly for the modal (it lives outside the wrapper's tree). New case for backdrop-click cancel. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/test/views/HomeView.test.ts | 58 +++++++-- frontend/src/views/HomeView.vue | 111 ++++++++++++------ ...-CffjbC.js => BaseBottomSheet-BPaL7SoR.js} | 2 +- ...r-fvRSiEvw.js => DevicePicker-DC7iFd_Y.js} | 2 +- public/build/assets/HomeView-BcvW47i7.js | 1 - public/build/assets/HomeView-Db9Al7L1.js | 1 + ...iew-DVdR3yC_.css => HomeView-DwSsh2DL.css} | 2 +- ...ew-DeOfnPqz.js => LibraryView-xvPIft8b.js} | 2 +- ...w-C_yo7IiK.js => SettingsView-DCS7ac_2.js} | 2 +- ...iew-DR8aAgLX.js => UploadView-BchKf6zv.js} | 2 +- .../{index-CPVhLfHG.js => index-BojlVxcP.js} | 4 +- public/build/index.html | 2 +- 12 files changed, 131 insertions(+), 58 deletions(-) rename public/build/assets/{BaseBottomSheet-C-CffjbC.js => BaseBottomSheet-BPaL7SoR.js} (98%) rename public/build/assets/{DevicePicker-fvRSiEvw.js => DevicePicker-DC7iFd_Y.js} (96%) delete mode 100644 public/build/assets/HomeView-BcvW47i7.js create mode 100644 public/build/assets/HomeView-Db9Al7L1.js rename public/build/assets/{HomeView-DVdR3yC_.css => HomeView-DwSsh2DL.css} (66%) rename public/build/assets/{LibraryView-DeOfnPqz.js => LibraryView-xvPIft8b.js} (98%) rename public/build/assets/{SettingsView-C_yo7IiK.js => SettingsView-DCS7ac_2.js} (92%) rename public/build/assets/{UploadView-DR8aAgLX.js => UploadView-BchKf6zv.js} (98%) rename public/build/assets/{index-CPVhLfHG.js => index-BojlVxcP.js} (99%) diff --git a/frontend/src/test/views/HomeView.test.ts b/frontend/src/test/views/HomeView.test.ts index 1cf2df8..05d1ee6 100644 --- a/frontend/src/test/views/HomeView.test.ts +++ b/frontend/src/test/views/HomeView.test.ts @@ -1031,7 +1031,11 @@ describe('HomeView', () => { // Two-step confirm: clicking "Remove this frame" opens the in-sheet // confirmation panel; clicking "Yes, remove" inside it actually fires // the API + closes the sheet. - it('Remove this frame opens an in-sheet confirmation panel', async () => { + // The confirmation modal is rendered via , so it lives + // outside the wrapper's element tree. Tests have to query document + // directly. The Remove button itself stays in the sheet; opening the + // modal does NOT replace it. + it('Remove this frame opens a popup modal teleported to body', async () => { const devicesStore = useDevicesStore() devicesStore.devices = [makeDevice({ id: 5, name: 'Den' })] vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue() @@ -1042,19 +1046,17 @@ describe('HomeView', () => { await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5) await flushPromises() - expect(wrapper.find('.home-view__remove-confirm').exists()).toBe(false) + expect(document.querySelector('.home-view__remove-modal')).toBeNull() await wrapper.find('.home-view__remove').trigger('click') await flushPromises() - expect(wrapper.find('.home-view__remove-confirm').exists()).toBe(true) - expect(wrapper.find('.home-view__remove-confirm-body').text()).toContain('selling or giving away') - // The original primary button is replaced by the confirm panel — no - // accidental "Remove" double-click can fire the API directly. - expect(wrapper.find('.home-view__remove').exists()).toBe(false) + const modal = document.querySelector('.home-view__remove-modal') + expect(modal).not.toBeNull() + expect(modal!.querySelector('.home-view__remove-confirm-body')!.textContent).toContain('selling or giving away') }) - it('Yes, remove inside the confirm panel calls the store and closes the sheet', async () => { + it('Yes, remove inside the modal calls the store and closes both', async () => { const devicesStore = useDevicesStore() devicesStore.devices = [makeDevice({ id: 5 })] vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue() @@ -1066,15 +1068,18 @@ describe('HomeView', () => { await flushPromises() await wrapper.find('.home-view__remove').trigger('click') await flushPromises() - await wrapper.find('.home-view__remove-confirm-btn').trigger('click') + + const confirmBtn = document.querySelector('.home-view__remove-confirm-btn') as HTMLButtonElement + confirmBtn.click() await flushPromises() expect(removeSpy).toHaveBeenCalledWith(5) const sheet = wrapper.findComponent({ name: 'BaseBottomSheet' }) expect(sheet.props('modelValue')).toBe(false) + expect(document.querySelector('.home-view__remove-modal')).toBeNull() }) - it('Cancel inside the confirm panel returns to the normal sheet without calling removeDevice', async () => { + it('Cancel inside the modal closes it without calling removeDevice', async () => { const devicesStore = useDevicesStore() devicesStore.devices = [makeDevice({ id: 5 })] vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue() @@ -1086,14 +1091,43 @@ describe('HomeView', () => { await flushPromises() await wrapper.find('.home-view__remove').trigger('click') await flushPromises() - await wrapper.find('.home-view__remove-cancel').trigger('click') + + const cancelBtn = document.querySelector('.home-view__remove-cancel') as HTMLButtonElement + cancelBtn.click() await flushPromises() expect(removeSpy).not.toHaveBeenCalled() - expect(wrapper.find('.home-view__remove-confirm').exists()).toBe(false) + expect(document.querySelector('.home-view__remove-modal')).toBeNull() + // The settings sheet stays open and the Remove button still shows. expect(wrapper.find('.home-view__remove').exists()).toBe(true) }) + // Backdrop-click closes the modal — common modal expectation. + it('clicking the backdrop closes the modal without removing', async () => { + const devicesStore = useDevicesStore() + devicesStore.devices = [makeDevice({ id: 5 })] + vi.spyOn(devicesStore, 'fetchDevices').mockResolvedValue() + const removeSpy = vi.spyOn(devicesStore, 'removeDevice').mockResolvedValue() + + const wrapper = mountView() + await flushPromises() + await wrapper.findComponent({ name: 'FrameCard' }).vm.$emit('edit', 5) + await flushPromises() + await wrapper.find('.home-view__remove').trigger('click') + await flushPromises() + + const backdrop = document.querySelector('.home-view__remove-modal') as HTMLElement + // @click.self on the backdrop fires the close — happy-dom needs a real + // dispatchEvent on the element itself with target=backdrop. + const ev = new MouseEvent('click', { bubbles: true }) + Object.defineProperty(ev, 'target', { value: backdrop, configurable: true }) + backdrop.dispatchEvent(ev) + await flushPromises() + + expect(removeSpy).not.toHaveBeenCalled() + expect(document.querySelector('.home-view__remove-modal')).toBeNull() + }) + // Update the existing propagation-note text test to assert the new // "disconnect power" hint that lets users force an immediate refresh. it('propagation note mentions disconnecting power as an immediate-refresh gesture', async () => { diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index c95a86a..48e89b1 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -226,39 +226,49 @@ {{ saving ? 'Saving…' : 'Save' }} - - -
-

- Remove this frame? -

-

- Use this if you’re selling or giving away the frame. It deletes - this frame from your account and unlinks it from your photos so the - next owner can claim it fresh. This can’t be undone. -

-
- - -
-
+ + + + +
+
+

+ Remove this frame? +

+

+ Use this if you’re selling or giving away the frame. It deletes + this frame from your account and unlinks it from your photos so + the next owner can claim it fresh. This can’t be undone. +

+
+ + +
+
+
+
+
+