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:
@@ -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 () => {
|
||||
|
||||
@@ -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 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.
|
||||
</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 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.
|
||||
</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 {
|
||||
|
||||
Reference in New Issue
Block a user