fix(home): remove confirmation is now a centered modal popup
CI / test (push) Has been cancelled

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 <body>:

  - 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 <Teleport>: 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) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 16:25:11 -04:00
parent e4f811581a
commit a1a4537c83
12 changed files with 131 additions and 58 deletions
+46 -12
View File
@@ -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 <Teleport to="body">, 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 () => {
+75 -36
View File
@@ -226,39 +226,49 @@
{{ saving ? 'Saving…' : 'Save' }}
</BaseButton>
<template v-if="!removeConfirmOpen">
<button
type="button"
class="home-view__remove"
@click="removeConfirmOpen = true"
>Remove this frame</button>
</template>
<div v-else class="home-view__remove-confirm" role="alertdialog" aria-labelledby="remove-confirm-title">
<p class="home-view__remove-confirm-title" id="remove-confirm-title">
Remove this frame?
</p>
<p class="home-view__remove-confirm-body">
Use this if youre 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 cant be undone.
</p>
<div class="home-view__remove-confirm-actions">
<button
type="button"
class="home-view__remove-cancel"
:disabled="removing"
@click="removeConfirmOpen = false"
>Cancel</button>
<button
type="button"
class="home-view__remove-confirm-btn"
:disabled="removing"
@click="performRemove"
>{{ removing ? 'Removing…' : 'Yes, remove' }}</button>
</div>
</div>
<button
type="button"
class="home-view__remove"
@click="removeConfirmOpen = true"
>Remove this frame</button>
</BaseBottomSheet>
<Teleport to="body">
<Transition name="home-view__remove-modal">
<div
v-if="removeConfirmOpen"
class="home-view__remove-modal"
role="alertdialog"
aria-labelledby="remove-confirm-title"
@click.self="removeConfirmOpen = false"
>
<div class="home-view__remove-modal-card">
<p class="home-view__remove-confirm-title" id="remove-confirm-title">
Remove this frame?
</p>
<p class="home-view__remove-confirm-body">
Use this if youre 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 cant be undone.
</p>
<div class="home-view__remove-confirm-actions">
<button
type="button"
class="home-view__remove-cancel"
:disabled="removing"
@click="removeConfirmOpen = false"
>Cancel</button>
<button
type="button"
class="home-view__remove-confirm-btn"
:disabled="removing"
@click="performRemove"
>{{ removing ? 'Removing…' : 'Yes, remove' }}</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
@@ -1048,12 +1058,41 @@ async function saveSettings() {
}
}
&__remove-confirm {
margin-top: var(--space-5);
padding: var(--space-3);
border: 1.5px solid var(--color-danger, #c0392b);
&__remove-modal {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-4);
background: rgba(20, 14, 8, 0.55);
backdrop-filter: blur(2px);
}
&__remove-modal-card {
width: 100%;
max-width: 360px;
padding: var(--space-4);
border-radius: var(--radius-md);
background: var(--color-surface);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
}
// Transition: card scales up subtly + fades; backdrop fades.
&__remove-modal-enter-active,
&__remove-modal-leave-active {
transition: opacity var(--duration-fast) ease;
}
&__remove-modal-enter-active .home-view__remove-modal-card,
&__remove-modal-leave-active .home-view__remove-modal-card {
transition: transform var(--duration-fast) ease;
}
&__remove-modal-enter-from,
&__remove-modal-leave-to { opacity: 0; }
&__remove-modal-enter-from .home-view__remove-modal-card,
&__remove-modal-leave-to .home-view__remove-modal-card {
transform: scale(0.96);
}
&__remove-confirm-title {
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{_ as e,d as t,f as n,g as r,j as i,k as a,m as o,pt as s,s as c,t as l,u,v as d,z as f}from"./_plugin-vue_export-helper-DRLwVS0w.js";import{n as p,t as m}from"./BaseBottomSheet-C-CffjbC.js";var h={class:`device-picker__list`},g=[`checked`,`onChange`],_={class:`device-picker__name`},v={class:`device-picker__orientation`},y=l(d({__name:`DevicePicker`,props:{modelValue:{type:Boolean},devices:{},selected:{},uploading:{type:Boolean}},emits:[`update:modelValue`,`update:selected`,`confirm`],setup(l,{emit:d}){let y=l,b=d;function x(e){y.selected.includes(e)?b(`update:selected`,y.selected.filter(t=>t!==e)):b(`update:selected`,[...y.selected,e])}let S=u(()=>{let e=y.selected.length;return e===0?`Add to frame`:`Add to ${e} frame${e>1?`s`:``}`});return(u,d)=>(a(),n(m,{"model-value":l.modelValue,label:`Choose frames`,"onUpdate:modelValue":d[1]||=e=>u.$emit(`update:modelValue`,e)},{default:f(()=>[d[2]||=t(`h2`,{class:`device-picker__title`},`Add to frames`,-1),d[3]||=t(`p`,{class:`device-picker__sub`},`Choose which frames will show this photo.`,-1),t(`div`,h,[(a(!0),o(c,null,i(l.devices,e=>(a(),o(`label`,{key:e.id,class:`device-picker__row`},[t(`input`,{type:`checkbox`,class:`device-picker__check`,checked:l.selected.includes(e.id),onChange:t=>x(e.id)},null,40,g),t(`span`,_,s(e.name),1),t(`span`,v,s(e.orientation),1)]))),128))]),e(p,{variant:`primary`,class:`device-picker__confirm`,disabled:l.selected.length===0||l.uploading,onClick:d[0]||=e=>u.$emit(`confirm`)},{default:f(()=>[r(s(l.uploading?`Uploading…`:S.value),1)]),_:1},8,[`disabled`])]),_:1},8,[`model-value`]))}}),[[`__scopeId`,`data-v-a6466fa5`]]);export{y as t};
import{_ as e,d as t,f as n,g as r,j as i,k as a,m as o,pt as s,s as c,t as l,u,v as d,z as f}from"./_plugin-vue_export-helper-DRLwVS0w.js";import{n as p,t as m}from"./BaseBottomSheet-BPaL7SoR.js";var h={class:`device-picker__list`},g=[`checked`,`onChange`],_={class:`device-picker__name`},v={class:`device-picker__orientation`},y=l(d({__name:`DevicePicker`,props:{modelValue:{type:Boolean},devices:{},selected:{},uploading:{type:Boolean}},emits:[`update:modelValue`,`update:selected`,`confirm`],setup(l,{emit:d}){let y=l,b=d;function x(e){y.selected.includes(e)?b(`update:selected`,y.selected.filter(t=>t!==e)):b(`update:selected`,[...y.selected,e])}let S=u(()=>{let e=y.selected.length;return e===0?`Add to frame`:`Add to ${e} frame${e>1?`s`:``}`});return(u,d)=>(a(),n(m,{"model-value":l.modelValue,label:`Choose frames`,"onUpdate:modelValue":d[1]||=e=>u.$emit(`update:modelValue`,e)},{default:f(()=>[d[2]||=t(`h2`,{class:`device-picker__title`},`Add to frames`,-1),d[3]||=t(`p`,{class:`device-picker__sub`},`Choose which frames will show this photo.`,-1),t(`div`,h,[(a(!0),o(c,null,i(l.devices,e=>(a(),o(`label`,{key:e.id,class:`device-picker__row`},[t(`input`,{type:`checkbox`,class:`device-picker__check`,checked:l.selected.includes(e.id),onChange:t=>x(e.id)},null,40,g),t(`span`,_,s(e.name),1),t(`span`,v,s(e.orientation),1)]))),128))]),e(p,{variant:`primary`,class:`device-picker__confirm`,disabled:l.selected.length===0||l.uploading,onClick:d[0]||=e=>u.$emit(`confirm`)},{default:f(()=>[r(s(l.uploading?`Uploading…`:S.value),1)]),_:1},8,[`disabled`])]),_:1},8,[`model-value`]))}}),[[`__scopeId`,`data-v-a6466fa5`]]);export{y as t};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{K as e,d as t,dt as n,ft as r,j as i,k as a,m as o,p as s,pt as c,s as l,t as u,u as d,v as f}from"./_plugin-vue_export-helper-DRLwVS0w.js";import{n as p,r as m,t as h}from"./index-CPVhLfHG.js";var g={class:`settings`},_={class:`settings__section`},v={class:`theme-grid`,role:`radiogroup`,"aria-label":`Choose theme`},y=[`aria-checked`,`aria-label`,`onClick`],b={class:`theme-swatch__label`},x={key:0,class:`theme-swatch__check`,"aria-hidden":`true`},S={class:`settings__section`},C={class:`settings__row`},w={class:`settings__row-value`},T=u(f({__name:`SettingsView`,setup(u){let f=m(),{saveTheme:T}=p(),E=d(()=>f.user?.theme??`warm-craft`);function D(e){T(e)}return(u,d)=>(a(),o(`main`,g,[d[5]||=t(`h1`,{class:`settings__title`},`Settings`,-1),t(`section`,_,[d[1]||=t(`h2`,{class:`settings__section-title`},`Theme`,-1),t(`div`,v,[(a(!0),o(l,null,i(e(h),e=>(a(),o(`button`,{key:e.id,type:`button`,role:`radio`,"aria-checked":E.value===e.id,"aria-label":e.label,class:n([`theme-swatch`,{"theme-swatch--active":E.value===e.id}]),style:r({"--swatch-bg":e.bg,"--swatch-primary":e.primary,"--swatch-text":e.text}),onClick:t=>D(e.id)},[d[0]||=t(`span`,{class:`theme-swatch__preview`,"aria-hidden":`true`},[t(`span`,{class:`theme-swatch__bar`}),t(`span`,{class:`theme-swatch__dot`})],-1),t(`span`,b,c(e.label),1),E.value===e.id?(a(),o(`span`,x,``)):s(``,!0)],14,y))),128))])]),t(`section`,S,[d[3]||=t(`h2`,{class:`settings__section-title`},`Account`,-1),t(`div`,C,[d[2]||=t(`span`,{class:`settings__row-label`},`Signed in as`,-1),t(`span`,w,c(e(f).user?.email),1)]),d[4]||=t(`a`,{href:`/logout`,class:`settings__logout`},`Sign out`,-1)])]))}}),[[`__scopeId`,`data-v-76ec3881`]]);export{T as default};
import{K as e,d as t,dt as n,ft as r,j as i,k as a,m as o,p as s,pt as c,s as l,t as u,u as d,v as f}from"./_plugin-vue_export-helper-DRLwVS0w.js";import{n as p,r as m,t as h}from"./index-BojlVxcP.js";var g={class:`settings`},_={class:`settings__section`},v={class:`theme-grid`,role:`radiogroup`,"aria-label":`Choose theme`},y=[`aria-checked`,`aria-label`,`onClick`],b={class:`theme-swatch__label`},x={key:0,class:`theme-swatch__check`,"aria-hidden":`true`},S={class:`settings__section`},C={class:`settings__row`},w={class:`settings__row-value`},T=u(f({__name:`SettingsView`,setup(u){let f=m(),{saveTheme:T}=p(),E=d(()=>f.user?.theme??`warm-craft`);function D(e){T(e)}return(u,d)=>(a(),o(`main`,g,[d[5]||=t(`h1`,{class:`settings__title`},`Settings`,-1),t(`section`,_,[d[1]||=t(`h2`,{class:`settings__section-title`},`Theme`,-1),t(`div`,v,[(a(!0),o(l,null,i(e(h),e=>(a(),o(`button`,{key:e.id,type:`button`,role:`radio`,"aria-checked":E.value===e.id,"aria-label":e.label,class:n([`theme-swatch`,{"theme-swatch--active":E.value===e.id}]),style:r({"--swatch-bg":e.bg,"--swatch-primary":e.primary,"--swatch-text":e.text}),onClick:t=>D(e.id)},[d[0]||=t(`span`,{class:`theme-swatch__preview`,"aria-hidden":`true`},[t(`span`,{class:`theme-swatch__bar`}),t(`span`,{class:`theme-swatch__dot`})],-1),t(`span`,b,c(e.label),1),E.value===e.id?(a(),o(`span`,x,``)):s(``,!0)],14,y))),128))])]),t(`section`,S,[d[3]||=t(`h2`,{class:`settings__section-title`},`Account`,-1),t(`div`,C,[d[2]||=t(`span`,{class:`settings__row-label`},`Signed in as`,-1),t(`span`,w,c(e(f).user?.email),1)]),d[4]||=t(`a`,{href:`/logout`,class:`settings__logout`},`Sign out`,-1)])]))}}),[[`__scopeId`,`data-v-76ec3881`]]);export{T as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -14,7 +14,7 @@
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="pictureFrame" />
<script type="module" crossorigin src="/build/assets/index-CPVhLfHG.js"></script>
<script type="module" crossorigin src="/build/assets/index-BojlVxcP.js"></script>
<link rel="modulepreload" crossorigin href="/build/assets/_plugin-vue_export-helper-DRLwVS0w.js">
<link rel="stylesheet" crossorigin href="/build/assets/index-BlLBHR1q.css">
</head>